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
+1
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"kaplay": "^3001.0.19",
"kokoro-js": "^1.2.1",
"nostr-tools": "^2.23.3",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
+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()
+303
View File
@@ -0,0 +1,303 @@
// Kokoro TTS engine — high-quality client-side text-to-speech
// Lazy-loads the 86MB q8 model on first use, caches in browser storage.
// Falls through to speechSynthesis if model isn't ready yet.
import type { KokoroTTS as KokoroTTSType } from 'kokoro-js'
type KokoroVoice = 'af_heart' | 'af_alloy' | 'af_aoede' | 'af_bella' | 'af_jessica' | 'af_kore' |
'af_nicole' | 'af_nova' | 'af_river' | 'af_sarah' | 'af_sky' |
'am_adam' | 'am_echo' | 'am_eric' | 'am_fenrir' | 'am_liam' | 'am_michael' | 'am_onyx' | 'am_puck' | 'am_santa' |
'bf_emma' | 'bf_isabella' | 'bf_alice' | 'bf_lily' |
'bm_daniel' | 'bm_fable' | 'bm_george' | 'bm_lewis'
interface VoiceMapping {
voice: KokoroVoice
speed: number
}
// Map each voice profile to a kokoro voice + speed modifier.
// With 28 distinct voices, each profile gets a genuinely different voice.
const VOICE_MAP: Record<string, VoiceMapping> = {
// --- Authoritative / announcer ---
announcer: { voice: 'am_fenrir', speed: 0.95 },
deep: { voice: 'am_onyx', speed: 0.85 },
smooth: { voice: 'am_liam', speed: 0.95 },
question_reader:{ voice: 'af_kore', speed: 1.05 },
news: { voice: 'am_michael', speed: 1.0 },
// --- High energy ---
hype: { voice: 'af_heart', speed: 1.3 },
screamer: { voice: 'af_sarah', speed: 1.5 },
sportscaster: { voice: 'am_michael', speed: 1.4 },
auctioneer: { voice: 'am_puck', speed: 1.8 },
hyper: { voice: 'af_nova', speed: 1.8 },
punk: { voice: 'am_puck', speed: 1.2 },
drill: { voice: 'am_onyx', speed: 1.15 },
karen: { voice: 'af_jessica', speed: 1.4 },
terrified: { voice: 'af_sky', speed: 1.6 },
power_up: { voice: 'af_river', speed: 1.3 },
wrestler_v: { voice: 'am_fenrir', speed: 1.0 },
// --- Deep / menacing ---
boomer: { voice: 'bm_george', speed: 0.7 },
movie: { voice: 'am_echo', speed: 0.8 },
demon_v: { voice: 'bm_lewis', speed: 0.7 },
final_boss: { voice: 'am_echo', speed: 0.55 },
boss_taunt: { voice: 'am_onyx', speed: 0.9 },
game_over: { voice: 'am_fenrir', speed: 0.8 },
giant: { voice: 'bm_george', speed: 0.5 },
mainframe: { voice: 'am_adam', speed: 0.6 },
// --- Calm / wise ---
preacher: { voice: 'bm_daniel', speed: 0.75 },
wizard_v: { voice: 'am_echo', speed: 0.85 },
sensei: { voice: 'bm_fable', speed: 0.7 },
professor: { voice: 'bm_daniel', speed: 0.8 },
ancient: { voice: 'am_eric', speed: 0.45 },
opera: { voice: 'bf_emma', speed: 0.65 },
// --- Cute / high ---
chipmunk: { voice: 'af_nicole', speed: 1.7 },
baby: { voice: 'bf_lily', speed: 1.1 },
fairy: { voice: 'af_bella', speed: 1.2 },
angel: { voice: 'af_bella', speed: 0.9 },
tutorial: { voice: 'af_nicole', speed: 1.1 },
valley: { voice: 'af_heart', speed: 1.2 },
// --- Robots / computers ---
robot: { voice: 'am_adam', speed: 0.9 },
ai_core: { voice: 'af_alloy', speed: 1.0 },
mech: { voice: 'am_adam', speed: 0.8 },
android_v: { voice: 'af_alloy', speed: 1.05 },
siri: { voice: 'af_aoede', speed: 1.0 },
hal: { voice: 'am_echo', speed: 0.7 },
dial_up: { voice: 'af_sky', speed: 1.4 },
glitch: { voice: 'af_sarah', speed: 1.7 },
glitchbot: { voice: 'am_puck', speed: 1.8 },
// --- Accents ---
posh: { voice: 'bf_emma', speed: 0.85 },
aussie: { voice: 'bm_george', speed: 1.05 },
scottish: { voice: 'bm_lewis', speed: 1.1 },
french: { voice: 'bf_isabella', speed: 0.9 },
texan: { voice: 'am_eric', speed: 0.8 },
// --- Character voices ---
whisper: { voice: 'af_bella', speed: 0.7 },
surfer: { voice: 'am_liam', speed: 1.0 },
pirate_v: { voice: 'am_eric', speed: 0.95 },
cowboy_v: { voice: 'am_eric', speed: 0.85 },
ninja_v: { voice: 'bm_fable', speed: 1.05 },
alien_v: { voice: 'bf_alice', speed: 0.8 },
echo_v: { voice: 'am_echo', speed: 0.8 },
// --- Old people ---
grandpa: { voice: 'am_eric', speed: 0.6 },
grandma: { voice: 'bf_lily', speed: 0.55 },
crotchety: { voice: 'bm_daniel', speed: 1.0 },
// --- Misc characters ---
drunk: { voice: 'am_liam', speed: 0.6 },
sleepy: { voice: 'bm_fable', speed: 0.4 },
stoner: { voice: 'am_liam', speed: 0.5 },
npc: { voice: 'af_river', speed: 0.95 },
conspiracy: { voice: 'bm_fable', speed: 1.2 },
}
// Default fallback
const DEFAULT_VOICE: VoiceMapping = { voice: 'am_fenrir', speed: 1.0 }
let ttsInstance: KokoroTTSType | null = null
let ttsLoading = false
let ttsLoadFailed = false
let _audioCtx: AudioContext | null = null
// Audio cache: key = "voice:speed:text" → AudioBuffer
const audioCache = new Map<string, AudioBuffer>()
const MAX_CACHE = 200
// Currently playing sources (for stop)
const activeSources: Set<AudioBufferSourceNode> = new Set()
function getAudioCtx(): AudioContext {
if (!_audioCtx) _audioCtx = new AudioContext()
if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(() => {})
return _audioCtx
}
/** Set the shared AudioContext (called from sounds.ts so we share one context) */
export function setAudioContext(ctx: AudioContext) {
_audioCtx = ctx
}
/** Is the kokoro model loaded and ready? */
export function isKokoroReady(): boolean {
return ttsInstance !== null
}
/** Is the kokoro model currently loading? */
export function isKokoroLoading(): boolean {
return ttsLoading
}
/** Start loading the kokoro model. Call early (e.g. on first user gesture). */
export async function initKokoro(onProgress?: (pct: number) => void): Promise<void> {
if (ttsInstance || ttsLoading || ttsLoadFailed) return
ttsLoading = true
try {
const { KokoroTTS } = await import('kokoro-js')
ttsInstance = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-ONNX', {
dtype: 'q8',
device: null, // auto-detect (WebGPU → WASM fallback)
progress_callback: onProgress ? (p: any) => {
if (p.progress !== undefined) onProgress(p.progress)
} : undefined,
})
ttsLoading = false
// Pre-cache common fight phrases in the background
_precacheCommon()
} catch (e) {
console.warn('[kokoro] Failed to load TTS model:', e)
ttsLoading = false
ttsLoadFailed = true
}
}
// Common phrases to pre-generate so they play instantly
const PRECACHE_PHRASES: Array<{ text: string; profile: string }> = [
{ text: 'K. O.!', profile: 'announcer' },
{ text: 'Devastating!', profile: 'deep' },
{ text: 'Flawless victory!', profile: 'deep' },
{ text: 'Finish it!', profile: 'announcer' },
{ text: 'Fatality!', profile: 'deep' },
{ text: 'Round one!', profile: 'announcer' },
{ text: 'Round two!', profile: 'announcer' },
{ text: 'Round three!', profile: 'announcer' },
{ text: 'Fight!', profile: 'announcer' },
]
async function _precacheCommon() {
if (!ttsInstance) return
for (const { text, profile } of PRECACHE_PHRASES) {
try {
await _generateAndCache(text, profile)
} catch { /* swallow — non-critical */ }
}
}
function _cacheKey(text: string, profile: string): string {
const m = VOICE_MAP[profile] || DEFAULT_VOICE
return `${m.voice}:${m.speed}:${text}`
}
async function _generateAndCache(text: string, profile: string): Promise<AudioBuffer | null> {
if (!ttsInstance) return null
const key = _cacheKey(text, profile)
const cached = audioCache.get(key)
if (cached) return cached
const mapping = VOICE_MAP[profile] || DEFAULT_VOICE
const raw = await ttsInstance.generate(text, {
voice: mapping.voice,
speed: mapping.speed,
})
// Convert Float32Array → AudioBuffer
const ctx = getAudioCtx()
const buf = ctx.createBuffer(1, raw.audio.length, raw.sampling_rate)
buf.getChannelData(0).set(raw.audio)
// Evict oldest if cache is full
if (audioCache.size >= MAX_CACHE) {
const oldest = audioCache.keys().next().value
if (oldest) audioCache.delete(oldest)
}
audioCache.set(key, buf)
return buf
}
/**
* Play audio through the given destination node with volume control.
* Returns a promise that resolves when playback finishes.
*/
function _playBuffer(buf: AudioBuffer, dest: AudioNode, volume: number): Promise<void> {
return new Promise<void>((resolve) => {
const ctx = getAudioCtx()
const src = ctx.createBufferSource()
src.buffer = buf
const gain = ctx.createGain()
gain.gain.value = volume
src.connect(gain)
gain.connect(dest)
activeSources.add(src)
src.onended = () => {
activeSources.delete(src)
resolve()
}
src.start()
})
}
/**
* Generate and play TTS. Returns a promise that resolves when done.
* Returns null if kokoro isn't ready (caller should fall back).
*/
export async function kokoroSpeakAsync(
text: string,
profileName: string,
dest: AudioNode,
volume: number = 0.7,
): Promise<boolean> {
if (!ttsInstance) return false
try {
const buf = await _generateAndCache(text, profileName)
if (!buf) return false
await _playBuffer(buf, dest, volume)
return true
} catch (e) {
console.warn('[kokoro] Speech generation failed:', e)
return false
}
}
/**
* Fire-and-forget version. Returns true if kokoro handled it, false to fall back.
*/
export function kokoroSpeak(
text: string,
profileName: string,
dest: AudioNode,
volume: number = 0.7,
): boolean {
if (!ttsInstance) return false
// Check cache for instant playback
const key = _cacheKey(text, profileName)
const cached = audioCache.get(key)
if (cached) {
_playBuffer(cached, dest, volume)
return true
}
// Generate async — will play when ready
_generateAndCache(text, profileName).then(buf => {
if (buf) _playBuffer(buf, dest, volume)
}).catch(() => {})
return true
}
/** Stop all currently playing kokoro audio */
export function kokoroStop() {
for (const src of activeSources) {
try { src.stop() } catch {}
}
activeSources.clear()
}
/** Clear the audio cache */
export function kokoroClearCache() {
audioCache.clear()
}
/** Get the kokoro voice mapping for a profile name */
export function getKokoroVoice(profileName: string): VoiceMapping {
return VOICE_MAP[profileName] || DEFAULT_VOICE
}
+1
View File
@@ -28,6 +28,7 @@ export default defineConfig({
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2}'],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB — kokoro TTS chunk is ~2.2MB
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,