// Client audio: short UI SFX (played on events) + a looping ambient track (toggle). // Files are served by the server from /audio (assets/ui). Everything is local; playback // only starts on a user gesture, per browser autoplay policy. export type Sfx = 'click' | 'select' | 'present' | 'accuse' | 'success' | 'fail' | 'page' const SFX_VOLUME = 0.32 const MUSIC_VOLUME = 0.28 const MUTE_KEY = 'cz-sfx-muted' let sfxMuted = (() => { try { return localStorage.getItem(MUTE_KEY) === '1' } catch { return false } })() const cache: Record = {} function base(name: Sfx): HTMLAudioElement { if (!cache[name]) { const a = new Audio(`/audio/sfx/${name}.wav`) a.volume = SFX_VOLUME a.preload = 'auto' cache[name] = a } return cache[name] } export function playSfx(name: Sfx): void { if (sfxMuted) return try { const node = base(name).cloneNode() as HTMLAudioElement node.volume = SFX_VOLUME void node.play().catch(() => {}) } catch { /* ignore */ } } export function sfxIsMuted(): boolean { return sfxMuted } export function setSfxMuted(muted: boolean): void { sfxMuted = muted try { localStorage.setItem(MUTE_KEY, muted ? '1' : '0') } catch { /* ignore */ } } let music: HTMLAudioElement | null = null function ensureMusic(): HTMLAudioElement { if (!music) { music = new Audio('/audio/music/ambient_theme.mp3') music.loop = true music.volume = MUSIC_VOLUME } return music } export function toggleMusic(): boolean { const m = ensureMusic() if (m.paused) { void m.play().catch(() => {}) return true } m.pause() return false } export function musicIsPlaying(): boolean { return !!music && !music.paused } // ---- suspect voice (Supertonic, synthesized server-side per reply) ---- // ONE reused element. Mobile browsers only allow audio that a user gesture has unlocked, so // we keep a single element and unlock it on the first tap (a fresh `new Audio()` per reply // would NOT be unlocked and would stay silent on phones). let voiceEl: HTMLAudioElement | null = null let voiceUrl: string | null = null function voiceElement(): HTMLAudioElement { if (!voiceEl) { voiceEl = new Audio() voiceEl.volume = 0.95 } return voiceEl } function releaseUrl(): void { if (voiceUrl) { URL.revokeObjectURL(voiceUrl) voiceUrl = null } } // A 0-sample silent WAV - played inside the first gesture to grant playback permission. const SILENT_WAV = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=' let audioUnlocked = false // Call once at startup: on the first user gesture, unlock the voice element (and the audio // pipeline) so the synthesized voice can play on mobile, where autoplay is blocked. export function unlockAudioOnce(): void { if (audioUnlocked || typeof window === 'undefined') return const handler = () => { if (audioUnlocked) return audioUnlocked = true try { const el = voiceElement() el.src = SILENT_WAV void el.play().then(() => { el.pause(); el.currentTime = 0 }).catch(() => {}) } catch { /* ignore */ } window.removeEventListener('pointerdown', handler) window.removeEventListener('touchend', handler) window.removeEventListener('keydown', handler) } window.addEventListener('pointerdown', handler) window.addEventListener('touchend', handler) window.addEventListener('keydown', handler) } export function stopSpeak(): void { if (voiceEl) { voiceEl.pause() voiceEl.onended = null try { voiceEl.currentTime = 0 } catch { /* ignore */ } } } export interface VoiceHandle { /** Spoken-audio length in ms (real, from metadata; estimated if unavailable). */ durationMs: number play: () => void stop: () => void } // Synthesize the suspect's line and load it WITHOUT playing yet, returning its duration so // the caller can start the typewriter + mouth in lock-step with the audio. The synth happens // during the suspect's "thinking" beat, so text and voice then begin together (no lag). export async function prepareSpeak( runId: string, suspectId: string, text: string, onEnd?: () => void, ): Promise { stopSpeak() const clean = text.trim() if (!clean) return null try { const res = await fetch( `/api/run/${encodeURIComponent(runId)}/tts/${encodeURIComponent(suspectId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: clean }) }, ) if (!res.ok) return null releaseUrl() const url = URL.createObjectURL(await res.blob()) voiceUrl = url const el = voiceElement() // the SAME element unlocked on first tap -> plays on mobile el.onended = null el.src = url const estimate = clean.length * 55 const durationMs = await new Promise((resolve) => { let settled = false const finish = (d: number) => { if (!settled) { settled = true resolve(d > 0 && isFinite(d) ? d : estimate) } } el.addEventListener('loadedmetadata', () => finish(el.duration * 1000), { once: true }) el.addEventListener('error', () => finish(estimate), { once: true }) setTimeout(() => finish(el.duration * 1000), 1500) }) el.onended = () => { releaseUrl() onEnd?.() } return { durationMs, play: () => void el.play().catch(() => {}), stop: () => el.pause(), } } catch { return null // voice is best-effort; the game stays fully playable as text } }