feat(tts): stream Qwen text and add assistant preset (#12770)

* feat(tts): stream Qwen text through audio pipelines

Feed cleaned LLM deltas into one persistent Qwen synthesis session.
Return audio before input ends and preserve the live RPC through model
cache, staging and in-flight wrappers. Stop generation on sink failures
or cancellation without replaying speech through a fallback.

Pin the incremental native fork and verify early audio, late text,
finalization and cancellation with lifecycle and real-model checks.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* build(tts): use the organization Qwen fork

Fetch the reviewed incremental synthesis commit from localai-org so
backend builds use the organization-maintained fork.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): add a Qwen streaming assistant preset

Expose an English pipeline using existing VAD, ASR, LLM and named-voice
TTS models. Enable incremental text and audio delivery, and document
separate component installs because the gallery has no dependency
installer.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gallery): use Parakeet TDT v3 for the assistant

Select the existing Parakeet TDT 0.6B v3 transcription entry and update
its prerequisite install command. Keep the preset's English voice and
incremental Qwen synthesis configuration.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(kokoros): reject unsupported backend RPCs

Implement the MotionStream and SlotCache methods required by the shared
protobuf service. Return Unimplemented so Kokoros compiles without
advertising support for either operation.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(tts): mark cleanup and logging results ignored

Explicitly ignore connection shutdown, test server shutdown and
Ginkgo diagnostic writer errors. These operations do not replace the
stream's inference or sink errors, and errcheck requires their return
values to be acknowledged.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(tts): stream buffered clauses in realtime pipelines

Start clause synthesis while LLM text is still arriving when both streaming flags are enabled. Keep explicit clause opt-out and native Qwen sessions, bound automatic queued work, and choose streaming support on resolved failover targets.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(tts): bound synthetic voice clone generation

The sine-wave reference checks speaker conditioning and WAV output, but does not provide a meaningful speech EOS target. Limit that smoke request to 128 frames and verify the request limit reaches native synthesis. Leave production sampling defaults unchanged.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(tts): require explicit clause buffering opt-in

Keep whole-reply synthesis for nonnative engines when clause_chunking is unset or false. Use the bounded cancellable worker when explicitly enabled, with unary clauses when TTS streaming is disabled. Preserve native Qwen precedence for every clause setting.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(gallery): add VITS and Piper streaming assistants

Provide comparable full pipeline configurations with shared VAD, Parakeet transcription, and Qwen chat components. Enable clause buffering explicitly and document the separate prerequisite model installs and Talk selection.

Assisted-by: Codex:gpt-6.1-sol
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-19 12:30:52 +02:00
1 parent 48f358dd95
commit f85087f992
34 files changed
+1780 -90

No files matched your search

