mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-25 12:16:00 +08:00
Reuse the existing notification tone when a chat run pauses for actionable tool approval. Track pending permission state inside the realtime handler so the sound plays when approval first becomes pending, including subscribe recovery, without replaying for inline plan prompts or duplicate websocket events.
86 lines
2.4 KiB
TypeScript
86 lines
2.4 KiB
TypeScript
const NOTIFICATION_SOUND_ENABLED_STORAGE_KEY = 'notificationSoundEnabled';
|
|
const AudioContextConstructor =
|
|
typeof window !== 'undefined'
|
|
? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
|
: undefined;
|
|
|
|
let audioContext: AudioContext | null = null;
|
|
|
|
export const isNotificationSoundEnabled = (): boolean => {
|
|
if (typeof localStorage === 'undefined') {
|
|
return true;
|
|
}
|
|
|
|
return localStorage.getItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY) !== 'false';
|
|
};
|
|
|
|
export const setNotificationSoundEnabled = (enabled: boolean): void => {
|
|
if (typeof localStorage === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY, String(enabled));
|
|
};
|
|
|
|
const getAudioContext = (): AudioContext | null => {
|
|
if (!AudioContextConstructor) {
|
|
return null;
|
|
}
|
|
|
|
if (!audioContext) {
|
|
audioContext = new AudioContextConstructor();
|
|
}
|
|
|
|
return audioContext;
|
|
};
|
|
|
|
const playTone = (
|
|
context: AudioContext,
|
|
frequency: number,
|
|
startsAt: number,
|
|
duration: number,
|
|
peakVolume: number,
|
|
): void => {
|
|
const oscillator = context.createOscillator();
|
|
const gain = context.createGain();
|
|
|
|
oscillator.type = 'sine';
|
|
oscillator.frequency.setValueAtTime(frequency, startsAt);
|
|
|
|
// Shape the volume so the synthesized tone starts and stops cleanly.
|
|
gain.gain.setValueAtTime(0.0001, startsAt);
|
|
gain.gain.exponentialRampToValueAtTime(peakVolume, startsAt + 0.015);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + duration);
|
|
|
|
oscillator.connect(gain);
|
|
gain.connect(context.destination);
|
|
oscillator.start(startsAt);
|
|
oscillator.stop(startsAt + duration + 0.02);
|
|
};
|
|
|
|
export const playNotificationSound = async ({ force = false } = {}): Promise<void> => {
|
|
if (!force && !isNotificationSoundEnabled()) {
|
|
return;
|
|
}
|
|
|
|
const context = getAudioContext();
|
|
if (!context) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (context.state === 'suspended') {
|
|
await context.resume();
|
|
}
|
|
|
|
const now = context.currentTime;
|
|
playTone(context, 740, now, 0.12, 0.075);
|
|
playTone(context, 988, now + 0.11, 0.16, 0.06);
|
|
} catch (error) {
|
|
// Browsers may block audio until the page receives a user gesture.
|
|
console.warn('Unable to play notification sound:', error);
|
|
}
|
|
};
|
|
|
|
export const playChatCompletionSound = (options = {}): Promise<void> => playNotificationSound(options);
|