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:
co-authored by
Claude Opus 4.6
parent
dc5ea69180
commit
a540320901
+35
-13
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user