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; };