Files
backspace/packages/web/src/utils/voice.ts
T
Jannis Braun ac20f22c72 fix(voice): consistent state on DM-call ↔ space-channel transitions
Two mirror-image bugs from voice/DM-call transitions leaving stale state.

DM call → space channel (stuck "Connecting…"):
The last participant to leave a DM call for a space channel receives a
`dm_call_ended` echo (server empties the DM room on their `voice_join`).
The handlers called `disconnectFn()` unconditionally, tearing down the
space room they had just connected to. Route `dm_call_ended` /
`dm_call_rejected` / terminal `dm_call_undeliverable` through a new
`teardownDmCall()` that only disconnects LiveKit when not in a space
channel (`currentVoiceChannelId` null).

Space channel → DM call (still shown as "in" the voice channel):
1. Entering a DM call never cleared `currentVoiceChannelId`, so
   `VoiceChannel` mapped the DM call's live LiveKit participants onto the
   old space channel. Add `clearSpaceVoiceForDmCall()`, called in
   `connect()` when `isDm`, restoring the invariant that a DM call has no
   `currentVoiceChannelId`.
2. `dm_call_accepted` gated the caller's connect on `!isLiveKitConnected`,
   so a caller already in a space channel was never connected to the DM
   room. Gate on `wasOutgoingCall` only (connect() de-dupes same-room).

Tests: teardownDmCall.test.ts, clearSpaceVoiceForDmCall.test.ts.
Docs: docs/systems/voice.md.
2026-07-01 02:08:53 +02:00

258 lines
12 KiB
TypeScript

