Files
cinny/src/app/utils/ringtones.ts
T
jared 5ef0a1fd3e fix(call): ringtone loudness, caller decline notice, All-Muted badge
Three issues from live testing:
- A1: the 'classic' ringtone (call.ogg, mastered near full scale) was much
  louder than the synthesized styles. Attenuate it (CLASSIC_GAIN 0.45) so all
  ringtones sit at a comparable level.
- A3/A4: the caller had no indication when a DM/group callee declined — their
  UI kept "ringing" until the notification lifetime expired. IncomingCallListener
  now listens for RTCDecline events for a call we're hosting in the room and
  toasts the caller ("<name> declined your call").
- G1: the PiP "All muted" badge fired when any single remote participant muted.
  useRemoteAllMuted now returns true only when there is >=1 remote and every
  remote participant is muted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:13:40 -04:00

205 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, destination: AudioNode): 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(destination);
osc.start(start);
osc.stop(start + dur + 0.02);
});
};
// The bundled call.ogg is mastered near full scale, so at equal `volume` it is
// perceptibly much louder than the synthesized styles (which peak at ~0.120.3).
// Attenuate it so all ringtones sit at a comparable loudness.
const CLASSIC_GAIN = 0.45;
const startClassic = (volume: number, loop: boolean): (() => void) => {
let audio: HTMLAudioElement | undefined;
try {
audio = new Audio(CallSound);
audio.loop = loop;
audio.volume = clamp01(volume) * CLASSIC_GAIN;
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) => {
const ctx = getCtx();
if (!ctx) return () => undefined;
// All notes route through a per-session master gain so stop() can silence
// everything instantly — including notes already scheduled slightly in the
// future — instead of letting the last phrase ring out after the user answers.
const master = ctx.createGain();
master.gain.value = 1;
master.connect(ctx.destination);
playPhrase(style, volume, master);
const id = loop
? window.setInterval(() => playPhrase(style, volume, master), PHRASES[style].period * 1000)
: 0;
let stopped = false;
return () => {
if (stopped) return;
stopped = true;
if (id) window.clearInterval(id);
try {
const now = ctx.currentTime;
master.gain.cancelScheduledValues(now);
master.gain.setValueAtTime(master.gain.value, now);
master.gain.linearRampToValueAtTime(0, now + 0.03);
} catch {
/* context may be closed */
}
window.setTimeout(() => {
try {
master.disconnect();
} catch {
/* already disconnected */
}
}, 100);
};
};
/**
* 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;
};