@@ -7,6 +7,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
77import { APIError } from '../_exceptions.js' ;
88import { initializeLogger } from '../log.js' ;
99import type { APIConnectOptions } from '../types.js' ;
10+ import { USERDATA_TTS_STARTED_TIME } from '../types.js' ;
1011import { FallbackAdapter } from './fallback_adapter.js' ;
1112import { 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 {
7398class 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} ) ;
0 commit comments