auto switch polling on output change when on default output

This commit is contained in:
Boof2015
2026-04-21 20:27:41 -04:00
parent db377c97b4
commit 70d299d5b8
5 changed files with 855 additions and 73 deletions
+5 -1
View File
@@ -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
+60 -11
View File
@@ -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<void> {
async startDevice(deviceId?: string, forceRestart = false): Promise<void> {
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<void> {
await this.runtime.startDevice(request?.deviceId)
await this.runtime.startDevice(request?.deviceId, request?.forceRestart === true)
}
async stop(): Promise<void> {
@@ -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<CaptureSourceDescriptor[]> {
@@ -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<void> {
async startDevice(deviceId?: string, options: CaptureStartOptions = {}): Promise<void> {
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<void> {
async start(deviceId?: string, options: CaptureStartOptions = {}): Promise<void> {
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,
}
}
+1 -10
View File
@@ -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
+301 -38
View File
@@ -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<void>
refreshDevices: () => Promise<void>
refreshBackendSupport: () => Promise<void>
refreshSystemSources: (options?: RefreshSourceOptions) => Promise<void>
refreshDevices: (options?: RefreshSourceOptions) => Promise<void>
refreshBackendSupport: (options?: RefreshSourceOptions) => Promise<void>
selectSystemSource: (sourceId: string | null) => Promise<void>
selectDevice: (deviceId: string | null) => Promise<void>
setCaptureMode: (mode: CaptureMode) => void
startCapture: () => Promise<void>
startCapture: (options?: StartCaptureOptions) => Promise<void>
stopCapture: () => void
}
@@ -57,6 +70,8 @@ function applyCaptureStatus(status: CaptureManagerStatus): Partial<AudioState> {
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<AudioState>((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<AudioState>((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<AudioState>((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<AudioState>((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<AudioState>((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<AudioState>((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<AudioState>((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<AudioState>((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<void> | null = null
let inputRefreshPromise: Promise<void> | 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,
+488 -13
View File
@@ -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[] | (() => T[] | Promise<T[]>)
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<T>(provider: HarnessSourceProvider<T> | undefined, fallback: T[]): Promise<T[]> {
if (!provider) {
return fallback
}
return typeof provider === 'function'
? provider()
: provider
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve
})
return { promise, resolve }
}
async function flushPromises(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
async function flushAsyncWork(): Promise<void> {
await new Promise<void>((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<number, () => 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<CaptureSourceDescriptor>
devices?: HarnessSourceProvider<MediaDeviceInfo>
startSystemAudio?: (deviceId?: string) => Promise<void>
}): { restore: () => void; calls: { startSystemAudio: number; startDevice: number } } {
startDevice?: (deviceId?: string, options?: StartDeviceOptions) => Promise<void>
}): {
restore: () => void
calls: {
listDevices: number
listSources: number
startDevice: number
startDeviceRequests: Array<{ deviceId: string | null; forceDeviceRestart: boolean }>
startSystemAudio: number
startSystemAudioDeviceIds: Array<string | undefined>
}
} {
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<string | undefined>,
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<CaptureSourceDescriptor[]>()
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()
}
})