mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
import type { ScopePopoutStateMap, WindowBounds } from '../types/popout'
|
|
import {
|
|
DEFAULT_PROFILE_NAME,
|
|
PROFILE_FILE_FORMAT,
|
|
PROFILE_FILE_VERSION,
|
|
PROFILE_LOCAL_STATE_FORMAT,
|
|
PROFILE_LOCAL_STATE_VERSION,
|
|
type Profile,
|
|
type ProfileLocalMetadata,
|
|
type PrismProfileFile,
|
|
type PrismProfileFileScopePopoutMap,
|
|
type PrismProfileFileV3,
|
|
type PrismProfileLocalStateV1,
|
|
} from '../types/profile'
|
|
import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } from '../types/scope'
|
|
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
|
|
import { isLUFSMeterReadout } from '../types/lufsmeter'
|
|
import { normalizeSpectrumPeakInfoMode } from '../types/spectrum'
|
|
import {
|
|
clampSpectrogramTiltDbPerOctave,
|
|
isSpectrogramOrientation,
|
|
} from '../types/spectrogram'
|
|
import { isVUMeterNeedleChannels, sanitizeVUReferenceDbfs } from '../types/vumeter'
|
|
import { clampWaveformScrollSpeed } from '../types/waveform'
|
|
|
|
export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
|
|
export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS]
|
|
|
|
export const DEFAULT_SCOPE_WIDTH_WEIGHTS: Record<ScopeKind, number> = {
|
|
spectrum: 1,
|
|
oscilloscope: 1,
|
|
vectorscope: 1,
|
|
spectrogram: 1,
|
|
vumeter: 0.5,
|
|
lufsmeter: 0.5,
|
|
waveform: 1,
|
|
nowPlaying: 1,
|
|
}
|
|
|
|
export function isScopeKind(value: unknown): value is ScopeKind {
|
|
return normalizeScopeKind(value) !== null
|
|
}
|
|
|
|
export function cloneScopeSettings(settings: ScopeSettings): ScopeSettings {
|
|
return JSON.parse(JSON.stringify(settings)) as ScopeSettings
|
|
}
|
|
|
|
function isLegacyProfileFileVersion(version: unknown): boolean {
|
|
return version === 1 || version === 2
|
|
}
|
|
|
|
function normalizeWaveformScrollSpeed(value: unknown, legacyProfileFileScale: boolean): number {
|
|
if (legacyProfileFileScale && value !== undefined && value !== null) {
|
|
const numeric = Number(value)
|
|
return clampWaveformScrollSpeed(Number.isFinite(numeric) ? numeric / 2 : value)
|
|
}
|
|
|
|
return clampWaveformScrollSpeed(value ?? DEFAULT_SCOPE_SETTINGS.waveform.scrollSpeed)
|
|
}
|
|
|
|
export function createDefaultScopePopouts(): ScopePopoutStateMap {
|
|
return SCOPE_KINDS.reduce((acc, kind) => {
|
|
acc[kind] = { poppedOut: false }
|
|
return acc
|
|
}, {} as ScopePopoutStateMap)
|
|
}
|
|
|
|
export function normalizeWindowBounds(
|
|
raw: unknown,
|
|
minWidth = 120,
|
|
minHeight = 80,
|
|
): WindowBounds | undefined {
|
|
if (typeof raw !== 'object' || raw === null) return undefined
|
|
|
|
const candidate = raw as Partial<WindowBounds>
|
|
if (
|
|
typeof candidate.x !== 'number'
|
|
|| typeof candidate.y !== 'number'
|
|
|| typeof candidate.width !== 'number'
|
|
|| typeof candidate.height !== 'number'
|
|
) {
|
|
return undefined
|
|
}
|
|
|
|
return {
|
|
x: Math.round(candidate.x),
|
|
y: Math.round(candidate.y),
|
|
width: Math.max(minWidth, Math.round(candidate.width)),
|
|
height: Math.max(minHeight, Math.round(candidate.height)),
|
|
}
|
|
}
|
|
|
|
export function normalizeScopeOrder(raw: unknown): ScopeKind[] {
|
|
if (!Array.isArray(raw)) return [...DEFAULT_SCOPE_ORDER]
|
|
|
|
const seen = new Set<ScopeKind>()
|
|
const normalized: ScopeKind[] = []
|
|
|
|
for (const rawKind of raw) {
|
|
const kind = normalizeScopeKind(rawKind)
|
|
if (!kind) continue
|
|
if (seen.has(kind)) continue
|
|
seen.add(kind)
|
|
normalized.push(kind)
|
|
}
|
|
|
|
for (const kind of DEFAULT_SCOPE_ORDER) {
|
|
if (!seen.has(kind)) {
|
|
normalized.push(kind)
|
|
}
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
export function normalizeHiddenScopes(raw: unknown): ScopeKind[] {
|
|
if (!Array.isArray(raw)) {
|
|
return SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind))
|
|
}
|
|
|
|
const seen = new Set<ScopeKind>()
|
|
const normalized: ScopeKind[] = []
|
|
for (const rawKind of raw) {
|
|
const kind = normalizeScopeKind(rawKind)
|
|
if (!kind || seen.has(kind)) continue
|
|
seen.add(kind)
|
|
normalized.push(kind)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
export function normalizeWidthWeights(raw: unknown): Record<ScopeKind, number> {
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<Record<ScopeKind, unknown>>
|
|
: {}
|
|
const legacyParsed = parsed as Partial<Record<ScopeKind | 'astra', unknown>>
|
|
|
|
return SCOPE_KINDS.reduce((acc, kind) => {
|
|
const value = legacyParsed[kind] ?? (kind === 'nowPlaying' ? legacyParsed.astra : undefined)
|
|
acc[kind] = typeof value === 'number' && Number.isFinite(value)
|
|
? Math.max(0.1, value)
|
|
: DEFAULT_SCOPE_WIDTH_WEIGHTS[kind]
|
|
return acc
|
|
}, {} as Record<ScopeKind, number>)
|
|
}
|
|
|
|
export function mergeScopeSettings(
|
|
raw: unknown,
|
|
options: { legacyProfileFileScale?: boolean } = {},
|
|
): ScopeSettings {
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<ScopeSettings>
|
|
: {}
|
|
const legacyParsed = parsed as Partial<ScopeSettings> & {
|
|
astra?: Partial<ScopeSettings['nowPlaying']>
|
|
}
|
|
const rawSpectrum: Partial<ScopeSettings['spectrum']> = typeof parsed.spectrum === 'object' && parsed.spectrum !== null
|
|
? parsed.spectrum
|
|
: {}
|
|
const rawSpectrogram: Partial<ScopeSettings['spectrogram']> = typeof parsed.spectrogram === 'object' && parsed.spectrogram !== null
|
|
? parsed.spectrogram
|
|
: {}
|
|
const rawWaveform: Partial<ScopeSettings['waveform']> = typeof parsed.waveform === 'object' && parsed.waveform !== null
|
|
? parsed.waveform
|
|
: {}
|
|
const rawLUFSMeter: Partial<ScopeSettings['lufsmeter']> = typeof parsed.lufsmeter === 'object' && parsed.lufsmeter !== null
|
|
? parsed.lufsmeter
|
|
: {}
|
|
const rawVUMeter: Partial<ScopeSettings['vumeter']> = typeof parsed.vumeter === 'object' && parsed.vumeter !== null
|
|
? parsed.vumeter
|
|
: {}
|
|
const rawNowPlaying: Partial<ScopeSettings['nowPlaying']> = typeof legacyParsed.nowPlaying === 'object' && legacyParsed.nowPlaying !== null
|
|
? legacyParsed.nowPlaying
|
|
: (typeof legacyParsed.astra === 'object' && legacyParsed.astra !== null ? legacyParsed.astra : {})
|
|
|
|
return {
|
|
spectrum: {
|
|
...DEFAULT_SCOPE_SETTINGS.spectrum,
|
|
...rawSpectrum,
|
|
peakInfoMode: normalizeSpectrumPeakInfoMode(rawSpectrum.peakInfoMode),
|
|
},
|
|
oscilloscope: { ...DEFAULT_SCOPE_SETTINGS.oscilloscope, ...(parsed.oscilloscope ?? {}) },
|
|
vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) },
|
|
spectrogram: {
|
|
...DEFAULT_SCOPE_SETTINGS.spectrogram,
|
|
...rawSpectrogram,
|
|
orientation: isSpectrogramOrientation(rawSpectrogram.orientation)
|
|
? rawSpectrogram.orientation
|
|
: DEFAULT_SCOPE_SETTINGS.spectrogram.orientation,
|
|
tiltDbPerOctave: clampSpectrogramTiltDbPerOctave(
|
|
rawSpectrogram.tiltDbPerOctave ?? DEFAULT_SCOPE_SETTINGS.spectrogram.tiltDbPerOctave
|
|
),
|
|
},
|
|
vumeter: {
|
|
...DEFAULT_SCOPE_SETTINGS.vumeter,
|
|
...rawVUMeter,
|
|
needleChannels: isVUMeterNeedleChannels(rawVUMeter.needleChannels)
|
|
? rawVUMeter.needleChannels
|
|
: DEFAULT_SCOPE_SETTINGS.vumeter.needleChannels,
|
|
referenceDb: sanitizeVUReferenceDbfs(rawVUMeter.referenceDb),
|
|
},
|
|
lufsmeter: {
|
|
...DEFAULT_SCOPE_SETTINGS.lufsmeter,
|
|
...rawLUFSMeter,
|
|
readout: isLUFSMeterReadout(rawLUFSMeter.readout)
|
|
? rawLUFSMeter.readout
|
|
: DEFAULT_SCOPE_SETTINGS.lufsmeter.readout,
|
|
},
|
|
waveform: {
|
|
...DEFAULT_SCOPE_SETTINGS.waveform,
|
|
mode: rawWaveform.mode === 'stereo' || rawWaveform.mode === 'mono'
|
|
? rawWaveform.mode
|
|
: DEFAULT_SCOPE_SETTINGS.waveform.mode,
|
|
scrollSpeed: normalizeWaveformScrollSpeed(rawWaveform.scrollSpeed, Boolean(options.legacyProfileFileScale)),
|
|
multiband: typeof rawWaveform.multiband === 'boolean'
|
|
? rawWaveform.multiband
|
|
: DEFAULT_SCOPE_SETTINGS.waveform.multiband,
|
|
},
|
|
nowPlaying: { ...DEFAULT_SCOPE_SETTINGS.nowPlaying, ...rawNowPlaying },
|
|
}
|
|
}
|
|
|
|
export function normalizeScopePopouts(raw: unknown): ScopePopoutStateMap {
|
|
const defaults = createDefaultScopePopouts()
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<Record<ScopeKind, Partial<ScopePopoutStateMap[ScopeKind]>>>
|
|
: {}
|
|
const legacyParsed = parsed as Partial<Record<ScopeKind | 'astra', Partial<ScopePopoutStateMap[ScopeKind]>>>
|
|
|
|
for (const kind of SCOPE_KINDS) {
|
|
const value = legacyParsed[kind] ?? (kind === 'nowPlaying' ? legacyParsed.astra : undefined)
|
|
defaults[kind] = {
|
|
poppedOut: Boolean(value?.poppedOut),
|
|
windowBounds: normalizeWindowBounds(value?.windowBounds),
|
|
}
|
|
}
|
|
|
|
return defaults
|
|
}
|
|
|
|
export function normalizeProfileName(value: unknown, fallback = DEFAULT_PROFILE_NAME): string {
|
|
if (typeof value !== 'string') return fallback
|
|
const trimmed = value.trim()
|
|
return trimmed || fallback
|
|
}
|
|
|
|
export function createDefaultProfile(name = DEFAULT_PROFILE_NAME): Profile {
|
|
return {
|
|
name,
|
|
scopeOrder: [...DEFAULT_SCOPE_ORDER],
|
|
hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)),
|
|
widthWeights: { ...DEFAULT_SCOPE_WIDTH_WEIGHTS },
|
|
scopeSettings: cloneScopeSettings(DEFAULT_SCOPE_SETTINGS),
|
|
scopePopouts: createDefaultScopePopouts(),
|
|
}
|
|
}
|
|
|
|
export function normalizeProfile(raw: unknown, fallbackName = DEFAULT_PROFILE_NAME): Profile {
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<Profile>
|
|
: {}
|
|
|
|
return {
|
|
name: normalizeProfileName(parsed.name, fallbackName),
|
|
scopeOrder: normalizeScopeOrder(parsed.scopeOrder),
|
|
hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes),
|
|
widthWeights: normalizeWidthWeights(parsed.widthWeights),
|
|
scopeSettings: mergeScopeSettings(parsed.scopeSettings),
|
|
scopePopouts: normalizeScopePopouts(parsed.scopePopouts),
|
|
windowBounds: normalizeWindowBounds(parsed.windowBounds),
|
|
}
|
|
}
|
|
|
|
export function normalizeProfileFileScopePopouts(raw: unknown): PrismProfileFileScopePopoutMap {
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<Record<ScopeKind, { poppedOut?: unknown }>>
|
|
: {}
|
|
const legacyParsed = parsed as Partial<Record<ScopeKind | 'astra', { poppedOut?: unknown }>>
|
|
|
|
return SCOPE_KINDS.reduce((acc, kind) => {
|
|
const value = legacyParsed[kind] ?? (kind === 'nowPlaying' ? legacyParsed.astra : undefined)
|
|
acc[kind] = { poppedOut: Boolean(value?.poppedOut) }
|
|
return acc
|
|
}, {} as PrismProfileFileScopePopoutMap)
|
|
}
|
|
|
|
export function normalizeProfileFile(
|
|
raw: unknown,
|
|
fallbackId: string,
|
|
fallbackName = DEFAULT_PROFILE_NAME,
|
|
) : PrismProfileFileV3 {
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<PrismProfileFile>
|
|
: {}
|
|
|
|
const id = typeof parsed.id === 'string' && parsed.id.trim()
|
|
? parsed.id.trim()
|
|
: fallbackId
|
|
|
|
const name = normalizeProfileName(parsed.name, fallbackName)
|
|
|
|
return {
|
|
format: PROFILE_FILE_FORMAT,
|
|
version: PROFILE_FILE_VERSION,
|
|
id,
|
|
name,
|
|
scopeOrder: normalizeScopeOrder(parsed.scopeOrder),
|
|
hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes),
|
|
widthWeights: normalizeWidthWeights(parsed.widthWeights),
|
|
scopeSettings: mergeScopeSettings(parsed.scopeSettings, {
|
|
legacyProfileFileScale: isLegacyProfileFileVersion(parsed.version),
|
|
}),
|
|
scopePopouts: normalizeProfileFileScopePopouts(parsed.scopePopouts),
|
|
}
|
|
}
|
|
|
|
export function profileToFileData(id: string, profile: Profile): PrismProfileFileV3 {
|
|
const normalized = normalizeProfile(profile, profile.name)
|
|
|
|
return {
|
|
format: PROFILE_FILE_FORMAT,
|
|
version: PROFILE_FILE_VERSION,
|
|
id,
|
|
name: normalized.name,
|
|
scopeOrder: [...normalized.scopeOrder],
|
|
hiddenScopes: [...normalized.hiddenScopes],
|
|
widthWeights: { ...normalized.widthWeights },
|
|
scopeSettings: cloneScopeSettings(normalized.scopeSettings),
|
|
scopePopouts: SCOPE_KINDS.reduce((acc, kind) => {
|
|
acc[kind] = { poppedOut: normalized.scopePopouts[kind]?.poppedOut === true }
|
|
return acc
|
|
}, {} as PrismProfileFileScopePopoutMap),
|
|
}
|
|
}
|
|
|
|
export function normalizeProfileLocalMetadata(raw: unknown): ProfileLocalMetadata {
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<ProfileLocalMetadata>
|
|
: {}
|
|
|
|
const scopePopoutBounds = typeof parsed.scopePopoutBounds === 'object' && parsed.scopePopoutBounds !== null
|
|
? SCOPE_KINDS.reduce((acc, kind) => {
|
|
const legacyBounds = parsed.scopePopoutBounds as Partial<Record<ScopeKind | 'astra', unknown>> | undefined
|
|
const bounds = normalizeWindowBounds(
|
|
legacyBounds?.[kind] ?? (kind === 'nowPlaying' ? legacyBounds?.astra : undefined),
|
|
)
|
|
if (bounds) {
|
|
acc[kind] = bounds
|
|
}
|
|
return acc
|
|
}, {} as Partial<Record<ScopeKind, WindowBounds>>)
|
|
: undefined
|
|
|
|
return {
|
|
windowBounds: normalizeWindowBounds(parsed.windowBounds),
|
|
scopePopoutBounds: scopePopoutBounds && Object.keys(scopePopoutBounds).length > 0
|
|
? scopePopoutBounds
|
|
: undefined,
|
|
}
|
|
}
|
|
|
|
export function createEmptyProfileLocalState(): PrismProfileLocalStateV1 {
|
|
return {
|
|
format: PROFILE_LOCAL_STATE_FORMAT,
|
|
version: PROFILE_LOCAL_STATE_VERSION,
|
|
migrationVersion: 0,
|
|
activeProfileId: null,
|
|
profiles: {},
|
|
}
|
|
}
|
|
|
|
export function normalizeProfileLocalState(raw: unknown): PrismProfileLocalStateV1 {
|
|
const parsed = typeof raw === 'object' && raw !== null
|
|
? raw as Partial<PrismProfileLocalStateV1>
|
|
: {}
|
|
|
|
const profiles = typeof parsed.profiles === 'object' && parsed.profiles !== null
|
|
? Object.entries(parsed.profiles).reduce((acc, [id, metadata]) => {
|
|
if (!id.trim()) return acc
|
|
acc[id] = normalizeProfileLocalMetadata(metadata)
|
|
return acc
|
|
}, {} as Record<string, ProfileLocalMetadata>)
|
|
: {}
|
|
|
|
return {
|
|
format: PROFILE_LOCAL_STATE_FORMAT,
|
|
version: PROFILE_LOCAL_STATE_VERSION,
|
|
migrationVersion: typeof parsed.migrationVersion === 'number' && Number.isFinite(parsed.migrationVersion)
|
|
? Math.max(0, Math.trunc(parsed.migrationVersion))
|
|
: 0,
|
|
activeProfileId: typeof parsed.activeProfileId === 'string' ? parsed.activeProfileId : null,
|
|
profiles,
|
|
}
|
|
}
|
|
|
|
export function extractLocalProfileMetadata(profile: Profile): ProfileLocalMetadata {
|
|
const normalized = normalizeProfile(profile, profile.name)
|
|
const scopePopoutBounds = SCOPE_KINDS.reduce((acc, kind) => {
|
|
const bounds = normalized.scopePopouts[kind]?.windowBounds
|
|
if (bounds) {
|
|
acc[kind] = bounds
|
|
}
|
|
return acc
|
|
}, {} as Partial<Record<ScopeKind, WindowBounds>>)
|
|
|
|
return {
|
|
windowBounds: normalized.windowBounds,
|
|
scopePopoutBounds: Object.keys(scopePopoutBounds).length > 0 ? scopePopoutBounds : undefined,
|
|
}
|
|
}
|
|
|
|
export function profileFileToProfile(
|
|
file: PrismProfileFile,
|
|
localMetadata?: ProfileLocalMetadata,
|
|
): Profile {
|
|
const metadata = normalizeProfileLocalMetadata(localMetadata)
|
|
|
|
return {
|
|
name: normalizeProfileName(file.name, DEFAULT_PROFILE_NAME),
|
|
scopeOrder: normalizeScopeOrder(file.scopeOrder),
|
|
hiddenScopes: normalizeHiddenScopes(file.hiddenScopes),
|
|
widthWeights: normalizeWidthWeights(file.widthWeights),
|
|
scopeSettings: mergeScopeSettings(file.scopeSettings, {
|
|
legacyProfileFileScale: isLegacyProfileFileVersion(file.version),
|
|
}),
|
|
scopePopouts: SCOPE_KINDS.reduce((acc, kind) => {
|
|
acc[kind] = {
|
|
poppedOut: Boolean(file.scopePopouts[kind]?.poppedOut),
|
|
windowBounds: metadata.scopePopoutBounds?.[kind],
|
|
}
|
|
return acc
|
|
}, {} as ScopePopoutStateMap),
|
|
windowBounds: metadata.windowBounds,
|
|
}
|
|
}
|