| 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> |