170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
|
|
import CallSound from '../../../public/sound/call.ogg';
|
||
|
|
import { RingtoneId } from '../state/settings';
|
||
|
|
|
||
|
|
export const RINGTONE_OPTIONS: { value: RingtoneId; label: string }[] = [
|
||
|
|
{ value: 'classic', label: 'Classic' },
|
||
|
|
{ value: 'chime', label: 'Chime' },
|
||
|
|
{ value: 'soft', label: 'Soft' },
|
||
|
|
{ value: 'retro', label: 'Retro' },
|
||
|
|
{ value: 'none', label: 'Silent' },
|
||
|
|
];
|
||
|
|
|
||
|
|
export const isRingtoneId = (v: unknown): v is RingtoneId =>
|
||
|
|
v === 'classic' || v === 'chime' || v === 'soft' || v === 'retro' || v === 'none';
|
||
|
|
|
||
|
|
type SynthStyle = 'chime' | 'soft' | 'retro';
|
||
|
|
|
||
|
|
const clamp01 = (n: number): number => Math.max(0, Math.min(1, n));
|
||
|
|
|
||
|
|
// Shared WebAudio context for synthesized ringtones. Kept separate from the
|
||
|
|
// join/leave-sound context (callSounds.ts) to keep blast radius small.
|
||
|
|
let sharedCtx: AudioContext | undefined;
|
||
|
|
const getCtx = (): AudioContext | undefined => {
|
||
|
|
try {
|
||
|
|
if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
|
||
|
|
if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
|
||
|
|
return sharedCtx;
|
||
|
|
} catch {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
type Note = {
|
||
|
|
freq: number;
|
||
|
|
/** Offset from phrase start, in seconds */
|
||
|
|
at: number;
|
||
|
|
/** Duration in seconds */
|
||
|
|
dur: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
// One looping phrase per synth style + the period before it repeats.
|
||
|
|
const PHRASES: Record<
|
||
|
|
SynthStyle,
|
||
|
|
{ type: OscillatorType; gain: number; period: number; notes: Note[] }
|
||
|
|
> = {
|
||
|
|
// Two-tone "ring … ring" telephone cadence.
|
||
|
|
chime: {
|
||
|
|
type: 'sine',
|
||
|
|
gain: 0.3,
|
||
|
|
period: 3,
|
||
|
|
notes: [
|
||
|
|
{ freq: 587.33, at: 0, dur: 0.35 },
|
||
|
|
{ freq: 880, at: 0.4, dur: 0.35 },
|
||
|
|
{ freq: 587.33, at: 1.0, dur: 0.35 },
|
||
|
|
{ freq: 880, at: 1.4, dur: 0.35 },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
// Gentle rising triangle pair.
|
||
|
|
soft: {
|
||
|
|
type: 'triangle',
|
||
|
|
gain: 0.24,
|
||
|
|
period: 3.2,
|
||
|
|
notes: [
|
||
|
|
{ freq: 523.25, at: 0, dur: 0.5 },
|
||
|
|
{ freq: 659.25, at: 0.55, dur: 0.7 },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
// Retro arpeggio sweep.
|
||
|
|
retro: {
|
||
|
|
type: 'square',
|
||
|
|
gain: 0.12,
|
||
|
|
period: 2.4,
|
||
|
|
notes: [
|
||
|
|
{ freq: 440, at: 0, dur: 0.12 },
|
||
|
|
{ freq: 554.37, at: 0.13, dur: 0.12 },
|
||
|
|
{ freq: 659.25, at: 0.26, dur: 0.12 },
|
||
|
|
{ freq: 880, at: 0.39, dur: 0.22 },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const playPhrase = (style: SynthStyle, volume: number): void => {
|
||
|
|
const ctx = getCtx();
|
||
|
|
if (!ctx) return;
|
||
|
|
const { type, gain: peak, notes } = PHRASES[style];
|
||
|
|
const scaledPeak = peak * clamp01(volume);
|
||
|
|
if (scaledPeak <= 0) return;
|
||
|
|
const now = ctx.currentTime;
|
||
|
|
notes.forEach(({ freq, at, dur }) => {
|
||
|
|
const osc = ctx.createOscillator();
|
||
|
|
const gain = ctx.createGain();
|
||
|
|
osc.type = type;
|
||
|
|
osc.frequency.value = freq;
|
||
|
|
const start = now + at;
|
||
|
|
// Short attack + exponential decay to avoid clicks.
|
||
|
|
gain.gain.setValueAtTime(0, start);
|
||
|
|
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
|
||
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
||
|
|
osc.connect(gain);
|
||
|
|
gain.connect(ctx.destination);
|
||
|
|
osc.start(start);
|
||
|
|
osc.stop(start + dur + 0.02);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const startClassic = (volume: number, loop: boolean): (() => void) => {
|
||
|
|
let audio: HTMLAudioElement | undefined;
|
||
|
|
try {
|
||
|
|
audio = new Audio(CallSound);
|
||
|
|
audio.loop = loop;
|
||
|
|
audio.volume = clamp01(volume);
|
||
|
|
audio.play().catch(() => undefined);
|
||
|
|
} catch {
|
||
|
|
audio = undefined;
|
||
|
|
}
|
||
|
|
return () => {
|
||
|
|
if (!audio) return;
|
||
|
|
audio.pause();
|
||
|
|
audio.currentTime = 0;
|
||
|
|
audio = undefined;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
|
||
|
|
playPhrase(style, volume);
|
||
|
|
if (!loop) return () => undefined;
|
||
|
|
const period = PHRASES[style].period * 1000;
|
||
|
|
const id = window.setInterval(() => playPhrase(style, volume), period);
|
||
|
|
return () => window.clearInterval(id);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Start an incoming-call ringtone, looping until the returned stop fn is
|
||
|
|
* called. `volume` is 0..1. Returns a no-op stop fn for 'none'.
|
||
|
|
*
|
||
|
|
* Synthesized styles share the WebAudio autoplay limitation of the bundled
|
||
|
|
* 'classic' file: until the page has had a user gesture the browser may keep
|
||
|
|
* audio suspended, so the very first ring after a cold page load can be
|
||
|
|
* silent. This matches the pre-existing behaviour of the classic ringtone.
|
||
|
|
*/
|
||
|
|
export const startRingtone = (id: RingtoneId, volume: number): (() => void) => {
|
||
|
|
if (id === 'none') return () => undefined;
|
||
|
|
if (id === 'classic') return startClassic(volume, true);
|
||
|
|
return startSynth(id, volume, true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// Only one preview may sound at a time; starting a new one cancels the last.
|
||
|
|
let activePreviewStop: (() => void) | null = null;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Play a single, non-looping preview of a ringtone (used by Settings).
|
||
|
|
* Auto-stops the bundled 'classic' clip after a few seconds and cancels any
|
||
|
|
* previously-playing preview. Returns a stop fn for early cancellation.
|
||
|
|
*/
|
||
|
|
export const previewRingtone = (id: RingtoneId, volume: number): (() => void) => {
|
||
|
|
activePreviewStop?.();
|
||
|
|
activePreviewStop = null;
|
||
|
|
if (id === 'none') return () => undefined;
|
||
|
|
|
||
|
|
const stop = id === 'classic' ? startClassic(volume, false) : startSynth(id, volume, false);
|
||
|
|
let timer = 0;
|
||
|
|
const wrapped = () => {
|
||
|
|
window.clearTimeout(timer);
|
||
|
|
stop();
|
||
|
|
if (activePreviewStop === wrapped) activePreviewStop = null;
|
||
|
|
};
|
||
|
|
timer = window.setTimeout(wrapped, 4000);
|
||
|
|
activePreviewStop = wrapped;
|
||
|
|
return wrapped;
|
||
|
|
};
|