mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
593 lines
18 KiB
TypeScript
593 lines
18 KiB
TypeScript
import { create } from 'zustand'
|
|
import { audioCapture, type CaptureManagerStatus } from '../audio/AudioCapture'
|
|
import type {
|
|
CaptureBackendKind,
|
|
CaptureBackendSupport,
|
|
CaptureMode,
|
|
CaptureSourceDescriptor,
|
|
} from '../../types/capture'
|
|
import { useUiStore } from './uiStore'
|
|
|
|
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[]
|
|
selectedSystemSourceId: string | null
|
|
selectedDeviceId: string | null
|
|
captureMode: CaptureMode
|
|
activeBackendKind: CaptureBackendKind | null
|
|
backendSupport: CaptureBackendSupport | null
|
|
isCapturing: boolean
|
|
captureStatus: 'idle' | 'connecting' | 'capturing' | 'error'
|
|
captureError: string | null
|
|
captureNotice: string | null
|
|
sampleRate: number
|
|
channelCount: number
|
|
activeSourceId: string | null
|
|
activeSourceLabel: string | null
|
|
inputGainDb: number
|
|
setInputGain: (db: number) => void
|
|
clearCaptureNotice: () => 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: (options?: StartCaptureOptions) => Promise<void>
|
|
stopCapture: () => void
|
|
}
|
|
|
|
interface StorageLike {
|
|
getItem: (key: string) => string | null
|
|
setItem: (key: string, value: string) => void
|
|
}
|
|
|
|
function applyCaptureStatus(status: CaptureManagerStatus): Partial<AudioState> {
|
|
return {
|
|
captureMode: status.captureMode,
|
|
activeBackendKind: status.activeBackendKind,
|
|
backendSupport: status.backendSupport,
|
|
sampleRate: status.sampleRate,
|
|
channelCount: status.channelCount,
|
|
isCapturing: status.isCapturing,
|
|
activeSourceId: status.activeSourceId,
|
|
activeSourceLabel: status.activeSourceLabel,
|
|
}
|
|
}
|
|
|
|
function buildSystemCaptureFallbackMessage(reason: string | null): string {
|
|
if (!reason) {
|
|
return 'System output capture is unavailable. Prism switched to Default Input.'
|
|
}
|
|
|
|
return `System output capture is unavailable: ${reason} Prism switched to Default Input.`
|
|
}
|
|
|
|
function showSystemCaptureFallbackBanner(message: string): void {
|
|
useUiStore.getState().showBanner({
|
|
tone: 'info',
|
|
message,
|
|
actions: [],
|
|
})
|
|
}
|
|
|
|
function describeInputDevice(deviceId: string, devices: MediaDeviceInfo[]): string {
|
|
const matchingDevice = devices.find((device) => device.deviceId === deviceId)
|
|
if (matchingDevice?.label) {
|
|
return matchingDevice.label
|
|
}
|
|
|
|
return `Input ${deviceId.slice(0, 8)}`
|
|
}
|
|
|
|
function describeSystemSource(sourceId: string, sources: CaptureSourceDescriptor[]): string {
|
|
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
|
|
}
|
|
|
|
return localStorage
|
|
}
|
|
|
|
export function normalizeInputGainDb(raw: unknown): number {
|
|
const normalized = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
|
|
const clamped = Math.min(INPUT_GAIN_MAX_DB, Math.max(INPUT_GAIN_MIN_DB, normalized))
|
|
const rounded = Math.round(clamped / INPUT_GAIN_STEP_DB) * INPUT_GAIN_STEP_DB
|
|
return Object.is(rounded, -0) ? 0 : rounded
|
|
}
|
|
|
|
export function normalizeAudioPreferences(raw: unknown): PersistedAudioState {
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<PersistedAudioState>
|
|
: {}
|
|
|
|
return {
|
|
inputGainDb: normalizeInputGainDb(parsed.inputGainDb),
|
|
}
|
|
}
|
|
|
|
export function loadAudioPreferences(storage = getStorage()): PersistedAudioState {
|
|
if (!storage) {
|
|
return normalizeAudioPreferences(null)
|
|
}
|
|
|
|
try {
|
|
const raw = storage.getItem(STORAGE_KEY)
|
|
if (!raw) {
|
|
return normalizeAudioPreferences(null)
|
|
}
|
|
|
|
return normalizeAudioPreferences(JSON.parse(raw))
|
|
} catch {
|
|
return normalizeAudioPreferences(null)
|
|
}
|
|
}
|
|
|
|
function persistAudioPreferences(inputGainDb: number, storage = getStorage()): void {
|
|
if (!storage) return
|
|
|
|
try {
|
|
storage.setItem(STORAGE_KEY, JSON.stringify({ inputGainDb }))
|
|
} catch {
|
|
// Ignore localStorage write failures.
|
|
}
|
|
}
|
|
|
|
const storedPreferences = loadAudioPreferences()
|
|
audioCapture.setInputGain(storedPreferences.inputGainDb)
|
|
|
|
export const useAudioStore = create<AudioState>((set, get) => ({
|
|
systemSources: [],
|
|
devices: [],
|
|
selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(),
|
|
selectedDeviceId: null,
|
|
captureMode: 'system',
|
|
activeBackendKind: null,
|
|
backendSupport: null,
|
|
isCapturing: false,
|
|
captureStatus: 'idle',
|
|
captureError: null,
|
|
captureNotice: null,
|
|
sampleRate: 48000,
|
|
channelCount: 2,
|
|
activeSourceId: null,
|
|
activeSourceLabel: null,
|
|
inputGainDb: storedPreferences.inputGainDb,
|
|
|
|
setInputGain: (db: number) => {
|
|
const nextInputGainDb = normalizeInputGainDb(db)
|
|
if (get().inputGainDb === nextInputGainDb) {
|
|
return
|
|
}
|
|
|
|
persistAudioPreferences(nextInputGainDb)
|
|
audioCapture.setInputGain(nextInputGainDb)
|
|
set({ inputGainDb: nextInputGainDb })
|
|
},
|
|
|
|
clearCaptureNotice: () => {
|
|
set({ captureNotice: null })
|
|
},
|
|
|
|
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)
|
|
? previousSelectedSystemSourceId
|
|
: fallbackSourceId
|
|
const shouldShowFallbackNotice = Boolean(
|
|
previousSelectedSystemSourceId
|
|
&& previousSelectedSystemSourceId !== nextSelectedSystemSourceId
|
|
&& 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,
|
|
)
|
|
|
|
if (
|
|
options.rebindActiveCapture === true
|
|
&& currentState.captureMode === 'system'
|
|
&& currentState.captureStatus === 'capturing'
|
|
&& currentState.isCapturing
|
|
&& (explicitSourceBecameUnavailable || defaultOutputChanged)
|
|
) {
|
|
await get().startCapture({ skipSourceRefresh: true })
|
|
}
|
|
},
|
|
|
|
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
|
|
: null
|
|
const shouldShowFallbackNotice = Boolean(
|
|
previousSelectedDeviceId
|
|
&& previousSelectedDeviceId !== nextSelectedDeviceId
|
|
&& 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,
|
|
)
|
|
|
|
if (
|
|
options.rebindActiveCapture === true
|
|
&& currentState.captureMode === 'device'
|
|
&& currentState.captureStatus === 'capturing'
|
|
&& currentState.isCapturing
|
|
&& (
|
|
explicitDeviceBecameUnavailable
|
|
|| (selectedDefaultInput && defaultInputChanged)
|
|
)
|
|
) {
|
|
await get().startCapture({
|
|
forceDeviceRestart: selectedDefaultInput,
|
|
skipSourceRefresh: true,
|
|
})
|
|
}
|
|
},
|
|
|
|
refreshBackendSupport: async (options: RefreshSourceOptions = {}) => {
|
|
const backendSupport = await audioCapture.refreshBackendSupport()
|
|
set({ backendSupport })
|
|
await get().refreshSystemSources(options)
|
|
},
|
|
|
|
selectSystemSource: async (sourceId: string | null) => {
|
|
audioCapture.setSelectedSystemSourceId(sourceId)
|
|
audioCapture.setCaptureMode('system')
|
|
set({
|
|
selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(),
|
|
captureMode: 'system',
|
|
captureError: null,
|
|
captureNotice: null,
|
|
})
|
|
},
|
|
|
|
selectDevice: async (deviceId: string | null) => {
|
|
audioCapture.setSelectedDeviceId(deviceId)
|
|
audioCapture.setCaptureMode('device')
|
|
set({
|
|
selectedDeviceId: deviceId,
|
|
captureMode: 'device',
|
|
captureError: null,
|
|
captureNotice: null,
|
|
})
|
|
},
|
|
|
|
setCaptureMode: (mode: CaptureMode) => {
|
|
audioCapture.setCaptureMode(mode)
|
|
set({ captureMode: mode })
|
|
},
|
|
|
|
startCapture: async (options: StartCaptureOptions = {}) => {
|
|
set({ captureStatus: 'connecting', captureError: null })
|
|
try {
|
|
const { captureMode } = get()
|
|
audioCapture.setCaptureMode(captureMode)
|
|
if (options.skipSourceRefresh !== true) {
|
|
await get().refreshBackendSupport({ rebindActiveCapture: false })
|
|
await get().refreshDevices({ rebindActiveCapture: false })
|
|
}
|
|
|
|
const { selectedDeviceId, selectedSystemSourceId, backendSupport } = get()
|
|
|
|
const startDefaultInputFallback = async (reason: string | null): Promise<void> => {
|
|
const message = buildSystemCaptureFallbackMessage(reason)
|
|
audioCapture.setSelectedDeviceId(null)
|
|
audioCapture.setCaptureMode('device')
|
|
set({
|
|
selectedDeviceId: null,
|
|
captureMode: 'device',
|
|
captureNotice: message,
|
|
})
|
|
showSystemCaptureFallbackBanner(message)
|
|
await audioCapture.startDevice(undefined, { forceDeviceRestart: true })
|
|
}
|
|
|
|
if (captureMode === 'system') {
|
|
if (!backendSupport?.nativeBackend.available) {
|
|
await startDefaultInputFallback(backendSupport?.nativeBackend.reason ?? null)
|
|
} else {
|
|
try {
|
|
await audioCapture.startSystemAudio(selectedSystemSourceId ?? undefined)
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : 'Native system capture failed.'
|
|
await startDefaultInputFallback(reason)
|
|
}
|
|
}
|
|
} else {
|
|
await audioCapture.startDevice(selectedDeviceId ?? undefined, {
|
|
forceDeviceRestart: options.forceDeviceRestart === true,
|
|
})
|
|
}
|
|
|
|
const status = audioCapture.getStatus()
|
|
set((state) => ({
|
|
...state,
|
|
...applyCaptureStatus(status),
|
|
captureStatus: 'capturing',
|
|
captureError: null,
|
|
}))
|
|
} catch (err) {
|
|
console.error('Failed to start audio capture:', err)
|
|
const message = err instanceof Error ? err.message : 'Unknown audio capture error'
|
|
set({
|
|
isCapturing: false,
|
|
captureStatus: 'error',
|
|
captureError: message,
|
|
activeSourceId: null,
|
|
activeSourceLabel: null,
|
|
})
|
|
}
|
|
},
|
|
|
|
stopCapture: () => {
|
|
audioCapture.stop()
|
|
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,
|
|
...applyCaptureStatus(status),
|
|
}))
|
|
})
|