Files
TX/artifacts/tx-os/src/lib/notification-sounds.ts
T

180 lines
4.9 KiB
TypeScript
Raw Normal View History

import type { NotificationSoundId } from "@workspace/api-client-react";
type ToneStep = {
freq: number;
duration: number;
delay?: number;
type?: OscillatorType;
gain?: number;
};
type SoundDef = {
id: NotificationSoundId;
steps: ToneStep[];
};
export const SOUND_LIBRARY: ReadonlyArray<SoundDef> = [
{
id: "ding",
steps: [
{ freq: 1318.5, duration: 0.35, type: "sine", gain: 0.5 },
{ freq: 880, duration: 0.45, delay: 0.05, type: "sine", gain: 0.35 },
],
},
{
id: "chime",
steps: [
{ freq: 523.25, duration: 0.3, type: "sine" },
{ freq: 659.25, duration: 0.3, delay: 0.12, type: "sine" },
{ freq: 783.99, duration: 0.5, delay: 0.24, type: "sine" },
],
},
{
id: "bell",
steps: [
{ freq: 1760, duration: 0.6, type: "triangle", gain: 0.45 },
{ freq: 880, duration: 0.6, delay: 0.0, type: "sine", gain: 0.25 },
],
},
{
id: "knock",
steps: [
{ freq: 180, duration: 0.12, type: "square", gain: 0.55 },
{ freq: 180, duration: 0.12, delay: 0.18, type: "square", gain: 0.55 },
],
},
{
id: "pop",
steps: [
{ freq: 600, duration: 0.08, type: "sine", gain: 0.55 },
{ freq: 1200, duration: 0.12, delay: 0.04, type: "sine", gain: 0.45 },
],
},
{
id: "alert",
steps: [
{ freq: 988, duration: 0.18, type: "square", gain: 0.4 },
{ freq: 988, duration: 0.18, delay: 0.22, type: "square", gain: 0.4 },
{ freq: 988, duration: 0.18, delay: 0.44, type: "square", gain: 0.4 },
],
},
{
id: "beep",
steps: [{ freq: 1000, duration: 0.25, type: "sine", gain: 0.5 }],
},
{
id: "soft",
steps: [
{ freq: 392, duration: 0.5, type: "sine", gain: 0.35 },
{ freq: 587.33, duration: 0.5, delay: 0.1, type: "sine", gain: 0.3 },
],
},
];
const SOUND_BY_ID = new Map(SOUND_LIBRARY.map((s) => [s.id, s]));
const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> = SOUND_LIBRARY.map(
(s) => s.id,
);
export { ALL_SOUND_IDS };
type AnyAudioContext = AudioContext;
class NotificationPlayer {
private ctx: AnyAudioContext | null = null;
private unlocked = false;
private lastPlayAt = 0;
private getCtx(): AnyAudioContext | null {
if (typeof window === "undefined") return null;
if (this.ctx) return this.ctx;
const Ctor =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!Ctor) return null;
try {
this.ctx = new Ctor();
} catch {
this.ctx = null;
}
return this.ctx;
}
/**
* Browsers require a user gesture before audio can play. Call this once
* the user interacts with the app to "unlock" the AudioContext.
*/
unlock(): void {
if (this.unlocked) return;
const ctx = this.getCtx();
if (!ctx) return;
if (ctx.state === "suspended") {
ctx.resume().catch(() => {});
}
// Play a tiny silent buffer to satisfy iOS/Safari unlock semantics.
try {
const buffer = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(ctx.destination);
src.start(0);
} catch {
/* ignore */
}
this.unlocked = true;
}
play(
soundId: NotificationSoundId,
opts: { volume?: number; vibrate?: boolean } = {},
): void {
// Throttle: at most one sound every 600ms to avoid cacophony when
// many events arrive at once.
const now =
typeof performance !== "undefined" ? performance.now() : Date.now();
if (now - this.lastPlayAt < 600) return;
this.lastPlayAt = now;
const def = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
if (!def) return;
const ctx = this.getCtx();
if (ctx) {
if (ctx.state === "suspended") ctx.resume().catch(() => {});
const baseGain = Math.max(0, Math.min(1, (opts.volume ?? 70) / 100));
const t0 = ctx.currentTime + 0.01;
for (const step of def.steps) {
try {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = step.type ?? "sine";
osc.frequency.value = step.freq;
const start = t0 + (step.delay ?? 0);
const peak = baseGain * (step.gain ?? 0.4);
g.gain.setValueAtTime(0.0001, start);
g.gain.exponentialRampToValueAtTime(
Math.max(peak, 0.0002),
start + 0.01,
);
g.gain.exponentialRampToValueAtTime(0.0001, start + step.duration);
osc.connect(g).connect(ctx.destination);
osc.start(start);
osc.stop(start + step.duration + 0.05);
} catch {
/* ignore single-step failures */
}
}
}
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
try {
navigator.vibrate([60, 40, 60]);
} catch {
/* ignore */
}
}
}
}
export const notificationPlayer = new NotificationPlayer();