Skip to content

Commit 892fc49

Browse files
localai-botmudlerrichiejp
authored
feat(realtime): stream the LLM / TTS / transcription pipeline stages (#10176)
* feat(realtime): pipeline streaming + disable_thinking config Add a nested pipeline.streaming.{llm,tts,transcription} block plus pipeline.disable_thinking, with StreamLLM/StreamTTS/StreamTranscription/ ThinkingDisabled helpers. Pointer-bools so unset keeps the unary path; existing configs are unaffected. Wiring into the realtime handler follows. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): sentence segmenter for streamed LLM->TTS pipelining streamSegmenter accumulates streamed LLM tokens and emits complete sentence/clause segments (terminator+whitespace, or newline) so TTS can synthesize each segment as it completes instead of waiting for the whole reply. Pure helper; the streaming handler wiring consumes it next. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): streaming TTS/transcription methods on Model interface Add TTSStream and TranscribeStream to the realtime Model interface and implement them on wrappedModel (delegating to backend.ModelTTSStream / ModelTranscriptionStream) and transcriptOnlyModel. ttsStream adapts the backend's WAV-framed stream (44-byte header carrying the sample rate, then PCM) into raw PCM + sample rate for the realtime transports. Handler wiring that consumes these (flag-gated) follows. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): emitSpeech with flag-gated streaming TTS emitSpeech synthesizes a piece of text and forwards audio to the client, streaming one output_audio.delta per backend PCM chunk when the pipeline sets streaming.tts, or one delta for the whole utterance otherwise. WebRTC gets raw PCM (it resamples internally); WebSocket gets base64 PCM at the session rate. It emits no transcript/audio-done events so a streamed reply can be split into multiple spoken segments sharing one response. Adds fakeModel/fakeTransport test doubles for the realtime Model/Transport interfaces, driving streaming assertions deterministically. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): route response audio through emitSpeech (streaming TTS) Replace the inline unary TTS block in the response handler with emitSpeech, which streams a response.output_audio.delta per backend PCM chunk when pipeline.streaming.tts is set and otherwise preserves the single-delta unary behaviour. emitSpeech returns the accumulated base64 audio, stored on the conversation item as before. Transcript and audio-done events stay in the handler so later per-segment streaming can reuse emitSpeech. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): streaming transcription text deltas Add emitTranscription and route commitUtterance through it. With pipeline.streaming.transcription set it streams each transcript fragment as a conversation.item.input_audio_transcription.delta via TranscribeStream then a completed event; otherwise it preserves the single completed-event unary behaviour. Returns the final transcript for response generation. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): pipeline disable_thinking maps to enable_thinking off applyPipelineThinking forces the LLM's ReasoningConfig.DisableReasoning when pipeline.disable_thinking is set, which gRPCPredictOpts turns into the enable_thinking=false backend metadata. Applied at newModel construction on the per-session LLM config copy, so it doesn't leak to other model users and needs no realtime-specific request plumbing. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): speechStreamer for token-streamed LLM->TTS emitSpeech now returns raw PCM (caller base64-encodes) so streamed segments accumulate correctly. speechStreamer consumes streamed LLM tokens: it strips reasoning via the streaming ReasoningExtractor, emits a transcript delta per content fragment, and sentence-pipes content into emitSpeech so each sentence is synthesized as soon as it's ready. Handler wiring (plain-content turns) follows. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): wire streamLLMResponse for token-streamed replies triggerResponseAtTurn takes a streamed path when pipeline.streaming.llm is set, the turn has no tools, and audio is requested: streamLLMResponse announces the assistant item, drives the LLM token callback through a speechStreamer (reasoning-stripped transcript deltas + sentence-piped TTS), and emits the terminal events. Tool turns and non-streaming pipelines keep the existing buffered path unchanged, so this is strictly opt-in. Assisted-by: Claude:claude-opus-4-8 go vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(realtime): document pipeline streaming + disable_thinking Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): register pipeline streaming/thinking config fields TestAllFieldsHaveRegistryEntries (core/config/meta) requires every config field to have a meta registry entry. The four new pipeline fields (disable_thinking, streaming.{llm,tts,transcription}) had none, failing tests-linux/tests-apple. Add toggle entries for them. Also handle the os.Remove return in realtime_speech_test.go to satisfy errcheck (golangci-lint). Assisted-by: Claude:claude-opus-4-8 go test, golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): always strip reasoning from spoken output disable_thinking maps to ReasoningConfig.DisableReasoning=true on the LLM config, which the backend reads as enable_thinking=false. But the realtime handler reads that SAME config to drive reasoning extraction, and there DisableReasoning=true means "skip stripping". PredictConfig() returns this LLM config, so both the streamed (speechStreamer) and buffered realtime paths stopped stripping <think>…</think> exactly when disable_thinking was on — leaking raw reasoning to the client whenever the model ignored the enable_thinking hint (e.g. lfm2.5). Add spokenReasoningConfig() which clears DisableReasoning for extraction (keeping custom tokens/tag pairs) and route both realtime paths through it. Spoken output now always strips reasoning, independent of the backend suppression hint. Assisted-by: Claude:claude-opus-4-8 go test, golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(realtime): clean TTS temp path before read (gosec G304) emitSpeech reads the WAV file the TTS backend wrote. The read moved here from realtime.go, so code-scanning flagged it as a new G304 alert even though the path is backend-controlled (a temp file), not user input. Wrap it in filepath.Clean — a real path normalization that also clears the alert, keeping with the repo's no-#nosec convention. Assisted-by: Claude:claude-opus-4-8 gosec, golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * 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> * feat(realtime): stream tool-call turns via tokenizer-template autoparser Per review (richiejp): tool-call deltas exist, so streaming should work with tools too. It does — for models that use their tokenizer template. The C++ autoparser then clears reply.Message and delivers content + tool calls via ChatDeltas, so the streamed transcript carries only spoken content (no tool-call JSON leak) and the tool calls are parsed from the final response. - Drop the len(tools)==0 gate; stream when no tools OR use_tokenizer_template (grammar-based function calling still buffers, since its call is emitted as JSON in the token stream and would leak into the transcript). - streamLLMResponse takes tools/toolChoice/toolTurn, reads ChatDelta content in the token callback, parses tool calls from the final ChatDeltas, and creates the assistant content item lazily so a content-less tool turn emits only the tool calls. - Extract emitToolCallItems from the buffered path so both paths finalize tool calls, response.done, and server-side assistant-tool follow-ups identically. Assisted-by: Claude:claude-opus-4-8 go test, golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): script-aware clause chunking + streamed-reply fixes Opt-in pipeline.streaming.clause_chunking splits the streamed LLM reply into speakable clauses and synthesizes each as soon as it completes, lowering time-to-first-audio instead of buffering the whole message. The splitter is script-aware (rivo/uniseg, pure Go): UAX#29 sentence segmentation handles CJK 。!? with no whitespace, CJK clause punctuation (,、;:) and Thai/Lao spaces give finer cuts, and a UAX#14 line-break cap bounds an over-long punctuation-less run. Unlike the old ASCII .!?/newline segmenter (dropped in 076dcdb) it does not degrade to whole-message buffering for CJK/Thai; scripts needing a dictionary (Khmer/Burmese) stay buffered until a space or end-of-message. Clauses are synthesized synchronously in the token callback (the LLM keeps generating into the gRPC stream meanwhile), so audio still starts mid-generation. Off by default — the whole-message path is unchanged. Also fix the streamed-reply path and the Talk page: - Don't swallow streamed autoparser content as reasoning: the tokenizer-template path already delivers reasoning-free content via ChatDeltas, so prefilling the thinking start token re-tagged it as an unclosed reasoning block, leaving no spoken reply. Disable the prefill on that path; closed tag pairs are still stripped (#9985). - Generate collision-free realtime IDs (16 random bytes) instead of a constant, so per-item bookkeeping (cancel, conversation.item.retrieve) works. - Key the Talk transcript by the server item_id and upsert entries. Realtime events arrive over a WebRTC data channel — outside React's event system — so React defers the setTranscript updaters while synchronous ref writes in handler bodies run first; the old index-tracking ref rendered a duplicate assistant bubble on completion. Upserts by item_id are idempotent and order-independent. - Drop the partial assistant bubble on a cancelled response (barge-in): the server discards the interrupted item and sends response.done with status "cancelled"; mirror that in the UI so the regenerated reply isn't rendered as a second assistant message. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Signed-off-by: Richard Palethorpe <io@richiejp.com> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Richard Palethorpe <io@richiejp.com>
1 parent 228a6df commit 892fc49

19 files changed

Lines changed: 1716 additions & 128 deletions

core/config/meta/registry.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,41 @@ func DefaultRegistry() map[string]FieldMetaOverride {
308308
},
309309
Order: 64,
310310
},
311+
"pipeline.disable_thinking": {
312+
Section: "pipeline",
313+
Label: "Disable Thinking",
314+
Description: "Suppress reasoning/thinking output from the pipeline LLM (sets enable_thinking=false on the underlying model). Use for models that emit <think> blocks you don't want spoken or streamed back to the realtime client.",
315+
Component: "toggle",
316+
Order: 65,
317+
},
318+
"pipeline.streaming.llm": {
319+
Section: "pipeline",
320+
Label: "Stream LLM",
321+
Description: "Stream LLM tokens to the realtime client as they are generated instead of waiting for the full response. Emits incremental response.output_audio_transcript.delta / text deltas.",
322+
Component: "toggle",
323+
Order: 66,
324+
},
325+
"pipeline.streaming.tts": {
326+
Section: "pipeline",
327+
Label: "Stream TTS",
328+
Description: "Stream synthesized audio chunks to the realtime client as they are produced (requires a TTS backend that implements TTSStream). Falls back to unary synthesis otherwise.",
329+
Component: "toggle",
330+
Order: 67,
331+
},
332+
"pipeline.streaming.transcription": {
333+
Section: "pipeline",
334+
Label: "Stream Transcription",
335+
Description: "Stream partial transcription text to the realtime client as the STT backend produces it (requires a transcription backend that implements AudioTranscriptionStream). Falls back to unary transcription otherwise.",
336+
Component: "toggle",
337+
Order: 68,
338+
},
339+
"pipeline.streaming.clause_chunking": {
340+
Section: "pipeline",
341+
Label: "Clause Chunking",
342+
Description: "Split the streamed reply into speakable clauses and synthesize each as soon as it completes, instead of buffering the whole message before TTS — lower time-to-first-audio. Script-aware (handles CJK 。!? and Thai/Lao spaces), so it does not whitespace-split. Requires Stream LLM; off buffers the whole message.",
343+
Component: "toggle",
344+
Order: 69,
345+
},
311346

