Merge branch 'feature/audio-device-ux': polish audio device UX
15 commits implementing: - useAudioDevices hook (single source of truth, privacy-respecting enumeration) - AudioManager extensions (test tone, upstream-track-end event, hot-plug helpers) - Seamless hot-plug in AppLayout (debounced re-acquire + new-device toast) - Mic-track-loss recovery via AudioManager.onInputTrackEnded (corrected from original published-track design) - Full Audio Input/Output sections in Voice & Video settings - Privacy fix in UserAreaPanel (eliminates unconditional getUserMedia probe) - Spec parallel to Camera Device Selection in voice.md
This commit is contained in:
@@ -292,6 +292,60 @@ The fullscreen toggle in `VoiceControlBar` flips the `voiceFullscreen` flag in `
|
||||
|
||||
---
|
||||
|
||||
## Audio Device Selection (Microphone & Speakers)
|
||||
|
||||
Users pick mic and speaker devices in two surfaces:
|
||||
1. **User Settings → Voice & Video** (`AudioInputSection.tsx`, `AudioOutputSection.tsx`) — full picker with input volume, live level meter, output volume, and a "Play test sound" button.
|
||||
2. **Bottom-left UserArea quick popups** (`ChannelSidebar.tsx UserAreaPanel`) — opened by the caret buttons next to mute (input picker) and deafen (output picker). Same picker UX, more compact.
|
||||
|
||||
Both surfaces are backed by the shared `useAudioDevices()` hook. The store fields `inputDeviceId` and `outputDeviceId` (both `string`, default `'default'`) are persisted in `voiceStore`.
|
||||
|
||||
### `useAudioDevices()` hook (canonical enumeration)
|
||||
- Mirrors `VideoSection.tsx`'s permission/enumeration/devicechange pattern.
|
||||
- Mount-time probe: `navigator.permissions.query({ name: 'microphone' })`. **Never auto-fires `getUserMedia`** — that requires an explicit user gesture via the returned `requestPermission()`.
|
||||
- States: `unknown` → `granted` | `prompt` | `denied`. Lists are populated only in `granted`.
|
||||
- Refreshes both `inputs` and `outputs` on every `devicechange` event.
|
||||
- Output devices are gated behind microphone permission (no separate output permission exists in browsers).
|
||||
- Returns `inputLabels` / `outputLabels` maps with disambiguation suffixes for duplicate names (e.g. `"USB Audio (1)"`, `"USB Audio (2)"`).
|
||||
|
||||
### Output routing — `AudioContext.setSinkId`
|
||||
All audio (remote voice, screen-share audio, sound effects) flows through `AudioManager`'s master bus → `AudioContext.destination`. Output device switching is therefore done via `AudioContext.setSinkId(deviceId)`, NOT via LiveKit's `switchActiveDevice('audiooutput')` (which targets `<audio>` elements that are killed by `AppLayout`'s MutationObserver). Safari < 17 lacks `setSinkId` on AudioContext — `AudioOutputSection` detects this and falls back to OS default with an explanatory note.
|
||||
|
||||
### Input pipeline — republish, never `switchActiveDevice`
|
||||
Input device changes flow through `AudioManager.setInputDevice(deviceId)` (serialized chain). The `useLiveKit syncMic` effect detects the bumped stream generation and unpublishes/republishes via `getFreshTrack()`. This asymmetry vs. the camera (which uses `room.switchActiveDevice('videoinput', …)`) is intentional and documented under "Architectural asymmetry" below — the published mic track is the output of a Web Audio graph (RNNoise, gain, AEC), not a raw `getUserMedia` track.
|
||||
|
||||
### Hot-plug seamlessness
|
||||
The global `devicechange` handler in `AppLayout.tsx` does four things on every event:
|
||||
1. **Prune** persisted IDs that no longer exist (`pruneStaleDevices`).
|
||||
2. **Re-acquire** the live mic stream when `inputDeviceId === 'default'` AND `AudioManager.hasActiveStream()`. Chromium does NOT migrate an existing `getUserMedia` track to the new OS-default — calling `setInputDevice('default')` triggers a fresh `getUserMedia` which picks up the new default; `syncMic` then republishes.
|
||||
3. **Re-apply** `setSinkId('')` when `outputDeviceId === 'default'`, for the analogous reason.
|
||||
4. **Toast** on a *new* `audioinput` group appearing (debounced 1s, deduped by `groupId` for 30s). Removals do not toast — the user already knows they unplugged it. Toast is informational ("AirPods Pro detected — choose it in Voice settings to switch") — never auto-switches; auto-switch would be a privacy/UX regression for users who deliberately keep a non-default device selected.
|
||||
|
||||
### Mic-track-loss recovery
|
||||
The published mic track is a *clone* of `AudioManager`'s `MediaStreamAudioDestinationNode` output (see `getFreshTrack()`), and a destination-node track does not end on upstream loss — it just outputs silence. So the published track's `onended` is the wrong signal. Instead, `AudioManager` installs `onended` on every track of the upstream `getUserMedia` stream and exposes a subscription API:
|
||||
|
||||
- `AudioManager.onInputTrackEnded(cb)` — subscribers receive a `'unplug' | 'revoke' | 'unknown'` reason hint and probe `getUserMedia` themselves to classify.
|
||||
- Deliberate replacements (`setInputDevice`, `setRnnoiseEnabled`, `setVoiceProcessing` re-init) detach the per-track listener BEFORE calling `.stop()` and null `currentStream` immediately, so subscribers are never notified for non-loss events. A surviving listener (e.g. attached by a future external caller) bails via the `currentStream !== capturedStream` identity check.
|
||||
|
||||
`useLiveKit` subscribes to this signal whenever a room is connected, captures `subscriberRoom = roomRef.current`, and on emission:
|
||||
1. Bail if the room has been replaced.
|
||||
2. Probe `getUserMedia({audio:{deviceId}})` to classify:
|
||||
- Probe succeeds → `setInputDevice(deviceId)` to re-acquire AND call `republishMicrophone(subscriberRoom, lastMicGenRef)` directly (the syncMic dep array does not include `streamGeneration`, so we cannot rely on it to re-fire).
|
||||
- `NotAllowedError` → `"Microphone permission was revoked"` (warning toast).
|
||||
- `NotFoundError` with non-default device → set store to `'default'` and toast `"Microphone disconnected — switched to system default"`. The store change triggers `syncMic`, which re-acquires + republishes via the shared helper.
|
||||
- `NotFoundError` on default → `"Microphone disconnected"`.
|
||||
- Other → `"Microphone could not be restored"`.
|
||||
|
||||
`republishMicrophone` is a module-level helper extracted from `syncMic` so both the normal device-change path and the recovery path share the staleness-check / unpublish / `getFreshTrack` / publish flow.
|
||||
|
||||
### Privacy gate — never auto-fire `getUserMedia`
|
||||
The `useAudioDevices` hook only calls `getUserMedia` from the explicit `requestPermission()` action. The previous `ChannelSidebar.UserAreaPanel.loadDevices` implementation fired `getUserMedia({audio:true})` on every panel open as long as no `AudioContext` existed — which flashed the mic indicator even when permission had been previously granted in another session. That probe has been removed.
|
||||
|
||||
### Resolved-default hint
|
||||
When `inputDeviceId === 'default'` and a stream is active, `AudioInputSection` shows a `Currently using: <label>` subline by reading `AudioManager.getCurrentInputDeviceId()` and looking up the label in `inputLabels`. This makes the "default → which device?" indirection visible to the user.
|
||||
|
||||
---
|
||||
|
||||
## Camera Device Selection
|
||||
|
||||
Users pick a camera in **User Settings → Voice & Video → Video**. Selection is persisted in `voiceStore.cameraDeviceId` (`string | null`; `null` = "let LiveKit/browser auto-pick on next fresh enable").
|
||||
|
||||
@@ -32,6 +32,12 @@ export class AudioManager {
|
||||
private rnnoiseReady = false;
|
||||
private keepAliveOscillator: OscillatorNode | null = null;
|
||||
|
||||
// Subscribers notified when the *upstream* getUserMedia track ends unexpectedly
|
||||
// (hardware unplug, OS-level revoke, system audio service crash). Distinct from
|
||||
// the published mic track's `onended` — the published track is a clone of the
|
||||
// WebAudio destination node, which never ends on upstream loss.
|
||||
private inputTrackEndedListeners: Set<(reason: 'unplug' | 'revoke' | 'unknown') => void> = new Set();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): AudioManager {
|
||||
@@ -205,7 +211,17 @@ export class AudioManager {
|
||||
|
||||
try {
|
||||
if (this.currentStream) {
|
||||
this.currentStream.getTracks().forEach(t => t.stop());
|
||||
// Detach our `onended` handlers BEFORE stopping. `.stop()` synchronously
|
||||
// queues an `ended` event on each track; by clearing the listener first
|
||||
// we guarantee the deliberate-replace path never notifies subscribers,
|
||||
// regardless of microtask/task ordering.
|
||||
const oldTracks = this.currentStream.getTracks();
|
||||
oldTracks.forEach(t => { t.onended = null; });
|
||||
oldTracks.forEach(t => t.stop());
|
||||
// Drop the reference immediately so any stray handler that survived
|
||||
// (e.g. attached by external code) sees `currentStream` no longer
|
||||
// pointing to the old stream and bails out via the identity check.
|
||||
this.currentStream = null;
|
||||
}
|
||||
|
||||
// Chrome AEC stays on during screen share — headphone users unaffected,
|
||||
@@ -231,10 +247,17 @@ export class AudioManager {
|
||||
} as any
|
||||
};
|
||||
|
||||
this.currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.currentStream = newStream;
|
||||
this.currentInputDeviceId = deviceId;
|
||||
this.streamGeneration++;
|
||||
|
||||
// Attach upstream-loss detection to every track in the new stream. If the
|
||||
// OS / hardware ends a track (unplug, revoke, audio-service crash), the
|
||||
// `ended` event fires and we notify subscribers — provided the stream is
|
||||
// still the active one (identity check guards against later replacements).
|
||||
this.attachInputEndedListeners(newStream);
|
||||
|
||||
if (this.ctx && this.inputGain) {
|
||||
if (this.inputSource) {
|
||||
this.inputSource.disconnect();
|
||||
@@ -250,6 +273,52 @@ export class AudioManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches an `onended` listener to every audio track in the supplied stream.
|
||||
* The listener identity-checks against `this.currentStream` so it only fires
|
||||
* for *unexpected* track loss — deliberate replacement clears the listener and
|
||||
* nulls `currentStream` BEFORE stopping, so neither path can leak a false
|
||||
* positive into subscribers.
|
||||
*/
|
||||
private attachInputEndedListeners(stream: MediaStream): void {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.onended = () => {
|
||||
// Stream has been replaced (deliberate device change, RNNoise toggle,
|
||||
// setVoiceProcessing reset) — this is not a hardware-loss event.
|
||||
if (this.currentStream !== stream) return;
|
||||
// Drop our reference so any subsequent `hasActiveStream()` check
|
||||
// reflects reality, and so a downstream re-acquire attempt via
|
||||
// `setInputDevice` does not short-circuit on the stale-but-non-null
|
||||
// currentStream.
|
||||
this.currentStream = null;
|
||||
// Reason classification is the consumer's responsibility — they probe
|
||||
// `getUserMedia` to distinguish unplug vs revoke vs unavailable. We
|
||||
// emit `'unknown'` so the type is still informative if a future caller
|
||||
// wires reason inference at this layer.
|
||||
const reason: 'unplug' | 'revoke' | 'unknown' = 'unknown';
|
||||
// Snapshot listeners before iteration: a subscriber that synchronously
|
||||
// unsubscribes during notification (e.g. cleanup-on-disconnect) would
|
||||
// otherwise mutate the set mid-iteration.
|
||||
const snapshot = Array.from(this.inputTrackEndedListeners);
|
||||
for (const cb of snapshot) {
|
||||
try { cb(reason); } catch (err) {
|
||||
console.error('[AudioManager] inputTrackEnded listener threw:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to upstream input-track-end events. Returns an unsubscribe.
|
||||
* Callers should treat `reason` as a hint and probe `getUserMedia` themselves
|
||||
* to distinguish unplug from revoke.
|
||||
*/
|
||||
onInputTrackEnded(cb: (reason: 'unplug' | 'revoke' | 'unknown') => void): () => void {
|
||||
this.inputTrackEndedListeners.add(cb);
|
||||
return () => { this.inputTrackEndedListeners.delete(cb); };
|
||||
}
|
||||
|
||||
private getInputTarget(): AudioNode {
|
||||
return (this.rnnoiseEnabled && this.rnnoiseNode) ? this.rnnoiseNode : this.inputGain!;
|
||||
}
|
||||
@@ -307,7 +376,10 @@ export class AudioManager {
|
||||
// Force track re-publish so LiveKit picks up the new pipeline
|
||||
this.streamGeneration++;
|
||||
if (this.currentStream) {
|
||||
this.currentStream.getTracks().forEach(t => t.stop());
|
||||
// Detach listeners before stopping (see `_setInputDeviceImpl`).
|
||||
const tracks = this.currentStream.getTracks();
|
||||
tracks.forEach(t => { t.onended = null; });
|
||||
tracks.forEach(t => t.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
}
|
||||
@@ -339,7 +411,10 @@ export class AudioManager {
|
||||
changed = true;
|
||||
}
|
||||
if (changed && this.currentStream) {
|
||||
this.currentStream.getTracks().forEach(t => t.stop());
|
||||
// Detach listeners before stopping (see `_setInputDeviceImpl`).
|
||||
const tracks = this.currentStream.getTracks();
|
||||
tracks.forEach(t => { t.onended = null; });
|
||||
tracks.forEach(t => t.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
}
|
||||
@@ -389,6 +464,46 @@ export class AudioManager {
|
||||
return this.masterBoost!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the deviceId of the currently active mic stream (the one being
|
||||
* captured by getUserMedia). May differ from the persisted store value when
|
||||
* the store says 'default' but Chromium has resolved that to a concrete ID.
|
||||
*/
|
||||
getCurrentInputDeviceId(): string {
|
||||
return this.currentInputDeviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a live mic stream is currently captured. Used by the global
|
||||
* devicechange handler to decide whether to force-reacquire on OS-default
|
||||
* change.
|
||||
*/
|
||||
hasActiveStream(): boolean {
|
||||
return !!this.currentStream?.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a short test tone through the master output bus, exercising the
|
||||
* current setSinkId binding. Used by the "Test Sound" button in audio
|
||||
* settings to confirm that audio is reaching the chosen output device.
|
||||
*/
|
||||
async playTestTone(): Promise<void> {
|
||||
await this.resumeContext();
|
||||
if (!this.ctx) return;
|
||||
const osc = this.ctx.createOscillator();
|
||||
osc.frequency.value = 440;
|
||||
const gain = this.ctx.createGain();
|
||||
// Soft envelope to avoid pop on start/stop.
|
||||
const now = this.ctx.currentTime;
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(0.15, now + 0.02);
|
||||
gain.gain.linearRampToValueAtTime(0, now + 0.4);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterBoost!);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.45);
|
||||
}
|
||||
|
||||
getContext(): AudioContext | null {
|
||||
return this.ctx;
|
||||
}
|
||||
|
||||
@@ -92,14 +92,122 @@ export function AppLayout() {
|
||||
AudioManager.getInstance().setOutputDevice(outputDeviceId);
|
||||
}, [outputDeviceId]);
|
||||
|
||||
// Sweep stale persisted device IDs (mic/speaker/camera) on mount and whenever
|
||||
// the device list changes (USB plug/unplug, permission unlock, etc.).
|
||||
// Audio device hot-plug handler.
|
||||
//
|
||||
// Three jobs on every devicechange event:
|
||||
// (1) Prune persisted IDs that no longer exist (delegated to voiceStore).
|
||||
// (2) Force re-acquire the live mic stream when the user's chosen input is
|
||||
// 'default' AND a stream is already live. Chromium does NOT migrate an
|
||||
// existing getUserMedia track to the new OS-default — it stays bound to
|
||||
// the device that was default at acquisition time. Setting the input
|
||||
// again with 'default' triggers a fresh getUserMedia, which picks up
|
||||
// the new OS default. The downstream syncMic effect republishes.
|
||||
// (3) Force re-apply setSinkId('') for output when 'default' is selected,
|
||||
// for the same reason on the output side.
|
||||
// (4) Toast on a *new* audioinput appearance (debounced + dedupe by groupId).
|
||||
// Removals do not toast — the user already knows they unplugged it.
|
||||
useEffect(() => {
|
||||
const prune = useVoiceStore.getState().pruneStaleDevices;
|
||||
prune(); // initial sweep
|
||||
const handler = () => prune();
|
||||
navigator.mediaDevices.addEventListener('devicechange', handler);
|
||||
return () => navigator.mediaDevices.removeEventListener('devicechange', handler);
|
||||
let lastInputGroupIds = new Set<string>();
|
||||
const recentToastByGroup = new Map<string, number>(); // groupId -> timestamp ms
|
||||
const TOAST_DEBOUNCE_MS = 1000;
|
||||
const TOAST_DEDUPE_WINDOW_MS = 30_000;
|
||||
let pendingDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Handler-level debounce: collapses devicechange storms (BT re-pair, USB
|
||||
// hub enumeration can fire 5-10x/sec) into a single prune + reacquire pass.
|
||||
// The toast logic has its own longer debounce (TOAST_DEBOUNCE_MS) layered
|
||||
// on top so user-visible toasts collapse storms even more aggressively.
|
||||
let pendingHandlerTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const HANDLER_DEBOUNCE_MS = 250;
|
||||
|
||||
const seedBaseline = async () => {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
lastInputGroupIds = new Set(
|
||||
devices.filter(d => d.kind === 'audioinput' && d.groupId).map(d => d.groupId)
|
||||
);
|
||||
} catch { /* enumeration may be blocked pre-permission; baseline is empty */ }
|
||||
};
|
||||
|
||||
const handleNewDeviceToasts = async () => {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const currentInputGroupIds = new Set(
|
||||
devices.filter(d => d.kind === 'audioinput' && d.groupId).map(d => d.groupId)
|
||||
);
|
||||
const now = Date.now();
|
||||
const newGroups: string[] = [];
|
||||
for (const gid of currentInputGroupIds) {
|
||||
if (lastInputGroupIds.has(gid)) continue;
|
||||
const lastToast = recentToastByGroup.get(gid) ?? 0;
|
||||
if (now - lastToast < TOAST_DEDUPE_WINDOW_MS) continue;
|
||||
newGroups.push(gid);
|
||||
recentToastByGroup.set(gid, now);
|
||||
}
|
||||
// GC dedupe map entries older than the window so it doesn't leak.
|
||||
for (const [gid, ts] of recentToastByGroup) {
|
||||
if (now - ts > TOAST_DEDUPE_WINDOW_MS) recentToastByGroup.delete(gid);
|
||||
}
|
||||
lastInputGroupIds = currentInputGroupIds;
|
||||
|
||||
if (newGroups.length > 0) {
|
||||
const newest = devices.find(d =>
|
||||
d.kind === 'audioinput' && d.groupId && newGroups.includes(d.groupId) && d.label,
|
||||
);
|
||||
const label = newest?.label || 'New audio device';
|
||||
useUIStore.getState().addToast(
|
||||
`${label} detected — choose it in Voice settings to switch`,
|
||||
'info',
|
||||
6000,
|
||||
);
|
||||
}
|
||||
} catch { /* enumeration failure is non-fatal */ }
|
||||
};
|
||||
|
||||
const reacquireLiveStream = async () => {
|
||||
const am = AudioManager.getInstance();
|
||||
const inputId = useVoiceStore.getState().inputDeviceId;
|
||||
const outputId = useVoiceStore.getState().outputDeviceId;
|
||||
// Re-acquire input only if user is on 'default' AND a stream is live.
|
||||
// Anything else: persisted ID is concrete, stream stays bound correctly.
|
||||
if (inputId === 'default' && am.hasActiveStream()) {
|
||||
try { await am.setInputDevice('default'); } catch { /* serialized chain handles errors */ }
|
||||
}
|
||||
if (outputId === 'default') {
|
||||
try { await am.setOutputDevice('default'); } catch { /* setSinkId may not exist on Safari */ }
|
||||
}
|
||||
};
|
||||
|
||||
const handler = () => {
|
||||
if (pendingHandlerTimer) clearTimeout(pendingHandlerTimer);
|
||||
pendingHandlerTimer = setTimeout(async () => {
|
||||
await prune();
|
||||
await reacquireLiveStream();
|
||||
if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer);
|
||||
pendingDebounceTimer = setTimeout(() => { handleNewDeviceToasts(); }, TOAST_DEBOUNCE_MS);
|
||||
}, HANDLER_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
// Seed baseline so the first event after mount doesn't false-positive every
|
||||
// existing device as "new". Register the listener only AFTER baseline is
|
||||
// seeded so the first real event compares against a populated set.
|
||||
let listenerRegistered = false;
|
||||
let cancelled = false;
|
||||
seedBaseline()
|
||||
.then(() => prune())
|
||||
.then(() => {
|
||||
if (cancelled) return;
|
||||
navigator.mediaDevices.addEventListener('devicechange', handler);
|
||||
listenerRegistered = true;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pendingHandlerTimer) clearTimeout(pendingHandlerTimer);
|
||||
if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer);
|
||||
if (listenerRegistered) {
|
||||
navigator.mediaDevices.removeEventListener('devicechange', handler);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
@@ -21,6 +21,8 @@ import { DmSearchBar } from './DmSearchBar';
|
||||
import { DmListItem } from './DmListItem';
|
||||
import { useDragManager, type DropTarget, type LayoutItem } from '../../hooks/useDragManager';
|
||||
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
|
||||
import { useAudioDevices } from '../../hooks/useAudioDevices';
|
||||
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
|
||||
|
||||
export function ChannelSidebar() {
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
@@ -843,15 +845,20 @@ function UserAreaPanel({
|
||||
onSettingsClick: (tab?: string) => void;
|
||||
}) {
|
||||
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
||||
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
|
||||
const setInputDevice = useVoiceStore((s) => s.setInputDevice);
|
||||
const setOutputDevice = useVoiceStore((s) => s.setOutputDevice);
|
||||
|
||||
const [selectedInputLabel, setSelectedInputLabel] = useState<string>('Default');
|
||||
const [selectedOutputLabel, setSelectedOutputLabel] = useState<string>('Default');
|
||||
// Shared hook drives lists, permission state, and live devicechange refresh.
|
||||
const { permState, inputs: inputDevices, outputs: outputDevices, inputLabels, outputLabels, requestPermission } = useAudioDevices();
|
||||
|
||||
const selectedInputLabel = inputDeviceId === 'default'
|
||||
? 'System Default'
|
||||
: inputLabels.get(inputDeviceId) ?? 'System Default';
|
||||
const selectedOutputLabel = outputDeviceId === 'default'
|
||||
? 'System Default'
|
||||
: outputLabels.get(outputDeviceId) ?? 'System Default';
|
||||
|
||||
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||
const storeSetInputVolume = useVoiceStore((s) => s.setInputVolume);
|
||||
@@ -864,38 +871,6 @@ function UserAreaPanel({
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
// Need to request permission first to get labels
|
||||
if (!AudioManager.getInstance().getContext()) {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop()));
|
||||
}
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
// Deduplicate by deviceId — USB devices sharing the same audio chipset
|
||||
// (e.g. C-Media 0d8c:0134) appear as multiple entries with identical IDs.
|
||||
const dedup = (list: MediaDeviceInfo[]): MediaDeviceInfo[] => {
|
||||
const seen = new Set<string>();
|
||||
return list.filter(d => {
|
||||
if (seen.has(d.deviceId)) return false;
|
||||
seen.add(d.deviceId);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
const inputs = dedup(devices.filter(d => d.kind === 'audioinput'));
|
||||
const outputs = dedup(devices.filter(d => d.kind === 'audiooutput'));
|
||||
setInputDevices(inputs);
|
||||
setOutputDevices(outputs);
|
||||
|
||||
const currentInput = inputs.find(d => d.deviceId === inputDeviceId);
|
||||
if (currentInput) setSelectedInputLabel(currentInput.label || 'Default');
|
||||
|
||||
const currentOutput = outputs.find(d => d.deviceId === outputDeviceId);
|
||||
if (currentOutput) setSelectedOutputLabel(currentOutput.label || 'Default');
|
||||
} catch {
|
||||
// permission denied
|
||||
}
|
||||
}, [inputDeviceId, outputDeviceId]);
|
||||
|
||||
// Start mic level monitoring when input panel opens
|
||||
useEffect(() => {
|
||||
if (openPanel !== 'input') {
|
||||
@@ -946,27 +921,24 @@ function UserAreaPanel({
|
||||
if (openPanel === panel) {
|
||||
setOpenPanel(null);
|
||||
} else {
|
||||
loadDevices();
|
||||
setOpenPanel(panel);
|
||||
setShowInputDeviceList(false);
|
||||
setShowOutputDeviceList(false);
|
||||
// Explicitly resume on interaction
|
||||
// Resume the AudioContext so the mic-level meter starts measuring on open.
|
||||
AudioManager.getInstance().resumeContext();
|
||||
}
|
||||
};
|
||||
|
||||
const selectInput = (device: MediaDeviceInfo) => {
|
||||
setInputDevice(device.deviceId); // Pure state update → triggers syncMic if in voice call
|
||||
AudioManager.getInstance().setInputDevice(device.deviceId); // Immediate preview for mic level meter
|
||||
setSelectedInputLabel(device.label || 'Default');
|
||||
const selectInput = (deviceId: string) => {
|
||||
setInputDevice(deviceId); // Pure state update → triggers syncMic if in voice call
|
||||
AudioManager.getInstance().setInputDevice(deviceId).catch(() => {});
|
||||
setShowInputDeviceList(false);
|
||||
};
|
||||
|
||||
const selectOutput = (device: MediaDeviceInfo) => {
|
||||
setOutputDevice(device.deviceId);
|
||||
setSelectedOutputLabel(device.label || 'Default');
|
||||
const selectOutput = (deviceId: string) => {
|
||||
setOutputDevice(deviceId);
|
||||
AudioManager.getInstance().setOutputDevice(deviceId).catch(() => {});
|
||||
setShowOutputDeviceList(false);
|
||||
AudioManager.getInstance().setOutputDevice(device.deviceId);
|
||||
};
|
||||
|
||||
// Generate mic level bars (20 bars like Discord)
|
||||
@@ -993,23 +965,35 @@ function UserAreaPanel({
|
||||
</svg>
|
||||
</button>
|
||||
{showInputDeviceList && (
|
||||
<div className="bg-surface-base rounded-lg shadow-lg mx-2 mb-2 py-1 border border-border-hard">
|
||||
{inputDevices.map(d => (
|
||||
<button
|
||||
key={d.deviceId}
|
||||
onClick={() => selectInput(d)}
|
||||
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-interactive-hover transition-colors flex items-center gap-2 ${
|
||||
inputDeviceId === d.deviceId ? 'text-txt-primary' : 'text-txt-secondary'
|
||||
}`}
|
||||
>
|
||||
{inputDeviceId === d.deviceId && (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-accent-primary flex-shrink-0">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={inputDeviceId === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="bg-surface-base rounded-lg shadow-lg mx-2 mb-2 py-1 border border-border-hard max-h-64 overflow-y-auto">
|
||||
{permState !== 'granted' && (
|
||||
<div className="px-3 py-2 text-[12px] text-txt-tertiary">
|
||||
Microphone permission needed.{' '}
|
||||
<button
|
||||
onClick={() => { requestPermission().catch(() => {}); }}
|
||||
className="underline text-accent-primary"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{permState === 'granted' && (
|
||||
<>
|
||||
<DropdownItem
|
||||
label="System Default"
|
||||
active={inputDeviceId === 'default'}
|
||||
onClick={() => selectInput('default')}
|
||||
/>
|
||||
{inputDevices.filter(d => d.deviceId !== 'default').map(d => (
|
||||
<DropdownItem
|
||||
key={d.deviceId}
|
||||
label={inputLabels.get(d.deviceId) ?? d.deviceId}
|
||||
active={inputDeviceId === d.deviceId}
|
||||
onClick={() => selectInput(d.deviceId)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1079,23 +1063,35 @@ function UserAreaPanel({
|
||||
</svg>
|
||||
</button>
|
||||
{showOutputDeviceList && (
|
||||
<div className="bg-surface-base rounded-lg shadow-lg mx-2 mb-2 py-1 border border-border-hard">
|
||||
{outputDevices.map(d => (
|
||||
<button
|
||||
key={d.deviceId}
|
||||
onClick={() => selectOutput(d)}
|
||||
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-interactive-hover transition-colors flex items-center gap-2 ${
|
||||
outputDeviceId === d.deviceId ? 'text-txt-primary' : 'text-txt-secondary'
|
||||
}`}
|
||||
>
|
||||
{outputDeviceId === d.deviceId && (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-accent-primary flex-shrink-0">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={outputDeviceId === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="bg-surface-base rounded-lg shadow-lg mx-2 mb-2 py-1 border border-border-hard max-h-64 overflow-y-auto">
|
||||
{permState !== 'granted' && (
|
||||
<div className="px-3 py-2 text-[12px] text-txt-tertiary">
|
||||
Audio permission needed.{' '}
|
||||
<button
|
||||
onClick={() => { requestPermission().catch(() => {}); }}
|
||||
className="underline text-accent-primary"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{permState === 'granted' && (
|
||||
<>
|
||||
<DropdownItem
|
||||
label="System Default"
|
||||
active={outputDeviceId === 'default'}
|
||||
onClick={() => selectOutput('default')}
|
||||
/>
|
||||
{outputDevices.filter(d => d.deviceId !== 'default').map(d => (
|
||||
<DropdownItem
|
||||
key={d.deviceId}
|
||||
label={outputLabels.get(d.deviceId) ?? d.deviceId}
|
||||
active={outputDeviceId === d.deviceId}
|
||||
onClick={() => selectOutput(d.deviceId)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useVoiceStore } from '../../../stores/voiceStore';
|
||||
import { AudioManager } from '../../../audio/AudioManager';
|
||||
import { useAudioDevices } from '../../../hooks/useAudioDevices';
|
||||
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
|
||||
|
||||
export function AudioInputSection() {
|
||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||
const setInputDevice = useVoiceStore((s) => s.setInputDevice);
|
||||
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||
const setInputVolume = useVoiceStore((s) => s.setInputVolume);
|
||||
const { permState, inputs, inputLabels, requestPermission } = useAudioDevices();
|
||||
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const [micLevel, setMicLevel] = useState(0);
|
||||
const [activeUpstreamId, setActiveUpstreamId] = useState<string | null>(null);
|
||||
// Bumped whenever AudioManager's AudioContext transitions to 'running'.
|
||||
// Used as a dep on effects that need to re-run once the context exists —
|
||||
// the user may open Settings before joining voice (no AudioContext yet),
|
||||
// then join voice and expect the meter / resolved-default hint to come
|
||||
// alive without reopening the panel.
|
||||
const [audioCtxGen, setAudioCtxGen] = useState(0);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
|
||||
// Click-outside-to-close.
|
||||
useEffect(() => {
|
||||
if (!listOpen) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setListOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
return () => document.removeEventListener('mousedown', onMouseDown);
|
||||
}, [listOpen]);
|
||||
|
||||
// Listen for AudioContext resume events so dependent effects re-trigger
|
||||
// when the context first becomes available (e.g. user joins voice after
|
||||
// opening Settings). onResumed fires on every 'running' state transition;
|
||||
// we only need an opaque generation bump to re-run downstream effects.
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') return;
|
||||
const am = AudioManager.getInstance();
|
||||
const unsubscribe = am.onResumed(() => setAudioCtxGen((g) => g + 1));
|
||||
return () => { unsubscribe(); };
|
||||
}, [permState]);
|
||||
|
||||
// Live mic-level meter. Reuses AudioManager's analyser node, which is part
|
||||
// of the canonical pipeline — no extra getUserMedia required once the user
|
||||
// is in voice. Re-runs on `audioCtxGen` bumps so the meter activates after
|
||||
// the AudioContext appears mid-session.
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') return;
|
||||
let stopped = false;
|
||||
const am = AudioManager.getInstance();
|
||||
const ctx = am.getContext();
|
||||
if (!ctx) return; // nothing to measure until the user joins voice or hits Test
|
||||
const analyser = am.getAnalyserNode();
|
||||
analyser.fftSize = 256;
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
if (stopped) return;
|
||||
analyser.getByteFrequencyData(data);
|
||||
const avg = data.reduce((a, b) => a + b, 0) / data.length;
|
||||
setMicLevel(Math.min(avg / 128, 1));
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
};
|
||||
}, [permState, audioCtxGen]);
|
||||
|
||||
// Track the resolved upstream deviceId for the "Currently using: X" hint.
|
||||
// Re-runs on `audioCtxGen` because the resolved-default ID is only known
|
||||
// after AudioManager has actually opened a stream.
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') return;
|
||||
const am = AudioManager.getInstance();
|
||||
const id = am.getCurrentInputDeviceId();
|
||||
setActiveUpstreamId(id === 'default' ? null : id);
|
||||
}, [permState, inputDeviceId, audioCtxGen]);
|
||||
|
||||
if (permState === 'unknown') {
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<div className="text-sm text-txt-tertiary">Checking microphone access…</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (permState === 'denied') {
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-txt-primary">⚠ Microphone access denied</div>
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
Grant microphone permission to choose an input device.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { requestPermission().catch(() => {}); }}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-secondary transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (permState === 'prompt') {
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
Microphone permission needed to list and choose an input device.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { requestPermission().catch(() => {}); }}
|
||||
className="text-[13px] px-3 py-2 rounded-md bg-accent-primary hover:bg-accent-primary-hover text-white font-medium transition-colors"
|
||||
>
|
||||
Enable microphone access
|
||||
</button>
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
// permState === 'granted'
|
||||
const selectedLabel = inputDeviceId === 'default'
|
||||
? 'System Default'
|
||||
: inputLabels.get(inputDeviceId) ?? 'System Default';
|
||||
const resolvedHint = inputDeviceId === 'default' && activeUpstreamId
|
||||
? inputLabels.get(activeUpstreamId)
|
||||
: null;
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setInputDevice(id);
|
||||
AudioManager.getInstance().setInputDevice(id).catch(() => {});
|
||||
setListOpen(false);
|
||||
};
|
||||
|
||||
const micBars = 20;
|
||||
const activeBars = Math.round(micLevel * micBars * (inputVolume / 100));
|
||||
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<div className="space-y-3">
|
||||
<div ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListOpen((v) => !v)}
|
||||
className="w-full px-3 py-2 flex items-center justify-between rounded-md bg-surface-base hover:bg-interactive-hover transition-colors"
|
||||
>
|
||||
<span className="text-[13px] text-txt-primary truncate text-left">{selectedLabel}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
|
||||
className={`text-txt-tertiary flex-shrink-0 ml-2 transition-transform ${listOpen ? 'rotate-90' : ''}`}>
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
{listOpen && (
|
||||
<div className="mt-1 rounded-md bg-surface-base border border-border-hard py-1 max-h-64 overflow-y-auto">
|
||||
<DropdownItem label="System Default" active={inputDeviceId === 'default'} onClick={() => handleSelect('default')} />
|
||||
{inputs.filter(d => d.deviceId !== 'default').map((d) => (
|
||||
<DropdownItem
|
||||
key={d.deviceId}
|
||||
label={inputLabels.get(d.deviceId) ?? d.deviceId}
|
||||
active={inputDeviceId === d.deviceId}
|
||||
onClick={() => handleSelect(d.deviceId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{resolvedHint && (
|
||||
<div className="text-xs text-txt-tertiary -mt-1">Currently using: {resolvedHint}</div>
|
||||
)}
|
||||
{inputs.length === 0 && (
|
||||
<div className="text-xs text-txt-tertiary">No microphones detected.</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="text-[13px] font-medium text-txt-primary">Input Volume</div>
|
||||
<div className="text-xs text-txt-tertiary tabular-nums">{inputVolume}%</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
value={inputVolume}
|
||||
onChange={(e) => setInputVolume(Number(e.target.value))}
|
||||
className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-surface-base [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
|
||||
style={{
|
||||
background: `linear-gradient(to right, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) 100%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-[3px] mt-2">
|
||||
{Array.from({ length: micBars }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${
|
||||
i < activeBars ? 'bg-status-online' : 'bg-interactive-muted'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-txt-tertiary mt-1.5">
|
||||
The level meter activates once you join a voice channel.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useVoiceStore } from '../../../stores/voiceStore';
|
||||
import { AudioManager } from '../../../audio/AudioManager';
|
||||
import { useAudioDevices } from '../../../hooks/useAudioDevices';
|
||||
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
|
||||
|
||||
export function AudioOutputSection() {
|
||||
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
|
||||
const setOutputDevice = useVoiceStore((s) => s.setOutputDevice);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const setOutputVolume = useVoiceStore((s) => s.setOutputVolume);
|
||||
const { permState, outputs, outputLabels, requestPermission } = useAudioDevices();
|
||||
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
// Default to "supported" — only flip false if a real context exists and
|
||||
// lacks setSinkId (Safari < 17). Pre-context users can still pick a device;
|
||||
// AudioManager.setOutputDevice defers the actual setSinkId until the
|
||||
// context exists (applyOutputDevice early-returns when ctx is null, and
|
||||
// initContext re-applies the persisted ID on creation).
|
||||
const [supportsSinkId, setSupportsSinkId] = useState(true);
|
||||
// Bumped whenever AudioManager's AudioContext transitions to 'running'.
|
||||
// Used as a dep on the supportsSinkId effect so the check re-evaluates
|
||||
// when the user joins voice after opening Settings — without this, a
|
||||
// user who opens Settings before joining voice would see an incorrect
|
||||
// "browser doesn't support setSinkId" fallback that never recovers.
|
||||
const [audioCtxGen, setAudioCtxGen] = useState(0);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click-outside-to-close.
|
||||
useEffect(() => {
|
||||
if (!listOpen) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setListOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
return () => document.removeEventListener('mousedown', onMouseDown);
|
||||
}, [listOpen]);
|
||||
|
||||
// Listen for AudioContext resume events so the supportsSinkId check
|
||||
// re-evaluates once the context first becomes available.
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') return;
|
||||
const am = AudioManager.getInstance();
|
||||
const unsubscribe = am.onResumed(() => setAudioCtxGen((g) => g + 1));
|
||||
return () => { unsubscribe(); };
|
||||
}, [permState]);
|
||||
|
||||
// Detect setSinkId support — Safari < 17 does not support it on AudioContext.
|
||||
// Default state is "supported"; we only flip to false once we have a real
|
||||
// context to inspect AND it lacks the API. This avoids hiding the picker
|
||||
// from users who open Settings before joining voice (no context yet).
|
||||
useEffect(() => {
|
||||
const ctx = AudioManager.getInstance().getContext();
|
||||
if (ctx && !('setSinkId' in ctx)) {
|
||||
setSupportsSinkId(false);
|
||||
} else {
|
||||
setSupportsSinkId(true);
|
||||
}
|
||||
}, [permState, audioCtxGen]);
|
||||
|
||||
if (permState === 'unknown') {
|
||||
return (
|
||||
<SectionShell title="Output Device">
|
||||
<div className="text-sm text-txt-tertiary">Checking audio access…</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
// Output-device labels are gated behind microphone permission. If permission
|
||||
// is not granted we can still let the user adjust output volume + test the
|
||||
// current default, but the picker is hidden.
|
||||
const showPicker = permState === 'granted' && supportsSinkId;
|
||||
const selectedLabel = outputDeviceId === 'default'
|
||||
? 'System Default'
|
||||
: outputLabels.get(outputDeviceId) ?? 'System Default';
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setOutputDevice(id);
|
||||
AudioManager.getInstance().setOutputDevice(id).catch(() => {});
|
||||
setListOpen(false);
|
||||
};
|
||||
|
||||
const handleTestTone = async () => {
|
||||
try { await AudioManager.getInstance().playTestTone(); } catch { /* best-effort */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionShell title="Output Device">
|
||||
<div className="space-y-3">
|
||||
{showPicker ? (
|
||||
<div ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListOpen((v) => !v)}
|
||||
className="w-full px-3 py-2 flex items-center justify-between rounded-md bg-surface-base hover:bg-interactive-hover transition-colors"
|
||||
>
|
||||
<span className="text-[13px] text-txt-primary truncate text-left">{selectedLabel}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
|
||||
className={`text-txt-tertiary flex-shrink-0 ml-2 transition-transform ${listOpen ? 'rotate-90' : ''}`}>
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
{listOpen && (
|
||||
<div className="mt-1 rounded-md bg-surface-base border border-border-hard py-1 max-h-64 overflow-y-auto">
|
||||
<DropdownItem label="System Default" active={outputDeviceId === 'default'} onClick={() => handleSelect('default')} />
|
||||
{outputs.filter(d => d.deviceId !== 'default').map((d) => (
|
||||
<DropdownItem
|
||||
key={d.deviceId}
|
||||
label={outputLabels.get(d.deviceId) ?? d.deviceId}
|
||||
active={outputDeviceId === d.deviceId}
|
||||
onClick={() => handleSelect(d.deviceId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : permState === 'granted' && !supportsSinkId ? (
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
This browser doesn't support choosing an output device. Audio plays to the system default.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
Grant microphone permission to list output devices (browsers gate output names behind microphone access).
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { requestPermission().catch(() => {}); }}
|
||||
className="text-[13px] px-3 py-2 rounded-md bg-accent-primary hover:bg-accent-primary-hover text-white font-medium transition-colors"
|
||||
>
|
||||
Enable audio access
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="text-[13px] font-medium text-txt-primary">Output Volume</div>
|
||||
<div className="text-xs text-txt-tertiary tabular-nums">{outputVolume}%</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
value={outputVolume}
|
||||
onChange={(e) => setOutputVolume(Number(e.target.value))}
|
||||
className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-surface-base [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
|
||||
style={{
|
||||
background: `linear-gradient(to right, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${outputVolume / 2}%, rgb(var(--interactive-muted)) ${outputVolume / 2}%, rgb(var(--interactive-muted)) 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTestTone}
|
||||
className="w-full text-[13px] px-3 py-2 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-primary transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
|
||||
</svg>
|
||||
Play test sound
|
||||
</button>
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useVoiceStore } from '../../../stores/voiceStore';
|
||||
import { Toggle } from '../../ui/Toggle';
|
||||
import { VideoSection } from './VideoSection';
|
||||
import { AudioInputSection } from './AudioInputSection';
|
||||
import { AudioOutputSection } from './AudioOutputSection';
|
||||
|
||||
export function VoicePanel() {
|
||||
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
|
||||
@@ -17,6 +19,10 @@ export function VoicePanel() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">Voice & Video</h2>
|
||||
|
||||
<AudioInputSection />
|
||||
<AudioOutputSection />
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Volume
|
||||
@@ -69,7 +75,7 @@ export function VoicePanel() {
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="text-sm text-txt-primary">Echo Cancellation</div>
|
||||
<div className="text-xs text-txt-tertiary">Removes echo when using speakers</div>
|
||||
<div className="text-xs text-txt-tertiary">Cancels echo from your speakers feeding back into the mic. Always on for voice channels and calls.</div>
|
||||
</div>
|
||||
<Toggle enabled={echoCancellation} onChange={setEchoCancellation} />
|
||||
</div>
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Section wrapper used by audio/video picker subsections (AudioInputSection,
|
||||
* AudioOutputSection, …). Provides the small uppercase title above a soft
|
||||
* inset card. Settings-panel-internal — kept under `_shared/` rather than
|
||||
* promoted to `ui/` because nothing outside settings panels needs this look.
|
||||
*/
|
||||
export function SectionShell({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{title}</div>
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DropdownItemProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single row of a settings picker dropdown (input device, output device, etc.).
|
||||
* Renders a checkmark on the active row and indents inactive labels by the
|
||||
* checkmark's width so labels align across rows. Settings-panel-internal.
|
||||
*/
|
||||
export function DropdownItem({ label, active, onClick }: DropdownItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-interactive-hover transition-colors flex items-center gap-2 ${
|
||||
active ? 'text-txt-primary' : 'text-txt-secondary'
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-accent-primary flex-shrink-0">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={`truncate ${active ? '' : 'pl-6'}`}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useAudioDevices } from './useAudioDevices';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function setupMediaDevicesMock(opts: {
|
||||
permissionState?: 'granted' | 'prompt' | 'denied';
|
||||
devices?: MediaDeviceInfo[];
|
||||
permissionThrows?: boolean;
|
||||
} = {}) {
|
||||
const listeners = new Set<Listener>();
|
||||
const permState = opts.permissionState ?? 'granted';
|
||||
const devices = opts.devices ?? [
|
||||
{ deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Mic', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
{ deviceId: 'mic-2', kind: 'audioinput', label: 'USB Headset', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
{ deviceId: 'spk-1', kind: 'audiooutput', label: 'Built-in Speakers', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
{ deviceId: 'spk-2', kind: 'audiooutput', label: 'USB Headset', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
];
|
||||
|
||||
const mediaDevices = {
|
||||
enumerateDevices: vi.fn().mockResolvedValue(devices),
|
||||
addEventListener: (_evt: string, l: Listener) => { listeners.add(l); },
|
||||
removeEventListener: (_evt: string, l: Listener) => { listeners.delete(l); },
|
||||
getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }),
|
||||
};
|
||||
Object.defineProperty(navigator, 'mediaDevices', { value: mediaDevices, configurable: true });
|
||||
|
||||
const permStatus = {
|
||||
state: permState,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const permissions = {
|
||||
query: opts.permissionThrows
|
||||
? vi.fn().mockRejectedValue(new Error('not supported'))
|
||||
: vi.fn().mockResolvedValue(permStatus),
|
||||
};
|
||||
Object.defineProperty(navigator, 'permissions', { value: permissions, configurable: true });
|
||||
|
||||
return { listeners, mediaDevices, permissions, permStatus, fireDeviceChange: () => listeners.forEach(l => l()) };
|
||||
}
|
||||
|
||||
describe('useAudioDevices', () => {
|
||||
beforeEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
it('starts in unknown state, transitions to granted, enumerates inputs and outputs', async () => {
|
||||
const m = setupMediaDevicesMock({ permissionState: 'granted' });
|
||||
const { result } = renderHook(() => useAudioDevices());
|
||||
|
||||
expect(result.current.permState).toBe('unknown');
|
||||
await waitFor(() => expect(result.current.permState).toBe('granted'));
|
||||
await waitFor(() => expect(result.current.inputs.length).toBe(2));
|
||||
expect(result.current.outputs.length).toBe(2);
|
||||
expect(result.current.inputs[0].deviceId).toBe('mic-1');
|
||||
});
|
||||
|
||||
it('returns prompt state when permission is prompt', async () => {
|
||||
setupMediaDevicesMock({ permissionState: 'prompt' });
|
||||
const { result } = renderHook(() => useAudioDevices());
|
||||
await waitFor(() => expect(result.current.permState).toBe('prompt'));
|
||||
expect(result.current.inputs).toEqual([]);
|
||||
expect(result.current.outputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns denied state when permission is denied', async () => {
|
||||
setupMediaDevicesMock({ permissionState: 'denied' });
|
||||
const { result } = renderHook(() => useAudioDevices());
|
||||
await waitFor(() => expect(result.current.permState).toBe('denied'));
|
||||
});
|
||||
|
||||
it('falls back to prompt when permissions.query throws', async () => {
|
||||
setupMediaDevicesMock({ permissionThrows: true });
|
||||
const { result } = renderHook(() => useAudioDevices());
|
||||
await waitFor(() => expect(result.current.permState).toBe('prompt'));
|
||||
});
|
||||
|
||||
it('refreshes lists on devicechange', async () => {
|
||||
const m = setupMediaDevicesMock({ permissionState: 'granted' });
|
||||
const { result } = renderHook(() => useAudioDevices());
|
||||
await waitFor(() => expect(result.current.inputs.length).toBe(2));
|
||||
|
||||
m.mediaDevices.enumerateDevices.mockResolvedValueOnce([
|
||||
{ deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Mic', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
]);
|
||||
act(() => { m.fireDeviceChange(); });
|
||||
await waitFor(() => expect(result.current.inputs.length).toBe(1));
|
||||
});
|
||||
|
||||
it('deduplicates devices by deviceId', async () => {
|
||||
setupMediaDevicesMock({
|
||||
permissionState: 'granted',
|
||||
devices: [
|
||||
{ deviceId: 'mic-1', kind: 'audioinput', label: 'A', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
{ deviceId: 'mic-1', kind: 'audioinput', label: 'A', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
{ deviceId: 'spk-1', kind: 'audiooutput', label: 'B', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useAudioDevices());
|
||||
await waitFor(() => expect(result.current.permState).toBe('granted'));
|
||||
expect(result.current.inputs.length).toBe(1);
|
||||
expect(result.current.outputs.length).toBe(1);
|
||||
});
|
||||
|
||||
it('requestPermission fires getUserMedia({audio:true}) and stops the stream', async () => {
|
||||
const m = setupMediaDevicesMock({ permissionState: 'prompt' });
|
||||
const { result } = renderHook(() => useAudioDevices());
|
||||
await waitFor(() => expect(result.current.permState).toBe('prompt'));
|
||||
|
||||
await act(async () => { await result.current.requestPermission(); });
|
||||
expect(m.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: true });
|
||||
});
|
||||
|
||||
it('builds display labels with disambiguation suffix for duplicate names', async () => {
|
||||
setupMediaDevicesMock({
|
||||
permissionState: 'granted',
|
||||
devices: [
|
||||
{ deviceId: 'mic-1', kind: 'audioinput', label: 'USB Audio', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
{ deviceId: 'mic-2', kind: 'audioinput', label: 'USB Audio', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
{ deviceId: 'mic-3', kind: 'audioinput', label: '', groupId: 'g3', toJSON: () => ({}) } as MediaDeviceInfo,
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useAudioDevices());
|
||||
await waitFor(() => expect(result.current.inputs.length).toBe(3));
|
||||
expect(result.current.inputLabels.get('mic-1')).toBe('USB Audio (1)');
|
||||
expect(result.current.inputLabels.get('mic-2')).toBe('USB Audio (2)');
|
||||
expect(result.current.inputLabels.get('mic-3')).toBe('Microphone 3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Permission state machine for audio device enumeration.
|
||||
*
|
||||
* - `unknown`: initial mount, before `permissions.query` resolves.
|
||||
* - `granted`: permission granted; both input + output lists populated.
|
||||
* - `prompt`: permission not yet decided; lists empty until requestPermission().
|
||||
* - `denied`: permission denied; lists empty.
|
||||
*
|
||||
* `permissions.query({ name: 'microphone' })` is the ONLY mount-time API call.
|
||||
* It is passive — does NOT light the mic indicator on any platform. We never
|
||||
* auto-fire `getUserMedia` to "unlock labels"; that requires an explicit user
|
||||
* gesture via `requestPermission()`.
|
||||
*
|
||||
* Note on output devices: there is no separate "speaker" permission. Browsers
|
||||
* gate output-device labels behind the same microphone permission. So a single
|
||||
* permission state covers both lists.
|
||||
*/
|
||||
export type AudioDevicesPermState = 'unknown' | 'granted' | 'prompt' | 'denied';
|
||||
|
||||
export interface UseAudioDevicesResult {
|
||||
permState: AudioDevicesPermState;
|
||||
inputs: MediaDeviceInfo[];
|
||||
outputs: MediaDeviceInfo[];
|
||||
inputLabels: Map<string, string>;
|
||||
outputLabels: Map<string, string>;
|
||||
/** Re-enumerate immediately. Safe to call any time after permission is granted. */
|
||||
refresh: () => void;
|
||||
/**
|
||||
* Explicit user gesture: fires `getUserMedia({audio:true})` to grant permission
|
||||
* and unlock device labels. Stops the stream immediately. Only call from a
|
||||
* click/keydown handler — calling this from an effect would defeat the privacy
|
||||
* model and flash the mic indicator.
|
||||
*/
|
||||
requestPermission: () => Promise<void>;
|
||||
}
|
||||
|
||||
function buildLabels(devices: MediaDeviceInfo[], kindLabel: string): Map<string, string> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const d of devices) {
|
||||
if (d.label) counts.set(d.label, (counts.get(d.label) ?? 0) + 1);
|
||||
}
|
||||
const seen = new Map<string, number>();
|
||||
const labels = new Map<string, string>();
|
||||
devices.forEach((d, i) => {
|
||||
if (!d.label) {
|
||||
labels.set(d.deviceId, `${kindLabel} ${i + 1}`);
|
||||
return;
|
||||
}
|
||||
const total = counts.get(d.label) ?? 1;
|
||||
if (total <= 1) {
|
||||
labels.set(d.deviceId, d.label);
|
||||
return;
|
||||
}
|
||||
const used = (seen.get(d.label) ?? 0) + 1;
|
||||
seen.set(d.label, used);
|
||||
labels.set(d.deviceId, `${d.label} (${used})`);
|
||||
});
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function useAudioDevices(): UseAudioDevicesResult {
|
||||
const [permState, setPermState] = useState<AudioDevicesPermState>('unknown');
|
||||
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
|
||||
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const enumerateGenRef = useRef(0);
|
||||
|
||||
const enumerate = useCallback(async () => {
|
||||
const gen = ++enumerateGenRef.current;
|
||||
try {
|
||||
const all = await navigator.mediaDevices.enumerateDevices();
|
||||
if (gen !== enumerateGenRef.current || !mountedRef.current) return;
|
||||
const seenIn = new Set<string>();
|
||||
const seenOut = new Set<string>();
|
||||
const ins: MediaDeviceInfo[] = [];
|
||||
const outs: MediaDeviceInfo[] = [];
|
||||
for (const d of all) {
|
||||
if (d.kind === 'audioinput' && !seenIn.has(d.deviceId)) {
|
||||
seenIn.add(d.deviceId);
|
||||
ins.push(d);
|
||||
} else if (d.kind === 'audiooutput' && !seenOut.has(d.deviceId)) {
|
||||
seenOut.add(d.deviceId);
|
||||
outs.push(d);
|
||||
}
|
||||
}
|
||||
setInputs(ins);
|
||||
setOutputs(outs);
|
||||
} catch {
|
||||
if (gen === enumerateGenRef.current && mountedRef.current) {
|
||||
setInputs([]);
|
||||
setOutputs([]);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Mount-time permission probe. Mirrors VideoSection.tsx:158-210.
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
let cancelled = false;
|
||||
let status: PermissionStatus | null = null;
|
||||
let onChange: (() => void) | null = null;
|
||||
|
||||
const apply = (state: PermissionState) => {
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
if (state === 'granted') setPermState('granted');
|
||||
else if (state === 'prompt') setPermState('prompt');
|
||||
else setPermState('denied');
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (!navigator.permissions || typeof navigator.permissions.query !== 'function') {
|
||||
if (!cancelled && mountedRef.current) setPermState('prompt');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const s = await navigator.permissions.query({ name: 'microphone' as PermissionName });
|
||||
status = s;
|
||||
apply(s.state);
|
||||
onChange = () => apply(s.state);
|
||||
s.addEventListener('change', onChange);
|
||||
} catch {
|
||||
if (!cancelled && mountedRef.current) setPermState('prompt');
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
if (status && onChange) {
|
||||
try { status.removeEventListener('change', onChange); } catch { /* best-effort */ }
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Enumerate when permission grants; refresh on devicechange.
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') {
|
||||
setInputs([]);
|
||||
setOutputs([]);
|
||||
return;
|
||||
}
|
||||
enumerate();
|
||||
const onChange = () => { enumerate(); };
|
||||
navigator.mediaDevices.addEventListener('devicechange', onChange);
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener('devicechange', onChange);
|
||||
};
|
||||
}, [permState, enumerate]);
|
||||
|
||||
const inputLabels = useMemo(() => buildLabels(inputs, 'Microphone'), [inputs]);
|
||||
const outputLabels = useMemo(() => buildLabels(outputs, 'Speakers'), [outputs]);
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
if (mountedRef.current) {
|
||||
setPermState('granted');
|
||||
await enumerate();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'NotAllowedError' && mountedRef.current) {
|
||||
setPermState('denied');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, [enumerate]);
|
||||
|
||||
return { permState, inputs, outputs, inputLabels, outputLabels, refresh: enumerate, requestPermission };
|
||||
}
|
||||
@@ -143,6 +143,49 @@ function destroyRoom(room: Room | null): Promise<void> | void {
|
||||
return room.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a fresh microphone track from the AudioManager pipeline is published
|
||||
* to the supplied room. If the existing publication is already current (live
|
||||
* MediaStreamTrack matching the latest AudioManager streamGeneration), this is
|
||||
* a no-op apart from un-muting. Otherwise the stale track is unpublished and a
|
||||
* cloned destination-node track is published in its place.
|
||||
*
|
||||
* Extracted from the syncMic effect so the input-track-loss recovery path can
|
||||
* call it directly — without relying on syncMic's React dep array catching a
|
||||
* change that never re-renders the hook.
|
||||
*/
|
||||
async function republishMicrophone(r: Room, lastMicGenRef: { current: number }): Promise<void> {
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const currentGen = audioManager.getStreamGeneration();
|
||||
|
||||
const micPub = r.localParticipant.getTrackPublications()
|
||||
.find(p => p.source === Track.Source.Microphone);
|
||||
|
||||
if (micPub?.track) {
|
||||
// Track already published — check if it's still current
|
||||
if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) {
|
||||
// Current and live — just unmute if needed
|
||||
if (micPub.isMuted) {
|
||||
await r.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Track is stale (device or constraint change) — replace it
|
||||
await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack);
|
||||
}
|
||||
|
||||
// Publish fresh track from AudioManager pipeline
|
||||
const audioTrack = audioManager.getFreshTrack();
|
||||
if (!audioTrack) return;
|
||||
|
||||
console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')');
|
||||
await r.localParticipant.publishTrack(audioTrack, {
|
||||
name: 'microphone',
|
||||
source: Track.Source.Microphone,
|
||||
});
|
||||
lastMicGenRef.current = currentGen;
|
||||
}
|
||||
|
||||
export function useLiveKit() {
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
@@ -353,36 +396,12 @@ export function useLiveKit() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Not muted — ensure mic is published and live
|
||||
// Not muted — ensure the AudioManager pipeline is on the right device
|
||||
// and at the right volume, then republish if the published track is
|
||||
// stale or missing.
|
||||
await audioManager.setInputDevice(inputDeviceId);
|
||||
audioManager.setInputVolume(inputVolume);
|
||||
|
||||
const currentGen = audioManager.getStreamGeneration();
|
||||
|
||||
if (micPub?.track) {
|
||||
// Track already published — check if it's still current
|
||||
if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) {
|
||||
// Current and live — just unmute if needed
|
||||
if (micPub.isMuted) {
|
||||
await r.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Track is stale (device or constraint change) — replace it
|
||||
await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack);
|
||||
}
|
||||
|
||||
// Publish fresh track from AudioManager pipeline
|
||||
const audioTrack = audioManager.getFreshTrack();
|
||||
if (!audioTrack) return;
|
||||
|
||||
console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')');
|
||||
await r.localParticipant.publishTrack(audioTrack, {
|
||||
name: 'microphone',
|
||||
source: Track.Source.Microphone,
|
||||
});
|
||||
lastMicGenRef.current = currentGen;
|
||||
|
||||
await republishMicrophone(r, lastMicGenRef);
|
||||
} catch (err) {
|
||||
console.error('[LiveKit] Failed to sync mic state:', err);
|
||||
}
|
||||
@@ -391,15 +410,76 @@ export function useLiveKit() {
|
||||
syncMic();
|
||||
|
||||
// Re-sync when AudioManager resumes
|
||||
const unsubscribe = AudioManager.getInstance().onResumed(() => {
|
||||
const unsubscribeResume = AudioManager.getInstance().onResumed(() => {
|
||||
syncMic();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeResume();
|
||||
};
|
||||
}, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
|
||||
|
||||
// Subscribe to upstream-input-track-end events from AudioManager whenever a
|
||||
// room is connected. The published mic track is a clone of a WebAudio
|
||||
// destination node and never ends on hardware loss; only the upstream
|
||||
// getUserMedia track does. AudioManager owns that signal — we react to it.
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
const am = AudioManager.getInstance();
|
||||
const subscriberRoom = roomRef.current;
|
||||
|
||||
const unsubscribe = am.onInputTrackEnded(async () => {
|
||||
// Room was replaced or torn down between event emission and handler run.
|
||||
if (roomRef.current !== subscriberRoom || !subscriberRoom) return;
|
||||
|
||||
const deviceId = useVoiceStore.getState().inputDeviceId;
|
||||
let copy = 'Microphone could not be restored';
|
||||
|
||||
try {
|
||||
// Probe is intentionally constraint-free — we want to know whether the
|
||||
// device is reachable, not whether full voice constraints succeed. Adding
|
||||
// constraints here would create probe-vs-acquire skew (probe might fail
|
||||
// for a constraint that AudioManager would have negotiated around).
|
||||
const probe = await navigator.mediaDevices.getUserMedia({
|
||||
audio: deviceId === 'default' ? true : { deviceId: { exact: deviceId } },
|
||||
});
|
||||
probe.getTracks().forEach(t => t.stop());
|
||||
|
||||
// Probe succeeded — device is back. Re-acquire and force a republish.
|
||||
if (roomRef.current !== subscriberRoom) return;
|
||||
try {
|
||||
await am.setInputDevice(deviceId);
|
||||
if (roomRef.current !== subscriberRoom) return;
|
||||
await republishMicrophone(subscriberRoom, lastMicGenRef);
|
||||
return;
|
||||
} catch {
|
||||
// copy already holds 'Microphone could not be restored'
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'NotAllowedError') {
|
||||
copy = 'Microphone permission was revoked';
|
||||
} else if (err?.name === 'NotFoundError') {
|
||||
if (deviceId !== 'default') {
|
||||
// The configured device disappeared. Fall back to default — the
|
||||
// store update triggers syncMic via its dep array, which calls
|
||||
// republishMicrophone with the freshly acquired default stream.
|
||||
useVoiceStore.getState().setInputDevice('default');
|
||||
copy = 'Microphone disconnected — switched to system default';
|
||||
} else {
|
||||
copy = 'Microphone disconnected';
|
||||
}
|
||||
} else {
|
||||
copy = 'Microphone could not be restored';
|
||||
}
|
||||
}
|
||||
|
||||
if (roomRef.current !== subscriberRoom) return;
|
||||
useUIStore.getState().addToast(copy, 'warning');
|
||||
});
|
||||
|
||||
return () => { unsubscribe(); };
|
||||
}, [isConnected]);
|
||||
|
||||
// Hot-swap the camera source when cameraDeviceId changes mid-call.
|
||||
// Compares against the published track's actual deviceId (getSettings().deviceId)
|
||||
// rather than a memoised previous store value, so the null → explicit-same-device
|
||||
@@ -613,6 +693,11 @@ export function useLiveKit() {
|
||||
};
|
||||
}
|
||||
}
|
||||
// NOTE: Microphone track-loss is handled at the AudioManager layer via
|
||||
// `onInputTrackEnded`, NOT here. The published mic track is a clone of
|
||||
// a WebAudio destination node and does not end on hardware loss — only
|
||||
// the upstream getUserMedia track does. See the `useEffect` that
|
||||
// subscribes to `AudioManager.onInputTrackEnded` above.
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
|
||||
|
||||
Reference in New Issue
Block a user