| 1 | /** |
| 2 | * 通知音效系统 |
| 3 | * |
| 4 | * 支持合成音效和 WAV 文件播放两种模式,默认关闭。 |
| 5 | * 两个场景的偏好分别存入 localStorage: |
| 6 | * notificationSoundSuccess —— 生成完成 |
| 7 | * notificationSoundAttention —— AI 提问 |
| 8 | * notificationSoundVolume —— 统一通知音量(0–100) |
| 9 | * 值:"off" | "synth" | "positive" | "correct" | "start" | "back" |
| 10 | */ |
| 11 | |
| 12 | export type SoundWavPref = "off" | "synth" | "positive" | "correct" | "start" | "back"; |
| 13 | |
| 14 | const SUCCESS_KEY = "notificationSoundSuccess"; |
| 15 | const ATTENTION_KEY = "notificationSoundAttention"; |
| 16 | export const NOTIFICATION_VOLUME_STORAGE_KEY = "notificationSoundVolume"; |
| 17 | export const NOTIFICATION_VOLUME_MIN = 0; |
| 18 | export const NOTIFICATION_VOLUME_MAX = 100; |
| 19 | export const DEFAULT_NOTIFICATION_VOLUME = 70; |
| 20 | |
| 21 | function readPref(key: string): SoundWavPref { |
| 22 | if (typeof localStorage === "undefined") return "off"; |
| 23 | const val = localStorage.getItem(key); |
| 24 | if (val === "off" || val === "synth" || val === "positive" || val === "correct" || val === "start" || val === "back") return val; |
| 25 | return "off"; |
| 26 | } |
| 27 | |
| 28 | function writePref(key: string, pref: SoundWavPref): void { |
| 29 | if (typeof localStorage !== "undefined") { |
| 30 | localStorage.setItem(key, pref); |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | export function getSuccessPreference(): SoundWavPref { return readPref(SUCCESS_KEY); } |
| 35 | export function setSuccessPreference(pref: SoundWavPref): void { writePref(SUCCESS_KEY, pref); } |
| 36 | export function getAttentionPreference(): SoundWavPref { return readPref(ATTENTION_KEY); } |
| 37 | export function setAttentionPreference(pref: SoundWavPref): void { writePref(ATTENTION_KEY, pref); } |
| 38 | |
| 39 | export function normalizeNotificationVolume(value: unknown): number { |
| 40 | const raw = typeof value === "string" ? value.trim() : value; |
| 41 | if (raw === "" || raw === null || raw === undefined) return DEFAULT_NOTIFICATION_VOLUME; |
| 42 | const numeric = Number(raw); |
| 43 | if (!Number.isFinite(numeric)) return DEFAULT_NOTIFICATION_VOLUME; |
| 44 | return Math.min(NOTIFICATION_VOLUME_MAX, Math.max(NOTIFICATION_VOLUME_MIN, Math.round(numeric))); |
| 45 | } |
| 46 | |
| 47 | export function getNotificationVolume(): number { |
| 48 | if (typeof localStorage === "undefined") return DEFAULT_NOTIFICATION_VOLUME; |
| 49 | try { |
| 50 | const value = localStorage.getItem(NOTIFICATION_VOLUME_STORAGE_KEY); |
| 51 | return value === null ? DEFAULT_NOTIFICATION_VOLUME : normalizeNotificationVolume(value); |
| 52 | } catch { |
| 53 | return DEFAULT_NOTIFICATION_VOLUME; |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | export function setNotificationVolume(volume: number): number { |
| 58 | const normalized = normalizeNotificationVolume(volume); |
| 59 | try { |
| 60 | if (typeof localStorage !== "undefined") { |
| 61 | localStorage.setItem(NOTIFICATION_VOLUME_STORAGE_KEY, String(normalized)); |
| 62 | } |
| 63 | } catch { |
| 64 | // Private browsing and locked-down WebViews may reject localStorage writes. |
| 65 | } |
| 66 | return normalized; |
| 67 | } |
| 68 | |
| 69 | export function notificationVolumeToGain(volume: unknown): number { |
| 70 | return normalizeNotificationVolume(volume) / NOTIFICATION_VOLUME_MAX; |
| 71 | } |
| 72 | |
| 73 | type WavSoundPref = Exclude<SoundWavPref, "off" | "synth">; |
| 74 | |
| 75 | // The bundled WAV files differ by up to 4.1 LUFS. These trims normalize them |
| 76 | // to the quietest source (-18.9 LUFS) without boosting any asset above its |
| 77 | // recorded peak. The master volume is applied after the source trim. |
| 78 | const WAV_LOUDNESS_TRIM: Record<WavSoundPref, number> = { |
| 79 | positive: 0.62, |
| 80 | correct: 0.85, |
| 81 | start: 1, |
| 82 | back: 0.70, |
| 83 | }; |
| 84 | |
| 85 | export function notificationWavGain(pref: WavSoundPref, outputVolume: number): number { |
| 86 | const safeVolume = Number.isFinite(outputVolume) |
| 87 | ? Math.min(1, Math.max(0, outputVolume)) |
| 88 | : 0; |
| 89 | return safeVolume * WAV_LOUDNESS_TRIM[pref]; |
| 90 | } |
| 91 | |
| 92 | function soundFilePath(pref: SoundWavPref): string { |
| 93 | switch (pref) { |
| 94 | case "positive": return "./sounds/mixkit-positive-notification-951.wav"; |
| 95 | case "correct": return "./sounds/mixkit-correct-answer-tone-2870.wav"; |
| 96 | case "start": return "./sounds/mixkit-software-interface-start-2574.wav"; |
| 97 | case "back": return "./sounds/mixkit-software-interface-back-2575.wav"; |
| 98 | default: return ""; |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // ── WAV audio cache ────────────────────────────────────────────────────────── |
| 103 | const audioBufferCache = new Map<string, AudioBuffer>(); |
| 104 | |
| 105 | async function loadBuffer(ctx: AudioContext, url: string): Promise<AudioBuffer | null> { |
| 106 | const cached = audioBufferCache.get(url); |
| 107 | if (cached) return cached; |
| 108 | try { |
| 109 | const resp = await fetch(url); |
| 110 | if (!resp.ok) return null; |
| 111 | const arrayBuffer = await resp.arrayBuffer(); |
| 112 | const decoded = await ctx.decodeAudioData(arrayBuffer); |
| 113 | audioBufferCache.set(url, decoded); |
| 114 | return decoded; |
| 115 | } catch { |
| 116 | return null; |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | function playBuffer(ctx: AudioContext, buffer: AudioBuffer, volume: number): void { |
| 121 | const src = ctx.createBufferSource(); |
| 122 | src.buffer = buffer; |
| 123 | const gain = ctx.createGain(); |
| 124 | gain.gain.value = volume; |
| 125 | src.connect(gain); |
| 126 | gain.connect(ctx.destination); |
| 127 | src.start(); |
| 128 | } |
| 129 | |
| 130 | // ── Synthesised sounds ─────────────────────────────────────────────────────── |
| 131 | |
| 132 | function playSynthNote(ctx: AudioContext, dest: AudioNode, freq: number, startTime: number, duration: number, volume: number): void { |
| 133 | const osc = ctx.createOscillator(); |
| 134 | osc.type = "sine"; |
| 135 | osc.frequency.setValueAtTime(freq, startTime); |
| 136 | const gain = ctx.createGain(); |
| 137 | gain.gain.setValueAtTime(0, startTime); |
| 138 | gain.gain.linearRampToValueAtTime(volume, startTime + 0.002); |
| 139 | gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); |
| 140 | osc.connect(gain); |
| 141 | gain.connect(dest); |
| 142 | osc.start(startTime); |
| 143 | osc.stop(startTime + duration); |
| 144 | |
| 145 | const shimmer = ctx.createOscillator(); |
| 146 | shimmer.type = "sine"; |
| 147 | shimmer.frequency.setValueAtTime(freq * 4, startTime); |
| 148 | const sGain = ctx.createGain(); |
| 149 | sGain.gain.setValueAtTime(0, startTime); |
| 150 | sGain.gain.linearRampToValueAtTime(volume * 0.12, startTime + 0.002); |
| 151 | sGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration * 0.6); |
| 152 | shimmer.connect(sGain); |
| 153 | sGain.connect(dest); |
| 154 | shimmer.start(startTime); |
| 155 | shimmer.stop(startTime + duration); |
| 156 | } |
| 157 | |
| 158 | function playSynthSuccess(ctx: AudioContext, outputVolume: number): void { |
| 159 | playSynthNote(ctx, ctx.destination, 1318.5, 0, 0.20, outputVolume * 0.35); |
| 160 | playSynthNote(ctx, ctx.destination, 1568.0, 0.07, 0.22, outputVolume * 0.30); |
| 161 | playSynthNote(ctx, ctx.destination, 2093.0, 0.14, 0.30, outputVolume * 0.24); |
| 162 | } |
| 163 | |
| 164 | function playSynthAttention(ctx: AudioContext, outputVolume: number): void { |
| 165 | playSynthNote(ctx, ctx.destination, 1760.0, 0, 0.14, outputVolume * 0.40); |
| 166 | playSynthNote(ctx, ctx.destination, 1318.5, 0.09, 0.22, outputVolume * 0.34); |
| 167 | } |
| 168 | |
| 169 | // ── Play helpers ───────────────────────────────────────────────────────────── |
| 170 | |
| 171 | async function playWav(pref: WavSoundPref, volume: number, fallback: (ctx: AudioContext, outputVolume: number) => void): Promise<void> { |
| 172 | const url = soundFilePath(pref); |
| 173 | if (!url) return; |
| 174 | const ctx = new AudioContext(); |
| 175 | try { |
| 176 | const buf = await loadBuffer(ctx, url); |
| 177 | if (buf) { |
| 178 | playBuffer(ctx, buf, notificationWavGain(pref, volume)); |
| 179 | } else { |
| 180 | fallback(ctx, volume); |
| 181 | } |
| 182 | } catch { |
| 183 | fallback(ctx, volume); |
| 184 | } |
| 185 | setTimeout(() => ctx.close(), 2000); |
| 186 | } |
| 187 | |
| 188 | // ── Public API ─────────────────────────────────────────────────────────────── |
| 189 | |
| 190 | export function playSuccessChime(): void { |
| 191 | const pref = getSuccessPreference(); |
| 192 | if (pref === "off") return; |
| 193 | const volume = notificationVolumeToGain(getNotificationVolume()); |
| 194 | if (volume <= 0) return; |
| 195 | if (pref === "synth") { |
| 196 | try { |
| 197 | const ctx = new AudioContext(); |
| 198 | playSynthSuccess(ctx, volume); |
| 199 | setTimeout(() => ctx.close(), 600); |
| 200 | } catch { /* silent */ } |
| 201 | } else { |
| 202 | void playWav(pref, volume, playSynthSuccess); |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | export function playAttentionChime(): void { |
| 207 | const pref = getAttentionPreference(); |
| 208 | if (pref === "off") return; |
| 209 | const volume = notificationVolumeToGain(getNotificationVolume()); |
| 210 | if (volume <= 0) return; |
| 211 | if (pref === "synth") { |
| 212 | try { |
| 213 | const ctx = new AudioContext(); |
| 214 | playSynthAttention(ctx, volume); |
| 215 | setTimeout(() => ctx.close(), 500); |
| 216 | } catch { /* silent */ } |
| 217 | } else { |
| 218 | void playWav(pref, volume, playSynthAttention); |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | export type AttentionChimeEvent = { |
| 223 | kind?: string; |
| 224 | hostId?: string; |
| 225 | tabId?: string; |
| 226 | turnId?: string; |
| 227 | approval?: { id?: string; turnId?: string }; |
| 228 | ask?: { id?: string; turnId?: string }; |
| 229 | }; |
| 230 | |
| 231 | export function attentionChimeEventKey(event: AttentionChimeEvent): string | undefined { |
| 232 | const kind = event.kind === "approval_request" ? "approval" : event.kind === "ask_request" ? "ask" : undefined; |
| 233 | const prompt = kind === "approval" ? event.approval : kind === "ask" ? event.ask : undefined; |
| 234 | if (!kind || !prompt?.id) return undefined; |
| 235 | // Turn ids are globally unique and survive detach/reattach. Tab ids and |
| 236 | // desktop runtime epochs are view bindings and can change during replay. |
| 237 | const turnId = prompt.turnId || event.turnId; |
| 238 | const identity = [kind, turnId, prompt.id]; |
| 239 | if (event.hostId && event.hostId !== "local") identity.push(event.hostId); |
| 240 | return turnId ? `turn:${JSON.stringify(identity)}` : `${kind}:${event.tabId ?? ""}:${prompt.id}`; |
| 241 | } |
| 242 | |
| 243 | // attentionChimeSeenCap bounds the dedupe set. Prompt ids are unique per |
| 244 | // prompt, so the set only ever grows; past the cap the oldest half is dropped |
| 245 | // (insertion order) — replay dedupe only needs to cover recently replayed |
| 246 | // prompts, not the whole session history. |
| 247 | const attentionChimeSeenCap = 512; |
| 248 | |
| 249 | // clearAttentionChimeKeys drops dedupe keys after a runtime rebuild. Approval |
| 250 | // and ask ids are per-controller counters starting at "1", so a rebuilt |
| 251 | // controller (model/effort/settings switch) reissues ids an earlier prompt on |
| 252 | // the same tab already used. Only legacy tab-scoped keys need resetting; |
| 253 | // turn-scoped identities must survive ready/rebuilt fences during reattach. |
| 254 | export function clearAttentionChimeKeys(seen: Set<string>, tabId?: string): void { |
| 255 | if (tabId === undefined || tabId === "") { |
| 256 | for (const key of seen) if (!key.startsWith("turn:")) seen.delete(key); |
| 257 | return; |
| 258 | } |
| 259 | for (const key of [...seen]) { |
| 260 | if (key.startsWith(`approval:${tabId}:`) || key.startsWith(`ask:${tabId}:`)) { |
| 261 | seen.delete(key); |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | export function shouldPlayAttentionChimeForEvent(event: AttentionChimeEvent, seen: Set<string>): boolean { |
| 267 | const key = attentionChimeEventKey(event); |
| 268 | if (!key || seen.has(key)) return false; |
| 269 | if (seen.size >= attentionChimeSeenCap) { |
| 270 | let drop = seen.size - attentionChimeSeenCap / 2; |
| 271 | for (const k of seen) { |
| 272 | if (drop-- <= 0) break; |
| 273 | seen.delete(k); |
| 274 | } |
| 275 | } |
| 276 | seen.add(key); |
| 277 | return true; |
| 278 | } |
| 279 |