@@ -25,7 +25,15 @@ import { log } from '../log.js';
2525import { IdentityTransform } from '../stream/identity_transform.js' ;
2626import { traceTypes , tracer } from '../telemetry/index.js' ;
2727import { USERDATA_TIMED_TRANSCRIPT } from '../types.js' ;
28- import { Future , Task , shortuuid , toError , waitForAbort } from '../utils.js' ;
28+ import {
29+ Future ,
30+ IdleTimeoutError ,
31+ Task ,
32+ shortuuid ,
33+ toError ,
34+ waitForAbort ,
35+ waitUntilTimeout ,
36+ } from '../utils.js' ;
2937import {
3038 type Agent ,
3139 type ModelSettings ,
@@ -46,6 +54,8 @@ import {
4654import { RunContext } from './run_context.js' ;
4755import type { SpeechHandle } from './speech_handle.js' ;
4856
57+ const TTS_READ_IDLE_TIMEOUT_MS = 10_000 ;
58+
4959/** @internal */
5060export class _LLMGenerationData {
5161 generatedText : string = '' ;
@@ -550,6 +560,7 @@ export function performTTSInference(
550560 model ?: string ,
551561 provider ?: string ,
552562) : [ Task < void > , _TTSGenerationData ] {
563+ const logger = log ( ) ;
553564 const audioStream = new IdentityTransform < AudioFrame > ( ) ;
554565 const outputWriter = audioStream . writable . getWriter ( ) ;
555566 const audioOutputStream = audioStream . readable ;
@@ -624,12 +635,15 @@ export function performTTSInference(
624635 // JS currently only does single inference, so initialPushedDuration is always 0.
625636 // TODO: Add FlushSentinel + multi-segment loop
626637 const initialPushedDuration = pushedDuration ;
627-
628638 while ( true ) {
629639 if ( signal . aborted ) {
630640 break ;
631641 }
632- const { done, value : frame } = await ttsStreamReader . read ( ) ;
642+
643+ const { done, value : frame } = await waitUntilTimeout (
644+ ttsStreamReader . read ( ) ,
645+ TTS_READ_IDLE_TIMEOUT_MS ,
646+ ) ;
633647 if ( done ) {
634648 break ;
635649 }
@@ -671,14 +685,15 @@ export function performTTSInference(
671685 pushedDuration += frameDuration ;
672686 }
673687 } catch ( error ) {
674- if ( error instanceof DOMException && error . name === 'AbortError' ) {
675- // Abort signal was triggered, handle gracefully
688+ if ( error instanceof IdleTimeoutError ) {
689+ logger . warn ( 'TTS stream stalled after producing audio, forcing close' ) ;
690+ } else if ( error instanceof DOMException && error . name === 'AbortError' ) {
676691 return ;
692+ } else {
693+ throw error ;
677694 }
678- throw error ;
679695 } finally {
680696 if ( ! timedTextsFut . done ) {
681- // Ensure downstream consumers don't hang on errors.
682697 timedTextsFut . resolve ( null ) ;
683698 }
684699 ttsStreamReader ?. releaseLock ( ) ;
@@ -773,9 +788,12 @@ async function forwardAudio(
773788 out : _AudioOut ,
774789 signal ?: AbortSignal ,
775790) : Promise < void > {
791+ const logger = log ( ) ;
776792 const reader = ttsStream . getReader ( ) ;
777793 let resampler : AudioResampler | null = null ;
778794
795+ const FORWARD_AUDIO_IDLE_TIMEOUT_MS = 10_000 ;
796+
779797 const onPlaybackStarted = ( ev : { createdAt : number } ) => {
780798 if ( ! out . firstFrameFut . done ) {
781799 out . firstFrameFut . resolve ( ev . createdAt ) ;
@@ -791,7 +809,10 @@ async function forwardAudio(
791809 break ;
792810 }
793811
794- const { done, value : frame } = await reader . read ( ) ;
812+ const { done, value : frame } = await waitUntilTimeout (
813+ reader . read ( ) ,
814+ FORWARD_AUDIO_IDLE_TIMEOUT_MS ,
815+ ) ;
795816 if ( done ) break ;
796817
797818 out . audio . push ( frame ) ;
@@ -819,6 +840,12 @@ async function forwardAudio(
819840 await audioOutput . captureFrame ( f ) ;
820841 }
821842 }
843+ } catch ( e ) {
844+ if ( e instanceof IdleTimeoutError ) {
845+ logger . warn ( 'audio forwarding stalled waiting for TTS frames, forcing close' ) ;
846+ } else {
847+ throw e ;
848+ }
822849 } finally {
823850 audioOutput . off ( AudioOutput . EVENT_PLAYBACK_STARTED , onPlaybackStarted ) ;
824851
0 commit comments