返回 DeepSeek-Reasonix
sound.ts
根目录 / desktop / frontend / src / lib / sound.ts
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
212 lines TYPESCRIPT