diff --git a/src/helpers/audioActivityDetector.js b/src/helpers/audioActivityDetector.js index 1510b5fcc..b24b7311f 100644 --- a/src/helpers/audioActivityDetector.js +++ b/src/helpers/audioActivityDetector.js @@ -17,6 +17,11 @@ const INACTIVE_RESET_MS = 60 * 1000; const LINUX_RECONCILE_MIN_SPACING_MS = 1000; const EXEC_OPTS = { timeout: 5000, encoding: "utf8" }; +function isLinuxMeetingPromptSourceOutput(sourceOutput) { + const properties = sourceOutput?.properties ?? {}; + return properties["media.role"] !== "production" && properties["stream.capture.sink"] !== "true"; +} + class AudioActivityDetector extends EventEmitter { // `getExcludedProcessIds` lists every pid whose mic use is OpenWhispr's own: // the Electron process tree by default, plus any live capture helpers when @@ -448,15 +453,6 @@ class AudioActivityDetector extends EventEmitter { _parsePactlSubscribeLine(line) { if (!this._running || !line.includes("source-output")) return; - - if (/Event\s+'new'\s+on\s+source-output/i.test(line)) { - this._activeSources++; - this._onMicStateChanged(true); - } else if (/Event\s+'remove'\s+on\s+source-output/i.test(line)) { - this._activeSources = Math.max(0, this._activeSources - 1); - this._onMicStateChanged(this._activeSources > 0); - } - this._queueLinuxReconcile(); } @@ -533,7 +529,9 @@ class AudioActivityDetector extends EventEmitter { } this._activeMicPids = activeMicPids; - this._activeSources = sourceOutputs.length; + // Prompt qualification is narrower than auto-end ownership: production + // and sink-monitor capture are recording evidence, not meeting evidence. + this._activeSources = sourceOutputs.filter(isLinuxMeetingPromptSourceOutput).length; this._setPidScopedCapability(true); this._onMicStateChanged(this._activeSources > 0); } catch (err) { @@ -801,8 +799,9 @@ class AudioActivityDetector extends EventEmitter { async _checkLinux() { try { - const { stdout } = await execAsync("pactl list source-outputs short", EXEC_OPTS); - return stdout.trim().length > 0; + const { stdout } = await execAsync("pactl --format=json list source-outputs", EXEC_OPTS); + const sourceOutputs = JSON.parse(stdout); + return sourceOutputs.some(isLinuxMeetingPromptSourceOutput); } catch { // pactl unavailable, try PipeWire } diff --git a/test/helpers/audioActivityDetector.test.js b/test/helpers/audioActivityDetector.test.js index b0c5c6383..6e53acf33 100644 --- a/test/helpers/audioActivityDetector.test.js +++ b/test/helpers/audioActivityDetector.test.js @@ -231,17 +231,29 @@ test("win32: MIC_START/MIC_STOP pids are tracked across partial chunks", async ( detector.stop(); }); -test("linux: pactl source-output events drive the sustained timer", async () => { - const { detector, children, calls } = createDetector("linux"); +test("linux: pactl source-output events reconcile before driving the sustained timer", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 10_000 }); + const { detector, children, calls } = createDetector("linux", { + execResponses: [ + { stdout: "[]" }, + { stdout: JSON.stringify([{ index: 7, properties: {} }]) }, + { stdout: "[]" }, + ], + }); await detector.start(); assert.equal(calls[0].command, "pactl"); assert.deepEqual(calls[0].args, ["subscribe"]); + t.mock.timers.tick(RECONCILE_SPACING_MS); children[0].stdout.emit("data", "Event 'new' on source-output #7\n"); + assert.equal(detector._sustainedTimer, null, "the raw event must not arm detection"); + await flushImmediate(); assert.notEqual(detector._sustainedTimer, null); + t.mock.timers.tick(RECONCILE_SPACING_MS); children[0].stdout.emit("data", "Event 'remove' on source-output #7\n"); + await flushImmediate(); assert.equal(detector._sustainedTimer, null); detector.stop(); }); @@ -541,6 +553,85 @@ test("linux: reconciles source-output ownership at startup and on events", async detector.stop(); }); +test("linux: production and sink-monitor streams do not drive meeting prompts", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 10_000 }); + const { detector, children } = createDetector("linux", { + execResponses: [ + { stdout: "[]" }, + { + stdout: JSON.stringify([ + { + properties: { + "application.process.id": "101", + "media.role": "production", + }, + }, + { properties: { "stream.capture.sink": "true" } }, + ]), + }, + ], + }); + + await detector.start(); + t.mock.timers.tick(RECONCILE_SPACING_MS); + children[0].stdout.emit("data", "Event 'new' on source-output #1\n"); + await flushImmediate(); + + assert.equal(detector._activeSources, 0); + assert.equal(detector._sustainedTimer, null); + assert.deepEqual(detector.getExternalMicState(), { + reliable: true, + externalMicActive: true, + }); + detector.stop(); +}); + +test("linux: browser and unattributed captures remain prompt-capable", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 10_000 }); + const { detector, children } = createDetector("linux", { + execResponses: [ + { stdout: "[]" }, + { + stdout: JSON.stringify([ + { + properties: { + "application.process.id": "202", + "application.process.binary": "chromium", + }, + }, + { properties: {} }, + ]), + }, + ], + }); + + await detector.start(); + t.mock.timers.tick(RECONCILE_SPACING_MS); + children[0].stdout.emit("data", "Event 'new' on source-output #1\n"); + await flushImmediate(); + + assert.equal(detector._activeSources, 2); + assert.notEqual(detector._sustainedTimer, null); + detector.stop(); +}); + +test("linux: polling uses the same prompt classification", async () => { + const { detector } = createDetector("linux", { + execResponses: [ + { + stdout: JSON.stringify([ + { properties: { "media.role": "production" } }, + { properties: { "stream.capture.sink": "true" } }, + ]), + }, + { stdout: JSON.stringify([{ properties: {} }]) }, + ], + }); + + assert.equal(await detector._checkLinux(), false); + assert.equal(await detector._checkLinux(), true); +}); + test("linux: a subscribe-event burst runs one leading and one spaced trailing reconcile", async (t) => { t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 10_000 }); const { detector, children, execCalls } = createDetector("linux", { @@ -668,7 +759,7 @@ test("linux: module streams without a process id stay excluded without costing r detector.stop(); }); -test("linux: ownership query failure is unreliable while aggregate events still prompt", async (t) => { +test("linux: a failed reconciliation does not invent prompt state", async (t) => { t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 10_000 }); const { detector, children } = createDetector("linux", { execResponses: [{ stdout: "[]" }, { stdout: "not-json" }], @@ -694,7 +785,7 @@ test("linux: ownership query failure is unreliable while aggregate events still { reliable: true, externalMicActive: false }, { reliable: false, externalMicActive: false }, ]); - assert.notEqual(detector._sustainedTimer, null, "aggregate activity must still drive prompts"); + assert.equal(detector._sustainedTimer, null); detector.stop(); });