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 = { 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 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() 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() 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 { const parsed = typeof raw === 'object' && raw !== null ? raw as Partial> : {} const legacyParsed = parsed as Partial> 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) } export function mergeScopeSettings( raw: unknown, options: { legacyProfileFileScale?: boolean } = {}, ): ScopeSettings { const parsed = typeof raw === 'object' && raw !== null ? raw as Partial : {} const legacyParsed = parsed as Partial & { astra?: Partial } const rawSpectrum: Partial = typeof parsed.spectrum === 'object' && parsed.spectrum !== null ? parsed.spectrum : {} const rawSpectrogram: Partial = typeof parsed.spectrogram === 'object' && parsed.spectrogram !== null ? parsed.spectrogram : {} const rawWaveform: Partial = typeof parsed.waveform === 'object' && parsed.waveform !== null ? parsed.waveform : {} const rawLUFSMeter: Partial = typeof parsed.lufsmeter === 'object' && parsed.lufsmeter !== null ? parsed.lufsmeter : {} const rawVUMeter: Partial = typeof parsed.vumeter === 'object' && parsed.vumeter !== null ? parsed.vumeter : {} const rawNowPlaying: Partial = 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>> : {} const legacyParsed = parsed as Partial>> 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 : {} 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> : {} const legacyParsed = parsed as Partial> 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 : {} 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 : {} const scopePopoutBounds = typeof parsed.scopePopoutBounds === 'object' && parsed.scopePopoutBounds !== null ? SCOPE_KINDS.reduce((acc, kind) => { const legacyBounds = parsed.scopePopoutBounds as Partial> | undefined const bounds = normalizeWindowBounds( legacyBounds?.[kind] ?? (kind === 'nowPlaying' ? legacyBounds?.astra : undefined), ) if (bounds) { acc[kind] = bounds } return acc }, {} as Partial>) : 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 : {} 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) : {} 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>) 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, } }