From 2e09754b9e7886b722593afc74513fe47b328334 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Wed, 22 Jul 2026 21:29:27 +0200 Subject: [PATCH] Prevent audio rate limits from closing Companion --- TODO.md | 4 ++ companion/installer/Lumi.Companion.iss | 2 +- companion/scripts/publish-companion.ps1 | 2 +- .../Lumi.Companion.App/CompanionRuntime.cs | 3 +- .../Lumi.Companion.App.csproj | 2 +- .../Lumi.Companion.Core/CompanionSocket.cs | 41 ++++++++++++++++--- .../backend/companion/gateway.js | 20 ++++++--- .../companion_manifest.json | 16 ++++---- plugins/lumi_transcription/plugin.json | 2 +- plugins/lumi_transcription/tests/verify.js | 10 ++++- 10 files changed, 78 insertions(+), 24 deletions(-) diff --git a/TODO.md b/TODO.md index a9a761c..0f9992a 100644 --- a/TODO.md +++ b/TODO.md @@ -54,6 +54,10 @@ Experimental.8 makes the installed single-file build discover and verify its OBS payload deterministically, adds an executable-level package diagnostic, and shows OBS maintenance failures directly instead of leaving them only in diagnostics. +Experimental.9 coalesces OBS callbacks into fixed 20 ms network frames and gives +audio its own soft gateway budget, so normal or pathological capture cadence can +never rate-limit and disconnect the Companion control connection. + Release-blocking work remains: - Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark diff --git a/companion/installer/Lumi.Companion.iss b/companion/installer/Lumi.Companion.iss index d09d8e6..e4ece72 100644 --- a/companion/installer/Lumi.Companion.iss +++ b/companion/installer/Lumi.Companion.iss @@ -1,5 +1,5 @@ #ifndef AppVersion - #define AppVersion "0.1.0-experimental.8" + #define AppVersion "0.1.0-experimental.9" #endif #ifndef SourceRoot #error SourceRoot must point at the self-contained Companion publish directory. diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 index dbff2cc..1e440fd 100644 --- a/companion/scripts/publish-companion.ps1 +++ b/companion/scripts/publish-companion.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "0.1.0-experimental.8", + [string]$Version = "0.1.0-experimental.9", [string]$BridgeVersion = "0.1.0-experimental.5" ) diff --git a/companion/src/Lumi.Companion.App/CompanionRuntime.cs b/companion/src/Lumi.Companion.App/CompanionRuntime.cs index fa41459..74f9d02 100644 --- a/companion/src/Lumi.Companion.App/CompanionRuntime.cs +++ b/companion/src/Lumi.Companion.App/CompanionRuntime.cs @@ -600,7 +600,8 @@ public sealed class CompanionRuntime : IAsyncDisposable { if (State.BenchmarkRunning) _benchmarkLastVoiceAt = DateTimeOffset.UtcNow; } - if (_socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame)) + var audioNeeded = State.TestRunning || State.BenchmarkRunning || State.ObsStreaming; + if (audioNeeded && _socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame)) _ = WriteLogAsync("audio_dropped", "An obsolete audio frame was dropped before network delivery."); return Task.CompletedTask; } diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index ffc86bd..c22cecc 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -5,7 +5,7 @@ enable enable app.manifest - 0.1.0-experimental.8 + 0.1.0-experimental.9 0.1.0.0 diff --git a/companion/src/Lumi.Companion.Core/CompanionSocket.cs b/companion/src/Lumi.Companion.Core/CompanionSocket.cs index d9cd888..f0b9f66 100644 --- a/companion/src/Lumi.Companion.Core/CompanionSocket.cs +++ b/companion/src/Lumi.Companion.Core/CompanionSocket.cs @@ -7,7 +7,13 @@ namespace Lumi.Companion.Core; public sealed class CompanionSocket : IAsyncDisposable { + private const int NetworkAudioPayloadBytes = 640; // 20 ms of 16 kHz mono PCM16. private readonly Channel> _audio = Channel.CreateBounded>(new BoundedChannelOptions(250) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true }); + private readonly object _bridgeAudioGate = new(); + private readonly byte[] _bridgeAudioBuffer = new byte[ProtocolV1.MaxAudioPayloadBytes + NetworkAudioPayloadBytes]; + private readonly byte[] _bridgeAudioHeader = new byte[ProtocolV1.AudioHeaderBytes]; + private int _bridgeAudioBuffered; + private uint _bridgeAudioSequence; private ClientWebSocket? _socket; private CancellationTokenSource? _lifetime; private readonly SemaphoreSlim _sendLock = new(1, 1); @@ -56,11 +62,36 @@ public sealed class CompanionSocket : IAsyncDisposable } public bool QueueBridgeAudio(ReadOnlyMemory encoded) { - if (encoded.Length < ProtocolV1.AudioHeaderBytes || !encoded.Span[..4].SequenceEqual("LACP"u8)) return false; - var normalized = encoded.ToArray(); - SessionId.TryWriteBytes(normalized.AsSpan(20, 16), bigEndian: true, out _); - BinaryPrimitives.WriteUInt64LittleEndian(normalized.AsSpan(12, 8), (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000UL); - return QueueEncodedAudio(normalized); + var input = encoded.Span; + if (input.Length < ProtocolV1.AudioHeaderBytes || input.Length > ProtocolV1.AudioHeaderBytes + ProtocolV1.MaxAudioPayloadBytes || + !input[..4].SequenceEqual("LACP"u8) || BinaryPrimitives.ReadUInt16LittleEndian(input[6..8]) != ProtocolV1.AudioHeaderBytes) return false; + var payloadBytes = (int)BinaryPrimitives.ReadUInt32LittleEndian(input[60..64]); + if (payloadBytes < 0 || payloadBytes % 2 != 0 || input.Length != ProtocolV1.AudioHeaderBytes + payloadBytes) return false; + lock (_bridgeAudioGate) + { + var sourceChanged = !_bridgeAudioHeader.AsSpan(36, 16).SequenceEqual(input[36..52]); + var stateChanged = _bridgeAudioHeader[5] != input[5]; + if (_bridgeAudioBuffered > 0 && (sourceChanged || stateChanged)) _bridgeAudioBuffered = 0; + input[..ProtocolV1.AudioHeaderBytes].CopyTo(_bridgeAudioHeader); + input[ProtocolV1.AudioHeaderBytes..].CopyTo(_bridgeAudioBuffer.AsSpan(_bridgeAudioBuffered)); + _bridgeAudioBuffered += payloadBytes; + var accepted = true; + while (_bridgeAudioBuffered >= NetworkAudioPayloadBytes) + { + var normalized = new byte[ProtocolV1.AudioHeaderBytes + NetworkAudioPayloadBytes]; + _bridgeAudioHeader.CopyTo(normalized, 0); + BinaryPrimitives.WriteUInt32LittleEndian(normalized.AsSpan(8, 4), _bridgeAudioSequence++); + BinaryPrimitives.WriteUInt64LittleEndian(normalized.AsSpan(12, 8), (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000UL); + SessionId.TryWriteBytes(normalized.AsSpan(20, 16), bigEndian: true, out _); + BinaryPrimitives.WriteUInt32LittleEndian(normalized.AsSpan(60, 4), NetworkAudioPayloadBytes); + _bridgeAudioBuffer.AsSpan(0, NetworkAudioPayloadBytes).CopyTo(normalized.AsSpan(ProtocolV1.AudioHeaderBytes)); + _bridgeAudioBuffered -= NetworkAudioPayloadBytes; + if (_bridgeAudioBuffered > 0) + _bridgeAudioBuffer.AsSpan(NetworkAudioPayloadBytes, _bridgeAudioBuffered).CopyTo(_bridgeAudioBuffer); + accepted &= QueueEncodedAudio(normalized); + } + return accepted; + } } public async Task SendAsync(string type, object payload, Guid? sessionId, CancellationToken cancellationToken) { diff --git a/plugins/lumi_transcription/backend/companion/gateway.js b/plugins/lumi_transcription/backend/companion/gateway.js index d40d1f7..e542cd1 100644 --- a/plugins/lumi_transcription/backend/companion/gateway.js +++ b/plugins/lumi_transcription/backend/companion/gateway.js @@ -2,6 +2,9 @@ const { WebSocketServer, WebSocket } = require("ws"); const { MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, AUDIO_HEADER_BYTES, parseEnvelope, validateHello, parseAudioFrame, envelope } = require("./protocol"); const { insecureDeviceAllowed } = require("./device_store"); +const MAX_CONTROL_MESSAGES_PER_SECOND = 120; +const MAX_AUDIO_MESSAGES_PER_SECOND = 200; + class CompanionGateway { constructor(options) { this.devices = options.devices; @@ -27,7 +30,9 @@ class CompanionGateway { let helloComplete = false; let lastPong = Date.now(); let windowStarted = Date.now(); - let messagesInWindow = 0; + let controlMessagesInWindow = 0; + let audioMessagesInWindow = 0; + let audioMessagesDropped = 0; let messageChain = Promise.resolve(); const send = (type, payload, sessionId = session?.id || null) => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(envelope(type, payload, sessionId))); @@ -42,15 +47,20 @@ class CompanionGateway { const body = Buffer.from(data); messageChain = messageChain.then(async () => { try { - if (Date.now() - windowStarted >= 1000) { windowStarted = Date.now(); messagesInWindow = 0; } - messagesInWindow += 1; - if (messagesInWindow > 300) throw coded("RATE_LIMIT", "Companion message rate exceeded its limit."); + if (Date.now() - windowStarted >= 1000) { + if (audioMessagesDropped) this.log.append({ kind: "audio_rate_limited", device_id: device.id, session_id: session?.id, dropped_frames: audioMessagesDropped }); + windowStarted = Date.now(); controlMessagesInWindow = 0; audioMessagesInWindow = 0; audioMessagesDropped = 0; + } if (isBinary) { if (!helloComplete || !session) throw coded("HELLO_REQUIRED", "Complete the handshake before sending audio."); + audioMessagesInWindow += 1; + if (audioMessagesInWindow > MAX_AUDIO_MESSAGES_PER_SECOND) { audioMessagesDropped += 1; return; } const result = await this.sessions.audio(session.id, parseAudioFrame(body)); if (result.gap) send("metric", { kind: "sequence_gap", missing_frames: result.gap }); return; } + controlMessagesInWindow += 1; + if (controlMessagesInWindow > MAX_CONTROL_MESSAGES_PER_SECOND) throw coded("RATE_LIMIT", "Companion control message rate exceeded its limit."); const message = parseEnvelope(body); if (!helloComplete) { const hello = validateHello(message); @@ -121,4 +131,4 @@ function sameHostOrigin(origin, host) { try { return new URL(origin).host === ho function coded(code, message) { return Object.assign(new Error(message), { code }); } function cleanReason(value) { return ["requested", "test_complete", "benchmark_complete", "silence_timeout", "disconnect"].includes(String(value)) ? String(value) : "requested"; } -module.exports = { CompanionGateway, sameHostOrigin }; +module.exports = { CompanionGateway, sameHostOrigin, MAX_CONTROL_MESSAGES_PER_SECOND, MAX_AUDIO_MESSAGES_PER_SECOND }; diff --git a/plugins/lumi_transcription/companion_manifest.json b/plugins/lumi_transcription/companion_manifest.json index 53620d4..2ca57c8 100644 --- a/plugins/lumi_transcription/companion_manifest.json +++ b/plugins/lumi_transcription/companion_manifest.json @@ -1,17 +1,17 @@ { "schema_version": 1, - "version": "0.1.0-experimental.8", + "version": "0.1.0-experimental.9", "signed": false, - "release_notes": "Fixes OBS integration package discovery in the installed single-file app, validates the packaged payload directly, and displays actionable installation errors.", + "release_notes": "Prevents OBS audio callback bursts from rate-limiting the Companion connection by sending steady 20 ms audio frames and keeping excess audio traffic separate from control messages.", "installer": { "id": "windows-x64-installer", "platform": "win32", "architecture": "x64", "label": "Windows x64 per-user installer", "filename": "Lumi.Companion-Setup.exe", - "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.8/Lumi.Companion-Setup.exe", - "sha256": "075fb4dd9a84ab10655d75899a6b4944269c9fe6371573778740d80b16ca19d0", - "bytes": 32041200 + "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.9/Lumi.Companion-Setup.exe", + "sha256": "22bdb0f351e5b4960e2c1ae40e3bdbd8de8a8d6326b30714defb5eb202c0efcf", + "bytes": 32039974 }, "artifacts": [ { @@ -20,9 +20,9 @@ "architecture": "x64", "label": "Windows x64 self-contained", "filename": "Lumi.Companion-win-x64.zip", - "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.8/Lumi.Companion-win-x64.zip", - "sha256": "46c58b044a213e328fa96fe86e48b9ea0b67e327439fed14fc485c711f03eb3e", - "bytes": 41757425, + "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.9/Lumi.Companion-win-x64.zip", + "sha256": "70d9a780859824aa7b8a12e9f25ae8871778b83458889237f171484b2a200dbc", + "bytes": 41757969, "entrypoint": "Lumi.Companion.App.exe" } ] diff --git a/plugins/lumi_transcription/plugin.json b/plugins/lumi_transcription/plugin.json index ad0e339..21bb26b 100644 --- a/plugins/lumi_transcription/plugin.json +++ b/plugins/lumi_transcription/plugin.json @@ -1,7 +1,7 @@ { "id": "lumi_transcription", "name": "Lumi Transcription", - "version": "0.1.0-experimental.8", + "version": "0.1.0-experimental.9", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "main": "index.js", "channel": "experimental", diff --git a/plugins/lumi_transcription/tests/verify.js b/plugins/lumi_transcription/tests/verify.js index 408a951..7452803 100644 --- a/plugins/lumi_transcription/tests/verify.js +++ b/plugins/lumi_transcription/tests/verify.js @@ -117,6 +117,7 @@ function verifyLocalhostTransportPolicy() { } function verifyCompanionVersionOrdering() { + assert.equal(plugin.compareVersions("0.1.0-experimental.9", "0.1.0-experimental.8"), 1); assert.equal(plugin.compareVersions("0.1.0-experimental.8", "0.1.0-experimental.7"), 1); assert.equal(plugin.compareVersions("0.1.0-experimental.7", "0.1.0-experimental.6"), 1); assert.equal(plugin.compareVersions("0.1.0-experimental.6", "0.1.0-experimental.5"), 1); @@ -357,10 +358,11 @@ async function verifyAuthenticatedGateway() { const devices = new DeviceStore(db); const sessionId = crypto.randomUUID(); let disconnected = false; + let audioMessages = 0; const sessions = { create: (_device, send) => ({ session: { id: sessionId, state: "idle", send }, resumed: false }), disconnect: () => { disconnected = true; }, - audio: async () => ({ accepted: true }), updateSource: () => ({}), updateObsState: async () => ({}), start: async () => ({}), stop: async () => ({}) + audio: async () => { audioMessages += 1; return { accepted: true }; }, updateSource: () => ({}), updateObsState: async () => ({}), start: async () => ({}), stop: async () => ({}) }; const gateway = new CompanionGateway({ devices, sessions }); const registry = createWebUpgradeRegistry(); @@ -379,6 +381,12 @@ async function verifyAuthenticatedGateway() { assert.equal(response.type, "hello_ack"); assert.equal(response.session_id, sessionId); assert.equal(devices.list().find((device) => device.id === credential.device_id).metadata.companion_version, "0.1.0-experimental.6"); + const sourceUuid = crypto.randomUUID(); + const audioFrame = protocol.encodeAudioFrame({ session_id: sessionId, source_uuid: sourceUuid, sequence: 1, capture_timestamp_us: Date.now() * 1000, pcm: Buffer.alloc(640) }); + for (let index = 0; index < 350; index += 1) client.send(audioFrame); + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.equal(client.readyState, WebSocket.OPEN, "Audio bursts must be bounded without closing the authenticated control connection"); + assert.ok(audioMessages > 0 && audioMessages < 350, "Excess audio frames were not softly rate-limited"); await new Promise((resolve) => { client.once("close", resolve); client.close(); }); for (let attempt = 0; attempt < 20 && !disconnected; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 5)); assert.equal(disconnected, true);