Skip to content

Commit 076dcdb

Browse files
committed
refactor(realtime): buffer whole message for TTS, drop sentence segmenter
Per review (richiejp): the sentence segmenter pipelined unary TTS by splitting on ASCII .!?/newline, which does nothing for languages without those boundaries (CJK/Thai) — there it already degraded to buffering the whole message anyway. Replace it with a uniform model: stream the LLM transcript live, buffer the full message, then synthesize it once. emitSpeech already streams the audio chunks when the backend implements TTSStream and falls back to a single unary delta otherwise, so this is real streaming TTS where supported and a clean whole-message synthesis elsewhere — no per-sentence emulation, no language assumptions. speechStreamer becomes transcriptStreamer (transcript deltas only); the whole-message synthesis moves into streamLLMResponse. Assisted-by: Claude:claude-opus-4-8 go test, golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 9ec1456 commit 076dcdb

5 files changed

Lines changed: 102 additions & 212 deletions

File tree

core/http/endpoints/openai/realtime_doubles_test.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ type fakeModel struct {
7474

7575
transcribeDeltas []string
7676
transcribeFinal *schema.TranscriptionResult
77+
78+
// Predict streaming: predictTokens are replayed through the token callback
79+
// (simulating streamed LLM output); predictResp/predictErr are returned by
80+
// the deferred predict function. predictChunkDeltas, when set, are delivered
81+
// per-token via TokenUsage.ChatDeltas to exercise the autoparser path.
82+
predictTokens []string
83+
predictChunkDeltas [][]*proto.ChatDelta
84+
predictResp backend.LLMResponse
85+
predictErr error
7786
}
7887

7988
func (m *fakeModel) VAD(context.Context, *schema.VADRequest) (*schema.VADResponse, error) {
@@ -84,8 +93,23 @@ func (m *fakeModel) Transcribe(context.Context, string, string, bool, bool, stri
8493
return m.transcribeFinal, nil
8594
}
8695

87-
func (m *fakeModel) Predict(context.Context, schema.Messages, []string, []string, []string, func(string, backend.TokenUsage) bool, []types.ToolUnion, *types.ToolChoiceUnion, *int, *int, map[string]float64) (func() (backend.LLMResponse, error), error) {
88-
return nil, nil
96+
func (m *fakeModel) Predict(_ context.Context, _ schema.Messages, _, _, _ []string, cb func(string, backend.TokenUsage) bool, _ []types.ToolUnion, _ *types.ToolChoiceUnion, _, _ *int, _ map[string]float64) (func() (backend.LLMResponse, error), error) {
97+
if m.predictErr != nil {
98+
return nil, m.predictErr
99+
}
100+
return func() (backend.LLMResponse, error) {
101+
for i, tok := range m.predictTokens {
102+
if cb == nil {
103+
continue
104+
}
105+
usage := backend.TokenUsage{}
106+
if i < len(m.predictChunkDeltas) {
107+
usage.ChatDeltas = m.predictChunkDeltas[i]
108+
}
109+
cb(tok, usage)
110+
}
111+
return m.predictResp, nil
112+
}, nil
89113
}
90114

91115
func (m *fakeModel) TTS(context.Context, string, string, string) (string, *proto.Result, error) {

core/http/endpoints/openai/realtime_segmenter.go

Lines changed: 0 additions & 61 deletions
This file was deleted.

core/http/endpoints/openai/realtime_segmenter_test.go

Lines changed: 0 additions & 41 deletions
This file was deleted.

core/http/endpoints/openai/realtime_stream.go

Lines changed: 36 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -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).
10271
func 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

Comments
 (0)