Files
SRCmail/lib/notification-sound.ts
dealerwebandLinus Rath e9ac2de6cb Fix: notification sound preview - base-path prefix + longer default beep
The sound picker's preview always played the default beep, even for the other choices, on a subpath deployment.

playFile() used a raw '/notification/x.mp3' path, which 404s under a deployment base path (e.g. /webmail); audio.play() then rejected and fell back to the beep for every non-default choice. Prefix the file with withBasePath().

The default beep was a 150 ms tone with no envelope - easy to miss on Bluetooth outputs, whose audio path can take 100-200 ms to wake up and route. Lengthen it to ~0.45 s with a fade in/out (also removes click artifacts).
2026-07-04 14:52:26 +02:00

68 lines
2.4 KiB
TypeScript

import { debug } from '@/lib/debug';
import { withBasePath } from '@/lib/browser-navigation';
export type NotificationSoundChoice = 'default' | 'cheerful' | 'involved' | 'swift' | 'relax';
export const NOTIFICATION_SOUNDS: { id: NotificationSoundChoice; file?: string }[] = [
{ id: 'default' },
{ id: 'cheerful', file: '/notification/cheerful-527.mp3' },
{ id: 'involved', file: '/notification/involved-notification.mp3' },
{ id: 'swift', file: '/notification/notification-tone-swift-gesture.mp3' },
{ id: 'relax', file: '/notification/relax-message-tone.mp3' },
];
function playBeep() {
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
// Longer, enveloped tone. A 150 ms blip was easy to miss on Bluetooth
// outputs, whose audio path can take 100-200 ms to wake up and route - by
// the time sound reached the headphones the blip was already over. The
// fade in/out also avoids click artifacts.
const now = audioContext.currentTime;
const duration = 0.45;
const peak = 0.12;
gainNode.gain.setValueAtTime(0.0001, now);
gainNode.gain.exponentialRampToValueAtTime(peak, now + 0.04);
gainNode.gain.setValueAtTime(peak, now + duration - 0.08);
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + duration);
oscillator.start(now);
oscillator.stop(now + duration + 0.02);
oscillator.onended = () => audioContext.close();
}
function playFile(file: string) {
// Prefix with the deployment base path (e.g. /webmail); a raw "/notification/
// x.mp3" 404s under a subpath, which made playFile fall back to the beep for
// every choice.
const audio = new Audio(withBasePath(file));
audio.volume = 0.3;
audio.play().catch((e) => {
debug.log('push', 'Could not play audio file, falling back to beep:', e);
playBeep();
});
}
export function playNotificationSound(sound?: NotificationSoundChoice) {
try {
const choice = sound ?? 'default';
const entry = NOTIFICATION_SOUNDS.find((s) => s.id === choice);
if (entry?.file) {
playFile(entry.file);
} else {
playBeep();
}
} catch (e) {
debug.log('push', 'Could not play notification sound:', e);
}
}