+3
View File
@@ -36,6 +36,9 @@ service Backend {
rpc AudioTranscriptionLive(stream TranscriptLiveRequest) returns (stream TranscriptLiveResponse) {}
rpc TTS(TTSRequest) returns (Result) {}
rpc TTSStream(TTSRequest) returns (stream Reply) {}
// First frame configures a persistent utterance; later frames carry only text.
// Half-close commits end of text, while audio continues until EOF.
rpc TTSLive(stream TTSRequest) returns (stream Reply) {}
rpc SoundGeneration(SoundGenerationRequest) returns (Result) {}
rpc TokenizeString(PredictOptions) returns (TokenizationResponse) {}
rpc Detokenize(DetokenizeRequest) returns (DetokenizeResponse) {}
+2
View File
@@ -4,6 +4,8 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(QWENTTS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/sources/qwentts.cpp)
# Upstream uses the top-level source directory for this cache default.
set(GGML_SOURCE_DIR ${QWENTTS_DIR}/ggml CACHE PATH "ggml source tree" FORCE)
# Override upstream's CMAKE_CUDA_ARCHITECTURES before add_subdirectory.
if(NOT DEFINED CMAKE_CUDA_ARCHITECTURES)
+3 -13
View File
@@ -6,19 +6,9 @@ GOCMD?=go
GO_TAGS?=
JOBS?=$(shell nproc --ignore=1)
# qwentts.cpp version
#
# Held at 35ebe537 rather than tracking latest: abab6b3 hangs in synthesis.
# TTS() never returns from the native call, so tests-qwen3-tts-cpp goes from
# ~5 minutes to the 20 minute Go test timeout. Reproduced on master on
# 2026-08-01 and again on re-run, and the bump PR (#11241) was merged with
# this same check already red.
#
# The regression is in 35ebe537..abab6b3, three upstream commits whose only
# functional change is 26dd8adb, "predictor: unroll the frame into one cgraph
# and sample in standard ops". Restore the bump once that is fixed upstream.
QWEN3TTS_REPO?=https://github.com/ServeurpersoCom/qwentts.cpp
QWEN3TTS_CPP_VERSION?=35ebe5376b82a0a59d008586d55bbe623d449011
# Incremental text input uses the persistent-session ABI in this fork.
QWEN3TTS_REPO?=https://github.com/localai-org/qwentts.cpp
QWEN3TTS_CPP_VERSION?=7cdb17cdaf6ef88961365e9b5177a0284501beb9
SO_TARGET?=libgoqwen3ttscpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF
+30
View File
@@ -83,3 +83,33 @@ QWEN3TTS_CODEC=/path/to/qwen-tokenizer-12hz-Q8_0.gguf \
QWEN3TTS_LIBRARY=backend/go/qwen3-tts-cpp/libgoqwen3ttscpp-fallback.so \
go test ./backend/go/qwen3-tts-cpp -ginkgo.label-filter=e2e
```
### Incremental text and audio
The `TTSLive` backend RPC accepts text fragments while emitting audio from one
persistent utterance. The first `TTSRequest` carries voice, language and sampling
options; subsequent requests carry only `text`. Each fragment must contain valid UTF-8;
the protobuf string boundary does not accept incomplete Unicode characters. Closing the request stream commits
end of input. Continue reading responses until EOF to receive the codec tail.
Responses contain one streaming WAV header followed by mono PCM16 chunks at 24 kHz.
Realtime audio pipelines select this path when their TTS model uses
`qwen3-tts-cpp` and `pipeline.streaming.llm` and `pipeline.streaming.tts` are
`true`. Cleaned LLM content feeds the same synthesis session; clause splitting is
bypassed. WebSocket and WebRTC transports receive audio as it becomes available.
The text queue is bounded, so slow audio consumers apply backpressure to text
generation. Audio emits before the full response is available; WebSocket
conversation history still retains the final audio, as in the existing path. Cancellation stops input and audio. A failed live session is not
retried because consumed text and emitted speech cannot be replayed safely.
This requires the incremental-input native fork. Its initial implementation uses
one native batch slot and commits whitespace-delimited words or individual CJK, Kana and
Hangul characters. Incomplete words remain buffered until a stable boundary. Reference audio without `ref_text` is supported; transcript-based ICL
cloning is rejected for incremental sessions. Other TTS backends retain their
existing whole-text or clause synthesis paths. The standard speech HTTP request
continues to accept a complete JSON input.
To run the real native streaming check, provide `QWEN3TTS_LIBRARY`,
`QWEN3TTS_MODEL`, `QWEN3TTS_CODEC`, and `QWEN3TTS_LIVE_TEST=1`, then run
`go test ./backend/go/qwen3-tts-cpp -ginkgo.focus TTSLiveNative -v` from the
repository root. It withholds late text and end of input until real PCM arrives.
@@ -189,3 +189,35 @@ int qt3_n_speakers(void) { return g_ctx ? qt_n_speakers(g_ctx) : 0; }
const char *qt3_speaker_name(int i) {
return g_ctx ? qt_speaker_name(g_ctx, i) : nullptr;
}
namespace {
struct live_callbacks {
qt3_text_cb text;
qt3_chunk_cb audio;
void *user;
};
static int live_next_text(void *opaque, const char **text, size_t *n_bytes) {
auto *callbacks = static_cast<live_callbacks *>(opaque);
return callbacks->text(callbacks->user, text, n_bytes);
}
static bool live_audio_chunk(const float *samples, int n_samples, void *opaque) {
auto *callbacks = static_cast<live_callbacks *>(opaque);
return callbacks->audio(samples, n_samples, callbacks->user) != 0;
}
}
int qt3_tts_live(const char *lang, const char *instruct, const char *speaker,
const float *ref_samples, int ref_n, const char *ref_text,
long long seed, float temperature, int top_k, float top_p,
float repetition_penalty, int max_new_tokens,
qt_text_input_cb next_text, qt3_chunk_cb cb, void *user_data) {
if (!g_ctx || !cb || !next_text) return 1;
qt_tts_params tp;
fill_params(&tp, nullptr, lang, instruct, speaker, ref_samples, ref_n,
ref_text, seed, temperature, top_k, top_p, repetition_penalty,
max_new_tokens);
live_callbacks callbacks{next_text, cb, user_data};
tp.on_chunk = live_audio_chunk;
tp.on_chunk_user_data = &callbacks;
return (int) qt_synthesize_incremental(g_ctx, &tp, live_next_text, &callbacks);
}
@@ -1,4 +1,5 @@
#pragma once
#include <stddef.h>
extern "C" {
@@ -34,6 +35,13 @@ int qt3_tts_stream(const char *text, const char *lang, const char *instruct,
int top_k, float top_p, float repetition_penalty,
int max_new_tokens, qt3_chunk_cb cb, void *user_data);
typedef int (*qt3_text_cb)(void *user_data, const char **text, size_t *n_bytes);
int qt3_tts_live(const char *lang, const char *instruct, const char *speaker,
const float *ref_samples, int ref_n, const char *ref_text,
long long seed, float temperature, int top_k, float top_p,
float repetition_penalty, int max_new_tokens,
qt3_text_cb next_text, qt3_chunk_cb cb, void *user_data);
// Free a buffer returned by qt3_tts.
void qt3_pcm_free(float *p);
+6 -1
View File
@@ -87,7 +87,12 @@ var _ = Describe("qwen3-tts-cpp e2e", Label("e2e"), func() {
dst := GinkgoT().TempDir() + "/clone.wav"
lang := "english"
// Empty Voice -> the config audio_path is used as the clone reference.
Expect(b.TTS(ttsReq("Cloned voice test.", "", &lang, dst))).To(Succeed())
// The sine reference exercises speaker conditioning, not speech quality.
// It has no meaningful EOS guarantee; bound this smoke independently
// of the production generation limit.
req := ttsReq("Cloned voice test.", "", &lang, dst)
req.Params = map[string]string{"max_new_tokens": "128"}
Expect(b.TTS(req)).To(Succeed())
fi, err := os.Stat(dst)
Expect(err).ToNot(HaveOccurred())
Expect(fi.Size()).To(BeNumerically(">", int64(44)))
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"context"
"fmt"
"io"
"runtime"
"sync"
"unsafe"
"github.com/ebitengine/purego"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
var CppTTSLive func(lang, instruct, speaker string, refSamples unsafe.Pointer, refN int,
refText string, seed int64, temperature float32, topK int, topP, repPen float32, maxNew int,
nextText, audio, user uintptr) int
var liveOnce sync.Once
var liveTextCB, liveAudioCB uintptr
// Native synthesis invokes both callbacks synchronously on its worker thread.
// The backend model lock and streamMu permit only one active native utterance.
var liveRecv func() (*pb.TTSRequest, error)
var liveSend func([]byte) error
var liveText []byte
var liveError error
var liveInput liveInputState
func nextLiveText(_ uintptr, textOut unsafe.Pointer, sizeOut unsafe.Pointer) uintptr {
if liveInput == liveInputEnded {
return 0
}
if liveInput == liveInputFailed {
return ^uintptr(0)
}
req, err := liveRecv()
if err == io.EOF {
liveInput, _ = reduceLiveInput(liveInput, liveInputEOF)
return 0
}
if err != nil {
liveError = err
liveInput, _ = reduceLiveInput(liveInput, liveInputError)
return ^uintptr(0)
}
liveInput, _ = reduceLiveInput(liveInput, liveInputChunk)
liveText = append(liveText[:0], []byte(req.Text)...)
if len(liveText) == 0 {
liveText = append(liveText, 0)
}
*(*unsafe.Pointer)(textOut) = unsafe.Pointer(&liveText[0])
*(*uintptr)(sizeOut) = uintptr(len(req.Text))
return 1
}
func sendLiveAudio(samples *float32, n int32, _ uintptr) uintptr {
if n <= 0 {
return 1
}
pcm := floatToPCM16LE(unsafe.Slice(samples, int(n)))
if err := liveSend(pcm); err != nil {
liveError = err
liveInput, _ = reduceLiveInput(liveInput, liveInputError)
return 0
}
return 1
}
func (q *Qwen3TtsCpp) TTSLive(ctx context.Context, recv func() (*pb.TTSRequest, error), send func([]byte) error) error {
req, err := recv()
if err != nil {
return err
}
lang, instruct, speaker, refText, ref, s, err := q.resolveRequest(req)
if err != nil {
return err
}
if refText != "" {
return fmt.Errorf("qwen3-tts: incremental input does not support ref_text; use speaker or reference audio without transcription")
}
if CppTTSLive == nil {
return fmt.Errorf("qwen3-tts: incremental synthesis unavailable in native library")
}
if err := send(wavHeader24k()); err != nil {
return err
}
liveOnce.Do(func() { liveTextCB = purego.NewCallback(nextLiveText); liveAudioCB = purego.NewCallback(sendLiveAudio) })
streamMu.Lock()
defer streamMu.Unlock()
first := req.Text != ""
liveRecv = func() (*pb.TTSRequest, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if first {
first = false
return req, nil
}
return recv()
}
liveSend = func(b []byte) error {
if err := ctx.Err(); err != nil {
return err
}
return send(b)
}
liveError = nil
liveInput = liveInputOpen
defer func() { liveRecv = nil; liveSend = nil; liveText = nil; liveError = nil }()
var refPtr unsafe.Pointer
if len(ref) > 0 {
refPtr = unsafe.Pointer(&ref[0])
}
rc := CppTTSLive(lang, instruct, speaker, refPtr, len(ref), refText, s.seed, s.temperature, s.topK, s.topP, s.repPen, s.maxNew, liveTextCB, liveAudioCB, 0)
runtime.KeepAlive(ref)
runtime.KeepAlive(liveText)
if liveError != nil {
return liveError
}
if err := ctx.Err(); err != nil {
return err
}
if rc != 0 {
return fmt.Errorf("qwen3-tts: incremental synthesis failed (rc=%d)", rc)
}
return nil
}
@@ -0,0 +1,99 @@
package main
import (
"context"
"encoding/binary"
"fmt"
. "github.com/onsi/ginkgo/v2"
"io"
"os"
"sync"
"time"
"github.com/ebitengine/purego"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// Opt-in real-model check. The input producer cannot send late text or EOF
// until it observes actual PCM, so a whole-text implementation deadlocks here.
var _ = Describe("TTSLiveNative", func() {
It("preserves the live synthesis contract", func() {
if os.Getenv("QWEN3TTS_LIVE_TEST") != "1" {
Skip(fmt.Sprint("set QWEN3TTS_LIVE_TEST=1 and model/codec/library paths"))
}
lib, e := purego.Dlopen(os.Getenv("QWEN3TTS_LIBRARY"), purego.RTLD_NOW|purego.RTLD_GLOBAL)
if e != nil {
Fail(fmt.Sprint(e))
}
purego.RegisterLibFunc(&CppLoad, lib, "qt3_load")
purego.RegisterLibFunc(&CppUnload, lib, "qt3_unload")
purego.RegisterLibFunc(&CppTTSLive, lib, "qt3_tts_live")
if rc := CppLoad(os.Getenv("QWEN3TTS_MODEL"), os.Getenv("QWEN3TTS_CODEC"), 1, 0); rc != 0 {
Fail(fmt.Sprint("load", rc))
}
defer CppUnload()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
input := make(chan *pb.TTSRequest)
early := make(chan struct{})
var once sync.Once
go func() {
select {
case input <- &pb.TTSRequest{Voice: "ryan", Language: func() *string { s := "english"; return &s }(), Text: "Hello there. ", Params: map[string]string{"max_new_tokens": "160", "seed": "42"}}:
case <-ctx.Done():
return
}
select {
case <-early:
case <-ctx.Done():
return
}
select {
case input <- &pb.TTSRequest{Text: "This is a streaming test. "}:
case <-ctx.Done():
return
}
close(input)
}()
chunks, bytes := 0, 0
var output []byte
outputPath := os.Getenv("QWEN3TTS_LIVE_OUTPUT")
b := &Qwen3TtsCpp{}
e = b.TTSLive(ctx, func() (*pb.TTSRequest, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case req, ok := <-input:
if !ok {
return nil, io.EOF
}
return req, nil
}
}, func(audio []byte) error {
if outputPath != "" {
output = append(output, audio...)
}
// The first callback is only a WAV header and cannot open the audio gate.
if chunks > 0 {
bytes += len(audio)
once.Do(func() { close(early) })
}
chunks++
return nil
})
if e != nil {
Fail(fmt.Sprint(e))
}
if bytes == 0 {
Fail(fmt.Sprint("no PCM"))
}
if outputPath != "" {
binary.LittleEndian.PutUint32(output[4:8], uint32(len(output)-8))
binary.LittleEndian.PutUint32(output[40:44], uint32(len(output)-44))
if err := os.WriteFile(outputPath, output, 0600); err != nil {
Fail(err.Error())
}
}
_, _ = fmt.Fprintf(GinkgoWriter, "PCM bytes=%d callbacks=%d; late text and EOS sent after first PCM", bytes, chunks)
})
})
+38
View File
@@ -0,0 +1,38 @@
package main
// Native may ask for text again after EOF while flushing its codec. Keep input
// terminal so that request cannot block waiting for a frame that will never come.
type liveInputState uint8
const (
liveInputOpen liveInputState = iota
liveInputEnded
liveInputFailed
)
type liveInputEvent uint8
const (
liveInputChunk liveInputEvent = iota
liveInputEOF
liveInputError
)
func reduceLiveInput(state liveInputState, event liveInputEvent) (liveInputState, string) {
switch state {
case liveInputOpen:
switch event {
case liveInputChunk:
return liveInputOpen, "R1-chunk"
case liveInputEOF:
return liveInputEnded, "R2-finish"
case liveInputError:
return liveInputFailed, "R3-fail"
}
case liveInputEnded:
return state, "R4-ended-ignore"
case liveInputFailed:
return state, "R5-failed-ignore"
}
panic("invalid live input transition")
}
@@ -0,0 +1,46 @@
package main
import (
"fmt"
. "github.com/onsi/ginkgo/v2"
)
var _ = Describe("LiveInputTraces", func() {
It("preserves the live synthesis contract", func() {
for _, tc := range []struct {
name string
events []liveInputEvent
state liveInputState
rules []string
}{
{"late input then EOF", []liveInputEvent{liveInputChunk, liveInputChunk, liveInputEOF, liveInputChunk}, liveInputEnded, []string{"R1-chunk", "R1-chunk", "R2-finish", "R4-ended-ignore"}},
{"disconnect before text", []liveInputEvent{liveInputError, liveInputEOF}, liveInputFailed, []string{"R3-fail", "R5-failed-ignore"}},
{"failure after audio", []liveInputEvent{liveInputChunk, liveInputError, liveInputChunk}, liveInputFailed, []string{"R1-chunk", "R3-fail", "R5-failed-ignore"}},
} {
By(tc.name)
func() {
state := liveInputOpen
for i, e := range tc.events {
next, rule := reduceLiveInput(state, e)
if rule != tc.rules[i] {
Fail(fmt.Sprint(rule))
}
state = next
}
if state != tc.state {
Fail(fmt.Sprint(state))
}
}()
}
// Exhaust the finite graph: no terminal state can reopen, and every open
// state admits both a normal completion and a cancellation.
for s := liveInputOpen; s <= liveInputFailed; s++ {
for e := liveInputChunk; e <= liveInputError; e++ {
next, _ := reduceLiveInput(s, e)
if s != liveInputOpen && next != s {
Fail(fmt.Sprint("terminal input reopened"))
}
}
}
})
})
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"context"
"errors"
"fmt"
. "github.com/onsi/ginkgo/v2"
"io"
"reflect"
"unsafe"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
var _ = Describe("TTSLiveAdapter", func() {
It("preserves the live synthesis contract", func() {
old := CppTTSLive
defer func() { CppTTSLive = old }()
var got []string
CppTTSLive = func(_, _, _ string, _ unsafe.Pointer, _ int, _ string, _ int64, _ float32, _ int, _, _ float32, _ int, _, _, _ uintptr) int {
for {
var text unsafe.Pointer
var size uintptr
result := nextLiveText(0, unsafe.Pointer(&text), unsafe.Pointer(&size))
if result == 0 {
return 0
}
if result != 1 {
return 1
}
got = append(got, string(unsafe.Slice((*byte)(text), int(size))))
samples := []float32{0.1, 0.2}
if sendLiveAudio(&samples[0], 2, 0) == 0 {
return 2
}
}
}
input := []*pb.TTSRequest{{Text: "early "}, {Text: "late"}}
chunks := 0
err := (&Qwen3TtsCpp{}).TTSLive(context.Background(), func() (*pb.TTSRequest, error) {
if len(input) == 0 {
return nil, io.EOF
}
r := input[0]
input = input[1:]
return r, nil
}, func([]byte) error { chunks++; return nil })
if err != nil {
Fail(fmt.Sprint(err))
}
if !reflect.DeepEqual(got, []string{"early ", "late"}) || chunks != 3 {
Fail(fmt.Sprintf("input=%v chunks=%d", got, chunks))
}
// A downstream failure must stop the native callback and preserve the exact
// error, rather than reporting successful synthesis or consuming more input.
boom := errors.New("audio consumer failed")
chunks = 0
err = (&Qwen3TtsCpp{}).TTSLive(context.Background(), func() (*pb.TTSRequest, error) { return &pb.TTSRequest{Text: "text"}, nil }, func([]byte) error {
chunks++
if chunks > 1 {
return boom
}
return nil
})
if !errors.Is(err, boom) {
Fail(fmt.Sprint(err))
}
})
})
+1
View File
@@ -38,6 +38,7 @@ func main() {
{&CppLoad, "qt3_load"},
{&CppTTS, "qt3_tts"},
{&CppTTSStream, "qt3_tts_stream"},
{&CppTTSLive, "qt3_tts_live"},
{&CppPCMFree, "qt3_pcm_free"},
{&CppUnload, "qt3_unload"},
}
@@ -3,7 +3,9 @@ package main
import (
"bytes"
"encoding/binary"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"testing"
"unsafe"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -104,6 +106,22 @@ var _ = Describe("parseSampling", func() {
})
})
var _ = Describe("TTS request sampling", func() {
It("forwards a request generation bound to the native synthesizer", func() {
original := CppTTS
DeferCleanup(func() { CppTTS = original })
var received int
CppTTS = func(_, _, _, _ string, _ unsafe.Pointer, _ int, _ string, _ int64, _ float32, _ int, _, _ float32, maxNew int, _ unsafe.Pointer) uintptr {
received = maxNew
return 0
}
b := &Qwen3TtsCpp{}
err := b.TTS(&pb.TTSRequest{Text: "Bounded synthesis", Dst: "unused.wav", Params: map[string]string{"max_new_tokens": "128"}})
Expect(err).To(MatchError("qwen3-tts: synthesis failed"))
Expect(received).To(Equal(128))
})
})
var _ = Describe("wavHeader24k", func() {
It("emits a 44-byte streaming WAV header at 24 kHz mono 16-bit", func() {
h := wavHeader24k()
+25
View File
@@ -201,6 +201,15 @@ impl Backend for KokorosService {
}
}
type TTSLiveStream = ReceiverStream<Result<backend::Reply, Status>>;
async fn tts_live(
&self,
_req: Request<tonic::Streaming<backend::TtsRequest>>,
) -> Result<Response<Self::TTSLiveStream>, Status> {
Err(Status::unimplemented("incremental text TTS is not supported"))
}
type TTSStreamStream = ReceiverStream<Result<backend::Reply, Status>>;
async fn tts_stream(
@@ -302,6 +311,22 @@ impl Backend for KokorosService {
// --- Unimplemented RPCs ---
type MotionStreamStream = ReceiverStream<Result<backend::MotionResponse, Status>>;
async fn motion_stream(
&self,
_: Request<tonic::Streaming<backend::MotionRequest>>,
) -> Result<Response<Self::MotionStreamStream>, Status> {
Err(Status::unimplemented("Not supported"))
}
async fn slot_cache(
&self,
_: Request<backend::SlotCacheRequest>,
) -> Result<Response<backend::SlotCacheResponse>, Status> {
Err(Status::unimplemented("Not supported"))
}
async fn predict(
&self,
_: Request<backend::PredictOptions>,
+30
View File
@@ -0,0 +1,30 @@
package backend
import (
"context"
"fmt"
"github.com/mudler/LocalAI/core/config"
grpcbackend "github.com/mudler/LocalAI/pkg/grpc"
"github.com/mudler/LocalAI/pkg/model"
)
// ModelTTSLive streams one persistent utterance. Closing text commits input EOS;
// this call returns only after the final audio or a cancellation/error.
func ModelTTSLive(ctx context.Context, text <-chan string, voice, language string, params map[string]string, loader *model.ModelLoader, appConfig *config.ApplicationConfig, cfg config.ModelConfig, onAudio func([]byte) error) error {
tts, err := loader.Load(ModelOptions(cfg, appConfig, model.WithContext(ctx))...)
if err != nil {
return err
}
live, ok := tts.(grpcbackend.TTSLiveBackend)
if !ok {
return fmt.Errorf("backend %q does not support incremental text TTS", cfg.Backend)
}
release, err := AcquireGlobalBackendSlot()
if err != nil {
return err
}
defer release()
req := newTTSRequest("", cfg.Model, voice, "", language, "", params, cfg.Model)
return live.TTSLive(ctx, req, text, onAudio)
}
+2 -2
View File
@@ -1204,8 +1204,8 @@ type PipelineStreaming struct {
// ClauseChunking splits the streamed LLM reply into speakable clauses and
// synthesizes each as soon as it completes, instead of buffering the whole
// message before TTS. Script-aware (CJK/Thai), so it does not rely on
// whitespace sentence boundaries. Requires LLM streaming; unset buffers the
// whole message (today's default).
// whitespace sentence boundaries. Requires explicit true; unset and false
// retain whole-reply synthesis for engines without native live text input.
ClauseChunking *bool `yaml:"clause_chunking,omitempty" json:"clause_chunking,omitempty"`
}
@@ -14,6 +14,8 @@ import (
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type rtSource map[string]config.ModelConfig
@@ -43,6 +45,22 @@ var _ = Describe("realtime failover", func() {
stageTargetConfig: func(name string) (*config.ModelConfig, error) { return &config.ModelConfig{Name: name}, nil }}}
}
It("checks streaming support on resolved chain targets before backend loading", func() {
m := chainModel()
m.TTSConfig = &config.ModelConfig{Backend: "sherpa-onnx"}
var resolved []string
m.stageTargetConfig = func(name string) (*config.ModelConfig, error) {
resolved = append(resolved, name)
return &config.ModelConfig{Name: name, Backend: "piper"}, nil
}
Expect(m.SupportsTTSStream()).To(BeTrue())
err := m.TTSStream(context.Background(), "Hello", "", "", func([]byte, int) error { return nil })
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(resolved).To(Equal([]string{"a", "b"}))
m.TTSConfig.Backend = "piper"
Expect(m.SupportsTTSStream()).To(BeTrue())
})
It("routes a chain stage through the plan and retries before commit", func() {
m := chainModel()
var tried []string
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"maps"
"slices"
"strings"
"sync"
"time"
@@ -28,6 +29,8 @@ import (
"github.com/mudler/LocalAI/pkg/grpc/proto"
model "github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
@@ -534,6 +537,9 @@ func (m *wrappedModel) setTTSParams(params map[string]string) {
func (m *wrappedModel) TTSStream(ctx context.Context, text, voice, language string, onAudio func(pcm []byte, sampleRate int) error) error {
return m.stageCall(ctx, config.PipelineStageTTS, m.TTSConfig, func(cfg *config.ModelConfig, commit func()) error {
if !ttsBackendSupportsStreaming(cfg.Backend) {
return status.Error(codes.Unimplemented, "TTS backend does not support streaming audio")
}
// Audio that reached the client cannot be taken back, so the first
// chunk ends the retries.
return ttsStream(ctx, m.modelLoader, m.appConfig, *cfg, text, voice, language, maps.Clone(m.ttsParams), func(pcm []byte, sr int) error {
@@ -1228,3 +1234,53 @@ func liveVoiceOptions(ctx context.Context, registry voicerecognition.Registry, c
}
return []backend.LiveOption{backend.WithKnownVoices(voices)}
}
// SupportsTTSLive selects the native persistent-input path explicitly. Other
// engines retain their existing whole-text or clause synthesis behaviour.
func (m *wrappedModel) SupportsTTSLive() bool {
return m.TTSConfig != nil && m.TTSConfig.Backend == "qwen3-tts-cpp"
}
func (m *wrappedModel) TTSLive(ctx context.Context, text <-chan string, voice, language string, onAudio func([]byte, int) error) error {
return m.stageCall(ctx, config.PipelineStageTTS, m.TTSConfig, func(cfg *config.ModelConfig, commit func()) error {
// Text is consumed once. Retrying a session would omit consumed input or
// duplicate speech already heard, so fallback ends before consuming it.
commit()
var header []byte
rate := 0
return backend.ModelTTSLive(ctx, text, voice, language, maps.Clone(m.ttsParams), m.modelLoader, m.appConfig, *cfg, func(b []byte) error {
if rate != 0 {
return onAudio(b, rate)
}
header = append(header, b...)
if len(header) < wavStreamHeaderBytes {
return nil
}
if string(header[:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return fmt.Errorf("invalid live TTS WAV header")
}
rate = int(binary.LittleEndian.Uint32(header[24:28]))
if rate == 0 {
return fmt.Errorf("invalid live TTS sample rate")
}
if len(header) > wavStreamHeaderBytes {
return onAudio(header[wavStreamHeaderBytes:], rate)
}
header = nil
return nil
})
})
}
// SupportsTTSStream avoids probing unary-only engines through the legacy
// streaming RPC. Unknown backends retain their existing streaming probe.
func (m *wrappedModel) SupportsTTSStream() bool {
if m.isChainStage(config.PipelineStageTTS) || m.TTSConfig == nil {
return true
}
return ttsBackendSupportsStreaming(m.TTSConfig.Backend)
}
func ttsBackendSupportsStreaming(backend string) bool {
capability := config.GetBackendCapability(backend)
return capability == nil || slices.Contains(capability.GRPCMethods, config.MethodTTSStream)
}
+22 -3
View File
@@ -10,6 +10,8 @@ import (
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
laudio "github.com/mudler/LocalAI/pkg/audio"
"github.com/mudler/LocalAI/pkg/sound"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// emitSpeech synthesizes text and sends the audio to the client. When the
@@ -31,6 +33,7 @@ func emitSpeech(ctx context.Context, t Transport, session *Session, responseID,
_, isWebRTC := t.(*WebRTCTransport)
audioCommitted := false
var wsAudio []byte // PCM at the session output rate, accumulated for the item record
// sendChunk hands one PCM buffer to the transport: WebRTC consumes the raw
@@ -40,6 +43,10 @@ func emitSpeech(ctx context.Context, t Transport, session *Session, responseID,
if len(pcm) == 0 {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
audioCommitted = true
if err := t.SendAudio(ctx, pcm, sampleRate); err != nil {
return err
}
@@ -68,11 +75,23 @@ func emitSpeech(ctx context.Context, t Transport, session *Session, responseID,
language = session.InputAudioTranscription.Language
}
if session.ModelConfig != nil && session.ModelConfig.Pipeline.StreamTTS() {
if err := session.ModelInterface.TTSStream(ctx, text, session.Voice, language, sendChunk); err != nil {
streamingAvailable := true
if capability, ok := session.ModelInterface.(interface{ SupportsTTSStream() bool }); ok {
streamingAvailable = capability.SupportsTTSStream()
}
if session.ModelConfig != nil && session.ModelConfig.Pipeline.StreamTTS() && streamingAvailable {
err := session.ModelInterface.TTSStream(ctx, text, session.Voice, language, sendChunk)
if err == nil {
return wsAudio, nil
}
// Only an unsupported RPC before audio may safely fall back. Once a
// transport sees PCM, replaying this clause could repeat heard speech.
if status.Code(err) != codes.Unimplemented || audioCommitted || ctx.Err() != nil {
return nil, err
}
return wsAudio, nil
}
if err := ctx.Err(); err != nil {
return nil, err
}
// Unary fallback: synthesize the whole utterance to a file, then emit once.
@@ -2,7 +2,12 @@ package openai
import (
"context"
"fmt"
proto "github.com/mudler/LocalAI/pkg/grpc/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -68,3 +73,50 @@ var _ = Describe("emitSpeech", func() {
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioDelta)).To(Equal(1))
})
})
type unsupportedStreamingSpeech struct {
fakeModel
emitBeforeError bool
unaryCalls int
}
func (m *unsupportedStreamingSpeech) TTSStream(_ context.Context, _, _, _ string, onAudio func([]byte, int) error) error {
if m.emitBeforeError {
if err := onAudio([]byte{1, 2}, 24000); err != nil {
return err
}
}
return status.Error(codes.Unimplemented, "streaming unavailable")
}
func (m *unsupportedStreamingSpeech) TTS(ctx context.Context, text, voice, language string) (string, *proto.Result, error) {
m.unaryCalls++
return m.fakeModel.TTS(ctx, text, voice, language)
}
var _ = Describe("TTS streaming capability fallback", func() {
for _, partial := range []bool{false, true} {
It(fmt.Sprintf("replays only unsupported streams without committed PCM (partial=%t)", partial), func() {
path := filepath.Join(GinkgoT().TempDir(), "speech.wav")
f, err := os.Create(path)
Expect(err).NotTo(HaveOccurred())
header := laudio.NewWAVHeader(2)
Expect(header.Write(f)).To(Succeed())
_, err = f.Write([]byte{3, 4})
Expect(err).NotTo(HaveOccurred())
Expect(f.Close()).To(Succeed())
model := &unsupportedStreamingSpeech{fakeModel: fakeModel{ttsFile: path}, emitBeforeError: partial}
on := true
session := &Session{OutputSampleRate: 24000, ModelInterface: model, ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{TTS: &on}}}}
transport := &fakeTransport{}
_, err = emitSpeech(context.Background(), transport, session, "response", "item", "Hello")
if partial {
Expect(status.Code(err)).To(Equal(codes.Unimplemented))
Expect(model.unaryCalls).To(BeZero())
} else {
Expect(err).NotTo(HaveOccurred())
Expect(model.unaryCalls).To(Equal(1))
}
Expect(transport.countEvents(types.ServerEventTypeResponseOutputAudioDelta)).To(Equal(1))
})
}
})
+62 -40
View File
@@ -16,10 +16,8 @@ import (
// transcriptStreamer turns streamed LLM tokens into the assistant's spoken
// transcript: it strips reasoning incrementally and sends one
// response.output_audio_transcript.delta per content fragment. It does NOT
// synthesize audio — the caller buffers the full message and synthesizes it
// once (streaming the audio chunks when the TTS backend supports TTSStream),
// which works uniformly for streaming and non-streaming TTS and for languages
// without sentence or word boundaries.
// synthesize audio. The caller sends cleaned content to a persistent live TTS
// session when supported, or uses the existing whole-text or clause path.
type transcriptStreamer struct {
ctx context.Context
t Transport
@@ -78,15 +76,18 @@ func (s *transcriptStreamer) content() string {
}
// streamLLMResponse drives a streamed realtime reply. It streams the assistant
// transcript as the LLM generates, then synthesizes the whole buffered message
// once (streaming the audio chunks when the TTS backend supports it, otherwise a
// single unary delta). Tool calls parsed from the autoparser ChatDeltas are
// transcript as the LLM generates and feeds a persistent live TTS session when
// available. Other engines keep whole-text or clause synthesis. Tool calls
// parsed from the autoparser ChatDeltas are
// emitted after the spoken content. The assistant content item is created lazily
// on the first content delta, so a content-less tool-call turn emits only the
// tool calls. It returns true when it has fully handled the response so the
// caller can return; callers must only invoke it for an audio modality, and with
// tools only when the model uses its tokenizer template (see triggerResponseAtTurn).
func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation, t Transport, r *liveResponse, history schema.Messages, images []string, llmCfg *config.ModelConfig, tools []types.ToolUnion, toolChoice *types.ToolChoiceUnion, toolTurn int) bool {
callerCtx := ctx
ctx, cancelWork := context.WithCancel(ctx)
defer cancelWork()
responseID := r.id
itemID := generateItemID()
item := types.MessageItemUnion{
@@ -163,37 +164,40 @@ func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation
streamer := newTranscriptStreamer(ctx, t, responseID, itemID, thinkingStartToken, reasoningCfg)
streamer.announce = announce
// Clause chunking (opt-in): synthesize each clause as soon as it completes
// instead of buffering the whole reply. Synthesis runs on a worker goroutine
// (ttsPipeline) rather than inline in the token callback: emitSpeech blocks
// until the whole clause is synthesized (and, for WebRTC, played back at
// real time), and the callback runs on the goroutine that drains the LLM
// gRPC stream — so speaking inline stalls generation and freezes the
// assistant transcript at every clause boundary. The worker lets generation
// and the transcript stream keep flowing while audio is produced behind them.
// Synthesize completed clauses on a bounded worker so audio playback can
// apply backpressure without running synthesis inside the token callback.
var chunker *clauseChunker
var ttsPipe *ttsPipeline
if session.ModelConfig != nil && session.ModelConfig.Pipeline.ChunkClauses() {
var livePipe *liveSpeechPipeline
liveModel, liveCapable := session.ModelInterface.(liveSpeechModel)
useLive := liveCapable && liveModel.SupportsTTSLive() && session.ModelConfig != nil && session.ModelConfig.Pipeline.StreamTTS()
defer func() {
if livePipe != nil {
livePipe.abort()
}
}()
if !useLive && session.ModelConfig != nil && session.ModelConfig.Pipeline.ChunkClauses() {
chunker = newClauseChunker(defaultClauseMinRunes, defaultClauseMaxRunes)
ttsPipe = newTTSPipeline(func(clause string) ([]byte, error) {
return emitSpeech(ctx, t, session, responseID, itemID, clause)
})
}
// Start synthesis only when the first speakable clause is available.
enqueueClause := func(clause string) bool {
if livePipe == nil {
livePipe = newLiveSpeechPipeline(ctx, func(workerCtx context.Context, input <-chan string) ([]byte, error) {
audio, err := emitBufferedSpeech(workerCtx, input, t, session, responseID, itemID)
if err != nil {
cancelWork()
}
return audio, err
})
}
return livePipe.push(clause)
}
var streamedAudio []byte
var ttsErr error
// Backstop: always join the TTS worker, even on an unexpected early return.
// wait() is idempotent, so the explicit drain below (which captures the
// streamed audio and first error) stays authoritative; this only guarantees
// the goroutine can never leak if a new return path is added.
if ttsPipe != nil {
defer func() { _, _ = ttsPipe.wait() }()
}
// fail reports a mid-stream failure. A cancelled context means the client
// interrupted (barge-in), so roll the turn back instead of erroring.
fail := func(code, msg string, err error) bool {
if ctx.Err() != nil {
if callerCtx.Err() != nil {
cancel()
} else {
sendError(t, code, fmt.Sprintf("%s: %v", msg, err), "", itemID)
@@ -215,13 +219,26 @@ func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation
text = functions.ContentFromChatDeltas(usage.ChatDeltas)
}
delta := streamer.onToken(text)
if useLive && delta != "" {
if livePipe == nil {
livePipe = newLiveSpeechPipeline(ctx, func(liveCtx context.Context, input <-chan string) ([]byte, error) {
audio, err := emitLiveSpeech(liveCtx, liveModel, input, t, session, responseID, itemID)
if err != nil {
cancelWork()
}
return audio, err
})
}
if !livePipe.push(delta) {
return false
}
}
if chunker != nil && delta != "" {
for _, clause := range chunker.push(delta) {
// Hand the clause to the worker and keep going — never block the
// recv loop on synthesis. A false return means a prior clause
// already failed; stop the prediction (the error is collected
// from the pipeline after predFunc returns).
if !ttsPipe.enqueue(clause) {
// The clause queue applies backpressure when audio falls behind.
// A false return means cancellation or a prior synthesis failure;
// collect the worker error after prediction stops.
if !enqueueClause(clause) {
return false
}
}
@@ -239,17 +256,22 @@ func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation
// Drain the TTS worker. On a clean finish, enqueue the trailing clause(s) the
// chunker was still holding; on an error or barge-in, stop synthesizing.
// wait() runs on every path so the worker goroutine never leaks, and it
// returns the audio streamed so far plus the first synthesis failure.
if ttsPipe != nil {
// The pipeline is joined below to collect audio and the first failure.
if chunker != nil {
if err == nil && ctx.Err() == nil {
for _, clause := range chunker.flush() {
if !ttsPipe.enqueue(clause) {
if !enqueueClause(clause) {
break
}
}
}
streamedAudio, ttsErr = ttsPipe.wait()
}
if livePipe != nil {
if err != nil || ctx.Err() != nil {
livePipe.cancel()
}
streamedAudio, ttsErr = livePipe.finish()
}
// A clause synthesis failed mid-stream (the callback stopped the prediction);
@@ -282,7 +304,7 @@ func streamLLMResponse(ctx context.Context, session *Session, conv *Conversation
// synthesize it once now — emitSpeech streams the audio chunks when the
// TTS backend supports TTSStream, otherwise it sends a single unary delta.
var audio []byte
if chunker != nil {
if chunker != nil || livePipe != nil {
audio = streamedAudio
} else {
audio, ttsErr = emitSpeech(ctx, t, session, responseID, itemID, content)
@@ -2,6 +2,7 @@ package openai
import (
"context"
"fmt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -83,37 +84,39 @@ var _ = Describe("transcriptStreamer", func() {
// streamLLMResponse drives a full streamed realtime turn: live transcript
// deltas while the LLM generates, then the whole message is synthesized once.
var _ = Describe("streamLLMResponse", func() {
It("streams transcript deltas then synthesizes the whole message once", func() {
on := true
m := &fakeModel{
predictTokens: []string{"Hello", " world.", " How are you?"},
predictResp: backend.LLMResponse{Response: "Hello world. How are you?"},
ttsStreamChunks: [][]byte{{9}},
ttsStreamRate: 24000,
}
session := &Session{
OutputSampleRate: 24000,
ModelInterface: m,
ModelConfig: &config.ModelConfig{
Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{LLM: &on, TTS: &on}},
},
}
conv := &Conversation{}
t := &fakeTransport{}
llmCfg := &config.ModelConfig{}
for _, clauseFlag := range []*bool{nil, new(false)} {
It(fmt.Sprintf("buffers the whole reply with clause chunking %v", clauseFlag), func() {
on := true
m := &fakeModel{
predictTokens: []string{"Hello", " world.", " How are you?"},
predictResp: backend.LLMResponse{Response: "Hello world. How are you?"},
ttsStreamChunks: [][]byte{{9}},
ttsStreamRate: 24000,
}
session := &Session{
OutputSampleRate: 24000,
ModelInterface: m,
ModelConfig: &config.ModelConfig{
Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{LLM: &on, TTS: &on, ClauseChunking: clauseFlag}},
},
}
conv := &Conversation{}
t := &fakeTransport{}
llmCfg := &config.ModelConfig{}
r := &liveResponse{id: "resp1"}
handled := streamLLMResponse(context.Background(), session, conv, t, r, nil, nil, llmCfg, nil, nil, 0)
r := &liveResponse{id: "resp1"}
handled := streamLLMResponse(context.Background(), session, conv, t, r, nil, nil, llmCfg, nil, nil, 0)
Expect(handled).To(BeTrue())
// One live transcript delta per streamed token.
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioTranscriptDelta)).To(Equal(3))
// The whole message is synthesized ONCE (not per sentence): a single
// emitSpeech replays the one TTS stream chunk.
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioDelta)).To(Equal(1))
Expect(t.transcriptDeltaText()).To(Equal("Hello world. How are you?"))
})
Expect(handled).To(BeTrue())
// One live transcript delta per streamed token.
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioTranscriptDelta)).To(Equal(3))
// The whole message is synthesized ONCE (not per sentence): a single
// emitSpeech replays the one TTS stream chunk.
Expect(t.countEvents(types.ServerEventTypeResponseOutputAudioDelta)).To(Equal(1))
Expect(t.transcriptDeltaText()).To(Equal("Hello world. How are you?"))
})
}
It("synthesizes each clause as it completes when clause chunking is enabled", func() {
on := true
m := &fakeModel{
@@ -0,0 +1,182 @@
package openai
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
"github.com/mudler/LocalAI/core/schema"
laudio "github.com/mudler/LocalAI/pkg/audio"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type bufferedSpeechModel struct {
fakeModel
engine, dir string
clauses []string
early chan struct{}
fail error
cancel context.CancelFunc
streamCalls, unaryCalls int
}
func (m *bufferedSpeechModel) SupportsTTSStream() bool {
return (&wrappedModel{TTSConfig: &config.ModelConfig{Backend: m.engine}}).SupportsTTSStream()
}
func (m *bufferedSpeechModel) Predict(ctx context.Context, _ schema.Messages, _, _, _ []string, cb func(string, backend.TokenUsage) bool, _ []types.ToolUnion, _ *types.ToolChoiceUnion, _, _ *int, _ map[string]float64) (func() (backend.LLMResponse, error), error) {
return func() (backend.LLMResponse, error) {
if !cb("First complete sentence. Next", backend.TokenUsage{}) {
return backend.LLMResponse{}, ctx.Err()
}
select {
case <-m.early:
case <-ctx.Done():
return backend.LLMResponse{}, ctx.Err()
}
if !cb(" complete sentence. Final tail.", backend.TokenUsage{}) {
return backend.LLMResponse{}, ctx.Err()
}
return backend.LLMResponse{Response: "First complete sentence. Next complete sentence. Final tail."}, nil
}, nil
}
func (m *bufferedSpeechModel) TTSStream(ctx context.Context, text, _, _ string, onAudio func([]byte, int) error) error {
m.streamCalls++
m.clauses = append(m.clauses, text)
if m.fail != nil {
return m.fail
}
if err := onAudio([]byte{byte(len(m.clauses)), 0}, 24000); err != nil {
return err
}
if len(m.clauses) == 1 {
if m.cancel != nil {
m.cancel()
}
}
return onAudio([]byte{byte(len(m.clauses)), 1}, 24000)
}
func (m *bufferedSpeechModel) TTS(ctx context.Context, text, _, _ string) (string, *pb.Result, error) {
m.unaryCalls++
m.clauses = append(m.clauses, text)
if m.fail != nil {
return "", nil, m.fail
}
file, err := os.CreateTemp(m.dir, "clause-*.wav")
if err != nil {
return "", nil, err
}
header := laudio.NewWAVHeader(2)
if err := header.Write(file); err != nil {
_ = file.Close()
return "", nil, err
}
if _, err := file.Write([]byte{byte(len(m.clauses)), 0}); err != nil {
_ = file.Close()
return "", nil, err
}
if err := file.Close(); err != nil {
return "", nil, err
}
return filepath.Clean(file.Name()), &pb.Result{Success: true}, nil
}
type bufferedAudioTransport struct {
fakeTransport
ready chan struct{}
once sync.Once
}
func (t *bufferedAudioTransport) SendAudio(ctx context.Context, pcm []byte, rate int) error {
if err := t.fakeTransport.SendAudio(ctx, pcm, rate); err != nil {
return err
}
t.once.Do(func() { close(t.ready) })
return nil
}
var _ = Describe("Buffered progressive TTS", func() {
for _, tc := range []struct {
engine string
stream bool
}{{"sherpa-onnx", true}, {"piper", true}, {"sherpa-onnx", false}} {
engine := tc.engine
It(fmt.Sprintf("emits ordered clauses before LLM EOF with %s (stream=%t)", engine, tc.stream), func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
on := true
m := &bufferedSpeechModel{engine: engine, dir: GinkgoT().TempDir(), early: make(chan struct{})}
session := &Session{OutputSampleRate: 24000, ModelInterface: m, ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{LLM: &on, TTS: &tc.stream, ClauseChunking: &on}}}}
transport := &bufferedAudioTransport{ready: m.early}
r := &liveResponse{id: "buffered"}
streamLLMResponse(ctx, session, &Conversation{}, transport, r, nil, nil, &config.ModelConfig{}, nil, nil, 0)
Expect(ctx.Err()).NotTo(HaveOccurred(), "audio must reach the transport before the predictor can finish")
Expect(m.clauses).To(Equal([]string{"First complete sentence.", "Next complete sentence.", "Final tail."}))
if engine == "piper" || !tc.stream {
Expect(m.streamCalls).To(BeZero())
Expect(m.unaryCalls).To(Equal(3))
Expect(transport.audio).To(HaveLen(3))
} else {
Expect(m.streamCalls).To(Equal(3))
Expect(transport.audio).To(HaveLen(6))
}
var sequence []byte
for _, chunk := range transport.audio {
sequence = append(sequence, chunk.pcm[0])
}
if engine == "piper" || !tc.stream {
Expect(sequence).To(Equal([]byte{1, 2, 3}))
} else {
Expect(sequence).To(Equal([]byte{1, 1, 2, 2, 3, 3}))
}
})
}
It("cancels a waiting predictor immediately on synthesis error", func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
on := true
m := &bufferedSpeechModel{engine: "sherpa-onnx", early: make(chan struct{}), fail: errors.New("synthesis failed")}
session := &Session{OutputSampleRate: 24000, ModelInterface: m, ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{LLM: &on, TTS: &on, ClauseChunking: &on}}}}
r := &liveResponse{id: "error"}
streamLLMResponse(ctx, session, &Conversation{}, &fakeTransport{}, r, nil, nil, &config.ModelConfig{}, nil, nil, 0)
Expect(ctx.Err()).NotTo(HaveOccurred())
Expect(r.outcome).To(Equal(outcomeFailed))
Expect(m.clauses).To(HaveLen(1))
})
It("stops queued speech on caller cancellation", func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
on := true
m := &bufferedSpeechModel{engine: "sherpa-onnx", early: make(chan struct{}), cancel: cancel}
session := &Session{OutputSampleRate: 24000, ModelInterface: m, ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{LLM: &on, TTS: &on, ClauseChunking: &on}}}}
r := &liveResponse{id: "cancel"}
streamLLMResponse(ctx, session, &Conversation{}, &fakeTransport{}, r, nil, nil, &config.ModelConfig{}, nil, nil, 0)
Expect(r.outcome).To(Equal(outcomeCancelled))
Expect(m.clauses).To(HaveLen(1))
})
})
var _ = Describe("Buffered speech backpressure", func() {
It("bounds pending clauses and wakes a blocked producer on cancellation", func() {
p := newLiveSpeechPipeline(context.Background(), func(ctx context.Context, _ <-chan string) ([]byte, error) { <-ctx.Done(); return nil, ctx.Err() })
defer p.abort()
for i := 0; i < cap(p.text); i++ {
Expect(p.push("queued clause")).To(BeTrue())
}
pushed := make(chan bool, 1)
go func() { pushed <- p.push("blocked clause") }()
Consistently(pushed, 20*time.Millisecond).ShouldNot(Receive())
p.cancel()
Eventually(pushed).Should(Receive(BeFalse()))
_, err := p.finish()
Expect(err).To(MatchError(context.Canceled))
})
})
@@ -0,0 +1,106 @@
package openai
import (
"context"
"encoding/base64"
"sync"
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
"github.com/mudler/LocalAI/pkg/sound"
)
type liveSpeechModel interface {
SupportsTTSLive() bool
TTSLive(context.Context, <-chan string, string, string, func([]byte, int) error) error
}
// liveSpeechPipeline bounds pending text and couples producer lifetime to the
// audio sink: cancellation/error wakes producers and the native input callback.
type liveSpeechPipeline struct {
text chan string
done chan struct{}
cancel context.CancelFunc
once sync.Once
stop <-chan struct{}
audio []byte
err error
}
func newLiveSpeechPipeline(ctx context.Context, run func(context.Context, <-chan string) ([]byte, error)) *liveSpeechPipeline {
ctx, cancel := context.WithCancel(ctx)
p := &liveSpeechPipeline{text: make(chan string, 8), done: make(chan struct{}), stop: ctx.Done(), cancel: cancel}
go func() { defer close(p.done); defer cancel(); p.audio, p.err = run(ctx, p.text) }()
return p
}
func (p *liveSpeechPipeline) push(text string) bool {
select {
case <-p.stop:
return false
default:
}
select {
case <-p.stop:
return false
case p.text <- text:
return true
}
}
func (p *liveSpeechPipeline) finish() ([]byte, error) {
p.once.Do(func() { close(p.text) })
<-p.done
return p.audio, p.err
}
func (p *liveSpeechPipeline) abort() { p.cancel(); _, _ = p.finish() }
func emitLiveSpeech(ctx context.Context, model liveSpeechModel, text <-chan string, t Transport, session *Session, responseID, itemID string) ([]byte, error) {
var audio []byte
language := ""
if session.InputAudioTranscription != nil {
language = session.InputAudioTranscription.Language
}
_, webRTC := t.(*WebRTCTransport)
err := model.TTSLive(ctx, text, session.Voice, language, func(pcm []byte, rate int) error {
if len(pcm) == 0 {
return nil
}
if err := t.SendAudio(ctx, pcm, rate); err != nil {
return err
}
if webRTC {
return nil
}
if rate != 0 && rate != session.OutputSampleRate {
pcm = sound.Int16toBytesLE(sound.ResampleInt16(sound.BytesToInt16sLE(pcm), rate, session.OutputSampleRate))
}
audio = append(audio, pcm...)
return t.SendEvent(types.ResponseOutputAudioDeltaEvent{ResponseID: responseID, ItemID: itemID, OutputIndex: 0, ContentIndex: 0, Delta: base64.StdEncoding.EncodeToString(pcm)})
})
return audio, err
}
// emitBufferedSpeech synthesizes independent complete clauses in order. Each
// clause can stream its own backend audio; this is not a native live session.
func emitBufferedSpeech(ctx context.Context, input <-chan string, t Transport, session *Session, responseID, itemID string) ([]byte, error) {
var audio []byte
for {
if err := ctx.Err(); err != nil {
return audio, err
}
select {
case <-ctx.Done():
return audio, ctx.Err()
case clause, ok := <-input:
if !ok {
return audio, nil
}
if err := ctx.Err(); err != nil {
return audio, err
}
chunk, err := emitSpeech(ctx, t, session, responseID, itemID, clause)
audio = append(audio, chunk...)
if err != nil {
return audio, err
}
}
}
}
@@ -0,0 +1,140 @@
package openai
import (
"context"
"errors"
"fmt"
. "github.com/onsi/ginkgo/v2"
"reflect"
"time"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
"github.com/mudler/LocalAI/core/schema"
)
type liveRouteModel struct {
fakeModel
early chan struct{}
received []string
calls int
failure error
failAfterAudio bool
}
func (*liveRouteModel) SupportsTTSLive() bool { return true }
func (m *liveRouteModel) TTSLive(ctx context.Context, text <-chan string, _, _ string, audio func([]byte, int) error) error {
m.calls++
if m.failure != nil && !m.failAfterAudio {
return m.failure
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case part, ok := <-text:
if !ok {
return nil
}
m.received = append(m.received, part)
if err := audio([]byte{byte(len(m.received)), 0}, 24000); err != nil {
return err
}
if len(m.received) == 1 {
close(m.early)
}
if m.failure != nil {
return m.failure
}
}
}
}
func (m *liveRouteModel) Predict(ctx context.Context, _ schema.Messages, _, _, _ []string, cb func(string, backend.TokenUsage) bool, _ []types.ToolUnion, _ *types.ToolChoiceUnion, _, _ *int, _ map[string]float64) (func() (backend.LLMResponse, error), error) {
return func() (backend.LLMResponse, error) {
if !cb("First ", backend.TokenUsage{}) {
return backend.LLMResponse{}, errors.New("callback stopped")
}
// No second text, input EOS, or completed LLM response exists at this point.
select {
case <-m.early:
case <-ctx.Done():
return backend.LLMResponse{}, ctx.Err()
}
if !cb("late text.", backend.TokenUsage{}) {
return backend.LLMResponse{}, errors.New("callback stopped")
}
return backend.LLMResponse{Response: "First late text."}, nil
}, nil
}
var _ = Describe("TTSLiveRealtimeRoute", func() {
for _, clauseFlag := range []*bool{nil, new(false), new(true)} {
It(fmt.Sprintf("preserves native synthesis with clause chunking %v", clauseFlag), func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
on := true
m := &liveRouteModel{early: make(chan struct{})}
session := &Session{OutputSampleRate: 24000, ModelInterface: m, ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{LLM: &on, TTS: &on, ClauseChunking: clauseFlag}}}}
transport := &fakeTransport{}
if !streamLLMResponse(ctx, session, &Conversation{}, transport, &liveResponse{id: "live"}, nil, nil, &config.ModelConfig{}, nil, nil, 0) {
Fail(fmt.Sprint("route unhandled"))
}
if ctx.Err() != nil {
Fail(fmt.Sprint("no audio before LLM finished", ctx.Err()))
}
if !reflect.DeepEqual(m.received, []string{"First ", "late text."}) || m.calls != 1 {
Fail(fmt.Sprintf("received %v in %d sessions", m.received, m.calls))
}
if len(transport.audio) != 2 {
Fail(fmt.Sprintf("audio chunks %d", len(transport.audio)))
}
})
}
})
var _ = Describe("TTSLivePipelineCancellation", func() {
It("preserves the live synthesis contract", func() {
started := make(chan struct{})
stopped := make(chan struct{})
p := newLiveSpeechPipeline(context.Background(), func(ctx context.Context, _ <-chan string) ([]byte, error) {
close(started)
defer close(stopped)
<-ctx.Done()
return nil, ctx.Err()
})
<-started
p.abort()
select {
case <-stopped:
case <-time.After(time.Second):
Fail(fmt.Sprint("worker leaked"))
}
if p.push("late") {
Fail(fmt.Sprint("accepted text after cancellation"))
}
})
})
var _ = Describe("TTSLiveRealtimeFailureCancelsPrediction", func() {
It("preserves the live synthesis contract", func() {
for _, afterAudio := range []bool{false, true} {
By(map[bool]string{false: "before PCM", true: "after PCM"}[afterAudio])
func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
on := true
m := &liveRouteModel{early: make(chan struct{}), failure: errors.New("native failed"), failAfterAudio: afterAudio}
session := &Session{OutputSampleRate: 24000, ModelInterface: m, ModelConfig: &config.ModelConfig{Pipeline: config.Pipeline{Streaming: config.PipelineStreaming{LLM: &on, TTS: &on}}}}
transport := &fakeTransport{}
r := &liveResponse{id: "failure"}
streamLLMResponse(ctx, session, &Conversation{}, transport, r, nil, nil, &config.ModelConfig{}, nil, nil, 0)
if ctx.Err() != nil {
Fail(fmt.Sprint("TTS failure did not cancel in-flight prediction promptly"))
}
if r.outcome != outcomeFailed || m.calls != 1 {
Fail(fmt.Sprintf("outcome=%v sessions=%d", r.outcome, m.calls))
}
}()
}
})
})
@@ -726,3 +726,30 @@ func copyFile(src, dst string) error {
}
return closeErr
}
func (f *FileStagingClient) TTSLive(ctx context.Context, in *pb.TTSRequest, text <-chan string, fn func([]byte) error) error {
lifecycle := f.newStagedInputLifecycle()
defer lifecycle.release()
in = proto.Clone(in).(*pb.TTSRequest)
// Translate model path from frontend to remote worker path (same as TTS above)
if in.Model != "" && isFilePath(in.Model) {
in.Model = f.translateModelPath(in.Model)
}
if in.Voice != "" && isFilePath(in.Voice) {
backendPath, err := f.stageInputFile(ctx, lifecycle, in.Voice, "inputs")
if err != nil {
return fmt.Errorf("staging streaming TTS voice reference: %w", err)
}
in.Voice = backendPath
}
if err := f.stageTTSReferences(ctx, lifecycle, in); err != nil {
return err
}
live, ok := f.Backend.(grpc.TTSLiveBackend)
if !ok {
return fmt.Errorf("backend does not support incremental text TTS")
}
return live.TTSLive(ctx, in, text, fn)
}
+10
View File
@@ -2,6 +2,7 @@ package nodes
import (
"context"
"fmt"
"sync"
"time"
@@ -294,3 +295,12 @@ func (c *InFlightTrackingClient) AudioTransform(ctx context.Context, in *pb.Audi
// the done() func tied to stream close, which the Backend interface doesn't surface
// here. If they ever need tracking, move them to grpc.InferenceBackend - the build
// will then force an explicit wrapper here.
func (c *InFlightTrackingClient) TTSLive(ctx context.Context, in *pb.TTSRequest, text <-chan string, fn func([]byte) error) error {
defer c.track(ctx)()
live, ok := c.inner.(grpc.TTSLiveBackend)
if !ok {
return fmt.Errorf("backend does not support incremental text TTS")
}
return c.reconcile(live.TTSLive(ctx, in, text, fn))
}
+66 -3
View File
@@ -33,6 +33,68 @@ Make sure all referenced models (`silero-vad-ggml`, `whisper-large-turbo`, `qwen
A pipeline stage can name a [failover chain]({{%relref "features/model-failover" %}}); the stage then switches targets without closing the session.
### Install the Qwen streaming assistant preset
The gallery entry `qwen3-streaming-assistant` configures an English voice
assistant with Silero VAD, Parakeet TDT 0.6B v3, Qwen3 0.6B, and Qwen3-TTS
0.6B CustomVoice using Ryan's voice. It streams LLM output text into one persistent TTS session while audio
is emitted.
The preset installs only its pipeline configuration. Install all four component
models first, keeping their default names so the pipeline can resolve them:
```bash
local-ai models install silero-vad-ggml
local-ai models install parakeet-cpp-tdt-0.6b-v3
local-ai models install qwen3-0.6b
local-ai models install qwen3-tts-cpp-customvoice --variant qwen3-tts-cpp-customvoice
local-ai models install qwen3-streaming-assistant
```
The TTS command pins the Q8 CustomVoice entry. These are separate installs;
installing the preset does not install missing components. Use a LocalAI build
with incremental Qwen3-TTS input support, then select `qwen3-streaming-assistant`
on the Talk page or as the Realtime API model. WebRTC also requires the `opus`
backend, available from the Backends page.
This preset targets English speech. It uses a named voice and does not require
reference audio or `ref_text`. CPU synthesis can be slower than playback; this
preset does not establish GPU or browser latency or throughput.
### Install a clause-buffered assistant preset
Two English presets use the same Silero VAD, Parakeet TDT 0.6B v3, and Qwen3
0.6B components. `vits-streaming-assistant` uses VITS-LJS through Sherpa;
`piper-streaming-assistant` uses Piper's Amy voice.
Install the shared components once, keeping their default names:
```bash
local-ai models install silero-vad-ggml
local-ai models install parakeet-cpp-tdt-0.6b-v3
local-ai models install qwen3-0.6b
```
Then install either TTS component and its matching preset, or install both:
```bash
local-ai models install vits-ljs-sherpa
local-ai models install vits-streaming-assistant
local-ai models install voice-en-us-amy-low
local-ai models install piper-streaming-assistant
```
These presets install pipeline configurations only. They do not install missing
components. Both enable LLM streaming, TTS streaming, and `clause_chunking: true`.
Sherpa streams audio within each clause. Piper emits one audio result per clause.
Each clause starts a separate synthesis, which can introduce pauses between clauses.
The single-speaker models need no voice override or reference audio.
Select the installed preset on the Talk page or use its name as the Realtime API
model. WebSocket and WebRTC share the pipeline; WebRTC also needs the `opus`
backend from the Backends page. Config installation and discovery checks do not
establish speech quality, browser latency, or hardware throughput.
### Streaming the pipeline
By default each stage runs to completion before the next begins: the whole utterance is transcribed, the full LLM reply is generated, then it is synthesized. Each stage can instead be streamed incrementally, which lowers the time-to-first-audio of a turn:
@@ -53,10 +115,11 @@ pipeline:
- **streaming.tts**: emit a `response.output_audio.delta` per audio chunk the TTS backend produces (requires a backend that supports streaming synthesis), instead of one delta for the whole utterance. Falls back to a single unary delta otherwise.
- **streaming.transcription**: stream `conversation.item.input_audio_transcription.delta` events as the transcript is produced (requires a transcription backend that supports streaming).
- **streaming.llm**: stream the LLM reply token-by-token as `response.output_audio_transcript.delta` events. The full reply is buffered and synthesized once it is complete - streamed as audio chunks when `streaming.tts` is enabled (and the TTS backend supports it), otherwise as a single unary delta. Reasoning/thinking is always stripped from the spoken transcript. Tool calls are supported while streaming when the LLM uses its tokenizer template (`use_tokenizer_template: true`): the backend's autoparser then delivers content and tool calls separately, so the spoken transcript never leaks tool-call tokens. Grammar-based function calling keeps the buffered path.
- **streaming.clause_chunking**: instead of buffering the whole reply before TTS, split it into speakable clauses and synthesize each as soon as it completes, lowering the time-to-first-audio. The splitter is script-aware: it uses Unicode sentence segmentation (so it handles CJK `。!?` with no whitespace), CJK clause punctuation (`,、;:`), and Thai/Lao spaces - it does **not** rely on whitespace sentence boundaries, so it works for languages such as Chinese, Japanese and Thai where the old per-sentence approach degraded to whole-message buffering. Requires `streaming.llm`; scripts that genuinely need a dictionary (e.g. Khmer, Burmese) simply stay buffered until a space or end-of-message. Off by default.
- **streaming.llm**: stream the LLM reply token-by-token as `response.output_audio_transcript.delta` events. When TTS streaming is disabled, the full reply is synthesized once it is complete unless explicit clause chunking is enabled. Native incremental input or explicitly enabled clause buffering can produce audio before the reply finishes, as described below. Reasoning/thinking is always stripped from the spoken transcript. Tool calls are supported while streaming when the LLM uses its tokenizer template (`use_tokenizer_template: true`): the backend's autoparser then delivers content and tool calls separately, so the spoken transcript never leaks tool-call tokens. Grammar-based function calling keeps the buffered path.
- **Incremental Qwen3-TTS input**: with a `qwen3-tts-cpp` TTS model, enabling both `streaming.llm` and `streaming.tts` forwards cleaned LLM text fragments into one persistent synthesis session. Audio can arrive before the LLM finishes. WebSocket and WebRTC use this path. It takes precedence over `clause_chunking`; other backends can opt into clause buffering as described below. Pending text is bounded and slow audio playback applies backpressure. Cancellation stops the session. A failed live utterance is not replayed through a fallback model. The initial native implementation supports a single batch slot and reference audio without `ref_text`; transcript-based ICL cloning is unavailable for live input. Incomplete words are buffered until a stable boundary; CJK, Kana and Hangul characters can commit without whitespace.
- **streaming.clause_chunking**: instead of buffering the whole reply before TTS, split it into speakable clauses and synthesize each as soon as it completes, lowering the time-to-first-audio. The splitter is script-aware: it uses Unicode sentence segmentation (so it handles CJK `。!?` with no whitespace), CJK clause punctuation (`,、;:`), and Thai/Lao spaces - it does **not** rely on whitespace sentence boundaries, so it works for languages such as Chinese, Japanese and Thai where the old per-sentence approach degraded to whole-message buffering. Requires `streaming.llm`; scripts that genuinely need a dictionary (e.g. Khmer, Burmese) simply stay buffered until a space or end-of-message. Set `clause_chunking: true` to enable a bounded clause queue. Unset and false retain whole-reply synthesis for backends without native incremental text input. With `streaming.tts` enabled, Sherpa/VITS can stream audio within each clause; unary engines such as Piper produce one audio result per clause. With `streaming.tts` disabled, each clause uses unary synthesis. These engines start a separate synthesis for each clause, so they may pause at clause boundaries. Cancellation stops queued work; a streaming failure after audio has been emitted never replays that clause through unary synthesis.
All streaming flags are off by default, so existing pipelines are unaffected.
All streaming flags are off by default. Pipelines with streaming disabled remain unaffected.
### Model warm-up (cold start)
+98
View File
@@ -23949,6 +23949,104 @@
- filename: vibevoice-cpp-asr/tokenizer.gguf
sha256: 37dc3b722d5677e37e29a57df55aa05c485116eeb5459e57ff8dde616b4986f6
uri: huggingface://mudler/vibevoice.cpp-models/tokenizer.gguf
- name: vits-streaming-assistant
urls:
- https://localai.io/features/openai-realtime/
description: |
English voice assistant preset: Silero VAD, Parakeet TDT 0.6B v3, Qwen3
0.6B chat, and VITS-LJS English single-speaker TTS through sherpa-onnx.
Buffers streamed LLM output into clauses and emits audio progressively.
Each clause starts a separate synthesis; this is not native incremental
text input. This installs only the pipeline configuration. First install
silero-vad-ggml, parakeet-cpp-tdt-0.6b-v3, qwen3-0.6b, and vits-ljs-sherpa.
WebRTC also needs the opus backend. No browser or hardware performance
is established by this preset.
license: mit
tags:
- voice-assistant
- audio-pipeline
- realtime
- streaming
- english
- sherpa-onnx
config_file:
pipeline:
vad: silero-vad-ggml
transcription: parakeet-cpp-tdt-0.6b-v3
llm: qwen3-0.6b
tts: vits-ljs-sherpa
disable_thinking: true
streaming:
llm: true
tts: true
clause_chunking: true
- name: piper-streaming-assistant
urls:
- https://localai.io/features/openai-realtime/
description: |
English voice assistant preset: Silero VAD, Parakeet TDT 0.6B v3, Qwen3
0.6B chat, and Piper Amy US English TTS through piper.
Buffers streamed LLM output into clauses and emits audio progressively.
Each clause starts a separate synthesis; this is not native incremental
text input. This installs only the pipeline configuration. First install
silero-vad-ggml, parakeet-cpp-tdt-0.6b-v3, qwen3-0.6b, and voice-en-us-amy-low.
WebRTC also needs the opus backend. No browser or hardware performance
is established by this preset.
license: mit
tags:
- voice-assistant
- audio-pipeline
- realtime
- streaming
- english
- piper
config_file:
pipeline:
vad: silero-vad-ggml
transcription: parakeet-cpp-tdt-0.6b-v3
llm: qwen3-0.6b
tts: voice-en-us-amy-low
disable_thinking: true
streaming:
llm: true
tts: true
clause_chunking: true
- name: qwen3-streaming-assistant
urls:
- https://github.com/localai-org/qwentts.cpp
- https://localai.io/features/openai-realtime/
description: |
English voice assistant preset: Silero VAD, Parakeet TDT 0.6B v3, Qwen3
0.6B chat, and Qwen3-TTS 0.6B CustomVoice with Ryan's voice. Streams LLM
text into one persistent TTS session while audio is emitted.
This installs only the pipeline configuration. First install the separate
gallery models silero-vad-ggml, parakeet-cpp-tdt-0.6b-v3, qwen3-0.6b, and
qwen3-tts-cpp-customvoice. Pin its variant to qwen3-tts-cpp-customvoice
for Q8. WebRTC also needs the opus backend.
Requires a LocalAI build with incremental Qwen3-TTS input support.
CPU synthesis may be slower than playback; GPU and browser performance
are not established by this preset.
license: mit
tags:
- voice-assistant
- audio-pipeline
- realtime
- streaming
- english
- qwen3
- qwen3-tts
config_file:
pipeline:
vad: silero-vad-ggml
transcription: parakeet-cpp-tdt-0.6b-v3
llm: qwen3-0.6b
tts: qwen3-tts-cpp-customvoice
disable_thinking: true
streaming:
llm: true
tts: true
tts:
voice: ryan
- &qwenttscpp_gallery
name: qwen3-tts-cpp
variants:
+167
View File
@@ -0,0 +1,167 @@
package grpc
import (
"context"
"errors"
"io"
"unicode/utf8"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// TTSLiveModel optionally accepts text during a single persistent synthesis.
// recv must unblock when ctx is cancelled. io.EOF commits the final text.
// send applies backpressure and must return its first error to the caller.
type TTSLiveModel interface {
TTSLive(context.Context, func() (*pb.TTSRequest, error), func([]byte) error) error
}
// TTSLiveBackend is optional: older backends retain their existing TTS APIs.
// Closing text signals end of input; audio may continue until this call returns.
type TTSLiveBackend interface {
TTSLive(context.Context, *pb.TTSRequest, <-chan string, func([]byte) error) error
}
func (s *server) TTSLive(stream pb.Backend_TTSLiveServer) error {
model, ok := s.llm.(TTSLiveModel)
if !ok {
return status.Error(codes.Unimplemented, "backend does not support incremental text TTS")
}
first, err := stream.Recv()
if err != nil {
return err
}
if err := s.checkModelIdentity(first); err != nil {
return err
}
if s.llm.Locking() {
s.llm.Lock()
defer s.llm.Unlock()
}
pending := true
return model.TTSLive(stream.Context(), func() (*pb.TTSRequest, error) {
if pending {
pending = false
return first, nil
}
req, err := stream.Recv()
if err != nil {
return nil, err
}
if req.Voice != "" || req.Model != "" || req.Language != nil || req.Instructions != nil || len(req.Params) > 0 || req.Dst != "" || req.ModelIdentity != "" {
return nil, status.Error(codes.InvalidArgument, "only text is permitted after the first TTSLive frame")
}
return req, nil
}, func(pcm []byte) error { return stream.Send(&pb.Reply{Audio: pcm}) })
}
func (c *Client) TTSLive(ctx context.Context, config *pb.TTSRequest, text <-chan string, send func([]byte) error) (returnErr error) {
parentCtx := ctx
if !c.parallel {
if !c.opMutex.TryLock() {
return status.Error(codes.ResourceExhausted, "backend is busy")
}
defer c.opMutex.Unlock()
}
c.setBusy(true)
defer c.setBusy(false)
complete := c.wdMark()
defer complete()
conn, err := c.dial()
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
stream, err := pb.NewBackendClient(conn).TTSLive(ctx)
if err != nil {
return err
}
if err := stream.Send(config); err != nil {
return err
}
sender := make(chan error, 1)
go func() {
for {
select {
case <-ctx.Done():
sender <- ctx.Err()
return
case chunk, ok := <-text:
if !ok {
err := stream.CloseSend()
if err != nil {
cancel()
}
sender <- err
return
}
if !utf8.ValidString(chunk) {
sender <- status.Error(codes.InvalidArgument, "TTSLive text fragments must be valid UTF-8")
cancel()
return
}
if err := stream.Send(&pb.TTSRequest{Text: chunk}); err != nil {
sender <- err
cancel()
return
}
}
}
}()
defer func() {
cancel()
sendErr := <-sender
if sendErr != nil && !errors.Is(sendErr, context.Canceled) && (returnErr == nil || (status.Code(returnErr) == codes.Canceled && parentCtx.Err() == nil)) {
returnErr = sendErr
}
}()
for {
reply, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if err := send(reply.Audio); err != nil {
return err
}
}
}
func (e *embedBackend) TTSLive(ctx context.Context, config *pb.TTSRequest, text <-chan string, send func([]byte) error) error {
model, ok := e.s.llm.(TTSLiveModel)
if !ok {
return status.Error(codes.Unimplemented, "backend does not support incremental text TTS")
}
if err := e.s.checkModelIdentity(config); err != nil {
return err
}
if e.s.llm.Locking() {
e.s.llm.Lock()
defer e.s.llm.Unlock()
}
pending := true
return model.TTSLive(ctx, func() (*pb.TTSRequest, error) {
if pending {
pending = false
return config, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case chunk, ok := <-text:
if !utf8.ValidString(chunk) {
return nil, status.Error(codes.InvalidArgument, "TTSLive text fragments must be valid UTF-8")
}
if !ok {
return nil, io.EOF
}
return &pb.TTSRequest{Text: chunk}, nil
}
}, send)
}
+150
View File
@@ -0,0 +1,150 @@
package grpc
import (
"context"
"errors"
"fmt"
. "github.com/onsi/ginkgo/v2"
"io"
"net"
"time"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
ggrpc "google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type liveTTSFixture struct{ base.SingleThread }
func (*liveTTSFixture) TTSLive(ctx context.Context, recv func() (*pb.TTSRequest, error), send func([]byte) error) error {
for {
r, e := recv()
if e == io.EOF {
return send([]byte("tail"))
}
if e != nil {
return e
}
if r.Text == "fail" {
return errors.New("synthesis failed")
}
if r.Text != "" {
if e := send([]byte(r.Text)); e != nil {
return e
}
}
}
}
var _ = Describe("TTSLiveEarlyAudioAndTail", func() {
It("preserves the live synthesis contract", func() {
listener, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
Fail(fmt.Sprint(e))
}
s := ggrpc.NewServer(serverOpts()...)
pb.RegisterBackendServer(s, &server{llm: &liveTTSFixture{}})
go func() { _ = s.Serve(listener) }()
defer s.Stop()
c := NewClient(listener.Addr().String(), true, nil, false).(TTSLiveBackend)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
text := make(chan string)
audio := make(chan string)
done := make(chan error, 1)
go func() {
done <- c.TTSLive(ctx, &pb.TTSRequest{}, text, func(b []byte) error {
select {
case audio <- string(b):
return nil
case <-ctx.Done():
return ctx.Err()
}
})
}()
text <- "early"
if got := <-audio; got != "early" {
Fail(fmt.Sprint(got))
}
// Input deliberately remains open until audio has reached the receiver.
text <- "late"
if got := <-audio; got != "late" {
Fail(fmt.Sprint(got))
}
close(text)
if got := <-audio; got != "tail" {
Fail(fmt.Sprint(got))
}
if e := <-done; e != nil {
Fail(fmt.Sprint(e))
}
})
})
var _ = Describe("TTSLiveCancellationAndSinkError", func() {
It("preserves the live synthesis contract", func() {
for _, sinkError := range []bool{false, true} {
By(map[bool]string{true: "sink error", false: "cancel waiting input"}[sinkError])
func() {
listener, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
Fail(fmt.Sprint(e))
}
s := ggrpc.NewServer(serverOpts()...)
pb.RegisterBackendServer(s, &server{llm: &liveTTSFixture{}})
go func() { _ = s.Serve(listener) }()
defer s.Stop()
c := NewClient(listener.Addr().String(), false, nil, false).(TTSLiveBackend)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
text := make(chan string)
done := make(chan error, 1)
sinkErr := errors.New("sink failed")
go func() {
done <- c.TTSLive(ctx, &pb.TTSRequest{Text: "start"}, text, func([]byte) error {
if sinkError {
return sinkErr
}
cancel()
return nil
})
}()
if e := <-done; e == nil {
Fail(fmt.Sprint("expected error"))
} else if sinkError && !errors.Is(e, sinkErr) {
Fail(fmt.Sprint(e))
}
if c.(*Client).IsBusy() {
Fail(fmt.Sprint("backend remains busy"))
}
}()
}
})
})
var _ = Describe("TTSLiveRejectsInvalidUnicode", func() {
It("preserves the live synthesis contract", func() {
listener, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
Fail(fmt.Sprint(e))
}
s := ggrpc.NewServer(serverOpts()...)
pb.RegisterBackendServer(s, &server{llm: &liveTTSFixture{}})
go func() { _ = s.Serve(listener) }()
defer s.Stop()
c := NewClient(listener.Addr().String(), true, nil, false).(TTSLiveBackend)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
text := make(chan string, 1)
text <- string([]byte{0xe4, 0xb8})
close(text)
e = c.TTSLive(ctx, &pb.TTSRequest{}, text, func([]byte) error { return nil })
if status.Code(e) != codes.InvalidArgument {
Fail(fmt.Sprintf("invalid text: %v", e))
}
if ctx.Err() != nil {
Fail(fmt.Sprint("invalid input left receiver blocked"))
}
})
})
+11
View File
@@ -2,6 +2,7 @@ package model
import (
"context"
"fmt"
"sync"
grpc "github.com/mudler/LocalAI/pkg/grpc"
@@ -143,3 +144,13 @@ func (c *ConnectionEvictingClient) Score(ctx context.Context, in *pb.ScoreReques
c.checkErr(err)
return result, err
}
func (c *ConnectionEvictingClient) TTSLive(ctx context.Context, in *pb.TTSRequest, text <-chan string, send func([]byte) error) error {
live, ok := c.Backend.(grpc.TTSLiveBackend)
if !ok {
return fmt.Errorf("backend does not support incremental text TTS")
}
err := live.TTSLive(ctx, in, text, send)
c.checkErr(err)
return err
}
+43
View File
@@ -0,0 +1,43 @@
package model
import (
"context"
grpcbackend "github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type liveForwardFixture struct{ grpcbackend.Backend }
func (*liveForwardFixture) TTSLive(ctx context.Context, _ *pb.TTSRequest, text <-chan string, send func([]byte) error) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case chunk, ok := <-text:
if !ok {
return nil
}
if err := send([]byte(chunk)); err != nil {
return err
}
}
}
}
var _ = Describe("TTSLive cache wrapper", func() {
It("preserves the optional incremental interface", func() {
wrapped := newConnectionEvictingClient(&liveForwardFixture{}, "fixture", func() { Fail("unexpected eviction") })
live, ok := wrapped.(grpcbackend.TTSLiveBackend)
Expect(ok).To(BeTrue())
text := make(chan string, 2)
text <- "early"
text <- "late"
close(text)
var got []string
Expect(live.TTSLive(context.Background(), &pb.TTSRequest{}, text, func(b []byte) error { got = append(got, string(b)); return nil })).To(Succeed())
Expect(got).To(Equal([]string{"early", "late"}))
})
})