mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
persist trim across sessions
This commit is contained in:
@@ -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
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user