From 9416eac19c2c1344a47a2f847e6b3d5a7356f589 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 19 May 2026 19:36:27 -0400 Subject: [PATCH] improve speed scale for waveform + version bump + profile migration --- package-lock.json | 4 +- package.json | 2 +- src/main/profileLibrary.ts | 2 +- .../components/ScopeSettingsSection.tsx | 11 ++++-- src/renderer/visualizers/Waveform.ts | 2 +- src/shared/profileState.ts | 34 +++++++++++++---- src/types/profile.ts | 17 ++++++++- src/types/settings.ts | 4 +- src/types/waveform.ts | 4 +- test/profile-library.test.ts | 37 ++++++++++++++++++- test/renderer-helpers.test.ts | 28 ++++++++++++++ 11 files changed, 122 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index acbde21..c315925 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.1.0-beta", + "version": "0.2.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.1.0-beta", + "version": "0.2.0-beta", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index a010853..100c158 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.1.0-beta", + "version": "0.2.0-beta", "description": "Open-source audio metering and visualization tool", "main": "./out/main/index.js", "scripts": { diff --git a/src/main/profileLibrary.ts b/src/main/profileLibrary.ts index 7afd4ca..17ab2fb 100644 --- a/src/main/profileLibrary.ts +++ b/src/main/profileLibrary.ts @@ -351,7 +351,7 @@ export class FileBackedProfileLibrary { throw new Error(`Unsupported profile format in ${basename(filePath)}.`) } - if (candidate.version !== 1 && candidate.version !== PROFILE_FILE_VERSION) { + if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== PROFILE_FILE_VERSION) { throw new Error(`Unsupported profile version in ${basename(filePath)}.`) } diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index c027982..7a7c994 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -18,6 +18,11 @@ import { findVUReferencePreset, sanitizeVUReferenceDbfs, } from '../../types/vumeter' +import { + MAX_WAVEFORM_SCROLL_SPEED, + MIN_WAVEFORM_SCROLL_SPEED, + WAVEFORM_SCROLL_SPEED_STEP, +} from '../../types/waveform' import ThemedSelect from './ThemedSelect' function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string { @@ -669,9 +674,9 @@ export default function ScopeSettingsSection({ label="Speed" value={current.scrollSpeed} valueLabel={`x${current.scrollSpeed.toFixed(0)}`} - min={1} - max={8} - step={1} + min={MIN_WAVEFORM_SCROLL_SPEED} + max={MAX_WAVEFORM_SCROLL_SPEED} + step={WAVEFORM_SCROLL_SPEED_STEP} fullWidth={false} onChange={(value) => onUpdate('waveform', { scrollSpeed: value })} /> diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index a433917..23e8466 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -62,7 +62,7 @@ const MULTIBAND_DOMINANCE_SENSITIVITY = 5 const MULTIBAND_FOCUSED_BLEND = 0.68 const MULTIBAND_FILL_ALPHA = 0.72 const MULTIBAND_EDGE_ALPHA = 1.0 -const BASE_PIXELS_PER_SECOND = 64 +const BASE_PIXELS_PER_SECOND = 128 const DISPLAY_MARGIN = 0.95 const defaultWaveformDataSource: WaveformDataSource = { diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 24f9066..918084c 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -9,7 +9,7 @@ import { type ProfileLocalMetadata, type PrismProfileFile, type PrismProfileFileScopePopoutMap, - type PrismProfileFileV2, + type PrismProfileFileV3, type PrismProfileLocalStateV1, } from '../types/profile' import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } from '../types/scope' @@ -45,6 +45,19 @@ 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 } @@ -131,7 +144,10 @@ export function normalizeWidthWeights(raw: unknown): Record { }, {} as Record) } -export function mergeScopeSettings(raw: unknown): ScopeSettings { +export function mergeScopeSettings( + raw: unknown, + options: { legacyProfileFileScale?: boolean } = {}, +): ScopeSettings { const parsed = typeof raw === 'object' && raw !== null ? raw as Partial : {} @@ -195,7 +211,7 @@ export function mergeScopeSettings(raw: unknown): ScopeSettings { mode: rawWaveform.mode === 'stereo' || rawWaveform.mode === 'mono' ? rawWaveform.mode : DEFAULT_SCOPE_SETTINGS.waveform.mode, - scrollSpeed: clampWaveformScrollSpeed(rawWaveform.scrollSpeed ?? DEFAULT_SCOPE_SETTINGS.waveform.scrollSpeed), + scrollSpeed: normalizeWaveformScrollSpeed(rawWaveform.scrollSpeed, Boolean(options.legacyProfileFileScale)), multiband: typeof rawWaveform.multiband === 'boolean' ? rawWaveform.multiband : DEFAULT_SCOPE_SETTINGS.waveform.multiband, @@ -272,7 +288,7 @@ export function normalizeProfileFile( raw: unknown, fallbackId: string, fallbackName = DEFAULT_PROFILE_NAME, -) : PrismProfileFileV2 { +) : PrismProfileFileV3 { const parsed = typeof raw === 'object' && raw !== null ? raw as Partial : {} @@ -291,12 +307,14 @@ export function normalizeProfileFile( scopeOrder: normalizeScopeOrder(parsed.scopeOrder), hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), widthWeights: normalizeWidthWeights(parsed.widthWeights), - scopeSettings: mergeScopeSettings(parsed.scopeSettings), + scopeSettings: mergeScopeSettings(parsed.scopeSettings, { + legacyProfileFileScale: isLegacyProfileFileVersion(parsed.version), + }), scopePopouts: normalizeProfileFileScopePopouts(parsed.scopePopouts), } } -export function profileToFileData(id: string, profile: Profile): PrismProfileFileV2 { +export function profileToFileData(id: string, profile: Profile): PrismProfileFileV3 { const normalized = normalizeProfile(profile, profile.name) return { @@ -402,7 +420,9 @@ export function profileFileToProfile( scopeOrder: normalizeScopeOrder(file.scopeOrder), hiddenScopes: normalizeHiddenScopes(file.hiddenScopes), widthWeights: normalizeWidthWeights(file.widthWeights), - scopeSettings: mergeScopeSettings(file.scopeSettings), + scopeSettings: mergeScopeSettings(file.scopeSettings, { + legacyProfileFileScale: isLegacyProfileFileVersion(file.version), + }), scopePopouts: SCOPE_KINDS.reduce((acc, kind) => { acc[kind] = { poppedOut: Boolean(file.scopePopouts[kind]?.poppedOut), diff --git a/src/types/profile.ts b/src/types/profile.ts index a2e5f33..d0200bc 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -3,7 +3,7 @@ import type { ScopeKind } from './scope' import type { ScopeSettings } from './settings' export const PROFILE_FILE_FORMAT = 'prism-profile' -export const PROFILE_FILE_VERSION = 2 +export const PROFILE_FILE_VERSION = 3 export const PROFILE_LOCAL_STATE_FORMAT = 'prism-profile-local' export const PROFILE_LOCAL_STATE_VERSION = 1 export const LEGACY_PROFILE_MIGRATION_VERSION = 1 @@ -39,6 +39,19 @@ export interface PrismProfileFileV1 { } export interface PrismProfileFileV2 { + format: typeof PROFILE_FILE_FORMAT + version: 2 + id: string + name: string + themeId?: string | null + scopeOrder: ScopeKind[] + hiddenScopes: ScopeKind[] + widthWeights: Record + scopeSettings: ScopeSettings + scopePopouts: PrismProfileFileScopePopoutMap +} + +export interface PrismProfileFileV3 { format: typeof PROFILE_FILE_FORMAT version: typeof PROFILE_FILE_VERSION id: string @@ -51,7 +64,7 @@ export interface PrismProfileFileV2 { scopePopouts: PrismProfileFileScopePopoutMap } -export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 +export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 export interface ProfileLocalMetadata { windowBounds?: WindowBounds diff --git a/src/types/settings.ts b/src/types/settings.ts index e6c68c2..ad5a8e1 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -9,7 +9,7 @@ import { } from './spectrogram' import { DEFAULT_VU_REFERENCE_DBFS, type VUMeterMode, type VUMeterNeedleChannels, type VUMeterOrientation } from './vumeter' import { DEFAULT_LUFS_METER_READOUT, type LUFSMeterMode, type LUFSMeterReadout } from './lufsmeter' -import { DEFAULT_WAVEFORM_MODE, type WaveformMode } from './waveform' +import { DEFAULT_WAVEFORM_MODE, DEFAULT_WAVEFORM_SCROLL_SPEED, type WaveformMode } from './waveform' import { DEFAULT_SPECTRUM_PEAK_INFO_MODE, type SpectrumPeakInfoMode } from './spectrum' export interface ScopeSettings { @@ -80,7 +80,7 @@ export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { spectrogram: { fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', orientation: DEFAULT_SPECTROGRAM_ORIENTATION, colorScheme: 'heat' }, vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS }, lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT }, - waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: 1, multiband: false }, + waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false }, nowPlaying: { showCoverArt: true, showTitle: true, diff --git a/src/types/waveform.ts b/src/types/waveform.ts index f61edda..1f4da14 100644 --- a/src/types/waveform.ts +++ b/src/types/waveform.ts @@ -1,8 +1,8 @@ export type WaveformMode = 'mono' | 'stereo' -export const MIN_WAVEFORM_SCROLL_SPEED = 0.5 +export const MIN_WAVEFORM_SCROLL_SPEED = 1 export const MAX_WAVEFORM_SCROLL_SPEED = 8 -export const WAVEFORM_SCROLL_SPEED_STEP = 0.5 +export const WAVEFORM_SCROLL_SPEED_STEP = 1 export const DEFAULT_WAVEFORM_SCROLL_SPEED = 1 export const DEFAULT_WAVEFORM_MODE: WaveformMode = 'mono' diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 5cf535c..38f3a57 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -8,6 +8,7 @@ import { createDefaultProfile, extractLocalProfileMetadata, mergeScopeSettings, + normalizeProfileFile, normalizeScopeOrder, profileFileToProfile, profileToFileData, @@ -81,6 +82,38 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(restored.scopeSettings.nowPlaying.showControls, true) }) +test('profile file waveform speed migration preserves legacy scroll feel', () => { + const base = profileToFileData('profile_speed', createDefaultProfile('Speed')) + const withWaveformSpeed = (version: number, scrollSpeed: unknown) => normalizeProfileFile({ + ...base, + version, + scopeSettings: { + ...base.scopeSettings, + waveform: { + ...base.scopeSettings.waveform, + scrollSpeed, + }, + }, + }, 'profile_speed') + + assert.equal(withWaveformSpeed(1, 8).scopeSettings.waveform.scrollSpeed, 4) + assert.equal(withWaveformSpeed(2, 8).scopeSettings.waveform.scrollSpeed, 4) + assert.equal(withWaveformSpeed(PROFILE_FILE_VERSION, 4).scopeSettings.waveform.scrollSpeed, 4) + assert.equal(withWaveformSpeed(1, 'fast').scopeSettings.waveform.scrollSpeed, 1) + + const legacyMissingSpeed = normalizeProfileFile({ + ...base, + version: 1, + scopeSettings: { + ...base.scopeSettings, + waveform: { + mode: 'mono', + }, + }, + }, 'profile_speed') + assert.equal(legacyMissingSpeed.scopeSettings.waveform.scrollSpeed, 1) +}) + test('mergeScopeSettings defaults missing or invalid spectrogram orientation to horizontal', () => { const vertical = mergeScopeSettings({ spectrogram: { @@ -253,7 +286,7 @@ test('partial files normalize, unsupported versions fail, and import does not ch assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.colorScheme, 'heat') assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.lufsmeter.readout, 'shortTerm') assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.mode, 'stereo') - assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.scrollSpeed, 2) + assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.scrollSpeed, 1) assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.multiband, true) assert.equal(Object.hasOwn(partialSnapshot.profiles.profile_partial.scopeSettings.waveform, 'gainDb'), false) assert.equal(partialSnapshot.profiles.profile_partial.scopePopouts.spectrogram.poppedOut, true) @@ -285,7 +318,7 @@ test('legacy profile files with themeId import successfully and ignore embedded const legacyPath = join(harness.rootDir, 'legacy-theme.prsm') await writeFile(legacyPath, `${JSON.stringify({ format: PROFILE_FILE_FORMAT, - version: PROFILE_FILE_VERSION, + version: 2, id: 'profile_legacy_theme', name: 'Legacy Theme', themeId: 'theme_midnight', diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 5d1dc31..c6afaa0 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -4501,6 +4501,34 @@ test('Waveform reconfigures and resets native multiband state on option changes' } }) +test('Waveform maps x4 to the old max scroll rate and x8 to faster headroom', () => { + const dom = installFakeCanvasDom() + const nativeAnalyzer = createFakeWaveformNativeAnalyzer() + const dataSource = { + getPendingWaveformSamples: () => [], + getPendingWaveformStereoSamples: () => [], + getSampleRate: () => 4096, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + const waveform = new Waveform(createFakeCanvas(), { + dataSource, + nativeAnalyzer, + scrollSpeed: 4, + }) + + try { + assert.equal(nativeAnalyzer.configs.at(-1)?.samplesPerColumn, 8) + + nativeAnalyzer.configs.length = 0 + waveform.setOptions({ scrollSpeed: 8 }) + assert.equal(nativeAnalyzer.configs.at(-1)?.samplesPerColumn, 4) + } finally { + waveform.dispose() + dom.restore() + } +}) + test('Vectorscope multiband uses native interleaved band points when available', () => { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))