| 1 | /** |
| 2 | * 通知音效系统 |
| 3 | * |
| 4 | * 支持合成音效和 WAV 文件播放两种模式,默认关闭。 |
| 5 | * 两个场景的偏好分别存入 localStorage: |
| 6 | * notificationSoundSuccess —— 生成完成 |
| 7 | * notificationSoundAttention —— AI 提问 |
| 8 | * 值:"off" | "synth" | "positive" | "correct" | "start" | "back" |
| 9 | */ |
| 10 | |
| 11 | export type SoundWavPref = "off" | "synth" | "positive" | "correct" | "start" | "back"; |
| 12 | |
| 13 | const SUCCESS_KEY = "notificationSoundSuccess"; |
| 14 | const ATTENTION_KEY = "notificationSoundAttention"; |
| 15 | |
| 16 | function readPref(key: string): SoundWavPref { |
| 17 | if (typeof localStorage === "undefined") return "off"; |
| 18 | const val = localStorage.getItem(key); |
| 19 | if (val === "off" || val === "synth" || val === "positive" || val === "correct" || val === "start" || val === "back") return val; |
| 20 | return "off"; |
| 21 | } |
| 22 | |
| 23 | function writePref(key: string, pref: SoundWavPref): void { |
| 24 | if (typeof localStorage !== "undefined") { |
| 25 | localStorage.setItem(key, pref); |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | export function getSuccessPreference(): SoundWavPref { return readPref(SUCCESS_KEY); } |
| 30 | export function setSuccessPreference(pref: SoundWavPref): void { writePref(SUCCESS_KEY, pref); } |
| 31 | export function getAttentionPreference(): SoundWavPref { return readPref(ATTENTION_KEY); } |
| 32 | export function setAttentionPreference(pref: SoundWavPref): void { writePref(ATTENTION_KEY, pref); } |
| 33 | |
| 34 | function soundFilePath(pref: SoundWavPref): string { |
| 35 | switch (pref) { |
| 36 | case "positive": return "./sounds/mixkit-positive-notification-951.wav"; |
| 37 | case "correct": return "./sounds/mixkit-correct-answer-tone-2870.wav"; |
| 38 | case "start": return "./sounds/mixkit-software-interface-start-2574.wav"; |
| 39 | case "back": return "./sounds/mixkit-software-interface-back-2575.wav"; |
| 40 | default: return ""; |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // ── WAV audio cache ────────────────────────────────────────────────────────── |
| 45 | const audioBufferCache = new Map<string, AudioBuffer>(); |
| 46 | |
| 47 | async function loadBuffer(ctx: AudioContext, url: string): Promise<AudioBuffer | null> { |
| 48 | const cached = audioBufferCache.get(url); |
| 49 | if (cached) return cached; |
| 50 | try { |
| 51 | const resp = await fetch(url); |
| 52 | if (!resp.ok) return null; |
| 53 | const arrayBuffer = await resp.arrayBuffer(); |
| 54 | const decoded = await ctx.decodeAudioData(arrayBuffer); |
| 55 | audioBufferCache.set(url, decoded); |
| 56 | return decoded; |
| 57 | } catch { |
| 58 | return null; |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | function playBuffer(ctx: AudioContext, buffer: AudioBuffer, volume: number): void { |
| 63 | const src = ctx.createBufferSource(); |
| 64 | src.buffer = buffer; |
| 65 | const gain = ctx.createGain(); |
| 66 | gain.gain.value = volume; |
| 67 | src.connect(gain); |
| 68 | gain.connect(ctx.destination); |
| 69 | src.start(); |
| 70 | } |
| 71 | |
| 72 | // ── Synthesised sounds ─────────────────────────────────────────────────────── |
| 73 | |
| 74 | function playSynthNote(ctx: AudioContext, dest: AudioNode, freq: number, startTime: number, duration: number, volume: number): void { |
| 75 | const osc = ctx.createOscillator(); |
| 76 | osc.type = "sine"; |
| 77 | osc.frequency.setValueAtTime(freq, startTime); |
| 78 | const gain = ctx.createGain(); |
| 79 | gain.gain.setValueAtTime(0, startTime); |
| 80 | gain.gain.linearRampToValueAtTime(volume, startTime + 0.002); |
| 81 | gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); |
| 82 | osc.connect(gain); |
| 83 | gain.connect(dest); |
| 84 | osc.start(startTime); |
| 85 | osc.stop(startTime + duration); |
| 86 | |
| 87 | const shimmer = ctx.createOscillator(); |
| 88 | shimmer.type = "sine"; |
| 89 | shimmer.frequency.setValueAtTime(freq * 4, startTime); |
| 90 | const sGain = ctx.createGain(); |
| 91 | sGain.gain.setValueAtTime(0, startTime); |
| 92 | sGain.gain.linearRampToValueAtTime(volume * 0.12, startTime + 0.002); |
| 93 | sGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration * 0.6); |
| 94 | shimmer.connect(sGain); |
| 95 | sGain.connect(dest); |
| 96 | shimmer.start(startTime); |
| 97 | shimmer.stop(startTime + duration); |
| 98 | } |
| 99 | |
| 100 | function playSynthSuccess(ctx: AudioContext): void { |
| 101 | playSynthNote(ctx, ctx.destination, 1318.5, 0, 0.20, 0.12); |
| 102 | playSynthNote(ctx, ctx.destination, 1568.0, 0.07, 0.22, 0.10); |
| 103 | playSynthNote(ctx, ctx.destination, 2093.0, 0.14, 0.30, 0.08); |
| 104 | } |
| 105 | |
| 106 | function playSynthAttention(ctx: AudioContext): void { |
| 107 | playSynthNote(ctx, ctx.destination, 1760.0, 0, 0.14, 0.10); |
| 108 | playSynthNote(ctx, ctx.destination, 1318.5, 0.09, 0.22, 0.08); |
| 109 | } |
| 110 | |
| 111 | // ── Play helpers ───────────────────────────────────────────────────────────── |
| 112 | |
| 113 | async function playWav(pref: SoundWavPref, volume: number, fallback: (ctx: AudioContext) => void): Promise<void> { |
| 114 | const url = soundFilePath(pref); |
| 115 | if (!url) return; |
| 116 | const ctx = new AudioContext(); |
| 117 | try { |
| 118 | const buf = await loadBuffer(ctx, url); |
| 119 | if (buf) { |
| 120 | playBuffer(ctx, buf, volume); |
| 121 | } else { |
| 122 | fallback(ctx); |
| 123 | } |
| 124 | } catch { |
| 125 | fallback(ctx); |
| 126 | } |
| 127 | setTimeout(() => ctx.close(), 2000); |
| 128 | } |
| 129 | |
| 130 | // ── Public API ─────────────────────────────────────────────────────────────── |
| 131 | |
| 132 | export function playSuccessChime(): void { |
| 133 | const pref = getSuccessPreference(); |
| 134 | if (pref === "off") return; |
| 135 | if (pref === "synth") { |
| 136 | try { |
| 137 | const ctx = new AudioContext(); |
| 138 | playSynthSuccess(ctx); |
| 139 | setTimeout(() => ctx.close(), 600); |
| 140 | } catch { /* silent */ } |
| 141 | } else { |
| 142 | void playWav(pref, 0.35, playSynthSuccess); |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | export function playAttentionChime(): void { |
| 147 | const pref = getAttentionPreference(); |
| 148 | if (pref === "off") return; |
| 149 | if (pref === "synth") { |
| 150 | try { |
| 151 | const ctx = new AudioContext(); |
| 152 | playSynthAttention(ctx); |
| 153 | setTimeout(() => ctx.close(), 500); |
| 154 | } catch { /* silent */ } |
| 155 | } else { |
| 156 | void playWav(pref, 0.25, playSynthAttention); |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | export type AttentionChimeEvent = { |
| 161 | kind?: string; |
| 162 | tabId?: string; |
| 163 | approval?: { id?: string }; |
| 164 | ask?: { id?: string }; |
| 165 | }; |
| 166 | |
| 167 | export function attentionChimeEventKey(event: AttentionChimeEvent): string | undefined { |
| 168 | if (event.kind === "approval_request" && event.approval?.id) return `approval:${event.tabId ?? ""}:${event.approval.id}`; |
| 169 | if (event.kind === "ask_request" && event.ask?.id) return `ask:${event.tabId ?? ""}:${event.ask.id}`; |
| 170 | return undefined; |
| 171 | } |
| 172 | |
| 173 | // attentionChimeSeenCap bounds the dedupe set. Prompt ids are unique per |
| 174 | // prompt, so the set only ever grows; past the cap the oldest half is dropped |
| 175 | // (insertion order) — replay dedupe only needs to cover recently replayed |
| 176 | // prompts, not the whole session history. |
| 177 | const attentionChimeSeenCap = 512; |
| 178 | |
| 179 | // clearAttentionChimeKeys drops dedupe keys after a runtime rebuild. Approval |
| 180 | // and ask ids are per-controller counters starting at "1", so a rebuilt |
| 181 | // controller (model/effort/settings switch) reissues ids an earlier prompt on |
| 182 | // the same tab already used — without this, the first prompt after a rebuild |
| 183 | // is misread as a replay and stays silent. A ready event without a tab id |
| 184 | // (settings rebuilds emit tab-less ready) clears everything: over-clearing |
| 185 | // only re-chimes a replayed pending prompt, which is a desirable reminder, |
| 186 | // while under-clearing mutes a live prompt. |
| 187 | export function clearAttentionChimeKeys(seen: Set<string>, tabId?: string): void { |
| 188 | if (tabId === undefined || tabId === "") { |
| 189 | seen.clear(); |
| 190 | return; |
| 191 | } |
| 192 | for (const key of [...seen]) { |
| 193 | if (key.startsWith(`approval:${tabId}:`) || key.startsWith(`ask:${tabId}:`)) { |
| 194 | seen.delete(key); |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | export function shouldPlayAttentionChimeForEvent(event: AttentionChimeEvent, seen: Set<string>): boolean { |
| 200 | const key = attentionChimeEventKey(event); |
| 201 | if (!key || seen.has(key)) return false; |
| 202 | if (seen.size >= attentionChimeSeenCap) { |
| 203 | let drop = seen.size - attentionChimeSeenCap / 2; |
| 204 | for (const k of seen) { |
| 205 | if (drop-- <= 0) break; |
| 206 | seen.delete(k); |
| 207 | } |
| 208 | } |
| 209 | seen.add(key); |
| 210 | return true; |
| 211 | } |
| 212 |