persist trim across sessions

This commit is contained in:
Boof2015
2026-04-08 23:06:21 -04:00
parent 27f4b9610a
commit 3ebd6619dc
4 changed files with 226 additions and 4 deletions
+78 -3
View File
@@ -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<AudioState> {
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<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: [],
@@ -89,11 +158,17 @@ export const useAudioStore = create<AudioState>((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: () => {
+119 -1
View File
@@ -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<string, string>()
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({
+1
View File
@@ -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)
+28
View File
@@ -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 }