Skip to content

Commit 4056283

Browse files
localai-botmudler
andauthored
[voice] feat: add managed voice cloning profiles (#10799)
* feat(ui): add voice library workflow Give administrators a production-ready flow to record or upload consented reference audio, manage reusable profiles, inspect API usage, discover compatible models, and hand a saved voice directly to text-to-speech. Assisted-by: Codex:gpt-5 * feat(voice): add managed voice cloning profiles Make reusable reference voices manageable through the admin API instead of requiring model-directory and YAML edits. Discover compatible installed and gallery models from server-side backend capabilities, retain explicit model configuration controls, and stage saved references for supported backends. Expose profile management through REST and MCP, document backend-specific behavior, and cover the workflow from profile creation through real Qwen3-TTS synthesis. Harden the agent-job HTTP test against completion racing cancellation. Assisted-by: Codex:gpt-5 --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent b90e1ca commit 4056283

70 files changed

Lines changed: 4809 additions & 145 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/go/crispasr/gocrispasr.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,11 +530,51 @@ func setVoice(voice string) {
530530
}
531531
}
532532

533+
// applyRequestVoice distinguishes named speakers from per-request reference
534+
// WAVs. The latter are used by F5-TTS and require the exact transcript under
535+
// the cross-backend params.ref_text contract.
536+
func applyRequestVoice(req *pb.TTSRequest) error {
537+
voice := strings.TrimSpace(req.Voice)
538+
if voice == "" {
539+
return nil
540+
}
541+
542+
info, statErr := os.Stat(voice)
543+
looksLikeFile := filepath.IsAbs(voice) || strings.EqualFold(filepath.Ext(voice), ".wav")
544+
if statErr == nil && info.Mode().IsRegular() {
545+
refText := ""
546+
if req.Params != nil {
547+
refText = strings.TrimSpace(req.Params["ref_text"])
548+
if refText == "" {
549+
refText = strings.TrimSpace(req.Params["voice_text"])
550+
}
551+
}
552+
if refText == "" {
553+
return fmt.Errorf("crispasr: params.ref_text is required with a reference voice WAV")
554+
}
555+
if rc := CppTTSSetVoiceFile(voice, refText); rc < 0 {
556+
return fmt.Errorf("crispasr: failed to apply reference voice %q (rc=%d)", voice, rc)
557+
}
558+
return nil
559+
}
560+
if looksLikeFile {
561+
if statErr != nil {
562+
return fmt.Errorf("crispasr: reference voice %q: %w", voice, statErr)
563+
}
564+
return fmt.Errorf("crispasr: reference voice %q is not a regular file", voice)
565+
}
566+
567+
setVoice(voice)
568+
return nil
569+
}
570+
533571
func (w *CrispASR) TTS(req *pb.TTSRequest) error {
534572
if req.Dst == "" {
535573
return fmt.Errorf("crispasr: TTS requires a destination path")
536574
}
537-
setVoice(req.Voice)
575+
if err := applyRequestVoice(req); err != nil {
576+
return err
577+
}
538578
pcm, err := w.synthesize(req.Text)
539579
if err != nil {
540580
return err
@@ -553,7 +593,9 @@ func (w *CrispASR) TTSStream(req *pb.TTSRequest, results chan []byte) error {
553593
if req.Text == "" {
554594
return fmt.Errorf("crispasr: TTSStream requires text")
555595
}
556-
setVoice(req.Voice)
596+
if err := applyRequestVoice(req); err != nil {
597+
return err
598+
}
557599
pcm, err := w.synthesize(req.Text)
558600
if err != nil {
559601
return err

backend/go/crispasr/gocrispasr_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,32 @@ var _ = Describe("CrispASR", func() {
172172
})
173173

174174
Context("TTS", func() {
175+
It("applies a per-request reference WAV and transcript", func() {
176+
refWAV := filepath.Join(GinkgoT().TempDir(), "reference.wav")
177+
Expect(os.WriteFile(refWAV, []byte("fixture"), 0o600)).To(Succeed())
178+
179+
original := CppTTSSetVoiceFile
180+
DeferCleanup(func() { CppTTSSetVoiceFile = original })
181+
var gotPath, gotText string
182+
CppTTSSetVoiceFile = func(path, refText string) int {
183+
gotPath, gotText = path, refText
184+
return 0
185+
}
186+
187+
Expect(applyRequestVoice(&pb.TTSRequest{
188+
Voice: refWAV,
189+
Params: map[string]string{"ref_text": "The exact words in the clip."},
190+
})).To(Succeed())
191+
Expect(gotPath).To(Equal(refWAV))
192+
Expect(gotText).To(Equal("The exact words in the clip."))
193+
})
194+
195+
It("rejects a reference WAV without a transcript", func() {
196+
refWAV := filepath.Join(GinkgoT().TempDir(), "reference.wav")
197+
Expect(os.WriteFile(refWAV, []byte("fixture"), 0o600)).To(Succeed())
198+
Expect(applyRequestVoice(&pb.TTSRequest{Voice: refWAV})).To(MatchError(ContainSubstring("params.ref_text")))
199+
})
200+
175201
It("synthesizes a non-empty WAV", func() {
176202
ttsModel := ttsModelOrSkip()
177203
ensureLibLoaded()

backend/go/qwen3-tts-cpp/README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Qwen3-TTS C++ backend
2+
3+
This backend runs Qwen3-TTS GGUF models through
4+
[qwentts.cpp](https://github.com/ServeurpersoCom/qwentts.cpp). It supports
5+
24 kHz speech generation, streaming, named speakers, voice design, and
6+
reference-audio cloning depending on the model variant.
7+
8+
## Gallery models
9+
10+
The following Base models accept LocalAI Voice Library profiles:
11+
12+
- `qwen3-tts-cpp`
13+
- `qwen3-tts-cpp-0.6b-base-q4`
14+
- `qwen3-tts-cpp-1.7b-base`
15+
- `qwen3-tts-cpp-1.7b-base-q4`
16+
17+
Gallery models containing `customvoice` or `voicedesign` implement those Qwen
18+
modes instead and are not advertised as raw reference-audio models.
19+
20+
Install a Base model with:
21+
22+
```bash
23+
local-ai models install qwen3-tts-cpp
24+
```
25+
26+
## Model configuration
27+
28+
Base filenames are detected automatically. Set `tts.voice_cloning` only when a
29+
verified private conversion has a name that does not identify it as a Base or
30+
VoiceClone model:
31+
32+
```yaml
33+
name: private-qwen-voice
34+
backend: qwen3-tts-cpp
35+
parameters:
36+
model: qwen-private/talker.gguf
37+
known_usecases:
38+
- tts
39+
tts:
40+
voice_cloning: true
41+
audio_path: voices/default-reference.wav # optional model-wide fallback
42+
```
43+
44+
The tokenizer GGUF is auto-discovered when its filename contains `tokenizer`
45+
and it is stored beside the talker. Otherwise set
46+
`options: ["tokenizer:qwen-private/tokenizer.gguf"]`.
47+
48+
`tts.voice_cloning: false` removes a model from Voice Library compatibility
49+
results and rejects saved `localai://voice-profiles/...` references. It does
50+
not disable Qwen's named-speaker or VoiceDesign modes. Setting it to `true`
51+
cannot add cloning to a backend that lacks LocalAI's reference-audio contract.
52+
53+
Request precedence is: a request `voice`, then `tts.voice`, then
54+
`tts.audio_path`. A saved profile supplies its private WAV and exact transcript
55+
for that request without changing the model YAML.
56+
57+
## API example
58+
59+
Create or select a profile in **Operate → Voice Library**, then pass its stable
60+
URI to either speech endpoint:
61+
62+
```bash
63+
curl http://localhost:8080/v1/audio/speech \
64+
-H 'Content-Type: application/json' \
65+
-d '{
66+
"model": "qwen3-tts-cpp",
67+
"input": "This request uses a saved reference voice.",
68+
"voice": "localai://voice-profiles/PROFILE_ID"
69+
}' \
70+
--output speech.wav
71+
```
72+
73+
## Native end-to-end test
74+
75+
The labeled test loads real GGUFs, synthesizes speech, streams audio, and
76+
exercises cloning with a generated 24 kHz reference WAV:
77+
78+
```bash
79+
make -C backend/go/qwen3-tts-cpp qwen3-tts-cpp
80+
81+
QWEN3TTS_MODEL=/path/to/qwen-talker-0.6b-base-Q8_0.gguf \
82+
QWEN3TTS_CODEC=/path/to/qwen-tokenizer-12hz-Q8_0.gguf \
83+
QWEN3TTS_LIBRARY=backend/go/qwen3-tts-cpp/libgoqwen3ttscpp-fallback.so \
84+
go test ./backend/go/qwen3-tts-cpp -ginkgo.label-filter=e2e
85+
```

backend/python/chatterbox/backend.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,9 @@ def TTS(self, request, context):
199199

200200
if "language" in self.options:
201201
kwargs["language_id"] = self.options["language"]
202-
if self.AudioPath is not None:
202+
if request.voice and os.path.isfile(request.voice):
203+
kwargs["audio_prompt_path"] = request.voice
204+
elif self.AudioPath is not None:
203205
kwargs["audio_prompt_path"] = self.AudioPath
204206

205207
# add options to kwargs
@@ -211,6 +213,8 @@ def TTS(self, request, context):
211213
# strings on the wire and are coerced to float/int/bool.
212214
if hasattr(request, "params") and request.params:
213215
for key, value in request.params.items():
216+
if key == "ref_text":
217+
continue
214218
kwargs[key] = coerce_param_value(value)
215219

216220
# Check if text exceeds 250 characters

backend/python/coqui/backend.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,19 @@ def TTS(self, request, context):
7979
if self.tts.is_multi_lingual and lang is None:
8080
return backend_pb2.Result(success=False, message=f"Model is multi-lingual, but no language was provided")
8181

82-
# if model is multi-speaker, use speaker_wav or the speaker_id from request.voice
83-
if self.tts.is_multi_speaker and self.AudioPath is None and request.voice is None:
82+
# A path-shaped per-request voice is a cloning reference; otherwise
83+
# preserve Coqui's named-speaker behavior.
84+
request_voice = request.voice if request.voice else ""
85+
speaker_wav = request_voice if os.path.isfile(request_voice) else self.AudioPath
86+
if self.tts.is_multi_speaker and speaker_wav is None and not request_voice:
8487
return backend_pb2.Result(success=False, message=f"Model is multi-speaker, but no speaker was provided")
8588

86-
if self.tts.is_multi_speaker and request.voice is not None:
87-
self.tts.tts_to_file(text=request.text, speaker=request.voice, language=lang, file_path=request.dst)
89+
if speaker_wav is not None:
90+
self.tts.tts_to_file(text=request.text, speaker_wav=speaker_wav, language=lang, file_path=request.dst)
91+
elif self.tts.is_multi_speaker and request_voice:
92+
self.tts.tts_to_file(text=request.text, speaker=request_voice, language=lang, file_path=request.dst)
8893
else:
89-
self.tts.tts_to_file(text=request.text, speaker_wav=self.AudioPath, language=lang, file_path=request.dst)
94+
self.tts.tts_to_file(text=request.text, language=lang, file_path=request.dst)
9095
except Exception as err:
9196
return backend_pb2.Result(success=False, message=f"Unexpected {err=}, {type(err)=}")
9297
return backend_pb2.Result(success=True)

backend/python/faster-qwen3-tts/backend.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,20 +83,23 @@ def LoadModel(self, request, context):
8383
return backend_pb2.Result(message="Model loaded successfully", success=True)
8484

8585
def _get_ref_audio_path(self, request):
86-
if not self.audio_path:
86+
# A per-request voice path is the canonical LocalAI voice-profile
87+
# contract. Keep AudioPath as the backwards-compatible YAML fallback.
88+
audio_path = request.voice if hasattr(request, "voice") and request.voice else self.audio_path
89+
if not audio_path:
8790
return None
88-
if os.path.isabs(self.audio_path):
89-
return self.audio_path
91+
if os.path.isabs(audio_path):
92+
return audio_path
9093
if self.model_file:
9194
model_file_base = os.path.dirname(self.model_file)
92-
ref_path = os.path.join(model_file_base, self.audio_path)
95+
ref_path = os.path.join(model_file_base, audio_path)
9396
if os.path.exists(ref_path):
9497
return ref_path
9598
if self.model_path:
96-
ref_path = os.path.join(self.model_path, self.audio_path)
99+
ref_path = os.path.join(self.model_path, audio_path)
97100
if os.path.exists(ref_path):
98101
return ref_path
99-
return self.audio_path
102+
return audio_path
100103

101104
def TTS(self, request, context):
102105
try:
@@ -122,13 +125,13 @@ def TTS(self, request, context):
122125
success=False,
123126
message="AudioPath is required for voice clone (set in LoadModel)"
124127
)
125-
ref_text = self.options.get("ref_text")
126-
if not ref_text and hasattr(request, 'ref_text') and request.ref_text:
127-
ref_text = request.ref_text
128+
ref_text = request.params.get("ref_text") if hasattr(request, "params") else None
129+
if not ref_text:
130+
ref_text = self.options.get("ref_text")
128131
if not ref_text:
129132
return backend_pb2.Result(
130133
success=False,
131-
message="ref_text is required for voice clone (set via LoadModel Options, e.g. ref_text:Your reference transcript)"
134+
message="ref_text is required for voice clone (set in request.params or LoadModel options)"
132135
)
133136

134137
chunk_size = self.options.get("chunk_size")

backend/python/fish-speech/backend.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ def LoadModel(self, request, context):
267267

268268
def _get_ref_audio_path(self, voice_name=None):
269269
"""Get reference audio path from voices dict or stored AudioPath."""
270+
if voice_name and os.path.isfile(voice_name):
271+
return voice_name
270272
if voice_name and voice_name in self.voices:
271273
audio_path = self.voices[voice_name]["audio"]
272274

@@ -332,7 +334,19 @@ def TTS(self, request, context):
332334
references = []
333335
voice_name = request.voice if request.voice else None
334336

335-
if voice_name and voice_name in self.voices:
337+
if voice_name and os.path.isfile(voice_name):
338+
ref_audio_path = self._get_ref_audio_path(voice_name)
339+
with open(ref_audio_path, "rb") as f:
340+
audio_bytes = f.read()
341+
ref_text = request.params.get("ref_text", "") if hasattr(request, "params") else ""
342+
references.append(
343+
ServeReferenceAudio(audio=audio_bytes, text=ref_text)
344+
)
345+
print(
346+
f"[INFO] Using per-request reference audio: {ref_audio_path}",
347+
file=sys.stderr,
348+
)
349+
elif voice_name and voice_name in self.voices:
336350
ref_audio_path = self._get_ref_audio_path(voice_name)
337351
if ref_audio_path and os.path.exists(ref_audio_path):
338352
with open(ref_audio_path, "rb") as f:
@@ -350,7 +364,9 @@ def TTS(self, request, context):
350364
if ref_audio_path and os.path.exists(ref_audio_path):
351365
with open(ref_audio_path, "rb") as f:
352366
audio_bytes = f.read()
353-
ref_text = self.options.get("ref_text", "")
367+
ref_text = request.params.get("ref_text", "") if hasattr(request, "params") else ""
368+
if not ref_text:
369+
ref_text = self.options.get("ref_text", "")
354370
references.append(
355371
ServeReferenceAudio(audio=audio_bytes, text=ref_text)
356372
)

backend/python/neutts/backend.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,18 @@ def TTS(self, request, context):
119119
# add options to kwargs
120120
kwargs.update(self.options)
121121

122-
ref_codes = self.model.encode_reference(self.AudioPath)
123-
124-
wav = self.model.infer(request.text, ref_codes, self.ref_text)
122+
ref_audio = request.voice if request.voice else self.AudioPath
123+
if not ref_audio:
124+
return backend_pb2.Result(success=False, message="reference audio is required")
125+
ref_text = request.params.get("ref_text") if hasattr(request, "params") else None
126+
if not ref_text:
127+
ref_text = self.ref_text
128+
if not ref_text:
129+
return backend_pb2.Result(success=False, message="ref_text is required")
130+
131+
ref_codes = self.model.encode_reference(ref_audio)
132+
133+
wav = self.model.infer(request.text, ref_codes, ref_text)
125134

126135
sf.write(request.dst, wav, 24000)
127136
except Exception as err:

backend/python/qwen-tts/backend.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ def _detect_mode(self, request):
362362
# model_type explicitly set
363363
if self.model_type == "CustomVoice":
364364
return "CustomVoice"
365-
if self.model_type == "VoiceClone":
365+
if self.model_type in ("VoiceClone", "Base"):
366366
return "VoiceClone"
367367
if self.model_type == "VoiceDesign":
368368
return "VoiceDesign"
@@ -380,6 +380,8 @@ def _detect_mode(self, request):
380380

381381
def _get_ref_audio_path(self, request, voice_name=None):
382382
"""Get reference audio path from stored AudioPath or from voices dict."""
383+
if hasattr(request, "voice") and request.voice and os.path.isfile(request.voice):
384+
return request.voice
383385
# If voice_name is provided and exists in voices dict, use that
384386
if voice_name and voice_name in self.voices:
385387
audio_path = self.voices[voice_name]["audio"]
@@ -735,6 +737,8 @@ def TTS(self, request, context):
735737
# model receives the types it expects. These override YAML-derived kwargs.
736738
if hasattr(request, "params") and request.params:
737739
for key, value in request.params.items():
740+
if key == "ref_text":
741+
continue
738742
generation_kwargs[key] = coerce_param_value(value)
739743

740744
# Generate audio based on mode
@@ -743,7 +747,8 @@ def TTS(self, request, context):
743747

744748
# Check if multi-voice mode is active (voices dict is populated)
745749
voice_name = None
746-
if self.voices:
750+
request_voice_path = request.voice if request.voice and os.path.isfile(request.voice) else None
751+
if self.voices and request_voice_path is None:
747752
# Get voice from request (priority) or options
748753
voice_name = request.voice if request.voice else None
749754
if not voice_name:
@@ -775,11 +780,9 @@ def TTS(self, request, context):
775780
if voice_name and voice_name in self.voices:
776781
ref_text_source = self.voices[voice_name]["ref_text"]
777782
else:
778-
ref_text_source = self.options.get("ref_text", None)
783+
ref_text_source = request.params.get("ref_text") if hasattr(request, "params") else None
779784
if not ref_text_source:
780-
# Try to get from request if available
781-
if hasattr(request, "ref_text") and request.ref_text:
782-
ref_text_source = request.ref_text
785+
ref_text_source = self.options.get("ref_text", None)
783786

784787
if not ref_text_source:
785788
# x_vector_only_mode doesn't require ref_text

0 commit comments

Comments
 (0)