Files
cinny/src/app/utils/callSounds.ts
T
jared 2c5f0b8b28
CI / Build & Quality Checks (push) Successful in 10m29s
Trigger Desktop Build / trigger (push) Successful in 5s
fix: make call join/leave sounds audible to all participants + server-side hard voice limit docs
Sounds (P5-16): browsers block the Web Audio context until a user gesture
starts it, so join/leave sounds — which fire later with no gesture — were
silent. unlockCallSounds() now primes/resumes the shared AudioContext inside
the Join click (centralized in useCallStart so every join path is covered),
making the per-client sounds reliably audible to everyone in the call.

Voice limit (P5-10): the limit is now a hard, cross-client server-side cap
enforced by the voice-limit-guard sidecar (matrix repo) that fronts
lk-jwt-service and refuses LiveKit tokens when a room is full. Updated
LOTUS_FEATURES.md / README.md / LOTUS_TODO.md to reflect that the client
'Channel Full' check is UX only and the server is authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:45:31 -04:00

107 lines
2.9 KiB
TypeScript

export type CallSoundStyle = 'chime' | 'soft' | 'retro';
let sharedCtx: AudioContext | undefined;
const getAudioContext = (): AudioContext | undefined => {
try {
if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
return sharedCtx;
} catch {
return undefined;
}
};
/**
* Create and resume the shared AudioContext from within a user gesture
* (e.g. clicking "Join"). Browsers block AudioContext playback until it has
* been started by a gesture, so join/leave sounds — which fire later without
* any gesture — would otherwise be silent. Call this on call entry so every
* participant's later membership-change sounds are actually audible.
*/
export const unlockCallSounds = (): void => {
getAudioContext();
};
type Note = {
freq: number;
/** Offset from now, in seconds */
at: number;
/** Duration in seconds */
dur: number;
};
const playNotes = (notes: Note[], type: OscillatorType, peakGain: number): void => {
const ctx = getAudioContext();
if (!ctx) 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/decay envelope to avoid clicks
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(peakGain, 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 SOUNDS: Record<CallSoundStyle, { join: () => void; leave: () => void }> = {
chime: {
join: () =>
playNotes(
[
{ freq: 587.33, at: 0, dur: 0.12 },
{ freq: 880, at: 0.1, dur: 0.2 },
],
'sine',
0.25,
),
leave: () =>
playNotes(
[
{ freq: 880, at: 0, dur: 0.12 },
{ freq: 587.33, at: 0.1, dur: 0.2 },
],
'sine',
0.25,
),
},
soft: {
join: () => playNotes([{ freq: 523.25, at: 0, dur: 0.4 }], 'triangle', 0.18),
leave: () => playNotes([{ freq: 392, at: 0, dur: 0.4 }], 'triangle', 0.18),
},
retro: {
join: () =>
playNotes(
[
{ freq: 440, at: 0, dur: 0.07 },
{ freq: 554.37, at: 0.07, dur: 0.07 },
{ freq: 659.25, at: 0.14, dur: 0.14 },
],
'square',
0.1,
),
leave: () =>
playNotes(
[
{ freq: 659.25, at: 0, dur: 0.07 },
{ freq: 554.37, at: 0.07, dur: 0.07 },
{ freq: 440, at: 0.14, dur: 0.14 },
],
'square',
0.1,
),
},
};
export const playCallJoinSound = (style: CallSoundStyle): void => SOUNDS[style]?.join();
export const playCallLeaveSound = (style: CallSoundStyle): void => SOUNDS[style]?.leave();