case0 / web /src /ui /audio.ts
HusseinEid's picture
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
}
}