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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user