feat: voice channel user limit (P5-10) + call join/leave sounds (P5-16)
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:
@@ -251,6 +251,39 @@ Automatically mutes the microphone after a configurable period of microphone-on
|
||||
|
||||
Hook: `src/app/hooks/useAfkAutoMute.ts`
|
||||
|
||||
### Voice Channel User Limit (P5-10)
|
||||
|
||||
Room admins can cap the number of participants allowed in a room's voice call.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Limit is stored in the `io.lotus.voice_limit` room state event with content `{ max_users: N }` (0 / absent = no limit)
|
||||
- `RoomVoiceLimit` component in Room Settings → General → **Voice** lets admins set the cap with a number input. Editing is gated by `permissions.stateEvent(StateEvent.LotusVoiceLimit, …)`, so only users with `state_default` power (or above) can change it
|
||||
- `CallPrescreen` (`CallView.tsx`) reads the limit reactively via `useStateEvent` and compares it against the live `useCallMembers` count
|
||||
- When the call is at capacity, the **Join** button is disabled and a "Channel Full (N/N)" message is shown
|
||||
- A user who is already a member of the session (rejoining) is never blocked — only new joiners are gated
|
||||
- Enforcement is local to Lotus Chat clients (no server-side gatekeeping)
|
||||
|
||||
Files: `src/app/features/common-settings/general/RoomVoiceLimit.tsx`, `src/app/features/call/CallView.tsx`, `StateEvent.LotusVoiceLimit` in `src/types/matrix/room.ts`
|
||||
|
||||
### Custom Join / Leave Sound Effects (P5-16)
|
||||
|
||||
A local sound plays when another participant joins or leaves a call you're in.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- `useCallJoinLeaveSounds(embed)` hook (wired in `CallUtils` inside `CallEmbedProvider`) listens to `MatrixRTCSession` membership changes via `useCallMembersChange`
|
||||
- Membership identity is tracked by `sender|deviceId`; a snapshot is taken when the session (re)starts so participants already present never trigger a sound
|
||||
- Your own membership is filtered out (`mx.getSafeUserId()` prefix), and sounds fire only while you are actually joined (`useCallJoined`)
|
||||
- Sounds are synthesized in-browser with the Web Audio API (`OscillatorNode` + envelope) — no audio assets to bundle. Join uses a rising motif, leave a falling one
|
||||
- Three styles: **Chime** (sine), **Soft** (triangle), **Retro** (square arpeggio), plus **Off**
|
||||
|
||||
**Settings (Settings → Calls):**
|
||||
|
||||
- **Join & Leave Sounds** dropdown — Off / Chime / Soft / Retro (default: Chime). Selecting a style previews the join sound immediately
|
||||
|
||||
Files: `src/app/utils/callSounds.ts`, `src/app/hooks/useCallJoinLeaveSounds.ts`
|
||||
|
||||
### Noise Suppression Toggle
|
||||
|
||||
A `noiseSuppression` URL parameter is passed to the Element Call widget URL, allowing the noise suppression feature to be toggled from within Lotus settings.
|
||||
|
||||
+6
-4
@@ -256,11 +256,12 @@ Themes:
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-10 · Voice Channel User Limit
|
||||
### [x] P5-10 · Voice Channel User Limit
|
||||
|
||||
**What:** Admins set max participants via custom state event `io.lotus.voice_limit: { max_users: N }`. Show "Channel Full (5/5)" to users over the limit. Local enforcement only.
|
||||
**[AUDIT REQUIRED]** Check if Element Call has its own participant limit that should be integrated with rather than duplicated.
|
||||
**Complexity:** Medium.
|
||||
**Complexity:** Medium.
|
||||
**Done:** `RoomVoiceLimit` admin control in Room Settings → General → Voice; `CallPrescreen` disables Join + shows "Channel Full (N/N)" when at capacity (rejoiners exempt). State event `StateEvent.LotusVoiceLimit`.
|
||||
|
||||
---
|
||||
|
||||
@@ -310,11 +311,12 @@ Themes:
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-16 · Custom Join / Leave Sound Effects
|
||||
### [x] P5-16 · Custom Join / Leave Sound Effects
|
||||
|
||||
**What:** Local-only sounds when participants join/leave a call you're in. Built-in options + per-user settable. Detect via Element Call participant list change events.
|
||||
**[AUDIT REQUIRED]** Find how Element Call exposes join/leave participant events to the parent window via postMessage bridge.
|
||||
**Complexity:** Medium.
|
||||
**Complexity:** Medium.
|
||||
**Done:** Detected via `MatrixRTCSession` membership changes (`useCallMembersChange`) rather than the EC postMessage bridge — more reliable, identity tracked by `sender|deviceId`. Sounds synthesized with Web Audio (no assets). Styles Off/Chime/Soft/Retro in Settings → Calls. Hook `useCallJoinLeaveSounds`, util `callSounds.ts`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
|
||||
- Dark/light mode inside calls matches your Lotus Chat theme
|
||||
- Calls are available in DMs and private groups only — no accidental mass rings
|
||||
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
||||
- Voice channel user limit: admins can cap how many people can be in a room's call; others see "Channel Full" until a spot opens
|
||||
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
||||
|
||||
### Customization & Appearance
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import CallSound from '../../../public/sound/call.ogg';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
@@ -406,6 +407,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
useCallMemberSoundSync(embed);
|
||||
useCallJoinLeaveSounds(embed);
|
||||
useCallThemeSync(embed);
|
||||
useCallHangupEvent(
|
||||
embed,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
||||
import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
@@ -74,6 +76,14 @@ function AlreadyInCallMessage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelFullMessage({ current, max }: { current: number; max: number }) {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Channel Full ({current}/{max}) — Wait for someone to leave before joining.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function CallPrescreen() {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
@@ -96,7 +106,14 @@ function CallPrescreen() {
|
||||
const callEmbed = useCallEmbed();
|
||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||
|
||||
const canJoin = hasPermission && livekitSupported && rtcSupported;
|
||||
// Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
|
||||
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
|
||||
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||||
// A user already counted in the session is rejoining and should not be blocked.
|
||||
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
|
||||
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
|
||||
|
||||
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
|
||||
|
||||
return (
|
||||
<Scroll variant="Surface" hideTrack>
|
||||
@@ -117,16 +134,17 @@ function CallPrescreen() {
|
||||
<CallMemberRenderer members={callMembers} />
|
||||
<PrescreenControls canJoin={canJoin} />
|
||||
<Box className={css.PrescreenMessage} alignItems="Center">
|
||||
{!inOtherCall &&
|
||||
(hasPermission ? (
|
||||
<JoinMessage
|
||||
hasParticipant={hasParticipant}
|
||||
livekitSupported={livekitSupported}
|
||||
rtcSupported={rtcSupported}
|
||||
/>
|
||||
) : (
|
||||
<NoPermissionMessage />
|
||||
))}
|
||||
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
|
||||
{!inOtherCall && hasPermission && channelFull && (
|
||||
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
|
||||
)}
|
||||
{!inOtherCall && hasPermission && !channelFull && (
|
||||
<JoinMessage
|
||||
hasParticipant={hasParticipant}
|
||||
livekitSupported={livekitSupported}
|
||||
rtcSupported={rtcSupported}
|
||||
/>
|
||||
)}
|
||||
{inOtherCall && <AlreadyInCallMessage />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { FormEventHandler, useCallback } from 'react';
|
||||
import { Box, Button, color, Input, Spinner, Text } from 'folds';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
|
||||
export type VoiceLimitContent = {
|
||||
max_users?: number;
|
||||
};
|
||||
|
||||
type RoomVoiceLimitProps = {
|
||||
permissions: RoomPermissionsAPI;
|
||||
};
|
||||
export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
|
||||
const canEdit = permissions.stateEvent(StateEvent.LotusVoiceLimit, mx.getSafeUserId());
|
||||
|
||||
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
|
||||
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||||
|
||||
const [submitState, submit] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (value: number) => {
|
||||
const content: VoiceLimitContent = value > 0 ? { max_users: value } : {};
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.LotusVoiceLimit as any, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
),
|
||||
);
|
||||
const submitting = submitState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
const target = evt.target as HTMLFormElement;
|
||||
const limitInput = target.elements.namedItem('limitInput') as HTMLInputElement | null;
|
||||
if (!limitInput) return;
|
||||
const parsed = parseInt(limitInput.value, 10);
|
||||
const value = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
|
||||
submit(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Voice Channel Limit"
|
||||
description="Set the maximum number of participants allowed in this room's voice call. Set to 0 for no limit. Enforced locally by Lotus Chat clients."
|
||||
>
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
|
||||
<Box style={{ maxWidth: '100px' }} grow="Yes">
|
||||
<Input
|
||||
key={maxUsers}
|
||||
name="limitInput"
|
||||
defaultValue={maxUsers}
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
readOnly={!canEdit}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
disabled={!canEdit || submitting}
|
||||
before={submitting ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{submitState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||
{(submitState.error as MatrixError).message}
|
||||
</Text>
|
||||
)}
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './RoomProfile';
|
||||
export * from './RoomPublish';
|
||||
export * from './RoomShareInvite';
|
||||
export * from './RoomUpgrade';
|
||||
export * from './RoomVoiceLimit';
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
RoomPublish,
|
||||
RoomShareInvite,
|
||||
RoomUpgrade,
|
||||
RoomVoiceLimit,
|
||||
} from '../../common-settings/general';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
@@ -54,6 +55,10 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<RoomEncryption permissions={permissions} />
|
||||
<RoomPublish permissions={permissions} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Voice</Text>
|
||||
<RoomVoiceLimit permissions={permissions} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Addresses</Text>
|
||||
<RoomPublishedAddresses permissions={permissions} />
|
||||
|
||||
@@ -65,6 +65,7 @@ import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||
|
||||
type ThemeSelectorProps = {
|
||||
themeNames: Record<string, string>;
|
||||
@@ -1113,6 +1114,15 @@ function Calls() {
|
||||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
||||
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
||||
settingsAtom,
|
||||
'callJoinLeaveSound',
|
||||
);
|
||||
|
||||
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||||
setCallJoinLeaveSound(value);
|
||||
if (value !== 'off') playCallJoinSound(value);
|
||||
};
|
||||
|
||||
const pttBind = useKeyBind(setPttKey);
|
||||
const deafenBind = useKeyBind(setDeafenKey);
|
||||
@@ -1227,6 +1237,34 @@ function Calls() {
|
||||
/>
|
||||
)}
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Join & Leave Sounds"
|
||||
description="Play a sound when someone joins or leaves a call you are in."
|
||||
after={
|
||||
<select
|
||||
value={callJoinLeaveSound}
|
||||
onChange={(e) =>
|
||||
handleJoinLeaveSoundChange(e.target.value as 'off' | 'chime' | 'soft' | 'retro')
|
||||
}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="chime">Chime</option>
|
||||
<option value="soft">Soft</option>
|
||||
<option value="retro">Retro</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useCallMembersChange, useCallSession } from './useCall';
|
||||
import { useCallJoined } from './useCallEmbed';
|
||||
import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds';
|
||||
|
||||
const membershipKey = (m: CallMembership): string => `${m.sender}|${m.deviceId}`;
|
||||
|
||||
/**
|
||||
* Plays a local sound effect when another participant joins or leaves
|
||||
* the call you are in. Style (or off) is configured in Settings → Calls.
|
||||
*/
|
||||
export function useCallJoinLeaveSounds(embed: CallEmbed): void {
|
||||
const mx = useMatrixClient();
|
||||
const [style] = useSetting(settingsAtom, 'callJoinLeaveSound');
|
||||
const joined = useCallJoined(embed);
|
||||
const session = useCallSession(embed.room);
|
||||
|
||||
const prevKeysRef = useRef<Set<string> | null>(null);
|
||||
|
||||
// Snapshot current members when the session (re)starts so we never play
|
||||
// sounds for participants who were already present.
|
||||
useEffect(() => {
|
||||
prevKeysRef.current = new Set(session.memberships.map(membershipKey));
|
||||
}, [session]);
|
||||
|
||||
useCallMembersChange(
|
||||
session,
|
||||
useCallback(
|
||||
(members: CallMembership[]) => {
|
||||
const next = new Set(members.map(membershipKey));
|
||||
const prev = prevKeysRef.current ?? next;
|
||||
prevKeysRef.current = next;
|
||||
|
||||
if (!joined || style === 'off') return;
|
||||
|
||||
const myPrefix = `${mx.getSafeUserId()}|`;
|
||||
let someoneJoined = false;
|
||||
let someoneLeft = false;
|
||||
next.forEach((key) => {
|
||||
if (!prev.has(key) && !key.startsWith(myPrefix)) someoneJoined = true;
|
||||
});
|
||||
prev.forEach((key) => {
|
||||
if (!next.has(key) && !key.startsWith(myPrefix)) someoneLeft = true;
|
||||
});
|
||||
|
||||
if (someoneJoined) playCallJoinSound(style);
|
||||
if (someoneLeft) playCallLeaveSound(style);
|
||||
},
|
||||
[joined, style, mx],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -131,6 +131,8 @@ export interface Settings {
|
||||
|
||||
afkAutoMute: boolean;
|
||||
afkTimeoutMinutes: number;
|
||||
|
||||
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -204,6 +206,8 @@ const defaultSettings: Settings = {
|
||||
|
||||
afkAutoMute: false,
|
||||
afkTimeoutMinutes: 10,
|
||||
|
||||
callJoinLeaveSound: 'chime',
|
||||
};
|
||||
|
||||
export const getSettings = (): Settings => {
|
||||
|
||||
@@ -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();
|
||||
@@ -40,6 +40,7 @@ export enum StateEvent {
|
||||
|
||||
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
||||
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
||||
LotusVoiceLimit = 'io.lotus.voice_limit',
|
||||
}
|
||||
|
||||
export enum MessageEvent {
|
||||
|
||||
Reference in New Issue
Block a user