Skip to content

Commit fc58569

Browse files
author
Jacob Weinhold
committed
fix(capture): pass the measured 800ms inactivity window to the transport sensor
The sensor's own default is 400 ms — chosen to span a packet gap, not a speech pause. Measured entry-timestamp staleness during real pauses is p50 550 ms, so the MEDIAN natural pause already trips a synthesized deactivation and one speech phase fragments. Re-measured against the current channelizer (5 x 30 min seeded tapes): 400 ms gives 1.67 lane activations per real turn, 800 ms gives 1.13 — the knee — for 1.8 pp more contamination; 1600 ms starts merging turns. The value was raised once before and lost in the re-platform. It comes back as a PARAMETER at the composition root, not as a forked constant in the sensor: the sensor already exposes `inactiveMs`, and forking its file a second time is how this was lost. Overridable via VEXA_CSRC_INACTIVE_MS. Scope: this fixes fragmentation, not lane contamination — no value of inactiveMs, lookbackMs or flickerHoldMs brings contamination into a usable range. Closes #150 Refs #110, #103, #138 Signed-off-by: Jacob Weinhold <jacob@philflow.io>
1 parent 3f5c3c0 commit fc58569

3 files changed

Lines changed: 102 additions & 3 deletions

File tree

core/meetings/modules/mixed-capture-core/src/csrc-poll.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,11 @@ const receiver = (sources: () => ContributingSourceLike[]) => ({
8585
check('past inactiveMs the deactivation is SYNTHESIZED (the transport never sends this edge)',
8686
out.length === 2 && out[1].active === false && out[1].csrc === 7 && out[1].tMs === t,
8787
JSON.stringify(out));
88-
check('the default inactivity window is 400ms', CSRC_INACTIVE_MS === 400);
88+
// The module's own default spans a PACKET gap (jitter, DTX), not a speech PAUSE. It is only a
89+
// default: the measured production window is longer and is passed in by the composition root
90+
// (#150), so what this file owns is the SEAM, not the number production runs on.
91+
check('the built-in window is the packet-gap default, and nothing more than a default',
92+
CSRC_INACTIVE_MS === 400);
8993

9094
// Speaking again is a NEW activation — turns are edges, not a level.
9195
speaking = true; lastSpoke = t; poll.poll();
@@ -100,6 +104,33 @@ const receiver = (sources: () => ContributingSourceLike[]) => ({
100104
check('a poll after destroy() emits nothing', out.length === after);
101105
}
102106

107+
// ── the inactivity window is a PARAMETER, not a constant ─────────────────────────────────
108+
// A caller with a MEASURED window must be able to hand it over instead of forking this file. That
109+
// seam is what #150 turns on: a second fork is how the measured 800 ms was lost the first time.
110+
{
111+
let t = 1_900_000_000_000;
112+
const out: CsrcTransition[] = [];
113+
let speaking = true;
114+
let lastSpoke = t;
115+
const poll = createCsrcPoll({
116+
onTransition: (x) => out.push(x),
117+
now: () => t,
118+
timeOrigin: () => 0,
119+
inactiveMs: 800,
120+
receivers: () => [receiver(() => [{ source: 9, timestamp: speaking ? t : lastSpoke, audioLevel: 0.4 }])],
121+
});
122+
poll.poll();
123+
check('override: the source opens a turn', out.length === 1 && out[0].active === true, JSON.stringify(out));
124+
speaking = false;
125+
t += 500; poll.poll();
126+
check('override: 500ms of silence — past the 400ms default, inside the caller\'s 800ms — holds the turn OPEN',
127+
out.length === 1, JSON.stringify(out));
128+
t += 400; poll.poll(); // 900ms > 800ms
129+
check('override: past the caller\'s window the deactivation is synthesized',
130+
out.length === 2 && out[1].active === false, JSON.stringify(out));
131+
poll.destroy();
132+
}
133+
103134
// ── two concurrent sources ──────────────────────────────────────────────────────────────────────
104135
{
105136
let t = 1_900_000_000_000;

core/meetings/services/bot/src/capture-bridge.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,24 @@ const TEAMS_ENABLE_CAPTIONS = process.env.VEXA_TEAMS_ENABLE_CAPTIONS === '1';
381381
/** How many times to try the menu path before giving up (each attempt is ~3 s of UI waits). */
382382
const TEAMS_ENABLE_CAPTIONS_ATTEMPTS = Math.max(1, Number(process.env.VEXA_TEAMS_ENABLE_CAPTIONS_ATTEMPTS || 3));
383383

384+
/**
385+
* How long the transport sensor holds a source active after its last observed contribution.
386+
*
387+
* The sensor's own default is 400 ms (`CSRC_INACTIVE_MS` in @vexa/mixed-capture-core) — a value
388+
* chosen to span a packet gap. Measured against real speech it is too short to span a PAUSE:
389+
* entry-timestamp staleness during natural pauses came out at p50 550 ms, so the MEDIAN pause
390+
* already trips a synthesized deactivation and one speech phase fragments into splinters. Re-measured
391+
* against the current channelizer (5 × 30 min seeded tapes): 400 ms yields 1.67 lane activations per
392+
* real turn (67 % over-splitting), 800 ms yields 1.13 — the knee — for 1.8 pp more contamination;
393+
* 1600 ms starts merging distinct turns. So the production window is 800 ms.
394+
*
395+
* It lives HERE, at the composition root, and not as a forked constant in the sensor: the sensor
396+
* already exposes `inactiveMs` as a parameter, and forking its file a second time is exactly how
397+
* this value was lost once before (#150). Overridable per deployment — and therefore per platform,
398+
* since each platform's bot is its own process — via VEXA_CSRC_INACTIVE_MS.
399+
*/
400+
const CSRC_INACTIVE_MS = Math.max(1, Number(process.env.VEXA_CSRC_INACTIVE_MS || 800));
401+
384402
/** Outcome of one enable attempt. `already-on` and `clicked` are successes; `failed` carries WHY,
385403
* because "captions never appeared" has two very different causes — the menu path changed, or the
386404
* tenant blocks captions — and only the reason distinguishes them on the first live run. */
@@ -796,7 +814,7 @@ export async function startCaptureBridge(
796814
// ── Start the page-side capture (VexaBrowserUtils preferred; production inline fallback). ──
797815
// The body of this callback runs IN THE BROWSER (Playwright serializes it); DOM globals are
798816
// reached via globalThis (this file type-checks against the Node lib — no DOM types here).
799-
await page.evaluate(async ({ isMixed, isPerTrack, isJitsi, isTeams, isZoom, botName, mainAudioGraceMs, mainAudioSilenceMs, mainAudioEnergyRms }) => {
817+
await page.evaluate(async ({ isMixed, isPerTrack, isJitsi, isTeams, isZoom, botName, mainAudioGraceMs, mainAudioSilenceMs, mainAudioEnergyRms, csrcInactiveMs }) => {
800818
const w = (globalThis as any) as Record<string, any>;
801819
if (isMixed) {
802820
// Zoom/Teams/Jitsi ride the WebRTC hook (installRemoteAudioHook, installed pre-nav), which mirrors
@@ -1159,6 +1177,9 @@ export async function startCaptureBridge(
11591177
w.logBot?.('[Csrc] observation ' + JSON.stringify(o));
11601178
w.__vexaObservation?.('csrc', o, Date.now());
11611179
},
1180+
// The MEASURED pause window, passed in rather than defaulted: the sensor's own 400 ms
1181+
// is shorter than the median natural speech pause (p50 550 ms) and fragments a turn.
1182+
inactiveMs: csrcInactiveMs,
11621183
log: (m: string) => w.logBot?.('[Csrc] ' + m),
11631184
});
11641185
} catch (e: any) {
@@ -1287,7 +1308,9 @@ export async function startCaptureBridge(
12871308
mainAudioGraceMs: Number(process.env.VEXA_TEAMS_MAIN_AUDIO_GRACE_MS || 15000),
12881309
// How long a PICKED mix may stay wholly silent before the lane abandons it for every track.
12891310
mainAudioSilenceMs: Number(process.env.VEXA_TEAMS_MAIN_AUDIO_SILENCE_MS || 20000),
1290-
mainAudioEnergyRms: Number(process.env.VEXA_TEAMS_MAIN_AUDIO_ENERGY_RMS || 0.006) }).catch((e) => {
1311+
mainAudioEnergyRms: Number(process.env.VEXA_TEAMS_MAIN_AUDIO_ENERGY_RMS || 0.006),
1312+
// The measured inactivity window (#150) — a parameter the sensor accepts, never a fork of it.
1313+
csrcInactiveMs: CSRC_INACTIVE_MS }).catch((e) => {
12911314
console.error(`[bot] capture bridge: page-side start failed: ${String(e)}`); // L4: surfaces only on the VM
12921315
});
12931316

core/meetings/services/bot/src/csrc-wiring.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,19 @@ const utils = g.VexaBrowserUtils as Record<string, unknown> | undefined;
138138
check('bundle: window.VexaBrowserUtils.createCsrcPoll is exported (RED at base — brick not bundled)',
139139
typeof utils?.createCsrcPoll === 'function', `keys: ${Object.keys(utils ?? {}).join(',')}`);
140140

141+
// Capture the options the REAL bridge hands the REAL sensor factory. The window the production
142+
// path runs on is not observable from a transition alone (only from WHEN one is absent), and the
143+
// number itself is owned by the composition root, not by the sensor — so it is asserted here, on
144+
// the object that actually crosses. RED before #150: the bridge passed no `inactiveMs` at all.
145+
type CsrcPollOpts = { inactiveMs?: number; pollMs?: number; now?: () => number; timeOrigin?: () => number;
146+
receivers?: () => unknown[]; onTransition: (t: { csrc: number; active: boolean; tMs: number }) => void };
147+
const realCreateCsrcPoll = utils!.createCsrcPoll as (o: CsrcPollOpts) => unknown;
148+
let productionCsrcOpts: CsrcPollOpts | undefined;
149+
(utils as Record<string, unknown>).createCsrcPoll = (o: CsrcPollOpts): unknown => {
150+
productionCsrcOpts = o;
151+
return realCreateCsrcPoll(o);
152+
};
153+
141154
// ── Fake Playwright Page + the Node seams ───────────────────────────────────────────────────────
142155
const page = {
143156
async exposeFunction(name: string, fn: unknown): Promise<void> { g[name] = fn; },
@@ -213,6 +226,38 @@ check('the spine and the stored sidecar agree on WHEN each edge happened',
213226
check('isolation: NO transition reached pipeline.recordHint — a csrc is an id, never a name',
214227
hints.length === 0, JSON.stringify(hints));
215228

229+
// ── the MEASURED inactivity window reaches the sensor (#150) ────────────────────────────────────
230+
// 400 ms — the sensor's own default — is shorter than the median natural speech pause (measured
231+
// p50 550 ms), so a turn fragments into 1.67 lane activations. The composition root passes the
232+
// measured 800 ms instead. Asserted twice: the value that crossed, and what that value DOES.
233+
check('the bridge passes an explicit inactivity window to the sensor (RED at base: undefined)',
234+
productionCsrcOpts?.inactiveMs === 800, `inactiveMs=${String(productionCsrcOpts?.inactiveMs)}`);
235+
{
236+
// Re-drive the sensor over the PRODUCTION options object — same `inactiveMs`, with only the
237+
// clock and the receivers replaced, so the window is proven by behaviour and not by a number.
238+
let t = 1_900_000_000_000;
239+
const edges: Array<{ active: boolean }> = [];
240+
let speaking = true;
241+
let lastSpoke = t;
242+
const poll = realCreateCsrcPoll({
243+
...productionCsrcOpts!,
244+
onTransition: (x) => edges.push({ active: x.active }),
245+
now: () => t,
246+
timeOrigin: () => 0,
247+
receivers: () => [{ track: { kind: 'audio' },
248+
getContributingSources: () => [{ source: 5, timestamp: speaking ? t : lastSpoke, audioLevel: 0.4 }] }],
249+
}) as { poll(): void; destroy(): void };
250+
poll.poll();
251+
speaking = false;
252+
t += 500; poll.poll();
253+
check('a 500ms pause — past the sensor default, inside the measured window — does NOT close the turn',
254+
edges.length === 1 && edges[0].active === true, JSON.stringify(edges));
255+
t += 400; poll.poll();
256+
check('past the measured window the deactivation is synthesized',
257+
edges.length === 2 && edges[1].active === false, JSON.stringify(edges));
258+
poll.destroy();
259+
}
260+
216261
(g as any).setInterval = realSetInterval;
217262
(g as any).clearInterval = realClearInterval;
218263
g.document = savedDocument;

0 commit comments

Comments
 (0)