Enhance native Kokoro voice blending

Bring the useful community enhancements into the built-in plugin with a current American voice catalog, speed controls, and in-memory weighted blending while preserving existing voice and speed configuration. Reuse the singleton Kokoro pipeline, validate blend inputs, show resolved ratios in status views, and cover the native flow with focused regression tests. Co-authored-by: twilso24 <122656888+twilso24@users.noreply.github.com>

Alessandro committed Aug 11, 2026 at 21:53 UTC 14b5a03e7f8f74deb7ed32b4fab78c12405b3544
10 files changed +487 -19
plugins/_kokoro_tts/AGENTS.md
+1
@@ -17,6 +17,7 @@
17 - Keep Kokoro dependencies on Docker/bootstrap paths, not opportunistic runtime installs.
18 - Preserve browser-native fallback when the plugin is disabled or unavailable.
19 - Do not expose generated speech artifacts outside intended response paths.
20 +- Preserve `voice` and `speed` configuration compatibility; weighted blends stay in memory and use positive finite weights.
21
22 ## Work Guidance
23
plugins/_kokoro_tts/README.md
+4 -1
@@ -5,12 +5,15 @@ Built-in speech synthesis plugin backed by Kokoro.
5 ## Behavior
6
7 - Registers Kokoro as the active TTS provider when the plugin is enabled.
8 +- Supports single voices, comma-separated equal blends, and optional in-memory weighted blends.
9 - Keeps browser-native `speechSynthesis` as the fallback path when disabled.
10 - Keeps Python dependencies on the core Docker/bootstrap path. This plugin does not install packages or binaries on demand.
11 +- Uses Kokoro's existing voice-pack cache and does not create persistent blend files.
12
13 ## Config
14
13 -- `voice`: Kokoro voice identifier
15 +- `voice`: Kokoro voice identifier or comma-separated equal blend
16 +- `voice_weights`: optional mapping of voice identifiers to positive weights; when present, it defines the active blend
17 - `speed`: Kokoro playback speed multiplier
18
19 ## Routes
plugins/_kokoro_tts/default_config.yaml
+1
@@ -1,2 +1,3 @@
1 voice: am_puck,am_onyx
2 +voice_weights: {}
3 speed: 1.1
plugins/_kokoro_tts/extensions/webui/voice-settings-main/kokoro-card.html
+1 -1
@@ -13,7 +13,7 @@
13 <div class="voice-plugin-meta">
14 <div class="voice-plugin-meta-row">
15 <span>Voice</span>
16 - <code x-text="$store.kokoroTts?.config?.voice || ''"></code>
16 + <code x-text="$store.kokoroTts?.voiceSummary || ''"></code>
17 </div>
18 <div class="voice-plugin-meta-row">
19 <span>Speed</span>
plugins/_kokoro_tts/helpers/runtime.py
+44 -4
@@ -3,6 +3,8 @@ from __future__ import annotations
3 import asyncio
4 import base64
5 import io
6 +import math
7 +import re
8 import warnings
9 from typing import Any
10
@@ -25,15 +27,17 @@ warnings.filterwarnings("ignore", category=UserWarning)
27 PLUGIN_NAME = "_kokoro_tts"
28 DEFAULT_CONFIG = {
29 "voice": "am_puck,am_onyx",
30 + "voice_weights": {},
31 "speed": 1.1,
32 }
33 +VOICE_ID_PATTERN = re.compile(r"^[a-z]{2}_[a-z0-9_]+$")
34
35 _pipeline = None
36 is_updating_model = False
37
38
39 def normalize_config(config: dict[str, Any] | None) -> dict[str, Any]:
36 - normalized = dict(DEFAULT_CONFIG)
40 + normalized = {**DEFAULT_CONFIG, "voice_weights": {}}
41 if not isinstance(config, dict):
42 return normalized
43
@@ -41,9 +45,25 @@ def normalize_config(config: dict[str, Any] | None) -> dict[str, Any]:
45 if voice:
46 normalized["voice"] = voice
47
48 + weights = config.get("voice_weights")
49 + if isinstance(weights, dict):
50 + for raw_voice, raw_weight in weights.items():
51 + voice_id = str(raw_voice or "").strip()
52 + if not VOICE_ID_PATTERN.fullmatch(voice_id):
53 + continue
54 + try:
55 + weight = float(raw_weight)
56 + except (TypeError, ValueError):
57 + continue
58 + if math.isfinite(weight) and weight > 0:
59 + normalized["voice_weights"][voice_id] = weight
60 +
61 + if normalized["voice_weights"]:
62 + normalized["voice"] = ",".join(normalized["voice_weights"])
63 +
64 try:
65 speed = float(config.get("speed", normalized["speed"]))
46 - if speed > 0:
66 + if math.isfinite(speed) and speed > 0:
67 normalized["speed"] = speed
68 except (TypeError, ValueError):
69 pass
@@ -113,23 +133,43 @@ async def synthesize_sentences(
133 return await _synthesize_sentences(
134 sentences,
135 voice=str(cfg["voice"]),
136 + voice_weights=dict(cfg["voice_weights"]),
137 speed=float(cfg["speed"]),
138 )
139
140
141 +def _resolve_voice(
142 + pipeline: Any, voice: str, voice_weights: dict[str, float]
143 +) -> Any:
144 + if not voice_weights:
145 + return voice
146 +
147 + total = sum(voice_weights.values())
148 + if not math.isfinite(total) or total <= 0:
149 + return voice
150 + blend = None
151 + for voice_id, weight in voice_weights.items():
152 + weighted_pack = pipeline.load_single_voice(voice_id) * (weight / total)
153 + blend = weighted_pack if blend is None else blend + weighted_pack
154 + return blend
155 +
156 +
157 async def _synthesize_sentences(
121 - sentences: list[str], *, voice: str, speed: float
158 + sentences: list[str], *, voice: str, voice_weights: dict[str, float], speed: float
159 ) -> str:
160 await _preload()
161
162 combined_audio: list[float] = []
163 + resolved_voice = _resolve_voice(_pipeline, voice, voice_weights)
164
165 try:
166 for sentence in sentences:
167 if not sentence.strip():
168 continue
169
132 - segments = _pipeline(sentence.strip(), voice=voice, speed=speed) # type: ignore[misc]
170 + segments = _pipeline( # type: ignore[misc]
171 + sentence.strip(), voice=resolved_voice, speed=speed
172 + )
173 for segment in list(segments):
174 audio_tensor = segment.audio
175 audio_numpy = audio_tensor.detach().cpu().numpy() # type: ignore[union-attr]
plugins/_kokoro_tts/plugin.yaml
+2 -2
@@ -1,7 +1,7 @@
1 name: _kokoro_tts
2 title: Kokoro TTS
3 -description: Built-in Kokoro text-to-speech plugin with browser TTS fallback when disabled.
4 -version: 1.0.0
3 +description: Built-in Kokoro text-to-speech plugin with weighted voice blending and browser fallback.
4 +version: 1.1.0
5 always_enabled: false
6 settings_sections:
7 - agent
plugins/_kokoro_tts/webui/config.html
+306 -10
@@ -4,36 +4,332 @@
4 </head>
5
6 <body>
7 - <div x-data>
7 + <div x-data="{
8 + catalog: [
9 + { id: 'af_alloy', label: 'Alloy', group: 'American Female' },
10 + { id: 'af_aoede', label: 'Aoede', group: 'American Female' },
11 + { id: 'af_bella', label: 'Bella', group: 'American Female' },
12 + { id: 'af_heart', label: 'Heart', group: 'American Female' },
13 + { id: 'af_jessica', label: 'Jessica', group: 'American Female' },
14 + { id: 'af_kore', label: 'Kore', group: 'American Female' },
15 + { id: 'af_nicole', label: 'Nicole', group: 'American Female' },
16 + { id: 'af_nova', label: 'Nova', group: 'American Female' },
17 + { id: 'af_river', label: 'River', group: 'American Female' },
18 + { id: 'af_sarah', label: 'Sarah', group: 'American Female' },
19 + { id: 'af_sky', label: 'Sky', group: 'American Female' },
20 + { id: 'am_adam', label: 'Adam', group: 'American Male' },
21 + { id: 'am_echo', label: 'Echo', group: 'American Male' },
22 + { id: 'am_eric', label: 'Eric', group: 'American Male' },
23 + { id: 'am_fenrir', label: 'Fenrir', group: 'American Male' },
24 + { id: 'am_liam', label: 'Liam', group: 'American Male' },
25 + { id: 'am_michael', label: 'Michael', group: 'American Male' },
26 + { id: 'am_onyx', label: 'Onyx', group: 'American Male' },
27 + { id: 'am_puck', label: 'Puck', group: 'American Male' },
28 + { id: 'am_santa', label: 'Santa', group: 'American Male' },
29 + ],
30 + initConfig() {
31 + if (!config.voice) config.voice = 'am_puck,am_onyx';
32 + const sanitized = {};
33 + const weights = config.voice_weights;
34 + if (weights && typeof weights === 'object' && !Array.isArray(weights)) {
35 + for (const [id, rawWeight] of Object.entries(weights)) {
36 + const weight = Number(rawWeight);
37 + if (/^[a-z]{2}_[a-z0-9_]+$/.test(id) && Number.isFinite(weight) && weight > 0) {
38 + sanitized[id] = weight;
39 + }
40 + }
41 + }
42 + config.voice_weights = sanitized;
43 + if (Object.keys(sanitized).length) this.syncVoice(Object.keys(sanitized));
44 + const speed = Number(config.speed);
45 + config.speed = Number.isFinite(speed) && speed > 0 ? speed : 1.1;
46 + },
47 + voiceIds() {
48 + const weighted = Object.keys(config.voice_weights || {});
49 + if (weighted.length) return weighted;
50 + return String(config.voice || '')
51 + .split(',')
52 + .map(id => id.trim())
53 + .filter(id => /^[a-z]{2}_[a-z0-9_]+$/.test(id));
54 + },
55 + hasWeights() {
56 + return Object.keys(config.voice_weights || {}).length > 0;
57 + },
58 + voiceInfo(id) {
59 + return this.catalog.find(voice => voice.id === id) || {
60 + id,
61 + label: id,
62 + group: 'Custom voice',
63 + };
64 + },
65 + availableVoices(group) {
66 + const selected = new Set(this.voiceIds());
67 + return this.catalog.filter(voice => voice.group === group && !selected.has(voice.id));
68 + },
69 + weightFor(id) {
70 + const weight = Number(config.voice_weights?.[id]);
71 + return Number.isFinite(weight) && weight > 0 ? weight : 1;
72 + },
73 + weightPercent(id) {
74 + const ids = this.voiceIds();
75 + const total = ids.reduce((sum, voiceId) => sum + this.weightFor(voiceId), 0);
76 + return total > 0 ? Math.round((this.weightFor(id) / total) * 100) : 0;
77 + },
78 + setWeight(id, rawWeight) {
79 + const next = {};
80 + for (const voiceId of this.voiceIds()) next[voiceId] = this.weightFor(voiceId);
81 + next[id] = Math.min(10, Math.max(0.1, Number(rawWeight) || 1));
82 + config.voice_weights = next;
83 + this.syncVoice(Object.keys(next));
84 + },
85 + useEqualWeights() {
86 + config.voice_weights = {};
87 + },
88 + addVoice(id) {
89 + if (!id || !/^[a-z]{2}_[a-z0-9_]+$/.test(id)) return;
90 + const ids = this.voiceIds();
91 + if (!ids.includes(id)) ids.push(id);
92 + if (this.hasWeights()) {
93 + const next = {};
94 + for (const voiceId of ids) next[voiceId] = this.weightFor(voiceId);
95 + config.voice_weights = next;
96 + }
97 + this.syncVoice(ids);
98 + },
99 + removeVoice(id) {
100 + const ids = this.voiceIds();
101 + if (ids.length <= 1) return;
102 + const remaining = ids.filter(voiceId => voiceId !== id);
103 + if (this.hasWeights()) {
104 + const next = {};
105 + for (const voiceId of remaining) next[voiceId] = this.weightFor(voiceId);
106 + config.voice_weights = next;
107 + }
108 + this.syncVoice(remaining);
109 + },
110 + syncVoice(ids) {
111 + config.voice = ids.join(',');
112 + },
113 + }">
114 <template x-if="config">
9 - <div class="plugin-config-page">
115 + <div class="plugin-config-page" x-init="initConfig()">
116 <div class="section-title">Kokoro TTS</div>
117 <div class="section-description">
12 - Configure the built-in Kokoro voice provider. When this plugin is disabled,
13 - spoken output falls back to the browser speech API.
118 + Choose a voice or blend several voices in memory. Disabling this plugin
119 + returns spoken output to the browser speech API.
120 </div>
121
122 <div class="field">
123 <div class="field-label">
18 - <div class="field-title">Voice</div>
19 - <div class="field-description">Kokoro voice identifier passed to the backend pipeline.</div>
124 + <div class="field-title">Voice expression</div>
125 + <div class="field-description">
126 + Pick a voice below or enter Kokoro voice identifiers separated by commas for an equal blend.
127 + </div>
128 </div>
129 <div class="field-control">
22 - <input type="text" x-model="config.voice" />
130 + <input
131 + type="text"
132 + list="kokoro-voice-catalog"
133 + x-model.trim="config.voice"
134 + @input="config.voice_weights = {}"
135 + placeholder="am_puck,am_onyx"
136 + />
137 + <datalist id="kokoro-voice-catalog">
138 + <template x-for="voice in catalog" :key="voice.id">
139 + <option :value="voice.id" :label="`${voice.label} — ${voice.group}`"></option>
140 + </template>
141 + </datalist>
142 + </div>
143 + </div>
144 +
145 + <div class="field">
146 + <div class="field-label">
147 + <div class="field-title">Voice blend</div>
148 + <div class="field-description">
149 + Adjusting a weight enables a weighted blend. Voice packs remain in Kokoro's normal cache; no blend files are created.
150 + </div>
151 + </div>
152 + <div class="field-control kokoro-blend-editor">
153 + <div class="kokoro-blend-toolbar">
154 + <span class="kokoro-mode" x-text="hasWeights() ? 'Weighted' : 'Equal weights'"></span>
155 + <button
156 + type="button"
157 + class="button"
158 + x-show="hasWeights()"
159 + @click="useEqualWeights()"
160 + >
161 + Use equal weights
162 + </button>
163 + </div>
164 +
165 + <template x-if="voiceIds().length === 0">
166 + <div class="kokoro-empty">
167 + Enter a standard Kokoro voice identifier to use the blend editor.
168 + </div>
169 + </template>
170 +
171 + <div class="kokoro-voice-list">
172 + <template x-for="id in voiceIds()" :key="id">
173 + <div class="kokoro-voice-row">
174 + <div class="kokoro-voice-copy">
175 + <strong x-text="voiceInfo(id).label"></strong>
176 + <span x-text="`${id} · ${voiceInfo(id).group}`"></span>
177 + </div>
178 + <label class="kokoro-weight">
179 + <span x-text="`${weightPercent(id)}%`"></span>
180 + <input
181 + type="range"
182 + min="0.1"
183 + max="10"
184 + step="0.1"
185 + :value="weightFor(id)"
186 + :aria-label="`Weight for ${voiceInfo(id).label}`"
187 + @input="setWeight(id, $event.target.value)"
188 + />
189 + </label>
190 + <button
191 + type="button"
192 + class="button icon-button"
193 + :disabled="voiceIds().length <= 1"
194 + :title="`Remove ${voiceInfo(id).label}`"
195 + :aria-label="`Remove ${voiceInfo(id).label}`"
196 + @click="removeVoice(id)"
197 + >
198 + <x-icon name="close"></x-icon>
199 + </button>
200 + </div>
201 + </template>
202 + </div>
203 +
204 + <select @change="addVoice($event.target.value); $event.target.value = ''">
205 + <option value="">Add a voice...</option>
206 + <optgroup label="American Female">
207 + <template x-for="voice in availableVoices('American Female')" :key="voice.id">
208 + <option :value="voice.id" x-text="`${voice.label} (${voice.id})`"></option>
209 + </template>
210 + </optgroup>
211 + <optgroup label="American Male">
212 + <template x-for="voice in availableVoices('American Male')" :key="voice.id">
213 + <option :value="voice.id" x-text="`${voice.label} (${voice.id})`"></option>
214 + </template>
215 + </optgroup>
216 + </select>
217 </div>
218 </div>
219
220 <div class="field">
221 <div class="field-label">
222 <div class="field-title">Speed</div>
29 - <div class="field-description">Playback speed multiplier for Kokoro synthesis.</div>
223 + <div class="field-description">Speech rate from 0.5× to 3.0×.</div>
224 </div>
31 - <div class="field-control">
32 - <input type="number" min="0.1" step="0.1" x-model.number="config.speed" />
225 + <div class="field-control kokoro-speed">
226 + <input
227 + type="range"
228 + min="0.5"
229 + max="3"
230 + step="0.1"
231 + x-model.number="config.speed"
232 + aria-label="Kokoro speech speed"
233 + />
234 + <output x-text="`${Number(config.speed || 1.1).toFixed(1)}×`"></output>
235 </div>
236 </div>
237 </div>
238 </template>
239 </div>
240 +
241 + <style>
242 + .kokoro-blend-editor {
243 + display: flex;
244 + flex-direction: column;
245 + gap: 0.65rem;
246 + }
247 +
248 + .kokoro-blend-toolbar,
249 + .kokoro-voice-row,
250 + .kokoro-speed {
251 + display: flex;
252 + align-items: center;
253 + gap: 0.75rem;
254 + }
255 +
256 + .kokoro-blend-toolbar {
257 + justify-content: space-between;
258 + }
259 +
260 + .kokoro-mode {
261 + padding: 0.2rem 0.55rem;
262 + border: 1px solid var(--color-border);
263 + border-radius: 999px;
264 + color: var(--color-text-secondary);
265 + font-size: var(--font-size-small);
266 + }
267 +
268 + .kokoro-voice-list {
269 + display: flex;
270 + flex-direction: column;
271 + gap: 0.45rem;
272 + }
273 +
274 + .kokoro-voice-row,
275 + .kokoro-empty {
276 + padding: 0.65rem;
277 + border: 1px solid var(--color-border);
278 + border-radius: 0.5rem;
279 + background: var(--color-input);
280 + }
281 +
282 + .kokoro-voice-copy {
283 + display: flex;
284 + flex: 1 1 11rem;
285 + min-width: 0;
286 + flex-direction: column;
287 + gap: 0.15rem;
288 + }
289 +
290 + .kokoro-voice-copy span,
291 + .kokoro-empty {
292 + color: var(--color-text-secondary);
293 + font-size: var(--font-size-small);
294 + }
295 +
296 + .kokoro-weight {
297 + display: flex;
298 + flex: 1 1 10rem;
299 + align-items: center;
300 + gap: 0.55rem;
301 + }
302 +
303 + .kokoro-weight span {
304 + width: 2.7rem;
305 + text-align: right;
306 + font-variant-numeric: tabular-nums;
307 + }
308 +
309 + .kokoro-weight input,
310 + .kokoro-speed input {
311 + min-width: 7rem;
312 + flex: 1;
313 + }
314 +
315 + .kokoro-speed output {
316 + min-width: 3rem;
317 + font-weight: 600;
318 + font-variant-numeric: tabular-nums;
319 + text-align: right;
320 + }
321 +
322 + @media (max-width: 620px) {
323 + .kokoro-voice-row {
324 + align-items: stretch;
325 + flex-wrap: wrap;
326 + }
327 +
328 + .kokoro-weight {
329 + order: 3;
330 + flex-basis: 100%;
331 + }
332 + }
333 + </style>
334 </body>
335 </html>
plugins/_kokoro_tts/webui/kokoro-tts-store.js
+13
@@ -13,6 +13,7 @@ const model = {
13 enabled: false,
14 config: {
15 voice: "",
16 + voice_weights: {},
17 speed: 1.1,
18 },
19 modelReady: false,
@@ -41,6 +42,7 @@ const model = {
42 this.enabled = !!status?.enabled;
43 this.config = {
44 voice: status?.config?.voice || "",
45 + voice_weights: status?.config?.voice_weights || {},
46 speed: Number(status?.config?.speed || 1.1),
47 };
48 this.modelReady = !!status?.model?.ready;
@@ -103,6 +105,17 @@ const model = {
105 return "warn";
106 },
107
108 + get voiceSummary() {
109 + const entries = Object.entries(this.config.voice_weights || {});
110 + if (!entries.length) return this.config.voice || "";
111 +
112 + const total = entries.reduce((sum, [, weight]) => sum + Number(weight || 0), 0);
113 + if (total <= 0) return this.config.voice || "";
114 + return entries
115 + .map(([voice, weight]) => `${voice} ${Math.round((Number(weight) / total) * 100)}%`)
116 + .join(" · ");
117 + },
118 +
119 async openConfig() {
120 const { store } = await import("/components/plugins/plugin-settings-store.js");
121 await store.openConfig(PLUGIN_NAME);
plugins/_kokoro_tts/webui/main.html
+1 -1
@@ -42,7 +42,7 @@
42 <div class="field-title">Resolved Config</div>
43 <div class="status-row">
44 <span class="status-key">Voice</span>
45 - <span class="status-value mono" x-text="$store.kokoroTts.config.voice"></span>
45 + <span class="status-value mono" x-text="$store.kokoroTts.voiceSummary"></span>
46 </div>
47 <div class="status-row">
48 <span class="status-key">Speed</span>
tests/test_kokoro_tts.py new
+114
@@ -0,0 +1,114 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +from pathlib import Path
5 +
6 +from plugins._kokoro_tts.helpers import runtime
7 +
8 +
9 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 +
11 +
12 +def test_config_keeps_legacy_voice_and_normalizes_weighted_blends() -> None:
13 + legacy = runtime.normalize_config(
14 + {"voice": "custom/voice.pt", "speed": 2.2}
15 + )
16 + assert legacy == {
17 + "voice": "custom/voice.pt",
18 + "voice_weights": {},
19 + "speed": 2.2,
20 + }
21 +
22 + weighted = runtime.normalize_config(
23 + {
24 + "voice": "ignored_when_weights_are_present",
25 + "voice_weights": {
26 + "af_heart": "3",
27 + "am_puck": 1,
28 + "../bad.pt": 5,
29 + "am_onyx": float("nan"),
30 + "am_echo": 0,
31 + },
32 + "speed": float("inf"),
33 + }
34 + )
35 + assert weighted == {
36 + "voice": "af_heart,am_puck",
37 + "voice_weights": {"af_heart": 3.0, "am_puck": 1.0},
38 + "speed": 1.1,
39 + }
40 +
41 +
42 +def test_weighted_blend_reuses_the_existing_pipeline() -> None:
43 + class FakePipeline:
44 + packs = {"af_heart": 2.0, "am_puck": 10.0}
45 +
46 + def __init__(self) -> None:
47 + self.loaded: list[str] = []
48 +
49 + def load_single_voice(self, voice: str) -> float:
50 + self.loaded.append(voice)
51 + return self.packs[voice]
52 +
53 + pipeline = FakePipeline()
54 + blend = runtime._resolve_voice(
55 + pipeline,
56 + "legacy",
57 + {"af_heart": 3.0, "am_puck": 1.0},
58 + )
59 +
60 + assert blend == 4.0
61 + assert pipeline.loaded == ["af_heart", "am_puck"]
62 + assert runtime._resolve_voice(pipeline, "am_puck,am_onyx", {}) == (
63 + "am_puck,am_onyx"
64 + )
65 +
66 +
67 +def test_synthesis_forwards_normalized_weights(monkeypatch) -> None:
68 + captured: dict = {}
69 +
70 + async def fake_synthesize(sentences, **kwargs):
71 + captured.update({"sentences": sentences, **kwargs})
72 + return "audio"
73 +
74 + monkeypatch.setattr(runtime, "_synthesize_sentences", fake_synthesize)
75 +
76 + result = asyncio.run(
77 + runtime.synthesize_sentences(
78 + ["Hello"],
79 + {
80 + "voice": "legacy",
81 + "voice_weights": {"af_heart": 2, "am_puck": 1},
82 + "speed": 1.4,
83 + },
84 + )
85 + )
86 +
87 + assert result == "audio"
88 + assert captured == {
89 + "sentences": ["Hello"],
90 + "voice": "af_heart,am_puck",
91 + "voice_weights": {"af_heart": 2.0, "am_puck": 1.0},
92 + "speed": 1.4,
93 + }
94 +
95 +
96 +def test_settings_expose_catalog_weights_and_speed_without_disk_blends() -> None:
97 + config_ui = (
98 + PROJECT_ROOT / "plugins/_kokoro_tts/webui/config.html"
99 + ).read_text(encoding="utf-8")
100 + store = (
101 + PROJECT_ROOT / "plugins/_kokoro_tts/webui/kokoro-tts-store.js"
102 + ).read_text(encoding="utf-8")
103 +
104 + assert "af_heart" in config_ui
105 + assert "am_puck" in config_ui
106 + assert "am_onyx" in config_ui
107 + assert "config.voice_weights" in config_ui
108 + assert "Use equal weights" in config_ui
109 + assert 'type="range"' in config_ui
110 + assert 'min="0.5"' in config_ui
111 + assert 'max="3"' in config_ui
112 + assert "create_blend" not in config_ui
113 + assert "lang_code" not in config_ui
114 + assert "voiceSummary" in store