@@ -12,46 +12,36 @@ import (
1212 "github.com/mudler/LocalAI/pkg/reasoning"
1313)
1414
15- // speechStreamer consumes streamed LLM tokens and drives the realtime output:
16- // it strips reasoning incrementally, emits a transcript text delta for each
17- // content fragment, and — when the pipeline streams TTS — sentence-pipes the
18- // content so each completed sentence is synthesized as soon as it's ready,
19- // overlapping generation, synthesis and playback.
20- //
21- // It is used only for plain-content turns (no tools): tool-call output can't be
22- // safely spoken mid-stream, so those turns keep the buffered path.
23- type speechStreamer struct {
15+ // transcriptStreamer turns streamed LLM tokens into the assistant's spoken
16+ // transcript: it strips reasoning incrementally and sends one
17+ // response.output_audio_transcript.delta per content fragment. It does NOT
18+ // synthesize audio — the caller buffers the full message and synthesizes it
19+ // once (streaming the audio chunks when the TTS backend supports TTSStream),
20+ // which works uniformly for streaming and non-streaming TTS and for languages
21+ // without sentence or word boundaries.
22+ type transcriptStreamer struct {
2423 ctx context.Context
2524 t Transport
26- session * Session
2725 responseID string
2826 itemID string
29-
30- extractor * reasoning.ReasoningExtractor
31- seg streamSegmenter
32- audio []byte
33- streamTTS bool
34- err error
27+ extractor * reasoning.ReasoningExtractor
3528}
3629
37- func newSpeechStreamer (ctx context.Context , t Transport , session * Session , responseID , itemID , thinkingStartToken string , reasoningCfg reasoning.Config ) * speechStreamer {
38- // Spoken output must never contain reasoning, even when disable_thinking set
39- // DisableReasoning (which would otherwise turn the extractor's stripping off).
40- reasoningCfg = spokenReasoningConfig (reasoningCfg )
41- return & speechStreamer {
30+ func newTranscriptStreamer (ctx context.Context , t Transport , responseID , itemID , thinkingStartToken string , reasoningCfg reasoning.Config ) * transcriptStreamer {
31+ return & transcriptStreamer {
4232 ctx : ctx ,
4333 t : t ,
44- session : session ,
4534 responseID : responseID ,
4635 itemID : itemID ,
47- extractor : reasoning .NewReasoningExtractor (thinkingStartToken , reasoningCfg ),
48- streamTTS : session .ModelConfig != nil && session .ModelConfig .Pipeline .StreamTTS (),
36+ extractor : reasoning .NewReasoningExtractor (thinkingStartToken , spokenReasoningConfig (reasoningCfg )),
4937 }
5038}
5139
52- // onToken handles one streamed LLM token. It is shaped to be used directly as
53- // the backend token callback's text sink.
54- func (s * speechStreamer ) onToken (token string ) {
40+ // onToken handles one streamed unit of model output, sending a transcript delta
41+ // for the new content (reasoning stripped). For plain-content models the unit is
42+ // the raw text token; for autoparser tool turns the backend clears the text and
43+ // delivers content via ChatDeltas, so the caller passes that content here.
44+ func (s * transcriptStreamer ) onToken (token string ) {
5545 _ , content := s .extractor .ProcessToken (token )
5646 if content == "" {
5747 return
@@ -64,41 +54,20 @@ func (s *speechStreamer) onToken(token string) {
6454 ContentIndex : 0 ,
6555 Delta : content ,
6656 })
67- if s .streamTTS {
68- for _ , segment := range s .seg .Push (content ) {
69- s .speak (segment )
70- }
71- }
7257}
7358
74- func (s * speechStreamer ) speak (text string ) {
75- pcm , err := emitSpeech (s .ctx , s .t , s .session , s .responseID , s .itemID , text )
76- if err != nil {
77- if s .err == nil {
78- s .err = err
79- }
80- return
81- }
82- s .audio = append (s .audio , pcm ... )
83- }
84-
85- // finish flushes any buffered sentence to TTS and returns the full cleaned
86- // content, the accumulated PCM audio, and the first error encountered (if any).
87- func (s * speechStreamer ) finish () (content string , audio []byte , err error ) {
88- if s .streamTTS {
89- if rem := s .seg .Flush (); rem != "" {
90- s .speak (rem )
91- }
92- }
93- return s .extractor .CleanedContent (), s .audio , s .err
59+ // content returns the full transcript so far with reasoning stripped.
60+ func (s * transcriptStreamer ) content () string {
61+ return s .extractor .CleanedContent ()
9462}
9563
9664// streamLLMResponse drives a streamed, plain-content (no tools) realtime reply.
97- // It announces the assistant item before tokens arrive, feeds the LLM token
98- // callback through a speechStreamer (transcript deltas + sentence-piped TTS),
99- // then emits the terminal events. It returns true when it has fully handled the
100- // response so the caller can return; callers must only invoke it for turns with
101- // no tools and an audio modality (see triggerResponseAtTurn).
65+ // It announces the assistant item before tokens arrive, streams transcript
66+ // deltas as the LLM generates, then synthesizes the whole buffered message once
67+ // (streaming the audio chunks when the TTS backend supports it, otherwise a
68+ // single unary delta) and emits the terminal events. It returns true when it has
69+ // fully handled the response so the caller can return; callers must only invoke
70+ // it for turns with no tools and an audio modality (see triggerResponseAtTurn).
10271func streamLLMResponse (ctx context.Context , session * Session , conv * Conversation , t Transport , responseID string , history schema.Messages , images []string , llmCfg * config.ModelConfig ) bool {
10372 // Announce the assistant item up front so streamed deltas target a known item.
10473 item := types.MessageItemUnion {
@@ -150,7 +119,7 @@ func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation
150119 }
151120 thinkingStartToken := reasoning .DetectThinkingStartToken (template , & llmCfg .ReasoningConfig )
152121
153- streamer := newSpeechStreamer (ctx , t , session , responseID , item .Assistant .ID , thinkingStartToken , llmCfg .ReasoningConfig )
122+ streamer := newTranscriptStreamer (ctx , t , responseID , item .Assistant .ID , thinkingStartToken , llmCfg .ReasoningConfig )
154123 cb := func (token string , _ backend.TokenUsage ) bool {
155124 if ctx .Err () != nil {
156125 return false
@@ -177,8 +146,16 @@ func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation
177146 return true
178147 }
179148
180- content , audio , err := streamer .finish ()
149+ // Buffer the whole message, then synthesize it once. emitSpeech streams the
150+ // audio chunks when the TTS backend supports TTSStream, otherwise it sends a
151+ // single unary delta — no per-sentence segmentation either way.
152+ content := streamer .content ()
153+ audio , err := emitSpeech (ctx , t , session , responseID , item .Assistant .ID , content )
181154 if err != nil {
155+ if ctx .Err () != nil {
156+ cancel ()
157+ return true
158+ }
182159 sendError (t , "tts_error" , fmt .Sprintf ("TTS generation failed: %v" , err ), "" , item .Assistant .ID )
183160 return true
184161 }
0 commit comments