312347
// --- Functions ---
313348
"function.grammar.parallel_calls": {

core/config/model_config.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,16 @@ type Pipeline struct {
499499
// the pipeline's LLM without editing the LLM model config. Overrides the LLM's
500500
// own reasoning_effort. Unset leaves the LLM model config in charge.
501501
ReasoningEffort string `yaml:"reasoning_effort,omitempty" json:"reasoning_effort,omitempty"`
502+
503+
// Streaming opts each pipeline stage into incremental delivery (LLM tokens,
504+
// TTS audio chunks, transcription text). Unset stages keep the blocking
505+
// unary path, so existing configs are unaffected.
506+
Streaming PipelineStreaming `yaml:"streaming,omitempty" json:"streaming,omitempty"`
507+
508+
// DisableThinking suppresses reasoning/thinking for the pipeline LLM (maps
509+
// to enable_thinking=false backend metadata) without editing the underlying
510+
// LLM model config. Unset leaves the LLM model config in charge.
511+
DisableThinking *bool `yaml:"disable_thinking,omitempty" json:"disable_thinking,omitempty"`
502512
}
503513

504514
// ApplyReasoningEffort resolves the effective reasoning effort — a per-request
@@ -530,6 +540,41 @@ func (c *ModelConfig) ApplyReasoningEffort(requestEffort string) {
530540
}
531541
}
532542

