feat: voice channel user limit (P5-10) + call join/leave sounds (P5-16)
CI / Build & Quality Checks (push) Successful in 10m54s
Trigger Desktop Build / trigger (push) Successful in 6s

P5-10 Voice Channel User Limit:
- New StateEvent.LotusVoiceLimit (io.lotus.voice_limit) with { max_users }
- RoomVoiceLimit admin control in Room Settings > General > Voice
  (power-level gated via permissions.stateEvent)
- CallPrescreen reads the limit reactively and disables Join with a
  'Channel Full (N/N)' message at capacity; existing members can rejoin

P5-16 Custom Join/Leave Sound Effects:
- useCallJoinLeaveSounds hook wired into CallUtils; detects participant
  join/leave via MatrixRTCSession membership changes (sender|deviceId),
  filters out self, only fires while joined
- Sounds synthesized in-browser with Web Audio (callSounds.ts) - no
  assets bundled; styles Off/Chime/Soft/Retro
- 'Join & Leave Sounds' selector in Settings > Calls (previews on change)

Docs: LOTUS_FEATURES.md, README.md, LOTUS_TODO.md (P5-10/P5-16 marked done)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 22:20:22 -04:00
parent 2b1c3256b6
commit 702e2e00eb
13 changed files with 371 additions and 15 deletions
+95
View File
@@ -0,0 +1,95 @@
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;
}
};
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();