From 70d299d5b8e91c96027d13763463c61123cd7c91 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:27:41 -0400 Subject: [PATCH] auto switch polling on output change when on default output --- src/renderer/App.tsx | 6 +- src/renderer/audio/AudioCapture.ts | 71 +++- src/renderer/components/BottomBar.tsx | 11 +- src/renderer/stores/audioStore.ts | 339 +++++++++++++++-- test/audio-store.test.ts | 501 +++++++++++++++++++++++++- 5 files changed, 855 insertions(+), 73 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 683ab0c..6c941f7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -8,7 +8,7 @@ import WindowResizeOverlay from './components/WindowResizeOverlay' import AppBanner from './components/AppBanner' import { resolveMainWindowSettingsHeight } from './mainWindowSettings' import { useSettingsStore } from './stores/settingsStore' -import { useAudioStore } from './stores/audioStore' +import { startAudioDeviceWatcher, useAudioStore } from './stores/audioStore' import { useNowPlayingStore } from './stores/nowPlayingStore' import { useThemeStore } from './stores/themeStore' import { useUiStore } from './stores/uiStore' @@ -50,6 +50,10 @@ export default function App(): JSX.Element { } }, []) + useEffect(() => { + return startAudioDeviceWatcher() + }, []) + useEffect(() => { let isDisposed = false diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index cb0f4d7..0c01f54 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -38,10 +38,13 @@ interface CaptureBackendStatus { reason: string | null sampleRate: number channelCount: number + activeSourceId: string | null + activeSourceLabel: string | null } interface CaptureBackendStartRequest { deviceId?: string + forceRestart?: boolean } interface CaptureBackend { @@ -141,6 +144,8 @@ export interface CaptureManagerStatus { sampleRate: number channelCount: number isCapturing: boolean + activeSourceId: string | null + activeSourceLabel: string | null } type StatusListener = (status: CaptureManagerStatus) => void @@ -178,6 +183,8 @@ class DeviceInputCaptureRuntime { private sampleRate = 48000 private channelCount = 2 private inputGainLinear = 1 + private activeSourceId: string | null = null + private activeSourceLabel: string | null = null setInputGain(db: number): void { this.inputGainLinear = inputGainDbToLinear(db) @@ -191,11 +198,12 @@ class DeviceInputCaptureRuntime { } } - async startDevice(deviceId?: string): Promise { + async startDevice(deviceId?: string, forceRestart = false): Promise { await this.ensureContext() const requestedDeviceId = deviceId ?? null - if (this.currentDeviceId !== requestedDeviceId || !this.stream || !this.sourceNode) { + if (forceRestart || this.currentDeviceId !== requestedDeviceId || !this.stream || !this.sourceNode) { + this.releaseStream() const nextStream = await this.requestDeviceStream(deviceId) this.attachStream(nextStream, requestedDeviceId) } @@ -229,6 +237,8 @@ class DeviceInputCaptureRuntime { reason: null, sampleRate: this.sampleRate, channelCount: this.channelCount, + activeSourceId: this.active ? this.activeSourceId : null, + activeSourceLabel: this.active ? this.activeSourceLabel : null, } } @@ -301,12 +311,33 @@ class DeviceInputCaptureRuntime { const audioTrack = stream.getAudioTracks()[0] ?? null const trackSettings = audioTrack?.getSettings() + const resolvedDeviceId = typeof trackSettings?.deviceId === 'string' && trackSettings.deviceId + ? trackSettings.deviceId + : deviceId this.channelCount = Math.max( 1, Math.floor(trackSettings?.channelCount ?? this.sourceNode.channelCount ?? 2), ) this.sampleRate = Math.max(1, Math.floor(this.audioContext.sampleRate)) + this.activeSourceId = resolvedDeviceId ?? null + this.activeSourceLabel = audioTrack?.label || null + } + + private releaseStream(): void { + if (this.sourceNode) { + this.sourceNode.disconnect() + this.sourceNode = null + } + + if (this.stream) { + this.stream.getTracks().forEach((track) => track.stop()) + this.stream = null + } + + this.currentDeviceId = null + this.activeSourceId = null + this.activeSourceLabel = null } private syncGainNode(): void { @@ -336,7 +367,7 @@ class DeviceInputCaptureBackend implements CaptureBackend { constructor(private readonly runtime: DeviceInputCaptureRuntime) {} async start(request?: CaptureBackendStartRequest): Promise { - await this.runtime.startDevice(request?.deviceId) + await this.runtime.startDevice(request?.deviceId, request?.forceRestart === true) } async stop(): Promise { @@ -366,6 +397,8 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { private channelCount = 2 private supportReason: string | null private performanceOffsetMilliseconds = 0 + private activeSourceId: string | null = null + private activeSourceLabel: string | null = null constructor(private readonly supportEntry: CaptureBackendSupportEntry) { this.supportReason = supportEntry.reason @@ -393,6 +426,8 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { this.sampleRate = Math.max(1, Math.floor(startResult.sampleRate) || 48000) this.channelCount = Math.max(1, Math.floor(startResult.channelCount) || 2) + this.activeSourceId = startResult.deviceId || null + this.activeSourceLabel = startResult.deviceLabel || null this.supportReason = null this.active = true this.startPolling() @@ -402,6 +437,8 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { this.stopPolling() this.getNativeCaptureModule()?.stop() this.active = false + this.activeSourceId = null + this.activeSourceLabel = null } async listSources(): Promise { @@ -440,6 +477,8 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { reason: this.supportReason, sampleRate: this.sampleRate, channelCount: this.channelCount, + activeSourceId: this.active ? this.activeSourceId : null, + activeSourceLabel: this.active ? this.activeSourceLabel : null, } } @@ -575,10 +614,16 @@ class NativeUnavailableCaptureBackend implements CaptureBackend { reason: this.reason, sampleRate: 48000, channelCount: 2, + activeSourceId: null, + activeSourceLabel: null, } } } +interface CaptureStartOptions { + forceDeviceRestart?: boolean +} + class AudioCapture { private readonly deviceInputRuntime = new DeviceInputCaptureRuntime() private readonly deviceInputBackend: CaptureBackend @@ -632,15 +677,13 @@ class AudioCapture { await this.start() } - async startDevice(deviceId?: string): Promise { + async startDevice(deviceId?: string, options: CaptureStartOptions = {}): Promise { this.captureMode = 'device' - if (deviceId) { - this.selectedDeviceId = deviceId - } - await this.start() + this.selectedDeviceId = deviceId ?? null + await this.start(undefined, options) } - async start(deviceId?: string): Promise { + async start(deviceId?: string, options: CaptureStartOptions = {}): Promise { if (deviceId) { this.selectedDeviceId = deviceId this.captureMode = 'device' @@ -656,7 +699,10 @@ class AudioCapture { ? this.selectedDeviceId ?? undefined : this.selectedSystemSourceId ?? DEFAULT_SYSTEM_SOURCE_ID - await requestedBackend.start({ deviceId: requestedDeviceId }) + await requestedBackend.start({ + deviceId: requestedDeviceId, + forceRestart: this.captureMode === 'device' && options.forceDeviceRestart === true, + }) this.activeBackend = requestedBackend const backendStatus = requestedBackend.getStatus() @@ -727,13 +773,16 @@ class AudioCapture { getStatus(): CaptureManagerStatus { const backendStatus = this.activeBackend?.getStatus() + const isCapturing = Boolean(backendStatus?.active && this.sessionId !== null) return { captureMode: this.captureMode, activeBackendKind: this.activeBackend?.kind ?? null, backendSupport: this.backendSupport, sampleRate: backendStatus?.sampleRate ?? 48000, channelCount: backendStatus?.channelCount ?? 2, - isCapturing: Boolean(this.activeBackend?.getStatus().active && this.sessionId !== null), + isCapturing, + activeSourceId: isCapturing ? backendStatus?.activeSourceId ?? null : null, + activeSourceLabel: isCapturing ? backendStatus?.activeSourceLabel ?? null : null, } } diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index ecb8c4d..b48b772 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX, type WheelEvent } from 'react' +import { useLayoutEffect, useRef, useState, type CSSProperties, type JSX, type WheelEvent } from 'react' import { useNowPlayingStore } from '../stores/nowPlayingStore' import { useAudioStore } from '../stores/audioStore' import { usePerformanceStore } from '../stores/performanceStore' @@ -133,9 +133,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): captureNotice, inputGainDb, clearCaptureNotice, - refreshSystemSources, - refreshDevices, - refreshBackendSupport, selectSystemSource, selectDevice, startCapture, @@ -143,12 +140,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): } = useAudioStore() const showBanner = useUiStore((s) => s.showBanner) - useEffect(() => { - void refreshBackendSupport() - void refreshSystemSources() - void refreshDevices() - }, [refreshBackendSupport, refreshSystemSources, refreshDevices]) - useLayoutEffect(() => { if (!onHeightChange || !rootRef.current) return diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts index 13b3453..75a36de 100644 --- a/src/renderer/stores/audioStore.ts +++ b/src/renderer/stores/audioStore.ts @@ -12,11 +12,22 @@ const STORAGE_KEY = 'prism:audio' const INPUT_GAIN_MIN_DB = -12 const INPUT_GAIN_MAX_DB = 12 const INPUT_GAIN_STEP_DB = 0.5 +const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__' +const AUDIO_DEVICE_WATCHER_POLL_MS = 5000 export interface PersistedAudioState { inputGainDb: number } +interface RefreshSourceOptions { + rebindActiveCapture?: boolean +} + +interface StartCaptureOptions { + forceDeviceRestart?: boolean + skipSourceRefresh?: boolean +} + interface AudioState { systemSources: CaptureSourceDescriptor[] devices: MediaDeviceInfo[] @@ -31,16 +42,18 @@ interface AudioState { captureNotice: string | null sampleRate: number channelCount: number + activeSourceId: string | null + activeSourceLabel: string | null inputGainDb: number setInputGain: (db: number) => void clearCaptureNotice: () => void - refreshSystemSources: () => Promise - refreshDevices: () => Promise - refreshBackendSupport: () => Promise + refreshSystemSources: (options?: RefreshSourceOptions) => Promise + refreshDevices: (options?: RefreshSourceOptions) => Promise + refreshBackendSupport: (options?: RefreshSourceOptions) => Promise selectSystemSource: (sourceId: string | null) => Promise selectDevice: (deviceId: string | null) => Promise setCaptureMode: (mode: CaptureMode) => void - startCapture: () => Promise + startCapture: (options?: StartCaptureOptions) => Promise stopCapture: () => void } @@ -57,6 +70,8 @@ function applyCaptureStatus(status: CaptureManagerStatus): Partial { sampleRate: status.sampleRate, channelCount: status.channelCount, isCapturing: status.isCapturing, + activeSourceId: status.activeSourceId, + activeSourceLabel: status.activeSourceLabel, } } @@ -89,6 +104,61 @@ function describeSystemSource(sourceId: string, sources: CaptureSourceDescriptor return sources.find((source) => source.id === sourceId)?.label ?? 'The selected output device' } +function areCaptureSourcesEqual( + left: CaptureSourceDescriptor[], + right: CaptureSourceDescriptor[], +): boolean { + if (left.length !== right.length) { + return false + } + + return left.every((source, index) => { + const candidate = right[index] + return source.id === candidate.id + && source.label === candidate.label + && source.kind === candidate.kind + && source.isDefault === candidate.isDefault + && source.sampleRate === candidate.sampleRate + && source.channelCount === candidate.channelCount + }) +} + +function areMediaDevicesEqual(left: MediaDeviceInfo[], right: MediaDeviceInfo[]): boolean { + if (left.length !== right.length) { + return false + } + + return left.every((device, index) => { + const candidate = right[index] + return device.deviceId === candidate.deviceId + && device.kind === candidate.kind + && device.label === candidate.label + && device.groupId === candidate.groupId + }) +} + +function getResolvedDefaultSystemSourceId(sources: CaptureSourceDescriptor[]): string | null { + return sources.find((source) => ( + source.kind === 'system' + && source.id !== DEFAULT_SYSTEM_SOURCE_ID + && source.isDefault === true + ))?.id ?? null +} + +function getDefaultInputSignature(devices: MediaDeviceInfo[]): string | null { + const defaultDevice = devices.find((device) => device.deviceId === 'default') ?? devices[0] ?? null + if (!defaultDevice) { + return null + } + + return [ + defaultDevice.deviceId, + defaultDevice.label, + defaultDevice.groupId, + defaultDevice.kind, + ].join('\0') +} + function getStorage(): StorageLike | null { if (typeof localStorage === 'undefined') { return null @@ -158,6 +228,8 @@ export const useAudioStore = create((set, get) => ({ captureNotice: null, sampleRate: 48000, channelCount: 2, + activeSourceId: null, + activeSourceLabel: null, inputGainDb: storedPreferences.inputGainDb, setInputGain: (db: number) => { @@ -175,10 +247,11 @@ export const useAudioStore = create((set, get) => ({ set({ captureNotice: null }) }, - refreshSystemSources: async () => { - const previousSelectedSystemSourceId = get().selectedSystemSourceId - const previousSources = get().systemSources + refreshSystemSources: async (options: RefreshSourceOptions = {}) => { const systemSources = await audioCapture.listSources('system') + const currentState = get() + const previousSelectedSystemSourceId = currentState.selectedSystemSourceId + const previousSources = currentState.systemSources const fallbackSourceId = systemSources[0]?.id ?? null const nextSelectedSystemSourceId = previousSelectedSystemSourceId && systemSources.some((source) => source.id === previousSelectedSystemSourceId) @@ -187,24 +260,61 @@ export const useAudioStore = create((set, get) => ({ const shouldShowFallbackNotice = Boolean( previousSelectedSystemSourceId && previousSelectedSystemSourceId !== nextSelectedSystemSourceId - && get().captureMode === 'system', + && currentState.captureMode === 'system', + ) + const sourceListChanged = !areCaptureSourcesEqual(previousSources, systemSources) + const selectedSourceChanged = previousSelectedSystemSourceId !== nextSelectedSystemSourceId + const nextCaptureNotice = shouldShowFallbackNotice + ? `${describeSystemSource(previousSelectedSystemSourceId!, previousSources)} is unavailable. Prism switched to Default Output.` + : currentState.captureNotice + + if (selectedSourceChanged) { + audioCapture.setSelectedSystemSourceId(nextSelectedSystemSourceId) + } + + if ( + sourceListChanged + || selectedSourceChanged + || nextCaptureNotice !== currentState.captureNotice + ) { + set((state) => ({ + ...state, + systemSources, + selectedSystemSourceId: nextSelectedSystemSourceId, + captureNotice: nextCaptureNotice, + })) + } + + const defaultSystemSourceId = getResolvedDefaultSystemSourceId(systemSources) + const selectedDefaultOutput = nextSelectedSystemSourceId === DEFAULT_SYSTEM_SOURCE_ID + const explicitSourceBecameUnavailable = Boolean( + previousSelectedSystemSourceId + && previousSelectedSystemSourceId !== DEFAULT_SYSTEM_SOURCE_ID + && previousSelectedSystemSourceId !== nextSelectedSystemSourceId, + ) + const defaultOutputChanged = Boolean( + selectedDefaultOutput + && currentState.activeSourceId + && defaultSystemSourceId + && currentState.activeSourceId !== defaultSystemSourceId, ) - audioCapture.setSelectedSystemSourceId(nextSelectedSystemSourceId) - set((state) => ({ - ...state, - systemSources, - selectedSystemSourceId: nextSelectedSystemSourceId, - captureNotice: shouldShowFallbackNotice - ? `${describeSystemSource(previousSelectedSystemSourceId!, previousSources)} is unavailable. Prism switched to Default Output.` - : state.captureNotice, - })) + if ( + options.rebindActiveCapture === true + && currentState.captureMode === 'system' + && currentState.captureStatus === 'capturing' + && currentState.isCapturing + && (explicitSourceBecameUnavailable || defaultOutputChanged) + ) { + await get().startCapture({ skipSourceRefresh: true }) + } }, - refreshDevices: async () => { - const previousSelectedDeviceId = get().selectedDeviceId - const previousDevices = get().devices + refreshDevices: async (options: RefreshSourceOptions = {}) => { const devices = await audioCapture.listDevices() + const currentState = get() + const previousSelectedDeviceId = currentState.selectedDeviceId + const previousDevices = currentState.devices const nextSelectedDeviceId = previousSelectedDeviceId && devices.some((device) => device.deviceId === previousSelectedDeviceId) ? previousSelectedDeviceId @@ -212,24 +322,60 @@ export const useAudioStore = create((set, get) => ({ const shouldShowFallbackNotice = Boolean( previousSelectedDeviceId && previousSelectedDeviceId !== nextSelectedDeviceId - && get().captureMode === 'device', + && currentState.captureMode === 'device', + ) + const deviceListChanged = !areMediaDevicesEqual(previousDevices, devices) + const selectedDeviceChanged = previousSelectedDeviceId !== nextSelectedDeviceId + const nextCaptureNotice = shouldShowFallbackNotice + ? `${describeInputDevice(previousSelectedDeviceId!, previousDevices)} is unavailable. Prism switched to Default Input.` + : currentState.captureNotice + + if (selectedDeviceChanged) { + audioCapture.setSelectedDeviceId(nextSelectedDeviceId) + } + + if ( + deviceListChanged + || selectedDeviceChanged + || nextCaptureNotice !== currentState.captureNotice + ) { + set((state) => ({ + ...state, + devices, + selectedDeviceId: nextSelectedDeviceId, + captureNotice: nextCaptureNotice, + })) + } + + const defaultInputChanged = getDefaultInputSignature(previousDevices) + !== getDefaultInputSignature(devices) + const selectedDefaultInput = nextSelectedDeviceId === null + const explicitDeviceBecameUnavailable = Boolean( + previousSelectedDeviceId + && previousSelectedDeviceId !== nextSelectedDeviceId, ) - audioCapture.setSelectedDeviceId(nextSelectedDeviceId) - set((state) => ({ - ...state, - devices, - selectedDeviceId: nextSelectedDeviceId, - captureNotice: shouldShowFallbackNotice - ? `${describeInputDevice(previousSelectedDeviceId!, previousDevices)} is unavailable. Prism switched to Default Input.` - : state.captureNotice, - })) + if ( + options.rebindActiveCapture === true + && currentState.captureMode === 'device' + && currentState.captureStatus === 'capturing' + && currentState.isCapturing + && ( + explicitDeviceBecameUnavailable + || (selectedDefaultInput && defaultInputChanged) + ) + ) { + await get().startCapture({ + forceDeviceRestart: selectedDefaultInput, + skipSourceRefresh: true, + }) + } }, - refreshBackendSupport: async () => { + refreshBackendSupport: async (options: RefreshSourceOptions = {}) => { const backendSupport = await audioCapture.refreshBackendSupport() set({ backendSupport }) - await get().refreshSystemSources() + await get().refreshSystemSources(options) }, selectSystemSource: async (sourceId: string | null) => { @@ -259,13 +405,15 @@ export const useAudioStore = create((set, get) => ({ set({ captureMode: mode }) }, - startCapture: async () => { + startCapture: async (options: StartCaptureOptions = {}) => { set({ captureStatus: 'connecting', captureError: null }) try { const { captureMode } = get() audioCapture.setCaptureMode(captureMode) - await get().refreshBackendSupport() - await get().refreshDevices() + if (options.skipSourceRefresh !== true) { + await get().refreshBackendSupport({ rebindActiveCapture: false }) + await get().refreshDevices({ rebindActiveCapture: false }) + } const { selectedDeviceId, selectedSystemSourceId, backendSupport } = get() @@ -279,7 +427,7 @@ export const useAudioStore = create((set, get) => ({ captureNotice: message, }) showSystemCaptureFallbackBanner(message) - await audioCapture.startDevice(undefined) + await audioCapture.startDevice(undefined, { forceDeviceRestart: true }) } if (captureMode === 'system') { @@ -294,7 +442,9 @@ export const useAudioStore = create((set, get) => ({ } } } else { - await audioCapture.startDevice(selectedDeviceId ?? undefined) + await audioCapture.startDevice(selectedDeviceId ?? undefined, { + forceDeviceRestart: options.forceDeviceRestart === true, + }) } const status = audioCapture.getStatus() @@ -311,16 +461,129 @@ export const useAudioStore = create((set, get) => ({ isCapturing: false, captureStatus: 'error', captureError: message, + activeSourceId: null, + activeSourceLabel: null, }) } }, stopCapture: () => { audioCapture.stop() - set({ isCapturing: false, captureStatus: 'idle', captureError: null }) + set({ + isCapturing: false, + captureStatus: 'idle', + captureError: null, + activeSourceId: null, + activeSourceLabel: null, + }) }, })) +interface AudioDeviceWatcher { + refCount: number + dispose: () => void +} + +let audioDeviceWatcher: AudioDeviceWatcher | null = null + +function createAudioDeviceWatcher(): AudioDeviceWatcher { + let disposed = false + let outputRefreshPromise: Promise | null = null + let inputRefreshPromise: Promise | null = null + let outputPollTimer: number | null = null + let inputPollTimer: number | null = null + + const refreshOutputDevices = (): void => { + if (disposed || outputRefreshPromise) { + return + } + + outputRefreshPromise = useAudioStore.getState() + .refreshSystemSources({ rebindActiveCapture: true }) + .catch((error) => { + console.error('Failed to refresh output devices:', error) + }) + .finally(() => { + outputRefreshPromise = null + }) + } + + const refreshInputDevices = (): void => { + if (disposed || inputRefreshPromise) { + return + } + + inputRefreshPromise = useAudioStore.getState() + .refreshDevices({ rebindActiveCapture: true }) + .catch((error) => { + console.error('Failed to refresh input devices:', error) + }) + .finally(() => { + inputRefreshPromise = null + }) + } + + const mediaDevices = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined + const handleDeviceChange = (): void => { + refreshInputDevices() + } + + if (typeof mediaDevices?.addEventListener === 'function') { + mediaDevices.addEventListener('devicechange', handleDeviceChange) + } + + if (typeof window !== 'undefined') { + outputPollTimer = window.setInterval(refreshOutputDevices, AUDIO_DEVICE_WATCHER_POLL_MS) + inputPollTimer = window.setInterval(refreshInputDevices, AUDIO_DEVICE_WATCHER_POLL_MS) + } + + refreshOutputDevices() + refreshInputDevices() + + return { + refCount: 1, + dispose: () => { + disposed = true + + if (outputPollTimer !== null && typeof window !== 'undefined') { + window.clearInterval(outputPollTimer) + outputPollTimer = null + } + + if (inputPollTimer !== null && typeof window !== 'undefined') { + window.clearInterval(inputPollTimer) + inputPollTimer = null + } + + if (typeof mediaDevices?.removeEventListener === 'function') { + mediaDevices.removeEventListener('devicechange', handleDeviceChange) + } + }, + } +} + +export function startAudioDeviceWatcher(): () => void { + if (!audioDeviceWatcher) { + audioDeviceWatcher = createAudioDeviceWatcher() + } else { + audioDeviceWatcher.refCount += 1 + } + + let didRelease = false + return () => { + if (didRelease || !audioDeviceWatcher) { + return + } + + didRelease = true + audioDeviceWatcher.refCount -= 1 + if (audioDeviceWatcher.refCount <= 0) { + audioDeviceWatcher.dispose() + audioDeviceWatcher = null + } + } +} + audioCapture.subscribeStatus((status) => { useAudioStore.setState((state) => ({ ...state, diff --git a/test/audio-store.test.ts b/test/audio-store.test.ts index 34352fc..5681b90 100644 --- a/test/audio-store.test.ts +++ b/test/audio-store.test.ts @@ -4,15 +4,24 @@ import { audioCapture } from '../src/renderer/audio/AudioCapture' import { loadAudioPreferences, normalizeAudioPreferences, + startAudioDeviceWatcher, useAudioStore, } from '../src/renderer/stores/audioStore' import { useUiStore } from '../src/renderer/stores/uiStore' -import type { CaptureBackendSupport } from '../src/types/capture' +import type { CaptureBackendSupport, CaptureSourceDescriptor } from '../src/types/capture' type GlobalWithStorage = typeof globalThis & { localStorage?: Storage } +type StartDeviceOptions = { + forceDeviceRestart?: boolean +} + +type HarnessSourceProvider = T[] | (() => T[] | Promise) + +const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__' + const initialAudioState = { ...useAudioStore.getState(), } @@ -84,6 +93,172 @@ function createBackendSupport(available: boolean, reason: string | null): Captur } } +function defaultSystemSource(): CaptureSourceDescriptor { + return { + id: DEFAULT_SYSTEM_SOURCE_ID, + label: 'Default Output', + kind: 'system', + isDefault: true, + } +} + +function systemSource( + id: string, + label: string, + isDefault = false, +): CaptureSourceDescriptor { + return { + id, + label, + kind: 'system', + isDefault, + sampleRate: 48000, + channelCount: 2, + } +} + +function mediaDevice(deviceId: string, label: string, groupId = ''): MediaDeviceInfo { + return { + deviceId, + groupId, + kind: 'audioinput', + label, + toJSON() { + return {} + }, + } as MediaDeviceInfo +} + +function getDefaultSystemSourceId(sources: CaptureSourceDescriptor[]): string | null { + return sources.find((source) => source.id !== DEFAULT_SYSTEM_SOURCE_ID && source.isDefault)?.id ?? null +} + +function getDefaultInputDeviceId(devices: MediaDeviceInfo[]): string | null { + return devices.find((device) => device.deviceId !== 'default')?.deviceId ?? devices[0]?.deviceId ?? null +} + +async function resolveHarnessSources(provider: HarnessSourceProvider | undefined, fallback: T[]): Promise { + if (!provider) { + return fallback + } + + return typeof provider === 'function' + ? provider() + : provider +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +async function flushAsyncWork(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + await flushPromises() +} + +function installFakeDeviceWatcherEnvironment(): { + dispatchDeviceChange: () => void + getIntervalCount: () => number + getListenerCount: () => number + runIntervals: () => void + restore: () => void +} { + const globalWithWindow = globalThis as typeof globalThis & { + window?: Window + navigator?: Navigator + } + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window') + const previousNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + const intervals = new Map void>() + const deviceChangeListeners = new Set<() => void>() + let nextIntervalId = 1 + + const fakeWindow = { + setInterval(callback: () => void): number { + const id = nextIntervalId + nextIntervalId += 1 + intervals.set(id, callback) + return id + }, + clearInterval(id: number): void { + intervals.delete(id) + }, + } as unknown as Window + + const fakeMediaDevices = { + addEventListener(type: string, listener: EventListener): void { + if (type === 'devicechange') { + deviceChangeListeners.add(listener as () => void) + } + }, + removeEventListener(type: string, listener: EventListener): void { + if (type === 'devicechange') { + deviceChangeListeners.delete(listener as () => void) + } + }, + enumerateDevices: async () => [], + } as unknown as MediaDevices + + const fakeNavigator = { + mediaDevices: fakeMediaDevices, + } as Navigator + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: fakeWindow, + }) + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: fakeNavigator, + }) + + return { + dispatchDeviceChange(): void { + for (const listener of deviceChangeListeners) { + listener() + } + }, + getIntervalCount(): number { + return intervals.size + }, + getListenerCount(): number { + return deviceChangeListeners.size + }, + runIntervals(): void { + for (const callback of [...intervals.values()]) { + callback() + } + }, + restore(): void { + if (previousWindowDescriptor) { + Object.defineProperty(globalThis, 'window', previousWindowDescriptor) + } else { + delete globalWithWindow.window + } + + if (previousNavigatorDescriptor) { + Object.defineProperty(globalThis, 'navigator', previousNavigatorDescriptor) + } else { + delete globalWithWindow.navigator + } + }, + } +} + function resetStores(): void { audioCapture.setInputGain(0) useAudioStore.setState({ @@ -101,6 +276,8 @@ function resetStores(): void { captureNotice: null, sampleRate: 48000, channelCount: 2, + activeSourceId: null, + activeSourceLabel: null, inputGainDb: 0, }) @@ -113,8 +290,21 @@ function resetStores(): void { function installAudioCaptureHarness(options: { support: CaptureBackendSupport + systemSources?: HarnessSourceProvider + devices?: HarnessSourceProvider startSystemAudio?: (deviceId?: string) => Promise -}): { restore: () => void; calls: { startSystemAudio: number; startDevice: number } } { + startDevice?: (deviceId?: string, options?: StartDeviceOptions) => Promise +}): { + restore: () => void + calls: { + listDevices: number + listSources: number + startDevice: number + startDeviceRequests: Array<{ deviceId: string | null; forceDeviceRestart: boolean }> + startSystemAudio: number + startSystemAudioDeviceIds: Array + } +} { const originalMethods = { refreshBackendSupport: audioCapture.refreshBackendSupport, listSources: audioCapture.listSources, @@ -129,37 +319,68 @@ function installAudioCaptureHarness(options: { } const calls = { + listDevices: 0, + listSources: 0, startSystemAudio: 0, startDevice: 0, + startSystemAudioDeviceIds: [] as Array, + startDeviceRequests: [] as Array<{ deviceId: string | null; forceDeviceRestart: boolean }>, } let captureMode: 'system' | 'device' = 'system' let selectedDeviceId: string | null = null - let selectedSystemSourceId = '__default_system_output__' + let selectedSystemSourceId = DEFAULT_SYSTEM_SOURCE_ID let activeBackendKind: 'device-input' | 'native-linux' | null = null let isCapturing = false + let activeSourceId: string | null = null + let activeSourceLabel: string | null = null audioCapture.refreshBackendSupport = async () => options.support - audioCapture.listSources = async () => [ - { id: '__default_system_output__', label: 'Default Output', kind: 'system', isDefault: true }, - ] - audioCapture.listDevices = async () => [] + audioCapture.listSources = async () => { + calls.listSources += 1 + return resolveHarnessSources(options.systemSources, [defaultSystemSource()]) + } + audioCapture.listDevices = async () => { + calls.listDevices += 1 + return resolveHarnessSources(options.devices, []) + } audioCapture.startSystemAudio = async (deviceId?: string) => { calls.startSystemAudio += 1 + calls.startSystemAudioDeviceIds.push(deviceId) if (options.startSystemAudio) { await options.startSystemAudio(deviceId) } else { + const systemSources = await resolveHarnessSources(options.systemSources, [defaultSystemSource()]) + const requestedSourceId = deviceId && deviceId !== DEFAULT_SYSTEM_SOURCE_ID + ? deviceId + : getDefaultSystemSourceId(systemSources) + const requestedSource = systemSources.find((source) => source.id === requestedSourceId) ?? null captureMode = 'system' activeBackendKind = 'native-linux' + activeSourceId = requestedSource?.id ?? null + activeSourceLabel = requestedSource?.label ?? null isCapturing = true } } - audioCapture.startDevice = async (deviceId?: string) => { + audioCapture.startDevice = async (deviceId?: string, startOptions?: StartDeviceOptions) => { calls.startDevice += 1 - captureMode = 'device' - selectedDeviceId = deviceId ?? null - activeBackendKind = 'device-input' - isCapturing = true + calls.startDeviceRequests.push({ + deviceId: deviceId ?? null, + forceDeviceRestart: startOptions?.forceDeviceRestart === true, + }) + if (options.startDevice) { + await options.startDevice(deviceId, startOptions) + } else { + const devices = await resolveHarnessSources(options.devices, []) + const resolvedDeviceId = deviceId ?? getDefaultInputDeviceId(devices) + const resolvedDevice = devices.find((device) => device.deviceId === resolvedDeviceId) ?? null + captureMode = 'device' + selectedDeviceId = deviceId ?? null + activeBackendKind = 'device-input' + activeSourceId = resolvedDevice?.deviceId ?? resolvedDeviceId + activeSourceLabel = resolvedDevice?.label ?? null + isCapturing = true + } } audioCapture.getStatus = () => ({ captureMode, @@ -168,6 +389,8 @@ function installAudioCaptureHarness(options: { sampleRate: 48000, channelCount: 2, isCapturing, + activeSourceId: isCapturing ? activeSourceId : null, + activeSourceLabel: isCapturing ? activeSourceLabel : null, }) audioCapture.setCaptureMode = (mode) => { captureMode = mode @@ -176,7 +399,7 @@ function installAudioCaptureHarness(options: { selectedDeviceId = id } audioCapture.setSelectedSystemSourceId = (id) => { - selectedSystemSourceId = id ?? '__default_system_output__' + selectedSystemSourceId = id ?? DEFAULT_SYSTEM_SOURCE_ID } return { @@ -314,3 +537,255 @@ test('audio store falls back to device input when native system capture fails at resetStores() } }) + +test('audio store does not restart capture when refreshed output devices are unchanged', async () => { + resetStores() + const support = createBackendSupport(true, null) + const sources = [ + defaultSystemSource(), + systemSource('speaker', 'Speakers', true), + ] + const harness = installAudioCaptureHarness({ + support, + systemSources: sources, + }) + + try { + useAudioStore.setState({ + backendSupport: support, + systemSources: sources, + selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID, + captureMode: 'system', + captureStatus: 'capturing', + isCapturing: true, + activeBackendKind: 'native-linux', + activeSourceId: 'speaker', + activeSourceLabel: 'Speakers', + }) + + await useAudioStore.getState().refreshSystemSources({ rebindActiveCapture: true }) + + assert.equal(harness.calls.startSystemAudio, 0) + assert.equal(useAudioStore.getState().activeSourceId, 'speaker') + } finally { + harness.restore() + resetStores() + } +}) + +test('audio store rebinds system capture when Default Output resolves to a new device', async () => { + resetStores() + const support = createBackendSupport(true, null) + let sources = [ + defaultSystemSource(), + systemSource('speaker', 'Speakers', true), + systemSource('headphones', 'Headphones', false), + ] + const harness = installAudioCaptureHarness({ + support, + systemSources: () => sources, + }) + + try { + useAudioStore.setState({ + backendSupport: support, + systemSources: sources, + selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID, + captureMode: 'system', + captureStatus: 'capturing', + isCapturing: true, + activeBackendKind: 'native-linux', + activeSourceId: 'speaker', + activeSourceLabel: 'Speakers', + }) + + sources = [ + defaultSystemSource(), + systemSource('speaker', 'Speakers', false), + systemSource('headphones', 'Headphones', true), + ] + + await useAudioStore.getState().refreshSystemSources({ rebindActiveCapture: true }) + + assert.equal(harness.calls.startSystemAudio, 1) + assert.deepEqual(harness.calls.startSystemAudioDeviceIds, [DEFAULT_SYSTEM_SOURCE_ID]) + assert.equal(useAudioStore.getState().activeSourceId, 'headphones') + assert.equal(useAudioStore.getState().captureStatus, 'capturing') + } finally { + harness.restore() + resetStores() + } +}) + +test('audio store keeps explicit output selections pinned when the OS default changes', async () => { + resetStores() + const support = createBackendSupport(true, null) + let sources = [ + defaultSystemSource(), + systemSource('speaker', 'Speakers', true), + systemSource('headphones', 'Headphones', false), + ] + const harness = installAudioCaptureHarness({ + support, + systemSources: () => sources, + }) + + try { + useAudioStore.setState({ + backendSupport: support, + systemSources: sources, + selectedSystemSourceId: 'headphones', + captureMode: 'system', + captureStatus: 'capturing', + isCapturing: true, + activeBackendKind: 'native-linux', + activeSourceId: 'headphones', + activeSourceLabel: 'Headphones', + }) + + sources = [ + defaultSystemSource(), + systemSource('speaker', 'Speakers', false), + systemSource('headphones', 'Headphones', false), + systemSource('monitor', 'Monitor', true), + ] + + await useAudioStore.getState().refreshSystemSources({ rebindActiveCapture: true }) + + assert.equal(harness.calls.startSystemAudio, 0) + assert.equal(useAudioStore.getState().selectedSystemSourceId, 'headphones') + assert.equal(useAudioStore.getState().activeSourceId, 'headphones') + } finally { + harness.restore() + resetStores() + } +}) + +test('audio store falls back to Default Output when an explicit output disappears', async () => { + resetStores() + const support = createBackendSupport(true, null) + let sources = [ + defaultSystemSource(), + systemSource('speaker', 'Speakers', false), + systemSource('headphones', 'Headphones', true), + ] + const harness = installAudioCaptureHarness({ + support, + systemSources: () => sources, + }) + + try { + useAudioStore.setState({ + backendSupport: support, + systemSources: sources, + selectedSystemSourceId: 'speaker', + captureMode: 'system', + captureStatus: 'capturing', + isCapturing: true, + activeBackendKind: 'native-linux', + activeSourceId: 'speaker', + activeSourceLabel: 'Speakers', + }) + + sources = [ + defaultSystemSource(), + systemSource('headphones', 'Headphones', true), + ] + + await useAudioStore.getState().refreshSystemSources({ rebindActiveCapture: true }) + + const state = useAudioStore.getState() + assert.equal(harness.calls.startSystemAudio, 1) + assert.equal(state.selectedSystemSourceId, DEFAULT_SYSTEM_SOURCE_ID) + assert.equal(state.activeSourceId, 'headphones') + assert.match(state.captureNotice ?? '', /Speakers is unavailable/i) + } finally { + harness.restore() + resetStores() + } +}) + +test('audio store forces default input reacquisition when the default input signature changes', async () => { + resetStores() + const support = createBackendSupport(true, null) + let devices = [ + mediaDevice('default', 'Default - Mic 1', 'default-group'), + mediaDevice('mic-1', 'Mic 1', 'group-1'), + ] + const harness = installAudioCaptureHarness({ + support, + devices: () => devices, + }) + + try { + useAudioStore.setState({ + backendSupport: support, + devices, + selectedDeviceId: null, + captureMode: 'device', + captureStatus: 'capturing', + isCapturing: true, + activeBackendKind: 'device-input', + activeSourceId: 'mic-1', + activeSourceLabel: 'Mic 1', + }) + + devices = [ + mediaDevice('default', 'Default - Mic 2', 'default-group'), + mediaDevice('mic-2', 'Mic 2', 'group-2'), + ] + + await useAudioStore.getState().refreshDevices({ rebindActiveCapture: true }) + + assert.equal(harness.calls.startDevice, 1) + assert.deepEqual(harness.calls.startDeviceRequests, [{ + deviceId: null, + forceDeviceRestart: true, + }]) + assert.equal(useAudioStore.getState().activeSourceId, 'mic-2') + } finally { + harness.restore() + resetStores() + } +}) + +test('audio device watcher coalesces refreshes and cleans up timers and listeners', async () => { + resetStores() + const support = createBackendSupport(true, null) + const pendingSources = deferred() + const harness = installAudioCaptureHarness({ + support, + systemSources: () => pendingSources.promise, + devices: [], + }) + const fakeEnvironment = installFakeDeviceWatcherEnvironment() + + try { + const stopWatcher = startAudioDeviceWatcher() + await flushPromises() + + assert.ok(fakeEnvironment.getIntervalCount() >= 1) + assert.equal(fakeEnvironment.getListenerCount(), 1) + assert.equal(harness.calls.listSources, 1) + + fakeEnvironment.runIntervals() + fakeEnvironment.runIntervals() + assert.equal(harness.calls.listSources, 1) + + pendingSources.resolve([defaultSystemSource()]) + await flushAsyncWork() + fakeEnvironment.runIntervals() + await flushAsyncWork() + + assert.equal(harness.calls.listSources, 2) + + stopWatcher() + + assert.equal(fakeEnvironment.getIntervalCount(), 0) + assert.equal(fakeEnvironment.getListenerCount(), 0) + } finally { + fakeEnvironment.restore() + harness.restore() + resetStores() + } +})