Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 32 additions & 1 deletion core/meetings/modules/mixed-capture-core/src/csrc-poll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ const receiver = (sources: () => ContributingSourceLike[]) => ({
check('past inactiveMs the deactivation is SYNTHESIZED (the transport never sends this edge)',
out.length === 2 && out[1].active === false && out[1].csrc === 7 && out[1].tMs === t,
JSON.stringify(out));
check('the default inactivity window is 400ms', CSRC_INACTIVE_MS === 400);
// The module's own default spans a PACKET gap (jitter, DTX), not a speech PAUSE. It is only a
// default: the measured production window is longer and is passed in by the composition root,
// so what this file owns is the SEAM, not the number production runs on.
check('the built-in window is the packet-gap default, and nothing more than a default',
CSRC_INACTIVE_MS === 400, String(CSRC_INACTIVE_MS));

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

// ── the inactivity window is a PARAMETER, not a constant ─────────────────────────────────
// A caller with a MEASURED window must be able to hand it over instead of forking this file —
// the seam, not a copied constant, is the contract.
{
let t = 1_900_000_000_000;
const out: CsrcTransition[] = [];
let speaking = true;
let lastSpoke = t;
const poll = createCsrcPoll({
onTransition: (x) => out.push(x),
now: () => t,
timeOrigin: () => 0,
inactiveMs: 800,
receivers: () => [receiver(() => [{ source: 9, timestamp: speaking ? t : lastSpoke, audioLevel: 0.4 }])],
});
poll.poll();
check('override: the source opens a turn', out.length === 1 && out[0].active === true, JSON.stringify(out));
speaking = false;
t += 500; poll.poll();
check('override: 500ms of silence — past the 400ms default, inside the caller\'s 800ms — holds the turn OPEN',
out.length === 1, JSON.stringify(out));
t += 400; poll.poll(); // 900ms > 800ms
check('override: past the caller\'s window the deactivation is synthesized',
out.length === 2 && out[1].active === false, JSON.stringify(out));
poll.destroy();
}

// ── two concurrent sources ──────────────────────────────────────────────────────────────────────
{
let t = 1_900_000_000_000;
Expand Down
48 changes: 46 additions & 2 deletions core/meetings/services/bot/src/capture-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,45 @@ const TEAMS_ENABLE_CAPTIONS = process.env.VEXA_TEAMS_ENABLE_CAPTIONS === '1';
/** How many times to try the menu path before giving up (each attempt is ~3 s of UI waits). */
const TEAMS_ENABLE_CAPTIONS_ATTEMPTS = Math.max(1, Number(process.env.VEXA_TEAMS_ENABLE_CAPTIONS_ATTEMPTS || 3));

/** The sensor's poll cadence (`CSRC_POLL_MS`). Not imported: this file must not pull the page-side
* bundle into the Node build. Kept as the floor only, so a drift there is a slacker floor here,
* never a wrong window. */
const CSRC_MIN_INACTIVE_MS = 100;
/** No plausible window is 10 s: the measurement below shows 1600 ms already merges distinct
* turns, so a huge override is a typo wearing a number — the NaN failure mode again, silent. */
const CSRC_MAX_INACTIVE_MS = 10_000;
/**
* The measured window — how long the transport sensor holds a source active after its last
* observed contribution. One name, so the fallback, the message and the default cannot drift.
*
* The sensor's own default is 400 ms (`CSRC_INACTIVE_MS` in @vexa/mixed-capture-core) — a value
* sized to span a packet gap. A speech PAUSE is longer: entry-timestamp staleness during natural
* pauses measures p50 550 ms, so at 400 ms the MEDIAN pause trips a synthesized deactivation and
* one speech phase fragments into splinters. Against the current channelizer (5 × 30 min seeded
* tapes): 400 ms yields 1.67 lane activations per real turn (67 % over-splitting), 800 ms yields
* 1.13 — the knee — for 1.8 pp more contamination; 1600 ms starts merging distinct turns. So the
* production window is 800 ms.
*
* The number lives HERE, at the composition root; the sensor owns the `inactiveMs` seam and its
* packet-gap default, nothing more. Overridable via VEXA_CSRC_INACTIVE_MS where the bot process
* env is operator-controlled: the runtime kernel forwards it to spawned bots (profiles.py's
* tuning allowlist), and the lite/process backend inherits the host env directly.
*/
const CSRC_DEFAULT_INACTIVE_MS = 800;
export function resolveCsrcInactiveMs(raw: string | undefined, warn: (m: string) => void = () => { /* silent */ }): number {
// `undefined` is "no override", which is the normal case and silent. An override that was SET —
// including an empty or blank one, which is what a half-finished deploy template looks like — is
// an intent that could not be honoured, and gets said out loud.
if (raw === undefined) return CSRC_DEFAULT_INACTIVE_MS;
const n = Number(raw);
if (!Number.isFinite(n) || n < CSRC_MIN_INACTIVE_MS || n > CSRC_MAX_INACTIVE_MS) {
warn(`[bot] VEXA_CSRC_INACTIVE_MS=${JSON.stringify(raw)} is not a usable window (finite, ${CSRC_MIN_INACTIVE_MS}..${CSRC_MAX_INACTIVE_MS}ms) — using the measured ${CSRC_DEFAULT_INACTIVE_MS}ms`);
return CSRC_DEFAULT_INACTIVE_MS;
}
return n;
}
const CSRC_RESOLVED_INACTIVE_MS = resolveCsrcInactiveMs(process.env.VEXA_CSRC_INACTIVE_MS, (m) => console.warn(m));

/** Outcome of one enable attempt. `already-on` and `clicked` are successes; `failed` carries WHY,
* because "captions never appeared" has two very different causes — the menu path changed, or the
* tenant blocks captions — and only the reason distinguishes them on the first live run. */
Expand Down Expand Up @@ -796,7 +835,7 @@ export async function startCaptureBridge(
// ── Start the page-side capture (VexaBrowserUtils preferred; production inline fallback). ──
// The body of this callback runs IN THE BROWSER (Playwright serializes it); DOM globals are
// reached via globalThis (this file type-checks against the Node lib — no DOM types here).
await page.evaluate(async ({ isMixed, isPerTrack, isJitsi, isTeams, isZoom, botName, mainAudioGraceMs, mainAudioSilenceMs, mainAudioEnergyRms }) => {
await page.evaluate(async ({ isMixed, isPerTrack, isJitsi, isTeams, isZoom, botName, mainAudioGraceMs, mainAudioSilenceMs, mainAudioEnergyRms, csrcInactiveMs }) => {
const w = (globalThis as any) as Record<string, any>;
if (isMixed) {
// Zoom/Teams/Jitsi ride the WebRTC hook (installRemoteAudioHook, installed pre-nav), which mirrors
Expand Down Expand Up @@ -1159,6 +1198,9 @@ export async function startCaptureBridge(
w.logBot?.('[Csrc] observation ' + JSON.stringify(o));
w.__vexaObservation?.('csrc', o, Date.now());
},
// The MEASURED pause window, passed in rather than defaulted: the sensor's own 400 ms
// is shorter than the median natural speech pause (p50 550 ms) and fragments a turn.
inactiveMs: csrcInactiveMs,
log: (m: string) => w.logBot?.('[Csrc] ' + m),
});
} catch (e: any) {
Expand Down Expand Up @@ -1287,7 +1329,9 @@ export async function startCaptureBridge(
mainAudioGraceMs: Number(process.env.VEXA_TEAMS_MAIN_AUDIO_GRACE_MS || 15000),
// How long a PICKED mix may stay wholly silent before the lane abandons it for every track.
mainAudioSilenceMs: Number(process.env.VEXA_TEAMS_MAIN_AUDIO_SILENCE_MS || 20000),
mainAudioEnergyRms: Number(process.env.VEXA_TEAMS_MAIN_AUDIO_ENERGY_RMS || 0.006) }).catch((e) => {
mainAudioEnergyRms: Number(process.env.VEXA_TEAMS_MAIN_AUDIO_ENERGY_RMS || 0.006),
// The measured inactivity window — a parameter the sensor accepts, never a fork of it.
csrcInactiveMs: CSRC_RESOLVED_INACTIVE_MS }).catch((e) => {
console.error(`[bot] capture bridge: page-side start failed: ${String(e)}`); // L4: surfaces only on the VM
});

Expand Down
107 changes: 103 additions & 4 deletions core/meetings/services/bot/src/csrc-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,19 @@
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import {
startCaptureBridge, makeCsrcSink, makeObservationSink,
type CsrcRecord, type CsrcCapableSink, type ObservationRecord, type ObservationCapableSink,
import type {
CsrcRecord, CsrcCapableSink, ObservationRecord, ObservationCapableSink,
} from './capture-bridge.js';
import type { Invocation } from './config.js';
import type { BotPipeline } from './pipeline.js';

// HERMETIC: capture-bridge resolves VEXA_CSRC_INACTIVE_MS at module load, so an ambient override
// in the developer's shell would fail the 800ms assertions below. Scrub BEFORE the module loads —
// which is why this import is dynamic (a static one hoists above the delete).
delete process.env.VEXA_CSRC_INACTIVE_MS;
const { startCaptureBridge, makeCsrcSink, makeObservationSink, resolveCsrcInactiveMs } =
await import('./capture-bridge.js');

let failed = 0;
const check = (name: string, cond: boolean, detail?: string) => {
console.log(` ${cond ? '✅' : '❌'} ${name}${cond || !detail ? '' : ` — ${detail}`}`);
Expand Down Expand Up @@ -87,10 +93,57 @@ const check = (name: string, cond: boolean, detail?: string) => {
warnings.some((w) => w.includes('observation-clock-skew')) && stored[2].t >= t, warnings.join(' | '));
}

// ── the window is resolved from env, and garbage never reaches the sensor ───────────────────────
// The sensor resolves its option with `??`, which passes NaN through, and a NaN window makes BOTH
// of its comparisons false: every source is re-deactivated on every poll — the fragmentation this
// value exists to end, worse and silent. So an unusable override falls back rather than propagating.
{
check('window: no override ⇒ the measured 800ms', resolveCsrcInactiveMs(undefined) === 800,
String(resolveCsrcInactiveMs(undefined)));
check('window: a usable override wins', resolveCsrcInactiveMs('1200') === 1200,
String(resolveCsrcInactiveMs('1200')));
check('window: garbage falls back instead of becoming NaN',
resolveCsrcInactiveMs('abc') === 800, String(resolveCsrcInactiveMs('abc')));
check('window: zero, negative and empty fall back too — none of them is a window',
resolveCsrcInactiveMs('0') === 800 && resolveCsrcInactiveMs('-5') === 800 && resolveCsrcInactiveMs('') === 800,
`${resolveCsrcInactiveMs('0')}/${resolveCsrcInactiveMs('-5')}/${resolveCsrcInactiveMs('')}`);
// A window shorter than one poll is stale before the next tick — the NaN failure mode wearing an
// ordinary number. It is rejected at the floor, not accepted because it happens to be positive.
check('window: shorter than one 100ms poll is rejected, one poll exactly is kept',
resolveCsrcInactiveMs('1') === 800 && resolveCsrcInactiveMs('99') === 800 && resolveCsrcInactiveMs('100') === 100,
`${resolveCsrcInactiveMs('1')}/${resolveCsrcInactiveMs('99')}/${resolveCsrcInactiveMs('100')}`);
// The ceiling is the floor's mirror: a fat-fingered 600000 would hold every source active for
// ten minutes and synthesize essentially no deactivations — the NaN failure mode wearing an
// ordinary number, equally silent. 1600ms already merges turns; 10s is generous headroom.
check('window: a huge override is rejected at the ceiling, ten seconds exactly is kept',
resolveCsrcInactiveMs('600000') === 800 && resolveCsrcInactiveMs('10001') === 800
&& resolveCsrcInactiveMs('10000') === 10000,
`${resolveCsrcInactiveMs('600000')}/${resolveCsrcInactiveMs('10001')}/${resolveCsrcInactiveMs('10000')}`);
{
const warnings: string[] = [];
const used = resolveCsrcInactiveMs('abc', (m) => warnings.push(m));
check('window: a rejected override is SAID OUT LOUD, never silently ignored',
used === 800 && warnings.length === 1 && warnings[0]!.includes('VEXA_CSRC_INACTIVE_MS="abc"'),
JSON.stringify(warnings));
const quiet: string[] = [];
resolveCsrcInactiveMs(undefined, (m) => quiet.push(m));
resolveCsrcInactiveMs('1200', (m) => quiet.push(m));
check('window: no override and a good override are both silent — only a REJECTION warns',
quiet.length === 0, JSON.stringify(quiet));
// An empty override is what a half-rendered deploy template looks like. Treating it as "not
// set" is how a knob gets ignored in silence — the exact failure the warning exists to prevent.
const blank: string[] = [];
check('window: an override SET to empty or blank warns too — it is an intent, not an absence',
resolveCsrcInactiveMs('', (m) => blank.push(m)) === 800
&& resolveCsrcInactiveMs(' ', (m) => blank.push(m)) === 800
&& blank.length === 2, JSON.stringify(blank));
}
}

// ── The real bundle (built by build-browser-utils.mjs — turbo test depends on build) ─────────────
const BUNDLE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'browser-utils.global.js');
if (!existsSync(BUNDLE)) {
console.error(`❌ missing ${BUNDLE} — build the capture bundle first (pnpm --filter @vexa/bot build).`);
console.error(`❌ missing ${BUNDLE} — build the capture bricks first (pnpm --filter @vexa/mixed-capture-core --filter @vexa/bot build).`);
process.exit(1);
}

Expand Down Expand Up @@ -138,6 +191,19 @@ const utils = g.VexaBrowserUtils as Record<string, unknown> | undefined;
check('bundle: window.VexaBrowserUtils.createCsrcPoll is exported (RED at base — brick not bundled)',
typeof utils?.createCsrcPoll === 'function', `keys: ${Object.keys(utils ?? {}).join(',')}`);

// Capture the options the REAL bridge hands the REAL sensor factory. The window the production
// path runs on is not observable from a transition alone (only from WHEN one is absent), and the
// number itself is owned by the composition root, not by the sensor — so it is asserted here, on
// the object that actually crosses. RED when the bridge passes no `inactiveMs` at all.
type CsrcPollOpts = { inactiveMs?: number; pollMs?: number; now?: () => number; timeOrigin?: () => number;
receivers?: () => unknown[]; onTransition: (t: { csrc: number; active: boolean; tMs: number }) => void };
const realCreateCsrcPoll = utils!.createCsrcPoll as (o: CsrcPollOpts) => unknown;
let productionCsrcOpts: CsrcPollOpts | undefined;
(utils as Record<string, unknown>).createCsrcPoll = (o: CsrcPollOpts): unknown => {
productionCsrcOpts = o;
return realCreateCsrcPoll(o);
};

// ── Fake Playwright Page + the Node seams ───────────────────────────────────────────────────────
const page = {
async exposeFunction(name: string, fn: unknown): Promise<void> { g[name] = fn; },
Expand Down Expand Up @@ -213,8 +279,41 @@ check('the spine and the stored sidecar agree on WHEN each edge happened',
check('isolation: NO transition reached pipeline.recordHint — a csrc is an id, never a name',
hints.length === 0, JSON.stringify(hints));

// ── the MEASURED inactivity window reaches the sensor ────────────────────────────────────────────
// 400 ms — the sensor's own default — is shorter than the median natural speech pause (measured
// p50 550 ms), so a turn fragments into 1.67 lane activations. The composition root passes the
// measured 800 ms instead. Asserted twice: the value that crossed, and what that value DOES.
check('the bridge passes an explicit inactivity window to the sensor (RED at base: undefined)',
productionCsrcOpts?.inactiveMs === 800, `inactiveMs=${String(productionCsrcOpts?.inactiveMs)}`);
{
// Re-drive the sensor over the PRODUCTION options object — same `inactiveMs`, with only the
// clock and the receivers replaced, so the window is proven by behaviour and not by a number.
let t = 1_900_000_000_000;
const edges: Array<{ active: boolean }> = [];
let speaking = true;
let lastSpoke = t;
const poll = realCreateCsrcPoll({
...productionCsrcOpts!,
onTransition: (x) => edges.push({ active: x.active }),
now: () => t,
timeOrigin: () => 0,
receivers: () => [{ track: { kind: 'audio' },
getContributingSources: () => [{ source: 5, timestamp: speaking ? t : lastSpoke, audioLevel: 0.4 }] }],
}) as { poll(): void; destroy(): void };
poll.poll();
speaking = false;
t += 500; poll.poll();
check('a 500ms pause — past the sensor default, inside the measured window — does NOT close the turn',
edges.length === 1 && edges[0].active === true, JSON.stringify(edges));
t += 400; poll.poll();
check('past the measured window the deactivation is synthesized',
edges.length === 2 && edges[1].active === false, JSON.stringify(edges));
poll.destroy();
}

(g as any).setInterval = realSetInterval;
(g as any).clearInterval = realClearInterval;
(utils as Record<string, unknown>).createCsrcPoll = realCreateCsrcPoll;
g.document = savedDocument;

if (failed) { console.error(`\n❌ csrc-wiring: ${failed} checks FAILED.`); process.exit(1); }
Expand Down
4 changes: 4 additions & 0 deletions core/runtime/src/runtime_kernel/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ def default_registry() -> ProfileRegistry:
"BOT_SPEAKER_CONFIRM_THRESHOLD",
"BOT_SPEAKER_MAX_BUFFER_SEC",
"BOT_SPEAKER_IDLE_TIMEOUT_SEC",
# CSRC transport-sensor inactivity window (capture-bridge resolves and guards it) —
# without this forwarding, an operator's override on the runtime container would
# silently never reach the spawned bot process.
"VEXA_CSRC_INACTIVE_MS",
)
if os.environ.get(key, "").strip()
}
Expand Down
4 changes: 4 additions & 0 deletions core/runtime/tests/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,16 @@ def test_meeting_bot_forwards_speaker_stream_tuning(monkeypatch):
monkeypatch.setenv("BOT_ALONE_SILENCE_WINDOW_MS", "60000")
monkeypatch.setenv("BOT_SPEAKER_MIN_AUDIO_SEC", "1")
monkeypatch.setenv("BOT_SPEAKER_CONFIRM_THRESHOLD", "1")
monkeypatch.setenv("VEXA_CSRC_INACTIVE_MS", "1200")
monkeypatch.delenv("BOT_SPEAKER_SUBMIT_INTERVAL_SEC", raising=False)
reg = default_registry()
assert reg.get("meeting-bot").base_env == {
"BOT_ALONE_SILENCE_WINDOW_MS": "60000",
"BOT_SPEAKER_MIN_AUDIO_SEC": "1",
"BOT_SPEAKER_CONFIRM_THRESHOLD": "1",
# the CSRC window override must actually reach the spawned bot's process env —
# capture-bridge guards the value; the kernel's job is only to carry it
"VEXA_CSRC_INACTIVE_MS": "1200",
}


Expand Down
7 changes: 7 additions & 0 deletions docs/changelog.d/1385-csrc-inactive-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **Teams CSRC lanes stop fragmenting on natural speech pauses (#1383).** The transport sensor's
400 ms inactivity window spans a packet gap, not a speech pause — the median pause (p50 ≈ 550 ms)
already tripped a synthesized deactivation, splitting one speaker turn into ~1.7 lane activations.
The composition root now passes the measured 800 ms window (the knee: 1.13 activations/turn),
overridable via `VEXA_CSRC_INACTIVE_MS` where the bot process env is operator-controlled; an
unusable override (empty, NaN, below one 100 ms poll, above 10 s) warns and falls back to the
measured default instead of silently poisoning the sensor.
Loading