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
5 changes: 5 additions & 0 deletions .changeset/quiet-rotate-segment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

fix(transcription): quiet the `rotateSegment` overlap warning. The single-overlap case (one rotation queued behind another) is expected at normal turn boundaries — rotations are safely serialized via `oldTask.result`. Track the queue depth instead and only warn when more than one rotation is stacked behind the in-flight one, and additionally suppress the warn during the synchronizer's startup window: production data shows the room-connection-state-changed event can stack two extra rotations onto the constructor-scheduled initial task, producing a benign depth=2 chain that drains before any audio is produced. After the initial task resolves, real mid-conversation backlogs still trip the warn.
59 changes: 59 additions & 0 deletions agents/src/voice/transcription/synchronizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,62 @@ describe('TranscriptionSynchronizer attachment warnings', () => {
await synchronizer.close();
});
});

describe('TranscriptionSynchronizer rotateSegment backlog warn', () => {
const backlogWarnPrefix = 'rotateSegment backlog:';

afterEach(() => {
vi.restoreAllMocks();
});

it('does not warn while the initial constructor-scheduled rotation is in flight', async () => {
const warn = vi.fn();
vi.spyOn(logModule, 'log').mockReturnValue({
warn,
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof logModule.log>);

const synchronizer = new TranscriptionSynchronizer(new MockAudioOutput(), new MockTextOutput());
// Force two extra rotateSegment calls synchronously, before the initial task can settle.
// This reproduces the production startup race (room connection + handoff stacking
// rotations onto the initial constructor-scheduled task).
synchronizer.audioOutput.onDetached();
synchronizer.textOutput.onDetached();

expect(
warn.mock.calls.filter((c) => typeof c[0] === 'string' && c[0].startsWith(backlogWarnPrefix)),
).toHaveLength(0);

await synchronizer.close();
});

it('warns when the chain stacks beyond depth 1 after the initial rotation has settled', async () => {
const warn = vi.fn();
vi.spyOn(logModule, 'log').mockReturnValue({
warn,
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof logModule.log>);

const synchronizer = new TranscriptionSynchronizer(new MockAudioOutput(), new MockTextOutput());

// Let the initial constructor-scheduled task drain so we leave the startup window.
await synchronizer.barrier();

// Now simulate a real mid-conversation backlog: three rotateSegment calls back to back
// so two end up queued behind the in-flight one.
synchronizer.audioOutput.onDetached();
synchronizer.audioOutput.onAttached();
synchronizer.audioOutput.onDetached();

const backlogWarns = warn.mock.calls.filter(
(c) => typeof c[0] === 'string' && c[0].startsWith(backlogWarnPrefix),
);
expect(backlogWarns.length).toBeGreaterThanOrEqual(1);

await synchronizer.close();
});
});
49 changes: 40 additions & 9 deletions agents/src/voice/transcription/synchronizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,16 @@ export class TranscriptionSynchronizer {

private options: TextSyncOptions;
private rotateSegmentTask: Task<void>;
// number of rotations queued behind the currently-running one; used to warn only
// when the backlog grows beyond a single expected overlap
private queuedRotations: number = 0;
// The constructor schedules an initial rotation task. During session startup the room
// connection + agent handoff can fire two more rotateSegment calls before that initial
// task drains, producing a benign depth=2 chain that does not affect the caller (the
// chain settles before any audio is produced). Suppress the backlog warn until the
// initial task has resolved at least once so it only fires on real mid-conversation
// backlogs.
private initialRotationDone: boolean = false;
private _outputsAttached: boolean = true;
private closed: boolean = false;

Expand Down Expand Up @@ -598,7 +608,17 @@ export class TranscriptionSynchronizer {
}

if (!this.rotateSegmentTask.done) {
this.logger.warn('rotateSegment called while previous segment is still being rotated');
// The new task chains on the old one via `oldTask.result`, so rotations are
// serialized and no transcript data is lost. A single overlap is expected when
// turn-boundary events (playback finished, attach/detach, new utterance) fire
// back-to-back; only warn once the backlog grows beyond one queued rotation, and
// skip the warn during the synchronizer's startup window (see initialRotationDone).
this.queuedRotations++;
if (this.queuedRotations > 1 && this.initialRotationDone) {
this.logger.warn(
`rotateSegment backlog: ${this.queuedRotations} rotations queued behind the in-flight one`,
);
}
}
this.rotateSegmentTask = Task.from((controller) =>
this.rotateSegmentTaskImpl(controller.signal, this.rotateSegmentTask),
Expand All @@ -619,16 +639,27 @@ export class TranscriptionSynchronizer {
}

private async rotateSegmentTaskImpl(abort: AbortSignal, oldTask?: Task<void>) {
if (oldTask) {
await oldTask.result;
}
try {
if (oldTask) {
await oldTask.result;
}

if (abort.aborted) {
return;
}
if (abort.aborted) {
return;
}

await this._impl.close();
this._impl = new SegmentSynchronizerImpl(this.options, this.textOutput.nextInChain, true);
await this._impl.close();
this._impl = new SegmentSynchronizerImpl(this.options, this.textOutput.nextInChain, true);
} finally {
if (this.queuedRotations > 0) {
this.queuedRotations--;
}
// Set synchronously inside the task body so that by the time `task.result`
// resolves, the flag is already true for any continuation (including
// `barrier()` callers). Using `Task.addDoneCallback` for this would be
// fragile against microtask reordering.
this.initialRotationDone = true;
}
}
}

Expand Down
Loading