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
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,14 @@ const receiver = (sources: () => ContributingSourceLike[]) => ({
t += 200; poll.poll();
check('inside the inactivity window a stale entry is NOT yet a deactivation',
out.length === 1, JSON.stringify(out));
t += 300; poll.poll(); // 500ms > 400ms default
t += 550; poll.poll(); // 750ms staleness — just inside the 800ms window
check('just inside the window the entry is STILL held open (the boundary, not the middle)',
out.length === 1, JSON.stringify(out));
t += 100; poll.poll(); // 850ms > 800ms default
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);
check('the default inactivity window is 800ms', CSRC_INACTIVE_MS === 800);

// Speaking again is a NEW activation — turns are edges, not a level.
speaking = true; lastSpoke = t; poll.poll();
Expand Down Expand Up @@ -117,7 +120,7 @@ const receiver = (sources: () => ContributingSourceLike[]) => ({
JSON.stringify(out));
check('health counts both as active', poll.health().active === 2, JSON.stringify(poll.health()));
live.delete(11);
t += 500; poll.poll();
t += 900; poll.poll();
check('only the source that went quiet deactivates; the other stays open',
out.length === 3 && out[2].csrc === 11 && out[2].active === false && poll.health().active === 1,
JSON.stringify(out));
Expand Down
11 changes: 8 additions & 3 deletions core/meetings/modules/mixed-capture-core/src/csrc-poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export interface CsrcPollOptions {
receivers?: () => CsrcReceiverLike[];
/** Poll cadence in ms. Default 100 — the granularity the transport itself updates at. */
pollMs?: number;
/** How long a source stays active after its last observed contribution. Default 400 ms. */
/** How long a source stays active after its last observed contribution. Default 800 ms. */
inactiveMs?: number;
/** Epoch-ms clock (injectable for tests). */
now?: () => number;
Expand All @@ -133,8 +133,13 @@ export interface CsrcPoll {

/** The transport updates roughly per packet; 100 ms is one packet-train, not an arbitrary tick. */
export const CSRC_POLL_MS = 100;
/** Chosen to span a packet gap (jitter, DTX, a brief pause) without holding a finished turn open. */
export const CSRC_INACTIVE_MS = 400;
/** Chosen to span a packet gap (jitter, DTX, a brief pause) without holding a finished turn open.
* Measured on a live Microsoft Teams meeting (2026-08-20): replaying this transition logic over a
* 599-sample tape of a 6 s speech / 7 s silence cycle yields 10 deactivations at 400 ms —
* fragmenting one speech phase into segments as short as 0.1 s — against 7 at 800 ms, where the
* reconstructed segments match the fixture's real structure. Teams' mixer pauses inside a turn
* for longer than one packet-train, so 400 ms closes turns that are still open. */
Comment on lines +137 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move capture history out of source

The date, sample count, 400→800 comparison, and “real structure” assertion are a capture's historical evidence rather than the enduring contract for this exported constant. Leaving them here will make the source stale and unverifiable on the next tuning change; record/replay evidence belongs in an evaluation fixture or delivery artifact, and this comment should retain only the present behavioral rationale.

AGENTS.md reference: AGENTS.md:L165-L166

Useful? React with 👍 / 👎.

export const CSRC_INACTIVE_MS = 800;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the mixed-lane handoff grace

Keep the combined delay unchanged for the non-Teams consumers of this shared default. startCaptureBridge supplies these transitions for every mixed platform, while createBotPipeline routes Zoom/Jitsi through ChunkedTranscriber/CsrcTurnSource, which adds its own 600 ms hysteresis. When A stops and B starts within the new 800 ms sensor window, A has no pending close when B's activation arrives, so the state machine opens a contested turn; B's first roughly 800 ms is therefore emitted as unattributed even though the speakers did not overlap (previously roughly 400 ms). Keep the global 400 ms/default total grace, or make 800 a Teams-only caller option, rather than globally extending this producer delay.

AGENTS.md reference: AGENTS.md:L156-L161

Useful? React with 👍 / 👎.

/** Beyond this, a timestamp is not the clock we think it is. Mirrors the bridge's hint guard. */
const MAX_CLOCK_SKEW_MS = 10 * 60 * 1000;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -651,8 +651,8 @@ export class ChunkedTranscriber {
if (!mine()) return;
if (this.turn && ev.tracks) this.turn.spanTracks = ev.tracks;
// A segmenter's speech-end lands a little early and needs a trailing STT pad; a transport
// deactivation already carries the sensor's own 400ms inactivity window, so padding it
// again would only feed Whisper more silence.
// deactivation already carries the sensor's own inactivity window (CSRC_INACTIVE_MS), so
// padding it again would only feed Whisper more silence.
this.closeTurn(ev.t1, ev.reason === 'silence' ? SILENCE_CLOSE_CONTEXT_MS : 0);
},
};
Expand Down
8 changes: 5 additions & 3 deletions core/meetings/modules/mixed-pipeline/src/turn-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,11 @@ const envNumber = (name: string, fallback: number): number => {
return Number.isFinite(n) && n > 0 ? n : fallback;
};

/** How long a source may go quiet before its turn is closed. The sensor already waits 400ms before
* declaring a deactivation, so this is the SECOND grace: it spans a breath, a DTX gap, a dropped
* packet train. Too small shatters a sentence into turns; too large merges a real handoff. */
/** How long a source may go quiet before its turn is closed. The sensor already waits
* CSRC_INACTIVE_MS (800 ms, measured on live Teams) before declaring a deactivation, so this is
* the SECOND grace: it spans a breath, a DTX gap, a dropped packet train. Too small shatters a
* sentence into turns; too large merges a real handoff. Combined worst-case close latency is the
* sum of both graces (~1.4 s at the defaults). */
export const CSRC_HYSTERESIS_MS = envNumber('VEXA_CSRC_HYSTERESIS_MS', 600);
/** Energetic audio this long with NOT ONE transition ⇒ the transport stopped talking to us while
* the meeting continued. Measured in audio time, on frames that carry energy, so an honestly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ async function main(): Promise<void> {
await page.evaluate('window.__fixtureSpeakingSince = performance.now();');
await sleep(400);
await page.evaluate('window.__fixtureSpeakingSince = null;');
await sleep(900); // > the 400ms inactivity window
await sleep(1300); // > the 800ms inactivity window, with real-clock margin
await stop();
await recorder.close();

Expand Down
Loading