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
  }
}