feat(calls): selectable incoming-call ringtone (#4)
Adds a ringtoneId setting (classic | chime | soft | retro | none) so the incoming-call ring is no longer hardcoded to call.ogg. The three synth styles are generated in-browser via a new utils/ringtones.ts module (mirroring the existing callSounds.ts WebAudio pattern), so no new binary assets are bundled; 'classic' keeps the existing call.ogg clip and 'none' is a silent, visual-only incoming-call UI. - ringtones.ts: startRingtone() loops until stopped; previewRingtone() plays a single non-looping preview and auto-cancels the prior preview. - IncomingCall: ring driven by the setting; <audio> element removed. - Settings > Calls: Ringtone selector with on-select preview, beside the existing Ringtone Volume slider. - settings.ts: persisted value whitelisted back to a known id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
|||||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import CallSound from '../../../public/sound/call.ogg';
|
import { startRingtone } from '../utils/ringtones';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
@@ -103,8 +103,8 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
const canAnswer = livekitSupported && rtcSupported;
|
const canAnswer = livekitSupported && rtcSupported;
|
||||||
const { room } = info;
|
const { room } = info;
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
|
||||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
const roomName = useRoomName(room);
|
const roomName = useRoomName(room);
|
||||||
const roomAvatar = useRoomAvatar(room, dm);
|
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(() => {
|
useEffect(() => {
|
||||||
const audioEl = audioRef.current;
|
if (info.notificationType !== 'ring') return undefined;
|
||||||
if (info.notificationType === 'ring') {
|
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
playSound();
|
return stop;
|
||||||
}
|
}, [info.notificationType, ringtoneId, ringtoneVolume]);
|
||||||
return () => {
|
|
||||||
if (audioEl) {
|
|
||||||
audioEl.pause();
|
|
||||||
audioEl.currentTime = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [playSound, info.notificationType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
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]);
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<OverlayCenter>
|
||||||
<OverlayCenter>
|
<FocusTrap
|
||||||
<FocusTrap
|
focusTrapOptions={{
|
||||||
focusTrapOptions={{
|
initialFocus: false,
|
||||||
initialFocus: false,
|
onDeactivate: () => onIgnore(),
|
||||||
onDeactivate: () => onIgnore(),
|
clickOutsideDeactivates: false,
|
||||||
clickOutsideDeactivates: false,
|
escapeDeactivates: false,
|
||||||
escapeDeactivates: false,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
<Text size="T200" align="Center">
|
||||||
<Text size="T200" align="Center">
|
{getMemberDisplayName(info.room, info.sender) ??
|
||||||
{getMemberDisplayName(info.room, info.sender) ??
|
getMxIdLocalPart(info.sender) ??
|
||||||
getMxIdLocalPart(info.sender) ??
|
info.sender}
|
||||||
info.sender}
|
</Text>
|
||||||
</Text>
|
<Box direction="Column" gap="500" alignItems="Center">
|
||||||
<Box direction="Column" gap="500" alignItems="Center">
|
<Box shrink="No">
|
||||||
<Box shrink="No">
|
<Avatar size="500" className={CallAvatarAnimation}>
|
||||||
<Avatar size="500" className={CallAvatarAnimation}>
|
<RoomAvatar
|
||||||
<RoomAvatar
|
roomId={room.roomId}
|
||||||
roomId={room.roomId}
|
src={avatarUrl}
|
||||||
src={avatarUrl}
|
alt={roomName}
|
||||||
alt={roomName}
|
renderFallback={() => (
|
||||||
renderFallback={() => (
|
<RoomIcon
|
||||||
<RoomIcon
|
roomType={room.getType()}
|
||||||
roomType={room.getType()}
|
size="400"
|
||||||
size="400"
|
joinRule={room.getJoinRule()}
|
||||||
joinRule={room.getJoinRule()}
|
filled
|
||||||
filled
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
</Avatar>
|
||||||
</Avatar>
|
|
||||||
</Box>
|
|
||||||
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
|
|
||||||
<Text size="H3" align="Center" truncate>
|
|
||||||
{roomName}
|
|
||||||
</Text>
|
|
||||||
<Text size="T300" align="Center">
|
|
||||||
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
{!livekitSupported && (
|
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
|
||||||
<Text
|
<Text size="H3" align="Center" truncate>
|
||||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
{roomName}
|
||||||
size="L400"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
Your homeserver does not support calling.
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<Text size="T300" align="Center">
|
||||||
{!webRTCSupported() && (
|
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
||||||
<Text
|
|
||||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
|
||||||
size="L400"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
Your browser does not support WebRTC, which is required for calling.
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
<Box direction="Column" gap="300">
|
|
||||||
<Button
|
|
||||||
style={{ flexGrow: 1 }}
|
|
||||||
variant="Success"
|
|
||||||
size="400"
|
|
||||||
radii="400"
|
|
||||||
onClick={() => onAnswer(room, info.intent === 'video')}
|
|
||||||
before={
|
|
||||||
<Icon
|
|
||||||
size="200"
|
|
||||||
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
|
||||||
filled
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
disabled={!canAnswer}
|
|
||||||
>
|
|
||||||
<Text as="span" size="B400">
|
|
||||||
Answer
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
style={{ flexGrow: 1 }}
|
|
||||||
variant={dm ? 'Critical' : 'Secondary'}
|
|
||||||
fill="Soft"
|
|
||||||
size="400"
|
|
||||||
radii="400"
|
|
||||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
|
||||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
|
||||||
>
|
|
||||||
<Text as="span" size="B400">
|
|
||||||
{dm ? 'Reject' : 'Ignore'}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
{!livekitSupported && (
|
||||||
</FocusTrap>
|
<Text
|
||||||
</OverlayCenter>
|
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||||
</Overlay>
|
size="L400"
|
||||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
align="Center"
|
||||||
<source src={CallSound} type="audio/ogg" />
|
>
|
||||||
</audio>
|
Your homeserver does not support calling.
|
||||||
</>
|
</Text>
|
||||||
|
)}
|
||||||
|
{!webRTCSupported() && (
|
||||||
|
<Text
|
||||||
|
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||||
|
size="L400"
|
||||||
|
align="Center"
|
||||||
|
>
|
||||||
|
Your browser does not support WebRTC, which is required for calling.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant="Success"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => onAnswer(room, info.intent === 'video')}
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="200"
|
||||||
|
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
disabled={!canAnswer}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Answer
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant={dm ? 'Critical' : 'Secondary'}
|
||||||
|
fill="Soft"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||||
|
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
{dm ? 'Reject' : 'Ignore'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
MessageLayout,
|
MessageLayout,
|
||||||
MessageSpacing,
|
MessageSpacing,
|
||||||
NoiseSuppressionMode,
|
NoiseSuppressionMode,
|
||||||
|
RingtoneId,
|
||||||
Settings,
|
Settings,
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
} from '../../../state/settings';
|
} from '../../../state/settings';
|
||||||
@@ -78,6 +79,7 @@ import { SequenceCardStyle } from '../styles.css';
|
|||||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||||
|
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||||
import { DenoiseTester } from './DenoiseTester';
|
import { DenoiseTester } from './DenoiseTester';
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
@@ -1242,12 +1244,18 @@ function Calls() {
|
|||||||
'callJoinLeaveSound',
|
'callJoinLeaveSound',
|
||||||
);
|
);
|
||||||
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
|
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||||||
setCallJoinLeaveSound(value);
|
setCallJoinLeaveSound(value);
|
||||||
if (value !== 'off') playCallJoinSound(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 pttBind = useKeyBind(setPttKey);
|
||||||
const deafenBind = useKeyBind(setDeafenKey);
|
const deafenBind = useKeyBind(setDeafenKey);
|
||||||
|
|
||||||
@@ -1573,6 +1581,19 @@ function Calls() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Ringtone"
|
||||||
|
description="Sound played for incoming calls. Selecting an option plays a preview."
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={ringtoneId}
|
||||||
|
onChange={(v) => handleRingtoneChange(v as RingtoneId)}
|
||||||
|
options={RINGTONE_OPTIONS}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Ringtone Volume"
|
title="Ringtone Volume"
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export type NoiseSuppressionMode = 'off' | 'browser' | 'ml';
|
|||||||
// CDN. Its wasm is single-threaded, so no COOP/COEP cross-origin isolation is
|
// CDN. Its wasm is single-threaded, so no COOP/COEP cross-origin isolation is
|
||||||
// required (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
|
// required (see LOTUS_DENOISE_ENGINEERING_REVIEW.md).
|
||||||
export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
|
export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
|
||||||
|
// Incoming-call ringtone. 'classic' is the bundled call.ogg clip; 'chime' /
|
||||||
|
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
|
||||||
|
// 'none' is silent (visual-only incoming-call UI).
|
||||||
|
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
|
||||||
export type ChatBackground =
|
export type ChatBackground =
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'blueprint'
|
| 'blueprint'
|
||||||
@@ -148,6 +152,7 @@ export interface Settings {
|
|||||||
afkTimeoutMinutes: number;
|
afkTimeoutMinutes: number;
|
||||||
|
|
||||||
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
||||||
|
ringtoneId: RingtoneId;
|
||||||
ringtoneVolume: number; // 0–100
|
ringtoneVolume: number; // 0–100
|
||||||
|
|
||||||
seasonalThemeOverride:
|
seasonalThemeOverride:
|
||||||
@@ -243,6 +248,7 @@ const defaultSettings: Settings = {
|
|||||||
afkTimeoutMinutes: 10,
|
afkTimeoutMinutes: 10,
|
||||||
|
|
||||||
callJoinLeaveSound: 'chime',
|
callJoinLeaveSound: 'chime',
|
||||||
|
ringtoneId: 'classic',
|
||||||
ringtoneVolume: 70,
|
ringtoneVolume: 70,
|
||||||
|
|
||||||
seasonalThemeOverride: 'auto',
|
seasonalThemeOverride: 'auto',
|
||||||
@@ -273,6 +279,15 @@ export const getSettings = (): Settings => {
|
|||||||
saved.callDenoiseModel === 'deepfilternet'
|
saved.callDenoiseModel === 'deepfilternet'
|
||||||
? saved.callDenoiseModel
|
? saved.callDenoiseModel
|
||||||
: defaultSettings.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: {
|
composerToolbarButtons: {
|
||||||
...DEFAULT_COMPOSER_TOOLBAR,
|
...DEFAULT_COMPOSER_TOOLBAR,
|
||||||
...(saved.composerToolbarButtons ?? {}),
|
...(saved.composerToolbarButtons ?? {}),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user