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