Skip to content

Commit a0144bb

Browse files
fix(tts): correct ttfb attribution (#1955)
Co-authored-by: rosetta-livekit-bot[bot] <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Co-authored-by: David Zhao <dz@livekit.io>
1 parent 42a5355 commit a0144bb

25 files changed

Lines changed: 592 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
Fix duplicated user chat items in observability: a superseding EOU bounce created while an earlier bounce was mid-commit could fire after the transcript was already committed and cleared, committing a second empty user turn with stale metrics. The transcript guard is now re-checked at fire time. Also, session-report chat items now only upload string content (matching Python), so non-string content can no longer render as garbage in the dashboard.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
Fix TTS TTFB attribution: `tts_node` TTFB is now anchored on the time the first sentence is sent to the TTS provider instead of the time the first LLM token arrives, so upstream text generation and tokenization latency is no longer counted as TTS latency.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents-plugin-neuphonic': patch
3+
---
4+
5+
Batch streamed text into sentences before sending it to the Neuphonic websocket (matching the Python plugin), with a configurable `tokenizer` option. This also anchors TTS TTFB on the first sentence sent to the provider instead of the first LLM token.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
Trim the chat context recorded on the `eou_detection` span to the last 6 items and exclude function calls, instructions, empty messages, handoffs, and config updates (matching Python), so the span no longer re-emits the whole conversation on every end-of-turn inference.

agents/src/inference/tts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,7 @@ export class SynthesizeStream<TModel extends TTSModels> extends BaseSynthesizeSt
617617
if (this.opts.model) generationConfig.model = this.opts.model;
618618
if (this.opts.language) generationConfig.language = this.opts.language;
619619

620+
this.markStarted();
620621
await sendClientEvent(
621622
{
622623
type: 'input_transcript',

agents/src/telemetry/traces.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -410,9 +410,15 @@ function chatItemToProto(item: ChatItem): ProtoChatItem {
410410
const msg: ProtoMessage = {
411411
id: item.id,
412412
role: ROLE_MAP[item.role] ?? (item.role.toUpperCase() as ProtoRole),
413-
content: item.content.map((c: ChatContent) => ({
414-
text: isInstructions(c) ? c.value : c,
415-
})),
413+
// Match Python's `_build_proto_chat_item`: only string content is uploaded.
414+
// Non-string content (image/audio) must not leak into the wire format —
415+
// the ChatContent proto's `text` field is a string, and non-string values
416+
// render as garbage in the dashboard.
417+
content: item.content
418+
.filter((c: ChatContent) => typeof c === 'string' || isInstructions(c))
419+
.map((c) => ({
420+
text: isInstructions(c) ? c.value : (c as string),
421+
})),
416422
createdAt: toRFC3339(item.createdAt),
417423
};
418424

agents/src/tts/fallback_adapter.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
77
import { APIError } from '../_exceptions.js';
88
import { initializeLogger } from '../log.js';
99
import type { APIConnectOptions } from '../types.js';
10+
import { USERDATA_TTS_STARTED_TIME } from '../types.js';
1011
import { FallbackAdapter } from './fallback_adapter.js';
1112
import { ChunkedStream, SynthesizeStream, TTS } from './tts.js';
1213

@@ -23,8 +24,31 @@ class MockSynthesizeStream extends SynthesizeStream {
2324
super(mockTts, connOptions);
2425
}
2526

27+
// Simulate sending text to the provider, like a real plugin does right
28+
// before ws.send(): optionally delay (sentence buffering / connection
29+
// setup), then mark the started time and record it for assertions.
30+
private async sendToProvider(): Promise<void> {
31+
if (this.mockTts.sendDelayMs > 0) {
32+
await new Promise((resolve) => setTimeout(resolve, this.mockTts.sendDelayMs));
33+
}
34+
this.markStarted();
35+
this.mockTts.lastMarkedTime = this.startedTime?.time;
36+
}
37+
2638
protected async run(): Promise<void> {
2739
if (this.shouldFail) {
40+
if (this.mockTts.failAfterInput) {
41+
// Simulate a provider that receives text but dies before emitting
42+
// any audio: the started time it recorded must still anchor the
43+
// fallback adapter's TTFB.
44+
for await (const data of this.input) {
45+
if (this.abortController.signal.aborted) break;
46+
if (data === SynthesizeStream.FLUSH_SENTINEL) continue;
47+
await this.sendToProvider();
48+
break;
49+
}
50+
throw new APIError('mock TTS failed after receiving input');
51+
}
2852
// Throw immediately, before any pushText has been called.
2953
// This is the scenario that previously deadlocked the FallbackAdapter:
3054
// the inner stream's mainTask finishes before forwardBufferToTTS gets
@@ -37,6 +61,7 @@ class MockSynthesizeStream extends SynthesizeStream {
3761
for await (const data of this.input) {
3862
if (this.abortController.signal.aborted) break;
3963
if (data === SynthesizeStream.FLUSH_SENTINEL) continue;
64+
await this.sendToProvider();
4065
this.queue.put({
4166
requestId: 'mock-req',
4267
segmentId: 'mock-seg',
@@ -73,6 +98,12 @@ class MockChunkedStream extends ChunkedStream {
7398
class MockTTS extends TTS {
7499
label: string;
75100
shouldFail = false;
101+
/** When failing, first consume a token (and mark started) before throwing. */
102+
failAfterInput = false;
103+
/** Simulated latency between receiving text and sending it to the provider. */
104+
sendDelayMs = 0;
105+
/** The started time the stream recorded when it "sent" text to the provider. */
106+
lastMarkedTime?: number;
76107

77108
constructor(label: string, sampleRate: number = SAMPLE_RATE) {
78109
super(sampleRate, 1, { streaming: true });
@@ -227,4 +258,114 @@ describe('TTS FallbackAdapter', () => {
227258

228259
await adapter.close();
229260
});
261+
262+
it('anchors ttfb on the time text was sent to the provider, not when it was pushed', async () => {
263+
const primary = new MockTTS('primary');
264+
// Simulate sentence buffering / connection latency between the text being
265+
// pushed to the TTS node and it actually being sent to the provider.
266+
primary.sendDelayMs = 120;
267+
const adapter = new FallbackAdapter({
268+
ttsInstances: [primary],
269+
maxRetryPerTTS: 0,
270+
recoveryDelayMs: 60_000,
271+
});
272+
273+
const stream = adapter.stream();
274+
const pushTime = performance.now() / 1000;
275+
stream.updateInputStream(
276+
new ReadableStream<string>({
277+
start(controller) {
278+
controller.enqueue('hello world');
279+
controller.close();
280+
},
281+
}),
282+
);
283+
284+
const startedTimes = new Set<unknown>();
285+
for await (const event of stream) {
286+
if (event === SynthesizeStream.END_OF_STREAM) break;
287+
startedTimes.add(event.frame.userdata[USERDATA_TTS_STARTED_TIME]);
288+
}
289+
290+
expect(startedTimes.size).toBe(1);
291+
const startedTime = [...startedTimes][0];
292+
// the stamp must be the exact time the underlying stream sent the text to
293+
// the provider, so the send delay is excluded from downstream TTFB
294+
expect(startedTime).toBe(primary.lastMarkedTime);
295+
expect(startedTime as number).toBeGreaterThanOrEqual(pushTime + 0.1);
296+
297+
stream.close();
298+
await adapter.close();
299+
});
300+
301+
it('keeps the ttfb anchor from a provider that failed after receiving text', async () => {
302+
const primary = new MockTTS('primary');
303+
primary.shouldFail = true;
304+
primary.failAfterInput = true;
305+
const secondary = new MockTTS('secondary');
306+
secondary.sendDelayMs = 50;
307+
const adapter = new FallbackAdapter({
308+
ttsInstances: [primary, secondary],
309+
maxRetryPerTTS: 0,
310+
recoveryDelayMs: 60_000,
311+
});
312+
313+
const stream = adapter.stream();
314+
stream.updateInputStream(
315+
new ReadableStream<string>({
316+
start(controller) {
317+
controller.enqueue('hello world');
318+
controller.close();
319+
},
320+
}),
321+
);
322+
323+
const startedTimes = new Set<unknown>();
324+
for await (const event of stream) {
325+
if (event === SynthesizeStream.END_OF_STREAM) break;
326+
startedTimes.add(event.frame.userdata[USERDATA_TTS_STARTED_TIME]);
327+
}
328+
329+
// the fallback adapter is measured as a single TTS node: the anchor stays
330+
// on the first provider that received the text — even though it failed
331+
// before emitting audio — so failover time counts towards TTFB
332+
expect(primary.lastMarkedTime).toBeDefined();
333+
expect(secondary.lastMarkedTime).toBeDefined();
334+
expect(startedTimes.size).toBe(1);
335+
const startedTime = [...startedTimes][0];
336+
expect(startedTime).toBe(primary.lastMarkedTime);
337+
expect(startedTime as number).toBeLessThan(secondary.lastMarkedTime!);
338+
339+
stream.close();
340+
await adapter.close();
341+
});
342+
343+
it('stamps chunked synthesis with the submission time, kept across failover', async () => {
344+
const primary = new MockTTS('primary');
345+
primary.shouldFail = true;
346+
const secondary = new MockTTS('secondary');
347+
const adapter = new FallbackAdapter({
348+
ttsInstances: [primary, secondary],
349+
maxRetryPerTTS: 0,
350+
recoveryDelayMs: 60_000,
351+
});
352+
353+
const submitTime = performance.now() / 1000;
354+
const chunked = adapter.synthesize('hello world');
355+
356+
const startedTimes = new Set<unknown>();
357+
for await (const event of chunked) {
358+
startedTimes.add(event.frame.userdata[USERDATA_TTS_STARTED_TIME]);
359+
}
360+
361+
// the full text is submitted at creation time; failing over to the
362+
// secondary must not move the anchor
363+
expect(startedTimes.size).toBe(1);
364+
const startedTime = [...startedTimes][0] as number;
365+
expect(typeof startedTime).toBe('number');
366+
expect(startedTime).toBeGreaterThanOrEqual(submitTime);
367+
expect(startedTime).toBeLessThan(submitTime + 0.1);
368+
369+
await adapter.close();
370+
});
230371
});

agents/src/tts/fallback_adapter.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,12 @@ class FallbackSynthesizeStream extends SynthesizeStream {
452452
}
453453
const resampler = this.adapter.createResamplerForTTS(i);
454454

455+
// ttfb measures the fallback adapter as a whole: anchor on the first
456+
// time a sentence was handed to any underlying TTS — even one that
457+
// failed before emitting audio — and never overwrite it when falling
458+
// back to another TTS (markStarted only takes effect once).
459+
let captureStartedTime: () => void = () => {};
460+
455461
try {
456462
this._logger.debug({ tts: originalTts.label }, 'attempting TTS stream');
457463

@@ -463,6 +469,13 @@ class FallbackSynthesizeStream extends SynthesizeStream {
463469
const stream = tts.stream({ connOptions });
464470
let bufferIndex = 0;
465471
let streamOutputCompleted = false;
472+
473+
captureStartedTime = () => {
474+
const startedTime = stream.startedTime;
475+
if (startedTime !== undefined) {
476+
this.markStarted(startedTime);
477+
}
478+
};
466479
const forwardBufferToTTS = async () => {
467480
while (true) {
468481
while (bufferIndex < this.tokenBuffer.length) {
@@ -505,6 +518,7 @@ class FallbackSynthesizeStream extends SynthesizeStream {
505518
}
506519

507520
sawRawAudio = true;
521+
captureStartedTime();
508522

509523
if (resampler) {
510524
for (const frame of resampler.push(audio.frame)) {
@@ -593,6 +607,9 @@ class FallbackSynthesizeStream extends SynthesizeStream {
593607
throw error;
594608
}
595609
} finally {
610+
// the stream may have received text and failed before emitting audio;
611+
// its started time must still anchor the fallback's ttfb
612+
captureStartedTime();
596613
resampler?.close();
597614
}
598615
}

agents/src/tts/stream_adapter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export class StreamAdapterWrapper extends SynthesizeStream {
108108
prevTask: Task<void> | undefined,
109109
controller: AbortController,
110110
) => {
111+
this.markStarted();
111112
const audioStream = this.#tts.synthesize(token, this.connOptions, this.abortSignal);
112113

113114
// wait for previous audio transcription to complete before starting

0 commit comments

Comments
 (0)