Fixes #8, persist monitor device across restarts

This commit is contained in:
Boof2015
2026-04-28 02:14:48 -04:00
parent 3c049f5f68
commit 4a900bc7c7
2 changed files with 261 additions and 15 deletions
+93 -7
View File
@@ -17,6 +17,9 @@ const AUDIO_DEVICE_WATCHER_POLL_MS = 5000
export interface PersistedAudioState {
inputGainDb: number
captureMode: CaptureMode
selectedSystemSourceId: string
selectedDeviceId: string | null
}
interface RefreshSourceOptions {
@@ -174,6 +177,22 @@ export function normalizeInputGainDb(raw: unknown): number {
return Object.is(rounded, -0) ? 0 : rounded
}
function normalizeCaptureMode(raw: unknown): CaptureMode {
return raw === 'device' ? 'device' : 'system'
}
function normalizeSystemSourceId(raw: unknown): string {
return typeof raw === 'string' && raw.trim().length > 0
? raw
: DEFAULT_SYSTEM_SOURCE_ID
}
function normalizeDeviceId(raw: unknown): string | null {
return typeof raw === 'string' && raw.trim().length > 0
? raw
: null
}
export function normalizeAudioPreferences(raw: unknown): PersistedAudioState {
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<PersistedAudioState>
@@ -181,9 +200,26 @@ export function normalizeAudioPreferences(raw: unknown): PersistedAudioState {
return {
inputGainDb: normalizeInputGainDb(parsed.inputGainDb),
captureMode: normalizeCaptureMode(parsed.captureMode),
selectedSystemSourceId: normalizeSystemSourceId(parsed.selectedSystemSourceId),
selectedDeviceId: normalizeDeviceId(parsed.selectedDeviceId),
}
}
function buildAudioPreferences(
inputGainDb: number,
captureMode: CaptureMode,
selectedSystemSourceId: string | null,
selectedDeviceId: string | null,
): PersistedAudioState {
return normalizeAudioPreferences({
inputGainDb,
captureMode,
selectedSystemSourceId,
selectedDeviceId,
})
}
export function loadAudioPreferences(storage = getStorage()): PersistedAudioState {
if (!storage) {
return normalizeAudioPreferences(null)
@@ -201,11 +237,11 @@ export function loadAudioPreferences(storage = getStorage()): PersistedAudioStat
}
}
function persistAudioPreferences(inputGainDb: number, storage = getStorage()): void {
function persistAudioPreferences(preferences: PersistedAudioState, storage = getStorage()): void {
if (!storage) return
try {
storage.setItem(STORAGE_KEY, JSON.stringify({ inputGainDb }))
storage.setItem(STORAGE_KEY, JSON.stringify(normalizeAudioPreferences(preferences)))
} catch {
// Ignore localStorage write failures.
}
@@ -213,13 +249,16 @@ function persistAudioPreferences(inputGainDb: number, storage = getStorage()): v
const storedPreferences = loadAudioPreferences()
audioCapture.setInputGain(storedPreferences.inputGainDb)
audioCapture.setSelectedSystemSourceId(storedPreferences.selectedSystemSourceId)
audioCapture.setSelectedDeviceId(storedPreferences.selectedDeviceId)
audioCapture.setCaptureMode(storedPreferences.captureMode)
export const useAudioStore = create<AudioState>((set, get) => ({
systemSources: [],
devices: [],
selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(),
selectedDeviceId: null,
captureMode: 'system',
selectedDeviceId: audioCapture.getSelectedDeviceId(),
captureMode: audioCapture.getCaptureMode(),
activeBackendKind: null,
backendSupport: null,
isCapturing: false,
@@ -234,11 +273,17 @@ export const useAudioStore = create<AudioState>((set, get) => ({
setInputGain: (db: number) => {
const nextInputGainDb = normalizeInputGainDb(db)
if (get().inputGainDb === nextInputGainDb) {
const currentState = get()
if (currentState.inputGainDb === nextInputGainDb) {
return
}
persistAudioPreferences(nextInputGainDb)
persistAudioPreferences(buildAudioPreferences(
nextInputGainDb,
currentState.captureMode,
currentState.selectedSystemSourceId,
currentState.selectedDeviceId,
))
audioCapture.setInputGain(nextInputGainDb)
set({ inputGainDb: nextInputGainDb })
},
@@ -270,6 +315,12 @@ export const useAudioStore = create<AudioState>((set, get) => ({
if (selectedSourceChanged) {
audioCapture.setSelectedSystemSourceId(nextSelectedSystemSourceId)
persistAudioPreferences(buildAudioPreferences(
currentState.inputGainDb,
currentState.captureMode,
nextSelectedSystemSourceId,
currentState.selectedDeviceId,
))
}
if (
@@ -332,6 +383,12 @@ export const useAudioStore = create<AudioState>((set, get) => ({
if (selectedDeviceChanged) {
audioCapture.setSelectedDeviceId(nextSelectedDeviceId)
persistAudioPreferences(buildAudioPreferences(
currentState.inputGainDb,
currentState.captureMode,
currentState.selectedSystemSourceId,
nextSelectedDeviceId,
))
}
if (
@@ -381,8 +438,16 @@ export const useAudioStore = create<AudioState>((set, get) => ({
selectSystemSource: async (sourceId: string | null) => {
audioCapture.setSelectedSystemSourceId(sourceId)
audioCapture.setCaptureMode('system')
const currentState = get()
const nextSelectedSystemSourceId = audioCapture.getSelectedSystemSourceId()
persistAudioPreferences(buildAudioPreferences(
currentState.inputGainDb,
'system',
nextSelectedSystemSourceId,
currentState.selectedDeviceId,
))
set({
selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(),
selectedSystemSourceId: nextSelectedSystemSourceId,
captureMode: 'system',
captureError: null,
captureNotice: null,
@@ -392,6 +457,13 @@ export const useAudioStore = create<AudioState>((set, get) => ({
selectDevice: async (deviceId: string | null) => {
audioCapture.setSelectedDeviceId(deviceId)
audioCapture.setCaptureMode('device')
const currentState = get()
persistAudioPreferences(buildAudioPreferences(
currentState.inputGainDb,
'device',
currentState.selectedSystemSourceId,
deviceId,
))
set({
selectedDeviceId: deviceId,
captureMode: 'device',
@@ -402,6 +474,13 @@ export const useAudioStore = create<AudioState>((set, get) => ({
setCaptureMode: (mode: CaptureMode) => {
audioCapture.setCaptureMode(mode)
const currentState = get()
persistAudioPreferences(buildAudioPreferences(
currentState.inputGainDb,
mode,
currentState.selectedSystemSourceId,
currentState.selectedDeviceId,
))
set({ captureMode: mode })
},
@@ -421,6 +500,13 @@ export const useAudioStore = create<AudioState>((set, get) => ({
const message = buildSystemCaptureFallbackMessage(reason)
audioCapture.setSelectedDeviceId(null)
audioCapture.setCaptureMode('device')
const currentState = get()
persistAudioPreferences(buildAudioPreferences(
currentState.inputGainDb,
'device',
currentState.selectedSystemSourceId,
null,
))
set({
selectedDeviceId: null,
captureMode: 'device',
+168 -8
View File
@@ -6,6 +6,7 @@ import {
normalizeAudioPreferences,
startAudioDeviceWatcher,
useAudioStore,
type PersistedAudioState,
} from '../src/renderer/stores/audioStore'
import { useUiStore } from '../src/renderer/stores/uiStore'
import type { CaptureBackendSupport, CaptureSourceDescriptor } from '../src/types/capture'
@@ -22,6 +23,20 @@ type HarnessSourceProvider<T> = T[] | (() => T[] | Promise<T[]>)
const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__'
function audioPreferences(overrides: Partial<PersistedAudioState> = {}): PersistedAudioState {
return {
inputGainDb: 0,
captureMode: 'system',
selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID,
selectedDeviceId: null,
...overrides,
}
}
function storedAudioPreferences(overrides: Partial<PersistedAudioState> = {}): string {
return JSON.stringify(audioPreferences(overrides))
}
const initialAudioState = {
...useAudioStore.getState(),
}
@@ -260,6 +275,9 @@ function installFakeDeviceWatcherEnvironment(): {
}
function resetStores(): void {
audioCapture.setSelectedSystemSourceId(DEFAULT_SYSTEM_SOURCE_ID)
audioCapture.setSelectedDeviceId(null)
audioCapture.setCaptureMode('system')
audioCapture.setInputGain(0)
useAudioStore.setState({
...initialAudioState,
@@ -423,7 +441,7 @@ function installAudioCaptureHarness(options: {
}
test('loadAudioPreferences falls back to 0 dB when storage is unavailable', () => {
assert.deepEqual(loadAudioPreferences(null), { inputGainDb: 0 })
assert.deepEqual(loadAudioPreferences(null), audioPreferences())
})
test('loadAudioPreferences falls back to 0 dB when stored JSON is invalid', () => {
@@ -432,17 +450,49 @@ test('loadAudioPreferences falls back to 0 dB when stored JSON is invalid', () =
setItem: () => {},
}
assert.deepEqual(loadAudioPreferences(storage), { inputGainDb: 0 })
assert.deepEqual(loadAudioPreferences(storage), audioPreferences())
})
test('loadAudioPreferences keeps legacy trim-only preferences compatible', () => {
const storage = {
getItem: () => JSON.stringify({ inputGainDb: 6.26 }),
setItem: () => {},
}
assert.deepEqual(loadAudioPreferences(storage), audioPreferences({ inputGainDb: 6.5 }))
})
test('normalizeAudioPreferences defaults invalid persisted selector values', () => {
assert.deepEqual(normalizeAudioPreferences({
inputGainDb: 3,
captureMode: 'surround',
selectedSystemSourceId: '',
selectedDeviceId: '',
}), audioPreferences({ inputGainDb: 3 }))
})
test('normalizeAudioPreferences preserves valid persisted selector values', () => {
assert.deepEqual(normalizeAudioPreferences({
inputGainDb: -3,
captureMode: 'device',
selectedSystemSourceId: 'speaker',
selectedDeviceId: 'mic-1',
}), audioPreferences({
inputGainDb: -3,
captureMode: 'device',
selectedSystemSourceId: 'speaker',
selectedDeviceId: 'mic-1',
}))
})
test('normalizeAudioPreferences clamps out-of-range trim values', () => {
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: -18 }), { inputGainDb: -12 })
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: 18 }), { inputGainDb: 12 })
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: -18 }), audioPreferences({ inputGainDb: -12 }))
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: 18 }), audioPreferences({ inputGainDb: 12 }))
})
test('normalizeAudioPreferences rounds trim values to 0.5 dB steps', () => {
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: 6.24 }), { inputGainDb: 6 })
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: 6.26 }), { inputGainDb: 6.5 })
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: 6.24 }), audioPreferences({ inputGainDb: 6 }))
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: 6.26 }), audioPreferences({ inputGainDb: 6.5 }))
})
test('audio store persists normalized trim values and forwards them to audioCapture', () => {
@@ -460,7 +510,7 @@ test('audio store persists normalized trim values and forwards them to audioCapt
assert.equal(useAudioStore.getState().inputGainDb, 6.5)
assert.deepEqual(forwardedValues, [6.5])
assert.equal(fakeStorage.getItem('prism:audio'), JSON.stringify({ inputGainDb: 6.5 }))
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({ inputGainDb: 6.5 }))
assert.equal(fakeStorage.getSetCount(), 1)
useAudioStore.getState().setInputGain(6.49)
@@ -472,7 +522,7 @@ test('audio store persists normalized trim values and forwards them to audioCapt
assert.equal(useAudioStore.getState().inputGainDb, 0)
assert.deepEqual(forwardedValues, [6.5, 0])
assert.equal(fakeStorage.getItem('prism:audio'), JSON.stringify({ inputGainDb: 0 }))
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({ inputGainDb: 0 }))
assert.equal(fakeStorage.getSetCount(), 2)
} finally {
audioCapture.setInputGain = originalSetInputGain
@@ -481,8 +531,49 @@ test('audio store persists normalized trim values and forwards them to audioCapt
}
})
test('audio store persists custom output source selections', async () => {
resetStores()
const fakeStorage = installFakeLocalStorage()
try {
await useAudioStore.getState().selectSystemSource('headphones')
const state = useAudioStore.getState()
assert.equal(state.captureMode, 'system')
assert.equal(state.selectedSystemSourceId, 'headphones')
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({
captureMode: 'system',
selectedSystemSourceId: 'headphones',
}))
} finally {
fakeStorage.restore()
resetStores()
}
})
test('audio store persists input source selections', async () => {
resetStores()
const fakeStorage = installFakeLocalStorage()
try {
await useAudioStore.getState().selectDevice('mic-1')
const state = useAudioStore.getState()
assert.equal(state.captureMode, 'device')
assert.equal(state.selectedDeviceId, 'mic-1')
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({
captureMode: 'device',
selectedDeviceId: 'mic-1',
}))
} finally {
fakeStorage.restore()
resetStores()
}
})
test('audio store auto-switches to device input when native system capture is unavailable on startup', async () => {
resetStores()
const fakeStorage = installFakeLocalStorage()
const harness = installAudioCaptureHarness({
support: createBackendSupport(false, 'PulseAudio is unavailable.'),
})
@@ -498,12 +589,17 @@ test('audio store auto-switches to device input when native system capture is un
assert.equal(state.captureError, null)
assert.equal(state.activeBackendKind, 'device-input')
assert.match(state.captureNotice ?? '', /switched to Default Input/i)
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({
captureMode: 'device',
selectedDeviceId: null,
}))
const banner = useUiStore.getState().banner
assert.ok(banner)
assert.equal(banner?.tone, 'info')
assert.match(banner?.message ?? '', /PulseAudio is unavailable/i)
} finally {
fakeStorage.restore()
harness.restore()
resetStores()
}
@@ -511,6 +607,7 @@ test('audio store auto-switches to device input when native system capture is un
test('audio store falls back to device input when native system capture fails at start time', async () => {
resetStores()
const fakeStorage = installFakeLocalStorage()
const harness = installAudioCaptureHarness({
support: createBackendSupport(true, null),
startSystemAudio: async () => {
@@ -528,11 +625,16 @@ test('audio store falls back to device input when native system capture fails at
assert.equal(state.captureStatus, 'capturing')
assert.equal(state.captureError, null)
assert.match(state.captureNotice ?? '', /PulseAudio monitor stream failed/i)
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({
captureMode: 'device',
selectedDeviceId: null,
}))
const banner = useUiStore.getState().banner
assert.ok(banner)
assert.match(banner?.message ?? '', /PulseAudio monitor stream failed/i)
} finally {
fakeStorage.restore()
harness.restore()
resetStores()
}
@@ -663,6 +765,7 @@ test('audio store keeps explicit output selections pinned when the OS default ch
test('audio store falls back to Default Output when an explicit output disappears', async () => {
resetStores()
const fakeStorage = installFakeLocalStorage()
const support = createBackendSupport(true, null)
let sources = [
defaultSystemSource(),
@@ -699,7 +802,64 @@ test('audio store falls back to Default Output when an explicit output disappear
assert.equal(state.selectedSystemSourceId, DEFAULT_SYSTEM_SOURCE_ID)
assert.equal(state.activeSourceId, 'headphones')
assert.match(state.captureNotice ?? '', /Speakers is unavailable/i)
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({
captureMode: 'system',
selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID,
}))
} finally {
fakeStorage.restore()
harness.restore()
resetStores()
}
})
test('audio store persists Default Input when an explicit input disappears', async () => {
resetStores()
const fakeStorage = installFakeLocalStorage()
const support = createBackendSupport(true, null)
let devices = [
mediaDevice('mic-1', 'Mic 1', 'group-1'),
mediaDevice('mic-2', 'Mic 2', 'group-2'),
]
const harness = installAudioCaptureHarness({
support,
devices: () => devices,
})
try {
useAudioStore.setState({
backendSupport: support,
devices,
selectedDeviceId: 'mic-1',
captureMode: 'device',
captureStatus: 'capturing',
isCapturing: true,
activeBackendKind: 'device-input',
activeSourceId: 'mic-1',
activeSourceLabel: 'Mic 1',
})
devices = [
mediaDevice('mic-2', 'Mic 2', 'group-2'),
]
await useAudioStore.getState().refreshDevices({ rebindActiveCapture: true })
const state = useAudioStore.getState()
assert.equal(harness.calls.startDevice, 1)
assert.deepEqual(harness.calls.startDeviceRequests, [{
deviceId: null,
forceDeviceRestart: true,
}])
assert.equal(state.selectedDeviceId, null)
assert.equal(state.activeSourceId, 'mic-2')
assert.match(state.captureNotice ?? '', /Mic 1 is unavailable/i)
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({
captureMode: 'device',
selectedDeviceId: null,
}))
} finally {
fakeStorage.restore()
harness.restore()
resetStores()
}