diff --git a/.changeset/quiet-rotate-segment.md b/.changeset/quiet-rotate-segment.md new file mode 100644 index 000000000..6e9a3e5e4 --- /dev/null +++ b/.changeset/quiet-rotate-segment.md @@ -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. diff --git a/agents/src/voice/transcription/synchronizer.test.ts b/agents/src/voice/transcription/synchronizer.test.ts index 287cd3d3e..657512efc 100644 --- a/agents/src/voice/transcription/synchronizer.test.ts +++ b/agents/src/voice/transcription/synchronizer.test.ts @@ -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); + + 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); + + 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(); + }); +}); diff --git a/agents/src/voice/transcription/synchronizer.ts b/agents/src/voice/transcription/synchronizer.ts index 39872b716..703bdc36e 100644 --- a/agents/src/voice/transcription/synchronizer.ts +++ b/agents/src/voice/transcription/synchronizer.ts @@ -518,6 +518,16 @@ export class TranscriptionSynchronizer { private options: TextSyncOptions; private rotateSegmentTask: Task; + // 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; @@ -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), @@ -619,16 +639,27 @@ export class TranscriptionSynchronizer { } private async rotateSegmentTaskImpl(abort: AbortSignal, oldTask?: Task) { - 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; + } } }