import { useVoiceStore } from '../stores/voiceStore';
import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
import { wsSend } from '../hooks/useWebSocket';
import { AudioManager } from '../audio/AudioManager';
import { useUIStore } from '../stores/uiStore';
// ---------------------------------------------------------------------------
// Effective-state helpers — single source of truth for broadcasts
// ---------------------------------------------------------------------------
/**
* Compute effective mute/deafen by merging user intent with server enforcement,
* then broadcast the effective voice_status over the WebSocket.
*
* @param overrideOrigin Pass explicitly when called from a WS handler that
* knows the origin. Omit to derive from currentVoiceChannelId.
*/
export function broadcastVoiceStatus(overrideOrigin?: string): void {
const vs = useVoiceStore.getState();
const { isMuted, isDeafened, isCameraOn, isScreenSharing, currentVoiceChannelId, spaceMutedUserIds, spaceDeafenedUserIds } = vs;
if (!currentVoiceChannelId) return;
const origin = overrideOrigin ?? getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin);
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId);
const spaceKey = (spaceId && myId) ? `${spaceId}:${myId}` : '';
const effectiveMuted = isMuted || spaceMutedUserIds.has(spaceKey);
const effectiveDeafened = isDeafened || spaceDeafenedUserIds.has(spaceKey);
wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: effectiveDeafened, isCameraOn, isScreenSharing }, origin);
}
/**
* Broadcast the effective deafen state to in-room participants via the
* LiveKit data channel. Dynamic-imports getActiveRoom to avoid circular deps.
*/
export function broadcastDeafenViaLiveKit(): void {
const vs = useVoiceStore.getState();
const { isDeafened, currentVoiceChannelId, spaceDeafenedUserIds } = vs;
if (!currentVoiceChannelId) return;
const origin = getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin);
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId);
const spaceKey = (spaceId && myId) ? `${spaceId}:${myId}` : '';
const effectiveDeafened = isDeafened || spaceDeafenedUserIds.has(spaceKey);
import('../hooks/useLiveKit').then(({ getActiveRoom }) => {
const room = getActiveRoom();
if (room) {
const encoder = new TextEncoder();
room.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: effectiveDeafened })),
{ reliable: true }
).catch(() => {});
}
});
}
/**
* Clear the client-side presence of a *space* voice channel when the local user
* transitions into a DM call.
*
* A DM call and a space voice channel are mutually exclusive: the invariant is
* that a DM call has **no** `currentVoiceChannelId` (only `activeDmCall`). The
* space→DM transition must therefore drop the space channel's client state, the
* mirror of `joinVoiceChannel` clearing `activeDmCall` on the DM→space
* transition.
*
* Why this is necessary even though the server already drops us from the space
* room (`dm_call_start` / `dm_call_accept` → `leaveCurrentRoom` →
* `broadcastRoomLeave`): `VoiceChannel` renders the occupant list for the
* channel equal to `currentVoiceChannelId` from the *live LiveKit participants*
* (its "our channel is the source of truth" branch). If `currentVoiceChannelId`
* still points at the old space channel, the DM call's participants get mapped
* onto it and the local user appears to still be sitting in the space channel.
*
* Clears `currentVoiceChannelId` directly (not via `setCurrentVoiceChannel`,
* which would also wipe the `activeDmCall` the caller/acceptor just set) and
* optimistically removes self from the old channel's `voiceUsers` so the
* sidebar updates immediately, without waiting for the server's leave
* broadcast.
*/
export function clearSpaceVoiceForDmCall(): void {
const { currentVoiceChannelId, removeVoiceUser } = useVoiceStore.getState();
if (!currentVoiceChannelId) return;
const origin = getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin);
if (myId) removeVoiceUser(currentVoiceChannelId, myId);
useVoiceStore.setState({ currentVoiceChannelId: null });
}
// ---------------------------------------------------------------------------
// Voice channel join
// ---------------------------------------------------------------------------
/**
* Centralized voice channel join that handles cross-instance cleanup.
* When switching from a channel on Instance A to one on Instance B,
* this sends an explicit voice_leave to Instance A first so it
* broadcasts a leave event and the client cleans up stale voice state.
*
* **iOS user-gesture discipline.** `getUserMedia({audio:…})` is fired
* synchronously here (before any await crosses the gesture boundary) so
* iOS Safari surfaces the microphone permission prompt immediately on
* the user's tap. The previous flow only acquired the mic in the
* `useLiveKit` `syncMic` effect, which fires AFTER `room.connect()`
* (token fetch + WS handshake) completes — many awaits past the
* activation window. iOS PWA standalone is especially strict and would
* silently never surface the prompt; the user appeared stuck on
* "Waiting for others to join…" until they locked/unlocked the device,
* which iOS treats as a fresh activation context that finally allowed
* the queued prompt to surface.
*
* **Denial path.** If the user denies the prompt (NotAllowedError),
* `voiceStore.micPermissionDenied` is set to true and we proceed with
* the LiveKit connect anyway. The user appears in the voice channel
* normally, can hear other participants, but no microphone track is
* ever published — `useLiveKit.syncMic` skips the publish branch when
* the flag is set. UI surfaces a "Grant microphone access" affordance
* (`MobileVoiceFullScreen`, `VoiceControlBar`) that retries
* `getUserMedia` from a fresh user gesture; on success the flag clears
* and `useLiveKit.republishMicrophone` is called directly. The flag
* resets to `false` automatically on `leaveVoice()` /
* `handleForceDisconnect()` so a rejoin attempts a fresh prompt.
*
* @param channelId The channel to join.
* @param connectFn The LiveKit connect function, obtained from
* `useVoiceStore.getState().connectFn`. When provided the
* LiveKit connection is initiated directly within the
* caller's gesture context (required on iOS).
*/
export function joinVoiceChannel(
channelId: string,
connectFn?: (channelId: string, isDm?: boolean) => Promise<void>,
): void {
const { currentVoiceChannelId, setCurrentVoiceChannel, addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
if (currentVoiceChannelId === channelId) return;
// Leave old instance if switching cross-origin
if (currentVoiceChannelId) {
const oldOrigin = getChannelOrigin(currentVoiceChannelId);
const newOrigin = getChannelOrigin(channelId);
if (oldOrigin !== newOrigin) {
wsSend({ type: 'voice_leave' }, oldOrigin);
}
// Optimistic: immediately remove self from old channel (using origin-aware ID)
const myOldId = getMyUserIdForOrigin(oldOrigin);
if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId);
}
setCurrentVoiceChannel(channelId);
// Optimistic: immediately show self in new channel (using origin-aware ID)
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
if (myNewId) addVoiceUser(channelId, myNewId);
// Pre-arm the microphone INSIDE the user-gesture context. This must
// happen before `connectFn` so the call to `setInputDevice` (which is
// routed through `inputSwitchChain.then(...)` and ends in
// `getUserMedia`) is invoked while the activation window is still open.
// Reset the prior denial flag so a re-attempt isn't pre-vetoed by
// syncMic, and clear AudioManager's cached denial so the next
// `getUserMedia` actually fires (rather than re-throwing the cached
// error from a previous denial in this session).
const voiceState = useVoiceStore.getState();
voiceState.setMicPermissionDenied(false);
const audioManager = AudioManager.getInstance();
audioManager.clearInputDenial();
// Resume the AudioContext synchronously inside the gesture too — iOS
// requires `AudioContext.resume()` to be invoked from a user
// activation. Fire-and-forget; `useLiveKit.connect` also calls this
// and will await the same context.
audioManager.resumeContext().catch((err) => {
console.warn('[voice] AudioContext resume failed:', err);
});
// Fire-and-forget. `setInputDevice` is internally serialized via
// `inputSwitchChain` so the later syncMic call short-circuits to the
// already-acquired stream rather than re-prompting. On denial we
// record the flag — syncMic will then skip the publish branch.
audioManager.setInputDevice(voiceState.inputDeviceId).catch((err: unknown) => {
const name = err instanceof Error ? err.name : '';
if (name === 'NotAllowedError') {
useVoiceStore.getState().setMicPermissionDenied(true);
useUIStore.getState().addToast(
'Microphone access denied. You joined as a listener — tap "Allow microphone" to grant access.',
'warning',
);
} else if (name === 'NotFoundError') {
// No mic hardware available. Proceed as listener.
useVoiceStore.getState().setMicPermissionDenied(true);
useUIStore.getState().addToast(
'No microphone detected. You joined as a listener.',
'info',
);
} else {
console.error('[voice] Mic pre-arm failed:', err);
}
});
// Direct connection within gesture context
if (connectFn) {
connectFn(channelId).catch((err) => {
console.error('[voice] Connection failed:', err);
setCurrentVoiceChannel(null);
if (myNewId) removeVoiceUser(channelId, myNewId);
});
}
}
/**
* Re-attempt microphone permission acquisition after a previous denial.
* Must be called from a user-gesture handler (button click, etc.) for iOS
* Safari to actually surface the permission prompt. On success, clears the
* `micPermissionDenied` flag and the next `useLiveKit` syncMic tick (or an
* external `republishMicrophone` call) publishes the freshly acquired
* stream.
*
* Returns `true` when the mic was acquired, `false` on any error
* (NotAllowedError, NotFoundError, etc.).
*/
export async function requestMicPermission(): Promise<boolean> {
const audioManager = AudioManager.getInstance();
const inputDeviceId = useVoiceStore.getState().inputDeviceId;
// Clear AudioManager's cached denial so the next `setInputDevice` call
// actually fires `getUserMedia` instead of re-throwing the cached error.
audioManager.clearInputDenial();
try {
// Resume context first — iOS may have suspended it during the denied
// state.
await audioManager.resumeContext();
await audioManager.setInputDevice(inputDeviceId);
useVoiceStore.getState().setMicPermissionDenied(false);
return true;
} catch (err: unknown) {
const name = err instanceof Error ? err.name : '';
if (name === 'NotAllowedError') {
useUIStore.getState().addToast(
'Microphone permission still denied. Open Settings → Safari to grant access.',
'warning',
);
} else if (name === 'NotFoundError') {
useUIStore.getState().addToast(
'No microphone detected.',
'warning',
);
} else {
useUIStore.getState().addToast(
'Could not access the microphone.',
'warning',
);
}
return false;
}
}