main
html 335 lines 11 KB
Raw
1 <html>
2 <head>
3 <title>Kokoro TTS</title>
4 </head>
5
6 <body>
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">
115 <div class="plugin-config-page" x-init="initConfig()">
116 <div class="section-title">Kokoro TTS</div>
117 <div class="section-description">
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">
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">
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>
223 <div class="field-description">Speech rate from 0.5× to 3.0×.</div>
224 </div>
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>