diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index d504d9c3f..679592398 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -40,7 +40,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { useMatrixClient } from '../hooks/useMatrixClient'; -import CallSound from '../../../public/sound/call.ogg'; +import { startRingtone } from '../utils/ringtones'; import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; import { useRemoteAllMuted } from '../hooks/useCallSpeakers'; @@ -103,8 +103,8 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr const canAnswer = livekitSupported && rtcSupported; const { room } = info; - const audioRef = useRef(null); const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume'); + const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId'); const roomName = useRoomName(room); const roomAvatar = useRoomAvatar(room, dm); @@ -125,25 +125,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr ), ); - const playSound = useCallback(() => { - const audioElement = audioRef.current; - if (!audioElement) return; - audioElement.volume = Math.max(0, Math.min(1, ringtoneVolume / 100)); - audioElement.play().catch(() => undefined); - }, [ringtoneVolume]); - useEffect(() => { - const audioEl = audioRef.current; - if (info.notificationType === 'ring') { - playSound(); - } - return () => { - if (audioEl) { - audioEl.pause(); - audioEl.currentTime = 0; - } - }; - }, [playSound, info.notificationType]); + if (info.notificationType !== 'ring') return undefined; + const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100))); + return stop; + }, [info.notificationType, ringtoneId, ringtoneVolume]); useEffect(() => { const remaining = info.senderTs + info.lifetime - Date.now(); @@ -156,112 +142,107 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr }, [info.senderTs, info.lifetime, onIgnore]); return ( - <> - }> - - onIgnore(), - clickOutsideDeactivates: false, - escapeDeactivates: false, - }} - > - - - - {getMemberDisplayName(info.room, info.sender) ?? - getMxIdLocalPart(info.sender) ?? - info.sender} - - - - - ( - - )} - /> - - - - - {roomName} - - - {info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'} - - + }> + + onIgnore(), + clickOutsideDeactivates: false, + escapeDeactivates: false, + }} + > + + + + {getMemberDisplayName(info.room, info.sender) ?? + getMxIdLocalPart(info.sender) ?? + info.sender} + + + + + ( + + )} + /> + - {!livekitSupported && ( - - Your homeserver does not support calling. + + + {roomName} - )} - {!webRTCSupported() && ( - - Your browser does not support WebRTC, which is required for calling. + + {info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'} - )} - - - - - - - - - + {!livekitSupported && ( + + Your homeserver does not support calling. + + )} + {!webRTCSupported() && ( + + Your browser does not support WebRTC, which is required for calling. + + )} + + + + + + + + + ); } diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index e12bc286c..fcd12f630 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -52,6 +52,7 @@ import { MessageLayout, MessageSpacing, NoiseSuppressionMode, + RingtoneId, Settings, settingsAtom, } from '../../../state/settings'; @@ -78,6 +79,7 @@ import { SequenceCardStyle } from '../styles.css'; import { useTauriUpdater } from '../../../hooks/useTauriUpdater'; import { useDateFormatItems } from '../../../hooks/useDateFormat'; import { playCallJoinSound } from '../../../utils/callSounds'; +import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones'; import { DenoiseTester } from './DenoiseTester'; type ThemeSelectorProps = { @@ -1242,12 +1244,18 @@ function Calls() { 'callJoinLeaveSound', ); const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume'); + const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId'); const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => { setCallJoinLeaveSound(value); if (value !== 'off') playCallJoinSound(value); }; + const handleRingtoneChange = (value: RingtoneId) => { + setRingtoneId(value); + previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100))); + }; + const pttBind = useKeyBind(setPttKey); const deafenBind = useKeyBind(setDeafenKey); @@ -1573,6 +1581,19 @@ function Calls() { /> )} + + handleRingtoneChange(v as RingtoneId)} + options={RINGTONE_OPTIONS} + /> + } + /> + { saved.callDenoiseModel === 'deepfilternet' ? saved.callDenoiseModel : defaultSettings.callDenoiseModel, + // Coerce any unknown persisted ringtone id back to the default. + ringtoneId: + saved.ringtoneId === 'classic' || + saved.ringtoneId === 'chime' || + saved.ringtoneId === 'soft' || + saved.ringtoneId === 'retro' || + saved.ringtoneId === 'none' + ? saved.ringtoneId + : defaultSettings.ringtoneId, composerToolbarButtons: { ...DEFAULT_COMPOSER_TOOLBAR, ...(saved.composerToolbarButtons ?? {}), diff --git a/src/app/utils/ringtones.ts b/src/app/utils/ringtones.ts new file mode 100644 index 000000000..31d4c4e76 --- /dev/null +++ b/src/app/utils/ringtones.ts @@ -0,0 +1,169 @@ +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; +};