543+
// @Description PipelineStreaming toggles incremental delivery per realtime stage.
544+
type PipelineStreaming struct {
545+
LLM *bool `yaml:"llm,omitempty" json:"llm,omitempty"`
546+
TTS *bool `yaml:"tts,omitempty" json:"tts,omitempty"`
547+
Transcription *bool `yaml:"transcription,omitempty" json:"transcription,omitempty"`
548+
// ClauseChunking splits the streamed LLM reply into speakable clauses and
549+
// synthesizes each as soon as it completes, instead of buffering the whole
550+
// message before TTS. Script-aware (CJK/Thai), so it does not rely on
551+
// whitespace sentence boundaries. Requires LLM streaming; unset buffers the
552+
// whole message (today's default).
553+
ClauseChunking *bool `yaml:"clause_chunking,omitempty" json:"clause_chunking,omitempty"`
554+
}
555+
556+
// StreamLLM reports whether LLM tokens should be streamed for this pipeline.
557+
func (p Pipeline) StreamLLM() bool { return p.Streaming.LLM != nil && *p.Streaming.LLM }
558+
559+
// StreamTTS reports whether TTS audio should be streamed for this pipeline.
560+
func (p Pipeline) StreamTTS() bool { return p.Streaming.TTS != nil && *p.Streaming.TTS }
561+
562+
// StreamTranscription reports whether transcription text should be streamed.
563+
func (p Pipeline) StreamTranscription() bool {
564+
return p.Streaming.Transcription != nil && *p.Streaming.Transcription
565+
}
566+
567+
// ChunkClauses reports whether the streamed reply should be split into
568+
// script-aware clauses and synthesized incrementally rather than buffered whole.
569+
func (p Pipeline) ChunkClauses() bool {
570+
return p.Streaming.ClauseChunking != nil && *p.Streaming.ClauseChunking
571+
}
572+
573+
// ThinkingDisabled reports whether the pipeline forces the LLM's thinking off.
574+
func (p Pipeline) ThinkingDisabled() bool {
575+
return p.DisableThinking != nil && *p.DisableThinking
576+
}
577+
533578
// @Description File configuration for model downloads
534579
type File struct {
535580
Filename string `yaml:"filename,omitempty" json:"filename,omitempty"`
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package config
2+
3+
import (
4+
. "github.com/onsi/ginkgo/v2"
5+
. "github.com/onsi/gomega"
6+
"gopkg.in/yaml.v3"
7+
)
8+
9+
// The realtime pipeline can stream each stage (LLM tokens, TTS audio,
10+
// transcription text) and can disable model "thinking" for the LLM. These are
11+
// opt-in per pipeline; everything defaults to off so existing configs keep the
12+
// unary behaviour.
13+
var _ = Describe("Pipeline streaming config", func() {
14+
It("defaults every streaming + thinking helper to false when unset", func() {
15+
var p Pipeline
16+
Expect(p.StreamLLM()).To(BeFalse())
17+
Expect(p.StreamTTS()).To(BeFalse())
18+
Expect(p.StreamTranscription()).To(BeFalse())
19+
Expect(p.ChunkClauses()).To(BeFalse())
20+
Expect(p.ThinkingDisabled()).To(BeFalse())
21+
})
22+
23+
It("parses the nested streaming block and disable_thinking from YAML", func() {
24+
var c ModelConfig
25+
err := yaml.Unmarshal([]byte(`
26+
name: gpt-realtime
27+
pipeline:
28+
llm: my-llm
29+
tts: my-tts
30+
transcription: my-stt
31+
streaming:
32+
llm: true
33+
tts: true
34+
transcription: true
35+
clause_chunking: true
36+
disable_thinking: true
37+
`), &c)
38+
Expect(err).ToNot(HaveOccurred())
39+
Expect(c.Pipeline.StreamLLM()).To(BeTrue())
40+
Expect(c.Pipeline.StreamTTS()).To(BeTrue())
41+
Expect(c.Pipeline.StreamTranscription()).To(BeTrue())
42+
Expect(c.Pipeline.ChunkClauses()).To(BeTrue())
43+
Expect(c.Pipeline.ThinkingDisabled()).To(BeTrue())
44+
})
45+
46+
It("treats an explicit false in the streaming block as disabled", func() {
47+
var c ModelConfig
48+
err := yaml.Unmarshal([]byte(`
49+
name: gpt-realtime
50+
pipeline:
51+
streaming:
52+
tts: false
53+
`), &c)
54+
Expect(err).ToNot(HaveOccurred())
55+
Expect(c.Pipeline.StreamTTS()).To(BeFalse())
56+
})
57+
})

0 commit comments

Comments
 (0)