From 4a900bc7c76bc16b6229c00e6701c36dffe4ad28 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:14:48 -0400 Subject: [PATCH] Fixes #8, persist monitor device across restarts --- src/renderer/stores/audioStore.ts | 100 +++++++++++++++-- test/audio-store.test.ts | 176 ++++++++++++++++++++++++++++-- 2 files changed, 261 insertions(+), 15 deletions(-) diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts index 75a36de..9162a9e 100644 --- a/src/renderer/stores/audioStore.ts +++ b/src/renderer/stores/audioStore.ts @@ -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 @@ -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((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((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((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((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((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((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((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((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', diff --git a/test/audio-store.test.ts b/test/audio-store.test.ts index 5681b90..b5af98e 100644 --- a/test/audio-store.test.ts +++ b/test/audio-store.test.ts @@ -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[] | Promise) const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__' +function audioPreferences(overrides: Partial = {}): PersistedAudioState { + return { + inputGainDb: 0, + captureMode: 'system', + selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID, + selectedDeviceId: null, + ...overrides, + } +} + +function storedAudioPreferences(overrides: Partial = {}): 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() }