Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions src/helpers/audioActivityDetector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
99 changes: 95 additions & 4 deletions test/helpers/audioActivityDetector.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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" }],
Expand All @@ -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();
});

Expand Down
Loading