Spaces:
Running
Running
File size: 5,618 Bytes
414dc55 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 | // 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
}
}
|