feat: replace Web Speech API with Kokoro TTS for high-quality voices

Adds kokoro-js (82M model, 28 distinct voices) as primary TTS engine with
automatic fallback to speechSynthesis while model loads. All 60+ voice
profiles mapped to real Kokoro voices. Common fight phrases pre-cached
for instant playback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 17:30:36 +00:00
co-authored by Claude Opus 4.6
parent dc5ea69180
commit a540320901
5 changed files with 913 additions and 13 deletions
+35 -13
View File
@@ -1,4 +1,7 @@
// Procedural 8-bit sound system + announcer voice using Web Audio + Speech Synthesis
// Procedural 8-bit sound system + announcer voice using Web Audio + Kokoro TTS
// Falls back to Speech Synthesis if Kokoro model hasn't loaded yet
import { initKokoro, isKokoroReady, kokoroSpeak, kokoroSpeakAsync, kokoroStop, setAudioContext } from './tts'
let ctx: AudioContext | null = null
let musicGain: GainNode | null = null
let sfxGain: GainNode | null = null
@@ -307,12 +310,18 @@ let _speechQueueDepth = 0
const VOICE_VOLUME_SCALE = 0.7
function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
if (typeof speechSynthesis === 'undefined') return
if (masterMuted) return
// Try Kokoro TTS first — high quality, no browser bugs
if (isKokoroReady() && sfxGain) {
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
if (cancelPrevious) kokoroStop()
kokoroSpeak(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
return
}
// Fallback: Web Speech API
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
// Chrome bug: speechSynthesis can get stuck. Nudge it.
if (speechSynthesis.paused) speechSynthesis.resume()
// Flush if queue is getting deep — max 2 queued to prevent buildup
if (cancelPrevious || (speechSynthesis.pending && speechSynthesis.speaking)) {
if (_speechQueueDepth > 2) {
speechSynthesis.cancel()
@@ -329,7 +338,6 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
utter.onerror = (ev) => {
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
// Retry once on non-cancel errors (interrupted = browser killed it, not us)
if (ev.error !== 'canceled' && ev.error !== 'interrupted') {
setTimeout(() => {
if (!masterMuted && typeof speechSynthesis !== 'undefined') {
@@ -352,11 +360,19 @@ const _isIOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navig
const _isDesktopChrome = typeof navigator !== 'undefined' && /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent)
/** Core async speak — resolves when speech finishes or bails fast if speech won't work */
function _speakAsyncCore(text: string, profileName: string, rateOverride?: number, cancelPrevious?: boolean): Promise<void> {
async function _speakAsyncCore(text: string, profileName: string, rateOverride?: number, cancelPrevious?: boolean): Promise<void> {
if (masterMuted) return
// Try Kokoro TTS first
if (isKokoroReady() && sfxGain) {
if (cancelPrevious) kokoroStop()
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const handled = await kokoroSpeakAsync(text, profileName, sfxGain, profile.volume * VOICE_VOLUME_SCALE)
if (handled) return
}
// Fallback: Web Speech API
return new Promise<void>((resolve) => {
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
if (typeof speechSynthesis === 'undefined') { resolve(); return }
if (!voicesLoaded) loadVoices()
// No voices available = speech won't work, bail immediately
if (!voicesLoaded) { resolve(); return }
if (speechSynthesis.paused) speechSynthesis.resume()
if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 }
@@ -377,14 +393,10 @@ function _speakAsyncCore(text: string, profileName: string, rateOverride?: numbe
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
resolve()
}
// Hard safety cap — never block longer than 8s
const safetyTimeout = setTimeout(cleanup, 8_000)
// Fast bail: if speech hasn't started within 500ms, it's not going to work (mobile/no gesture)
const startupCheck = setTimeout(() => {
if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup()
}, 500)
// Desktop Chrome keepalive: periodic pause/resume prevents Chrome's 15s silent cutoff
// Do NOT do this on iOS — it permanently kills speech on Safari
let keepalive: ReturnType<typeof setInterval> | null = null
if (_isDesktopChrome) {
keepalive = setInterval(() => {
@@ -412,6 +424,7 @@ function speakAsync(text: string, profileName: string, cancelPrevious: boolean =
export function stopAllAudio() {
stopMusic()
kokoroStop()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
_speechQueueDepth = 0
// Disconnect gain nodes to instantly kill all in-flight oscillators/buffers,
@@ -435,6 +448,11 @@ export function stopAllAudio() {
// Public voice functions
export function announce(text: string, pitch?: number, rate?: number) {
if (masterMuted) return
// Kokoro ignores pitch/rate overrides — just use the announcer profile
if (isKokoroReady()) {
speak(text, 'announcer')
return
}
if (pitch !== undefined || rate !== undefined) {
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
@@ -2846,6 +2864,7 @@ export function setMasterMute(muted: boolean) {
if (muted) {
if (musicGain) musicGain.gain.value = 0
if (sfxGain) sfxGain.gain.value = 0
kokoroStop()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
} else {
if (musicGain) musicGain.gain.value = MUSIC_VOL
@@ -2863,7 +2882,10 @@ export async function ensureAudioContext() {
if (c.state === 'suspended') {
try { await c.resume() } catch {}
}
// Prime speech synthesis on user gesture — mobile browsers require
// Share AudioContext with kokoro and start loading the model
setAudioContext(c)
initKokoro()
// Prime speech synthesis as fallback — mobile browsers require
// a speak() call inside a user gesture to unlock speechSynthesis
if (typeof speechSynthesis !== 'undefined') {
if (!voicesLoaded) loadVoices()