Skip to content

Commit 897374b

Browse files
committed
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>
1 parent af81dba commit 897374b

5 files changed

Lines changed: 170 additions & 19 deletions

File tree

core/http/endpoints/openai/realtime.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,9 +1728,8 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
17281728
// Synthesize and send the audio. With pipeline.streaming.tts enabled
17291729
// emitSpeech forwards a response.output_audio.delta per backend PCM
17301730
// chunk as it's produced; otherwise it sends the whole utterance as a
1731-
// single delta. The returned base64 audio is stored on the item below.
1732-
var err error
1733-
audioString, err = emitSpeech(ctx, t, session, responseID, item.Assistant.ID, finalSpeech)
1731+
// single delta. The returned PCM is stored (base64) on the item below.
1732+
pcmAudio, err := emitSpeech(ctx, t, session, responseID, item.Assistant.ID, finalSpeech)
17341733
if err != nil {
17351734
if ctx.Err() != nil {
17361735
xlog.Debug("TTS cancelled (barge-in)")
@@ -1741,6 +1740,9 @@ func triggerResponseAtTurn(ctx context.Context, session *Session, conv *Conversa
17411740
sendError(t, "tts_error", fmt.Sprintf("TTS generation failed: %v", err), "", item.Assistant.ID)
17421741
return
17431742
}
1743+
if !isWebRTC {
1744+
audioString = base64.StdEncoding.EncodeToString(pcmAudio)
1745+
}
17441746

17451747
if !isWebRTC {
17461748
sendEvent(t, types.ResponseOutputAudioDoneEvent{

core/http/endpoints/openai/realtime_speech.go

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,12 @@ import (
2020
// those so a streamed reply can be split into several spoken segments that share
2121
// one response/item.
2222
//
23-
// It returns the base64-encoded audio (at the session output rate) accumulated
24-
// across all chunks, which the caller stores on the conversation item. For
25-
// WebRTC the audio goes over the RTP track instead, so the returned string is
26-
// empty.
27-
func emitSpeech(ctx context.Context, t Transport, session *Session, responseID, itemID, text string) (string, error) {
23+
// It returns the PCM audio (at the session output rate) accumulated across all
24+
// chunks, which the caller base64-encodes onto the conversation item. For WebRTC
25+
// the audio goes over the RTP track instead, so the returned slice is empty.
26+
func emitSpeech(ctx context.Context, t Transport, session *Session, responseID, itemID, text string) ([]byte, error) {
2827
if text == "" {
29-
return "", nil
28+
return nil, nil
3029
}
3130

3231
_, isWebRTC := t.(*WebRTCTransport)
@@ -70,31 +69,31 @@ func emitSpeech(ctx context.Context, t Transport, session *Session, responseID,
7069

7170
if session.ModelConfig != nil && session.ModelConfig.Pipeline.StreamTTS() {
7271
if err := session.ModelInterface.TTSStream(ctx, text, session.Voice, language, sendChunk); err != nil {
73-
return "", err
72+
return nil, err
7473
}
75-
return base64.StdEncoding.EncodeToString(wsAudio), nil
74+
return wsAudio, nil
7675
}
7776

7877
// Unary fallback: synthesize the whole utterance to a file, then emit once.
7978
audioFilePath, res, err := session.ModelInterface.TTS(ctx, text, session.Voice, language)
8079
if err != nil {
81-
return "", err
80+
return nil, err
8281
}
8382
if res != nil && !res.Success {
84-
return "", fmt.Errorf("tts generation failed: %s", res.Message)
83+
return nil, fmt.Errorf("tts generation failed: %s", res.Message)
8584
}
8685
defer func() { _ = os.Remove(audioFilePath) }()
8786

8887
audioBytes, err := os.ReadFile(audioFilePath)
8988
if err != nil {
90-
return "", fmt.Errorf("read tts audio: %w", err)
89+
return nil, fmt.Errorf("read tts audio: %w", err)
9190
}
9291
pcm, sampleRate := laudio.ParseWAV(audioBytes)
9392
if sampleRate == 0 {
9493
sampleRate = session.OutputSampleRate
9594
}
9695
if err := sendChunk(pcm, sampleRate); err != nil {
97-
return "", err
96+
return nil, err
9897
}
99-
return base64.StdEncoding.EncodeToString(wsAudio), nil
98+
return wsAudio, nil
10099
}

core/http/endpoints/openai/realtime_speech_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package openai
22

33
import (
44
"context"
5-
"encoding/base64"
65
"os"
76

87
. "github.com/onsi/ginkgo/v2"
@@ -40,8 +39,8 @@ var _ = Describe("emitSpeech", func() {
4039

4140
Expect(err).ToNot(HaveOccurred())
4241
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioDelta)).To(Equal(3))
43-
// The returned audio is the base64 of all chunks concatenated.
44-
Expect(audio).To(Equal(base64.StdEncoding.EncodeToString([]byte{1, 2, 3, 4, 5, 6})))
42+
// The returned audio is all chunks concatenated (session output rate).
43+
Expect(audio).To(Equal([]byte{1, 2, 3, 4, 5, 6}))
4544
})
4645

4746
It("sends a single output_audio.delta in unary mode", func() {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package openai
2+
3+
import (
4+
"context"
5+
6+
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
7+
"github.com/mudler/LocalAI/pkg/reasoning"
8+
)
9+
10+
// speechStreamer consumes streamed LLM tokens and drives the realtime output:
11+
// it strips reasoning incrementally, emits a transcript text delta for each
12+
// content fragment, and — when the pipeline streams TTS — sentence-pipes the
13+
// content so each completed sentence is synthesized as soon as it's ready,
14+
// overlapping generation, synthesis and playback.
15+
//
16+
// It is used only for plain-content turns (no tools): tool-call output can't be
17+
// safely spoken mid-stream, so those turns keep the buffered path.
18+
type speechStreamer struct {
19+
ctx context.Context
20+
t Transport
21+
session *Session
22+
responseID string
23+
itemID string
24+
25+
extractor *reasoning.ReasoningExtractor
26+
seg streamSegmenter
27+
audio []byte
28+
streamTTS bool
29+
err error
30+
}
31+
32+
func newSpeechStreamer(ctx context.Context, t Transport, session *Session, responseID, itemID, thinkingStartToken string, reasoningCfg reasoning.Config) *speechStreamer {
33+
return &speechStreamer{
34+
ctx: ctx,
35+
t: t,
36+
session: session,
37+
responseID: responseID,
38+
itemID: itemID,
39+
extractor: reasoning.NewReasoningExtractor(thinkingStartToken, reasoningCfg),
40+
streamTTS: session.ModelConfig != nil && session.ModelConfig.Pipeline.StreamTTS(),
41+
}
42+
}
43+
44+
// onToken handles one streamed LLM token. It is shaped to be used directly as
45+
// the backend token callback's text sink.
46+
func (s *speechStreamer) onToken(token string) {
47+
_, content := s.extractor.ProcessToken(token)
48+
if content == "" {
49+
return
50+
}
51+
_ = s.t.SendEvent(types.ResponseOutputAudioTranscriptDeltaEvent{
52+
ServerEventBase: types.ServerEventBase{},
53+
ResponseID: s.responseID,
54+
ItemID: s.itemID,
55+
OutputIndex: 0,
56+
ContentIndex: 0,
57+
Delta: content,
58+
})
59+
if s.streamTTS {
60+
for _, segment := range s.seg.Push(content) {
61+
s.speak(segment)
62+
}
63+
}
64+
}
65+
66+
func (s *speechStreamer) speak(text string) {
67+
pcm, err := emitSpeech(s.ctx, s.t, s.session, s.responseID, s.itemID, text)
68+
if err != nil {
69+
if s.err == nil {
70+
s.err = err
71+
}
72+
return
73+
}
74+
s.audio = append(s.audio, pcm...)
75+
}
76+
77+
// finish flushes any buffered sentence to TTS and returns the full cleaned
78+
// content, the accumulated PCM audio, and the first error encountered (if any).
79+
func (s *speechStreamer) finish() (content string, audio []byte, err error) {
80+
if s.streamTTS {
81+
if rem := s.seg.Flush(); rem != "" {
82+
s.speak(rem)
83+
}
84+
}
85+
return s.extractor.CleanedContent(), s.audio, s.err
86+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package openai
2+
3+
import (
4+
"context"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
9+
"github.com/mudler/LocalAI/core/config"
10+
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
11+
"github.com/mudler/LocalAI/pkg/reasoning"
12+
)
13+
14+
// speechStreamer consumes streamed LLM tokens: it strips reasoning, emits a
15+
// transcript delta per content fragment, and sentence-pipes content into TTS so
16+
// audio starts before the full reply is generated.
17+
var _ = Describe("speechStreamer", func() {
18+
It("emits a transcript delta per token and speaks each completed sentence", func() {
19+
on := true
20+
m := &fakeModel{ttsStreamChunks: [][]byte{{7}}, ttsStreamRate: 24000}
21+
session := &Session{
22+
OutputSampleRate: 24000,
23+
ModelInterface: m,
24+
ModelConfig: &config.ModelConfig{
25+
Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{TTS: &on}},
26+
},
27+
}
28+
t := &fakeTransport{}
29+
s := newSpeechStreamer(context.Background(), t, session, "resp1", "item1", "", reasoning.Config{})
30+
31+
for _, tok := range []string{"Hello", " world.", " Bye"} {
32+
s.onToken(tok)
33+
}
34+
content, audio, err := s.finish()
35+
36+
Expect(err).ToNot(HaveOccurred())
37+
Expect(content).To(Equal("Hello world. Bye"))
38+
// One transcript delta per (non-empty) token.
39+
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioTranscriptDelta)).To(Equal(3))
40+
// Two sentences spoken: "Hello world." mid-stream + "Bye" on flush; one
41+
// chunk each.
42+
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioDelta)).To(Equal(2))
43+
Expect(audio).To(Equal([]byte{7, 7}))
44+
})
45+
46+
It("does not synthesize audio when TTS streaming is disabled", func() {
47+
m := &fakeModel{ttsStreamChunks: [][]byte{{7}}, ttsStreamRate: 24000}
48+
session := &Session{
49+
OutputSampleRate: 24000,
50+
ModelInterface: m,
51+
ModelConfig: &config.ModelConfig{}, // streaming.tts off
52+
}
53+
t := &fakeTransport{}
54+
s := newSpeechStreamer(context.Background(), t, session, "resp1", "item1", "", reasoning.Config{})
55+
56+
s.onToken("Hello world.")
57+
content, audio, err := s.finish()
58+
59+
Expect(err).ToNot(HaveOccurred())
60+
Expect(content).To(Equal("Hello world."))
61+
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioTranscriptDelta)).To(Equal(1))
62+
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioDelta)).To(Equal(0))
63+
Expect(audio).To(BeEmpty())
64+
})
65+
})

0 commit comments

Comments
 (0)