mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
auto switch polling on output change when on default output
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user