180 lines
4.9 KiB
TypeScript
180 lines
4.9 KiB
TypeScript
|
|
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();
|