Spaces:
Running
Running
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55 | // 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<string, HTMLAudioElement> = {} | |
| 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<VoiceHandle | null> { | |
| 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<number>((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 | |
| } | |
| } | |