From 3ebd6619dc45c587ca1577eb8df26ef57b602d4d Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:06:21 -0400 Subject: [PATCH] persist trim across sessions --- src/renderer/stores/audioStore.ts | 81 +++++++++++++++++++- test/audio-store.test.ts | 120 +++++++++++++++++++++++++++++- test/profile-library.test.ts | 1 + test/renderer-helpers.test.ts | 28 +++++++ 4 files changed, 226 insertions(+), 4 deletions(-) diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts index 41e73a0..13b3453 100644 --- a/src/renderer/stores/audioStore.ts +++ b/src/renderer/stores/audioStore.ts @@ -8,6 +8,15 @@ import type { } 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 + +export interface PersistedAudioState { + inputGainDb: number +} + interface AudioState { systemSources: CaptureSourceDescriptor[] devices: MediaDeviceInfo[] @@ -35,6 +44,11 @@ interface AudioState { stopCapture: () => void } +interface StorageLike { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void +} + function applyCaptureStatus(status: CaptureManagerStatus): Partial { return { captureMode: status.captureMode, @@ -75,6 +89,61 @@ function describeSystemSource(sourceId: string, sources: CaptureSourceDescriptor return sources.find((source) => source.id === sourceId)?.label ?? 'The selected output device' } +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 + : {} + + 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((set, get) => ({ systemSources: [], devices: [], @@ -89,11 +158,17 @@ export const useAudioStore = create((set, get) => ({ captureNotice: null, sampleRate: 48000, channelCount: 2, - inputGainDb: 0, + inputGainDb: storedPreferences.inputGainDb, setInputGain: (db: number) => { - audioCapture.setInputGain(db) - set({ inputGainDb: db }) + const nextInputGainDb = normalizeInputGainDb(db) + if (get().inputGainDb === nextInputGainDb) { + return + } + + persistAudioPreferences(nextInputGainDb) + audioCapture.setInputGain(nextInputGainDb) + set({ inputGainDb: nextInputGainDb }) }, clearCaptureNotice: () => { diff --git a/test/audio-store.test.ts b/test/audio-store.test.ts index fccfb58..34352fc 100644 --- a/test/audio-store.test.ts +++ b/test/audio-store.test.ts @@ -1,10 +1,18 @@ import assert from 'node:assert/strict' import test from 'node:test' import { audioCapture } from '../src/renderer/audio/AudioCapture' -import { useAudioStore } from '../src/renderer/stores/audioStore' +import { + loadAudioPreferences, + normalizeAudioPreferences, + useAudioStore, +} from '../src/renderer/stores/audioStore' import { useUiStore } from '../src/renderer/stores/uiStore' import type { CaptureBackendSupport } from '../src/types/capture' +type GlobalWithStorage = typeof globalThis & { + localStorage?: Storage +} + const initialAudioState = { ...useAudioStore.getState(), } @@ -13,6 +21,54 @@ const initialUiState = { ...useUiStore.getState(), } +function installFakeLocalStorage(): { + getSetCount: () => number + getItem: (key: string) => string | null + restore: () => void +} { + const storage = new Map() + let setCount = 0 + const globalWithStorage = globalThis as GlobalWithStorage + const previousLocalStorage = globalWithStorage.localStorage + + globalWithStorage.localStorage = { + getItem(key: string): string | null { + return storage.get(key) ?? null + }, + setItem(key: string, value: string): void { + setCount += 1 + storage.set(key, value) + }, + removeItem(key: string): void { + storage.delete(key) + }, + clear(): void { + storage.clear() + }, + key(index: number): string | null { + return [...storage.keys()][index] ?? null + }, + get length(): number { + return storage.size + }, + } as Storage + + return { + getSetCount: () => setCount, + getItem(key: string): string | null { + return storage.get(key) ?? null + }, + restore(): void { + if (previousLocalStorage === undefined) { + delete globalWithStorage.localStorage + return + } + + globalWithStorage.localStorage = previousLocalStorage + }, + } +} + function createBackendSupport(available: boolean, reason: string | null): CaptureBackendSupport { return { nativeBackend: { @@ -29,6 +85,7 @@ function createBackendSupport(available: boolean, reason: string | null): Captur } function resetStores(): void { + audioCapture.setInputGain(0) useAudioStore.setState({ ...initialAudioState, systemSources: [], @@ -68,6 +125,7 @@ function installAudioCaptureHarness(options: { setCaptureMode: audioCapture.setCaptureMode, setSelectedDeviceId: audioCapture.setSelectedDeviceId, setSelectedSystemSourceId: audioCapture.setSelectedSystemSourceId, + setInputGain: audioCapture.setInputGain, } const calls = { @@ -132,6 +190,7 @@ function installAudioCaptureHarness(options: { audioCapture.setCaptureMode = originalMethods.setCaptureMode audioCapture.setSelectedDeviceId = originalMethods.setSelectedDeviceId audioCapture.setSelectedSystemSourceId = originalMethods.setSelectedSystemSourceId + audioCapture.setInputGain = originalMethods.setInputGain void selectedDeviceId void selectedSystemSourceId @@ -140,6 +199,65 @@ function installAudioCaptureHarness(options: { } } +test('loadAudioPreferences falls back to 0 dB when storage is unavailable', () => { + assert.deepEqual(loadAudioPreferences(null), { inputGainDb: 0 }) +}) + +test('loadAudioPreferences falls back to 0 dB when stored JSON is invalid', () => { + const storage = { + getItem: () => '{not valid json', + setItem: () => {}, + } + + assert.deepEqual(loadAudioPreferences(storage), { inputGainDb: 0 }) +}) + +test('normalizeAudioPreferences clamps out-of-range trim values', () => { + assert.deepEqual(normalizeAudioPreferences({ inputGainDb: -18 }), { inputGainDb: -12 }) + assert.deepEqual(normalizeAudioPreferences({ inputGainDb: 18 }), { 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 }) +}) + +test('audio store persists normalized trim values and forwards them to audioCapture', () => { + resetStores() + const fakeStorage = installFakeLocalStorage() + const originalSetInputGain = audioCapture.setInputGain + const forwardedValues: number[] = [] + + audioCapture.setInputGain = (db: number) => { + forwardedValues.push(db) + } + + try { + useAudioStore.getState().setInputGain(6.26) + + 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.getSetCount(), 1) + + useAudioStore.getState().setInputGain(6.49) + + assert.deepEqual(forwardedValues, [6.5]) + assert.equal(fakeStorage.getSetCount(), 1) + + useAudioStore.getState().setInputGain(0) + + 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.getSetCount(), 2) + } finally { + audioCapture.setInputGain = originalSetInputGain + fakeStorage.restore() + resetStores() + } +}) + test('audio store auto-switches to device input when native system capture is unavailable on startup', async () => { resetStores() const harness = installAudioCaptureHarness({ diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 3ba9dfb..8fd0a99 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -61,6 +61,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal('themeId' in file, false) assert.equal(JSON.stringify(file).includes('windowBounds'), false) assert.equal(JSON.stringify(file).includes('frameTarget'), false) + assert.equal(JSON.stringify(file).includes('inputGainDb'), false) assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true }) assert.equal(file.scopeSettings.spectrum.heatmapSmoothing, 0.67) assert.equal(file.scopeOrder.includes('astra'), false) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 30015d6..cb79180 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -27,6 +27,7 @@ import { } from '../src/shared/windowGeometry' import { calculateResizedWindowBounds } from '../src/shared/windowResize' import { createDefaultTheme, resolveNativeThemeSource, resolveTheme } from '../src/shared/themeState' +import { useAudioStore } from '../src/renderer/stores/audioStore' import { usePerformanceStore } from '../src/renderer/stores/performanceStore' import { buildProfileDraft, profilesMatch } from '../src/renderer/stores/profileDraft' import { @@ -1577,6 +1578,33 @@ test('applying a profile snapshot does not change the machine-local frame target } }) +test('applying a profile snapshot does not change the machine-local trim', () => { + const previousAudioState = useAudioStore.getState() + const previousSettingsState = useSettingsStore.getState() + + try { + useAudioStore.setState({ inputGainDb: 6.5 }) + + const defaultProfile = createDefaultProfile('Default') + const alternateProfile = createDefaultProfile('Live Mix') + alternateProfile.hiddenScopes = [] + alternateProfile.scopeSettings.waveform.gainDb = 6 + + useSettingsStore.getState().applyExternalProfileSnapshot({ + activeProfileId: 'profile_live_mix', + profiles: { + profile_default: defaultProfile, + profile_live_mix: alternateProfile, + }, + }) + + assert.equal(useAudioStore.getState().inputGainDb, 6.5) + } finally { + useAudioStore.setState(previousAudioState) + useSettingsStore.setState(previousSettingsState) + } +}) + test('profile draft comparisons return to clean after reverting a change', () => { const baselineProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) baselineProfile.windowBounds = { x: 24, y: 48, width: 900, height: 180 }