diff --git a/src/main/profileLibrary.ts b/src/main/profileLibrary.ts index 17ab2fb..e1f976f 100644 --- a/src/main/profileLibrary.ts +++ b/src/main/profileLibrary.ts @@ -351,7 +351,12 @@ export class FileBackedProfileLibrary { throw new Error(`Unsupported profile format in ${basename(filePath)}.`) } - if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== PROFILE_FILE_VERSION) { + if ( + candidate.version !== 1 + && candidate.version !== 2 + && candidate.version !== 3 + && candidate.version !== PROFILE_FILE_VERSION + ) { throw new Error(`Unsupported profile version in ${basename(filePath)}.`) } diff --git a/src/plugin-ui/OscilloscopeScope.tsx b/src/plugin-ui/OscilloscopeScope.tsx index 1d67932..0238320 100644 --- a/src/plugin-ui/OscilloscopeScope.tsx +++ b/src/plugin-ui/OscilloscopeScope.tsx @@ -1,10 +1,12 @@ -import { useEffect, useRef, type JSX } from 'react' +import { useEffect, useLayoutEffect, useRef, type JSX } from 'react' import { Oscilloscope } from '../renderer/visualizers/Oscilloscope' import type { ScopeSettings } from '../types/settings' import type { ResolvedOscilloscopeTheme } from '../types/theme' import type { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer' import type { PluginWebViewDataSource } from './PluginWebViewDataSource' import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions' +import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform' +import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout' interface OscilloscopeScopeProps { dataSource: PluginWebViewDataSource @@ -22,6 +24,9 @@ export default function OscilloscopeScope({ const containerRef = useRef(null) const canvasRef = useRef(null) const vizRef = useRef(null) + const rotationRef = useRef(settings.rotation) + const applySizeRef = useRef<(() => void) | null>(null) + rotationRef.current = settings.rotation useEffect(() => { const container = containerRef.current @@ -36,16 +41,12 @@ export default function OscilloscopeScope({ vizRef.current = viz const applySize = (): void => { - const rect = container.getBoundingClientRect() - const dpr = window.devicePixelRatio || 1 - const pixelWidth = Math.max(1, Math.floor(rect.width * dpr)) - const pixelHeight = Math.max(1, Math.floor(rect.height * dpr)) - if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { - canvas.width = pixelWidth - canvas.height = pixelHeight + const { changed } = applyPluginScopeCanvasLayout(container, canvas, rotationRef.current) + if (changed) { viz.resize() } } + applySizeRef.current = applySize applySize() viz.start() @@ -54,6 +55,7 @@ export default function OscilloscopeScope({ return () => { observer.disconnect() + applySizeRef.current = null viz.dispose() vizRef.current = null } @@ -65,9 +67,17 @@ export default function OscilloscopeScope({ vizRef.current?.setOptions(oscilloscopeSettingsToOptions(settings, theme)) }, [settings, theme]) + useLayoutEffect(() => { + applySizeRef.current?.() + }, [settings.rotation]) + return (
- +
) } diff --git a/src/plugin-ui/SpectrogramScope.tsx b/src/plugin-ui/SpectrogramScope.tsx index 29cad2b..fde8003 100644 --- a/src/plugin-ui/SpectrogramScope.tsx +++ b/src/plugin-ui/SpectrogramScope.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, type JSX } from 'react' +import { useEffect, useLayoutEffect, useRef, type JSX } from 'react' import { Spectrogram } from '../renderer/visualizers/Spectrogram' import type { ScopeSettings } from '../types/settings' import type { ResolvedSpectrogramTheme } from '../types/theme' @@ -6,6 +6,8 @@ import type { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer' import type { PluginWebViewDataSource } from './PluginWebViewDataSource' import { spectrogramSettingsToOptions } from './spectrogramOptions' import { resolveScrollingCanvasSize } from './scrollingCanvas' +import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform' +import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout' interface SpectrogramScopeProps { dataSource: PluginWebViewDataSource @@ -23,6 +25,9 @@ export default function SpectrogramScope({ const containerRef = useRef(null) const canvasRef = useRef(null) const vizRef = useRef(null) + const rotationRef = useRef(settings.rotation) + const applySizeRef = useRef<(() => void) | null>(null) + rotationRef.current = settings.rotation useEffect(() => { const container = containerRef.current @@ -37,15 +42,17 @@ export default function SpectrogramScope({ vizRef.current = viz const applySize = (): void => { - const rect = container.getBoundingClientRect() - const dpr = window.devicePixelRatio || 1 - const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr) - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width - canvas.height = height + const { changed } = applyPluginScopeCanvasLayout( + container, + canvas, + rotationRef.current, + resolveScrollingCanvasSize, + ) + if (changed) { viz.resize() } } + applySizeRef.current = applySize applySize() viz.start() @@ -54,6 +61,7 @@ export default function SpectrogramScope({ return () => { observer.disconnect() + applySizeRef.current = null viz.dispose() vizRef.current = null } @@ -65,9 +73,17 @@ export default function SpectrogramScope({ vizRef.current?.setOptions(spectrogramSettingsToOptions(settings, theme)) }, [settings, theme]) + useLayoutEffect(() => { + applySizeRef.current?.() + }, [settings.rotation]) + return (
- +
) } diff --git a/src/plugin-ui/SpectrumScope.tsx b/src/plugin-ui/SpectrumScope.tsx index 4d1c511..fe420fd 100644 --- a/src/plugin-ui/SpectrumScope.tsx +++ b/src/plugin-ui/SpectrumScope.tsx @@ -6,10 +6,11 @@ import type { SpectrumPeakInfo } from '../types/spectrum' import type { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer' import type { PluginWebViewDataSource } from './PluginWebViewDataSource' import { spectrumSettingsToOptions } from './spectrumOptions' +import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform' +import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout' import { formatSpectrumPeakDb, formatSpectrumPeakFrequency, - measureCanvasResizeState, resolveFollowingPeakOverlayStyle, type CanvasResizeState, type SizeMeasurement, @@ -32,11 +33,14 @@ export default function SpectrumScope({ const canvasRef = useRef(null) const analyzerRef = useRef(null) const resizeStateRef = useRef(null) + const rotationRef = useRef(settings.rotation) + const applySizeRef = useRef<(() => void) | null>(null) const peakOverlayRef = useRef(null) const [peak, setPeak] = useState(null) const [overlaySize, setOverlaySize] = useState(null) const peakMode = settings.peakInfoMode + rotationRef.current = settings.rotation // Create the analyzer once per data source / shim. useEffect(() => { @@ -54,14 +58,13 @@ export default function SpectrumScope({ analyzerRef.current = analyzer const applySize = (): void => { - const state = measureCanvasResizeState(container) - resizeStateRef.current = state - if (canvas.width !== state.pixelWidth || canvas.height !== state.pixelHeight) { - canvas.width = state.pixelWidth - canvas.height = state.pixelHeight + const { changed, layout } = applyPluginScopeCanvasLayout(container, canvas, rotationRef.current) + resizeStateRef.current = layout + if (changed) { analyzer.resize() } } + applySizeRef.current = applySize applySize() analyzer.start() @@ -70,6 +73,7 @@ export default function SpectrumScope({ return () => { observer.disconnect() + applySizeRef.current = null analyzer.dispose() analyzerRef.current = null } @@ -87,6 +91,10 @@ export default function SpectrumScope({ }) }, [settings, theme]) + useLayoutEffect(() => { + applySizeRef.current?.() + }, [settings.rotation]) + // Measure the overlay so "following" placement can avoid the screen edges. useLayoutEffect(() => { const overlay = peakOverlayRef.current @@ -107,12 +115,22 @@ export default function SpectrumScope({ const showPeak = peakMode !== 'off' && peak !== null const overlayStyle: CSSProperties | undefined = peakMode === 'following' && peak - ? resolveFollowingPeakOverlayStyle(peak, resizeStateRef.current, overlaySize) + ? resolveFollowingPeakOverlayStyle( + peak, + resizeStateRef.current, + overlaySize, + settings.rotation, + settings.mirrorHorizontal, + ) : undefined return (
- + {showPeak && peak && (
(null) const canvasRef = useRef(null) const vizRef = useRef(null) + const rotationRef = useRef(settings.rotation) + const applySizeRef = useRef<(() => void) | null>(null) + rotationRef.current = settings.rotation useEffect(() => { const container = containerRef.current @@ -37,15 +42,17 @@ export default function WaveformScope({ vizRef.current = viz const applySize = (): void => { - const rect = container.getBoundingClientRect() - const dpr = window.devicePixelRatio || 1 - const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr) - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width - canvas.height = height + const { changed } = applyPluginScopeCanvasLayout( + container, + canvas, + rotationRef.current, + resolveScrollingCanvasSize, + ) + if (changed) { viz.resize() } } + applySizeRef.current = applySize applySize() viz.start() @@ -54,6 +61,7 @@ export default function WaveformScope({ return () => { observer.disconnect() + applySizeRef.current = null viz.dispose() vizRef.current = null } @@ -65,9 +73,17 @@ export default function WaveformScope({ vizRef.current?.setOptions(waveformSettingsToOptions(settings, theme)) }, [settings, theme]) + useLayoutEffect(() => { + applySizeRef.current?.() + }, [settings.rotation]) + return (
- +
) } diff --git a/src/plugin-ui/peakOverlay.ts b/src/plugin-ui/peakOverlay.ts index 0854171..4e820c7 100644 --- a/src/plugin-ui/peakOverlay.ts +++ b/src/plugin-ui/peakOverlay.ts @@ -1,5 +1,11 @@ import type { CSSProperties } from 'react' import type { SpectrumPeakInfo } from '../types/spectrum' +import type { ScopeDisplayRotation } from '../types/scopeTransform' +import { + measureScopeCanvasLayout, + transformNormalizedScopePoint, + type ScopeCanvasLayout, +} from '../renderer/scopeCanvasTransform' /** * Peak-overlay positioning + formatting, mirroring ScopeModule.tsx so the plugin's @@ -7,13 +13,7 @@ import type { SpectrumPeakInfo } from '../types/spectrum' * copy (pure functions) so the plugin doesn't import the heavy ScopeModule. */ -export interface CanvasResizeState { - cssWidth: number - cssHeight: number - pixelWidth: number - pixelHeight: number - dpr: number -} +export type CanvasResizeState = ScopeCanvasLayout export interface SizeMeasurement { width: number @@ -45,25 +45,19 @@ export function formatSpectrumPeakFrequency(value: number): string { return `${value.toFixed(2)}Hz` } -export function measureCanvasResizeState(container: HTMLElement): CanvasResizeState { - const rect = container.getBoundingClientRect() - const cssWidth = Math.max(1, Math.floor(rect.width)) - const cssHeight = Math.max(1, Math.floor(rect.height)) - const dpr = window.devicePixelRatio || 1 - - return { - cssWidth, - cssHeight, - pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)), - pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)), - dpr, - } +export function measureCanvasResizeState( + container: HTMLElement, + rotation: ScopeDisplayRotation = 0, +): CanvasResizeState { + return measureScopeCanvasLayout(container, rotation) } export function resolveFollowingPeakOverlayStyle( peakInfo: SpectrumPeakInfo, resizeState: CanvasResizeState | null, overlaySize: SizeMeasurement | null, + rotation: ScopeDisplayRotation = 0, + mirrorHorizontal = false, ): CSSProperties { if (!resizeState) { return { @@ -72,12 +66,17 @@ export function resolveFollowingPeakOverlayStyle( } } - const width = resizeState.cssWidth - const height = resizeState.cssHeight + const width = resizeState.viewportCssWidth + const height = resizeState.viewportCssHeight const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX - const peakX = peakInfo.normalizedX * width - const peakY = peakInfo.normalizedY * height + const transformedPeak = transformNormalizedScopePoint( + { x: peakInfo.normalizedX, y: peakInfo.normalizedY }, + rotation, + mirrorHorizontal, + ) + const peakX = transformedPeak.x * width + const peakY = transformedPeak.y * height const maxLeft = Math.max( SPECTRUM_PEAK_OVERLAY_MARGIN_PX, width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX, diff --git a/src/plugin-ui/scopeCanvasLayout.ts b/src/plugin-ui/scopeCanvasLayout.ts new file mode 100644 index 0000000..6ebd31d --- /dev/null +++ b/src/plugin-ui/scopeCanvasLayout.ts @@ -0,0 +1,34 @@ +import type { ScopeDisplayRotation } from '../types/scopeTransform' +import { + measureScopeCanvasLayout, + type ScopeCanvasLayout, +} from '../renderer/scopeCanvasTransform' + +type PixelSizeResolver = ( + cssWidth: number, + cssHeight: number, + dpr: number, +) => { width: number; height: number } + +export function applyPluginScopeCanvasLayout( + container: HTMLElement, + canvas: HTMLCanvasElement, + rotation: ScopeDisplayRotation, + pixelSizeResolver?: PixelSizeResolver, +): { changed: boolean; layout: ScopeCanvasLayout } { + const layout = measureScopeCanvasLayout(container, rotation) + const resolvedPixels = pixelSizeResolver + ? pixelSizeResolver(layout.cssWidth, layout.cssHeight, layout.dpr) + : { width: layout.pixelWidth, height: layout.pixelHeight } + + canvas.style.width = `${layout.cssWidth}px` + canvas.style.height = `${layout.cssHeight}px` + + const changed = canvas.width !== resolvedPixels.width || canvas.height !== resolvedPixels.height + if (changed) { + canvas.width = resolvedPixels.width + canvas.height = resolvedPixels.height + } + + return { changed, layout } +} diff --git a/src/plugin-ui/spectrogramOptions.ts b/src/plugin-ui/spectrogramOptions.ts index 6f29c42..8ff02cd 100644 --- a/src/plugin-ui/spectrogramOptions.ts +++ b/src/plugin-ui/spectrogramOptions.ts @@ -20,7 +20,7 @@ export function spectrogramSettingsToOptions( contrast: settings.contrast, clarityMode: settings.clarityMode, scaleMode: settings.scaleMode, - orientation: settings.orientation, + orientation: 'horizontal', colorScheme: settings.colorScheme, } } diff --git a/src/plugin-ui/useScopeHostSync.ts b/src/plugin-ui/useScopeHostSync.ts index db67e7c..2cf9776 100644 --- a/src/plugin-ui/useScopeHostSync.ts +++ b/src/plugin-ui/useScopeHostSync.ts @@ -3,21 +3,13 @@ import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings' import type { ScopeKind } from '../types/scope' import type { PrismResolvedTheme } from '../types/theme' import { createBundledThemes, createDefaultTheme, parseThemeFileContent, resolveTheme } from '../shared/themeState' +import { mergeScopeSettings as normalizeAllScopeSettings } from '../shared/profileState' import { emitToHost, onHostEvent } from './juceBridge' const DEFAULT_THEME = resolveTheme(createDefaultTheme()) function mergeScopeSettings(kind: K, raw: unknown): ScopeSettings[K] { - const defaults = DEFAULT_SCOPE_SETTINGS[kind] as Record - if (typeof raw !== 'object' || raw === null) return { ...defaults } as ScopeSettings[K] - const parsed = raw as Record - const next: Record = { ...defaults } - for (const key of Object.keys(defaults)) { - if (key in parsed && typeof parsed[key] === typeof defaults[key]) { - next[key] = parsed[key] - } - } - return next as ScopeSettings[K] + return normalizeAllScopeSettings({ [kind]: raw })[kind] } function resolveAppTheme(themeId: string, themeFile: string): PrismResolvedTheme { diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index a59fdad..574da37 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react' -import type { ScopeKind } from '../../types/scope' +import { isTransformableScopeKind, type ScopeKind } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' +import type { ScopeDisplayRotation } from '../../types/scopeTransform' import type { PrismResolvedTheme, ResolvedAstraTheme, @@ -25,6 +26,13 @@ import { VUMeter, type VUMeterDataSource } from '../visualizers/VUMeter' import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter' import { Waveform, type WaveformDataSource } from '../visualizers/Waveform' import type { FrameScheduler } from '../visualizers/frameScheduler' +import { + getScopeCanvasTransformStyle, + isSameScopeCanvasLayout, + measureScopeCanvasLayout, + transformNormalizedScopePoint, + type ScopeCanvasLayout, +} from '../scopeCanvasTransform' type ScopeModuleTheme = | ResolvedSpectrumTheme @@ -59,14 +67,6 @@ interface Visualizer { setOptions(options: Record): void } -interface CanvasResizeState { - cssWidth: number - cssHeight: number - pixelWidth: number - pixelHeight: number - dpr: number -} - const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10 const SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX = 248 const SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX = 42 @@ -106,8 +106,10 @@ function formatSpectrumPeakFrequency(value: number): string { function resolveFollowingPeakOverlayStyle( peakInfo: SpectrumPeakInfo, - resizeState: CanvasResizeState | null, + resizeState: ScopeCanvasLayout | null, overlaySize: SizeMeasurement | null, + rotation: ScopeDisplayRotation, + mirrorHorizontal: boolean, ): CSSProperties { if (!resizeState) { return { @@ -116,12 +118,17 @@ function resolveFollowingPeakOverlayStyle( } } - const width = resizeState.cssWidth - const height = resizeState.cssHeight + const width = resizeState.viewportCssWidth + const height = resizeState.viewportCssHeight const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX - const peakX = peakInfo.normalizedX * width - const peakY = peakInfo.normalizedY * height + const transformedPeak = transformNormalizedScopePoint( + { x: peakInfo.normalizedX, y: peakInfo.normalizedY }, + rotation, + mirrorHorizontal, + ) + const peakX = transformedPeak.x * width + const peakY = transformedPeak.y * height const maxLeft = Math.max( SPECTRUM_PEAK_OVERLAY_MARGIN_PX, width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX, @@ -145,36 +152,6 @@ function resolveFollowingPeakOverlayStyle( } } -function measureCanvasResizeState(container: HTMLDivElement): CanvasResizeState { - const rect = container.getBoundingClientRect() - const cssWidth = Math.max(1, Math.floor(rect.width)) - const cssHeight = Math.max(1, Math.floor(rect.height)) - const dpr = window.devicePixelRatio || 1 - - return { - cssWidth, - cssHeight, - pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)), - pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)), - dpr, - } -} - -function isSameCanvasResizeState( - left: CanvasResizeState | null, - right: CanvasResizeState | null, -): boolean { - if (!left || !right) { - return false - } - - return left.cssWidth === right.cssWidth - && left.cssHeight === right.cssHeight - && left.pixelWidth === right.pixelWidth - && left.pixelHeight === right.pixelHeight - && left.dpr === right.dpr -} - export function scopeSettingsToOptions( kind: ScopeKind, settings: ScopeSettings[ScopeKind], @@ -253,7 +230,7 @@ export function scopeSettingsToOptions( contrast: s.contrast, clarityMode: s.clarityMode, scaleMode: s.scaleMode, - orientation: s.orientation, + orientation: 'horizontal', colorScheme: s.colorScheme, } } @@ -411,8 +388,8 @@ export default function ScopeModule({ const canvasRef = useRef(null) const visualizerRef = useRef(null) const initializedRef = useRef(false) - const pendingResizeRef = useRef(null) - const appliedResizeRef = useRef(null) + const pendingResizeRef = useRef(null) + const appliedResizeRef = useRef(null) const resizeFrameRef = useRef(null) const snapshotCanvasRef = useRef(null) const peakOverlayRef = useRef(null) @@ -424,6 +401,12 @@ export default function ScopeModule({ const windowBgAlpha = useWindowBackgroundAlpha() const mySettings = settings ?? storeSettings const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind) + const rotation: ScopeDisplayRotation = isTransformableScopeKind(scopeKind) + ? (mySettings as ScopeSettings[typeof scopeKind]).rotation + : 0 + const mirrorHorizontal = isTransformableScopeKind(scopeKind) + ? (mySettings as ScopeSettings[typeof scopeKind]).mirrorHorizontal + : false const spectrumPeakMode = scopeKind === 'spectrum' ? (mySettings as ScopeSettings['spectrum']).peakInfoMode : 'off' @@ -516,7 +499,9 @@ export default function ScopeModule({ initializedRef.current = false setSpectrumPeakInfo(null) } - }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, myTheme, mySettings, scopeKind, windowBgAlpha]) + // Settings and theme changes are applied live by the setOptions effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataSource, frameScheduler, handleSpectrumPeakInfo, scopeKind]) useEffect(() => { if (!visualizerRef.current || !initializedRef.current) return @@ -537,7 +522,7 @@ export default function ScopeModule({ visualizerRef.current.setOptions(opts) }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, mySettings, myTheme, scopeKind, windowBgAlpha]) - useEffect(() => { + useLayoutEffect(() => { const container = containerRef.current const canvas = canvasRef.current if (!container || !canvas) return @@ -552,7 +537,7 @@ export default function ScopeModule({ const applyResize = (): void => { resizeFrameRef.current = null const nextResize = pendingResizeRef.current - if (!nextResize || isSameCanvasResizeState(appliedResizeRef.current, nextResize)) { + if (!nextResize || isSameScopeCanvasLayout(appliedResizeRef.current, nextResize)) { return } @@ -593,7 +578,7 @@ export default function ScopeModule({ } const scheduleResize = (): void => { - pendingResizeRef.current = measureCanvasResizeState(container) + pendingResizeRef.current = measureScopeCanvasLayout(container, rotation) if (resizeFrameRef.current !== null) { return } @@ -603,7 +588,7 @@ export default function ScopeModule({ }) } - pendingResizeRef.current = measureCanvasResizeState(container) + pendingResizeRef.current = measureScopeCanvasLayout(container, rotation) applyResize() const observer = new ResizeObserver(() => { @@ -621,12 +606,18 @@ export default function ScopeModule({ appliedResizeRef.current = null snapshotCanvasRef.current = null } - }, []) + }, [rotation]) const spectrumPeakOverlayStyle = scopeKind === 'spectrum' && spectrumPeakMode === 'following' && spectrumPeakInfo - ? resolveFollowingPeakOverlayStyle(spectrumPeakInfo, appliedResizeRef.current, peakOverlaySize) + ? resolveFollowingPeakOverlayStyle( + spectrumPeakInfo, + appliedResizeRef.current, + peakOverlaySize, + rotation, + mirrorHorizontal, + ) : undefined return ( @@ -643,11 +634,7 @@ export default function ScopeModule({ {scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && ( diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 7a7c994..7188ca5 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -1,7 +1,8 @@ import { useState, type CSSProperties, type JSX, type ReactNode } from 'react' import type { ScopeKind } from '../../types/scope' -import { SCOPE_LABELS } from '../../types/scope' +import { SCOPE_LABELS, isTransformableScopeKind } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' +import { SCOPE_DISPLAY_ROTATIONS, type ScopeDisplayRotation } from '../../types/scopeTransform' import { MAX_SPECTROGRAM_CONTRAST, MAX_SPECTROGRAM_TILT_DB_PER_OCTAVE, @@ -62,6 +63,15 @@ function nowPlayingVisibleLabels(settings: ScopeSettings['nowPlaying']): string[ return labels } +function appendTransformSummary( + parts: string[], + settings: { rotation: ScopeDisplayRotation; mirrorHorizontal: boolean }, +): string { + if (settings.rotation !== 0) parts.push(`R${settings.rotation}°`) + if (settings.mirrorHorizontal) parts.push('Mirror') + return parts.join(' · ') +} + export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string { switch (kind) { case 'spectrum': { @@ -76,12 +86,15 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] } else if (scopeSettings.peakInfoMode === 'following') { parts.push('Peak Follow') } - return parts.join(' · ') + return appendTransformSummary(parts, scopeSettings) } case 'oscilloscope': { const scopeSettings = settings as ScopeSettings['oscilloscope'] const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run' - return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode + return appendTransformSummary( + scopeSettings.underfillEnabled ? [mode, 'Fill'] : [mode], + scopeSettings, + ) } case 'vectorscope': { const scopeSettings = settings as ScopeSettings['vectorscope'] @@ -91,7 +104,12 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] } case 'spectrogram': { const scopeSettings = settings as ScopeSettings['spectrogram'] - return `${scopeSettings.orientation.toUpperCase()} · ${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}` + return [ + `${scopeSettings.rotation}°`, + scopeSettings.scaleMode.toUpperCase(), + scopeSettings.clarityMode, + ...(scopeSettings.mirrorHorizontal ? ['Mirror'] : []), + ].join(' · ') } case 'vumeter': { const scopeSettings = settings as ScopeSettings['vumeter'] @@ -112,7 +130,7 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] if (scopeSettings.multiband) { summary.push('RGB') } - return summary.join(' · ') + return appendTransformSummary(summary, scopeSettings) } case 'nowPlaying': { const visible = nowPlayingVisibleLabels(settings as ScopeSettings['nowPlaying']) @@ -515,15 +533,6 @@ export default function ScopeSettingsSection({ - onUpdate('spectrogram', { orientation: value as ScopeSettings['spectrogram']['orientation'] })} - > - - - - ) })()} + + {isTransformableScopeKind(kind) && (() => { + const current = settings as ScopeSettings[typeof kind] + return ( + <> + onUpdate(kind, { rotation: Number(value) as ScopeDisplayRotation })} + > + {SCOPE_DISPLAY_ROTATIONS.map((rotation) => ( + + ))} + + + + onUpdate(kind, { mirrorHorizontal: !current.mirrorHorizontal })} + /> + + + ) + })()}
) diff --git a/src/renderer/scopeCanvasTransform.ts b/src/renderer/scopeCanvasTransform.ts new file mode 100644 index 0000000..7c4cdcc --- /dev/null +++ b/src/renderer/scopeCanvasTransform.ts @@ -0,0 +1,109 @@ +import type { CSSProperties } from 'react' +import type { ScopeDisplayRotation } from '../types/scopeTransform' + +export interface ScopeCanvasLayout { + viewportCssWidth: number + viewportCssHeight: number + cssWidth: number + cssHeight: number + pixelWidth: number + pixelHeight: number + dpr: number +} + +export interface NormalizedScopePoint { + x: number + y: number +} + +export function isQuarterTurn(rotation: ScopeDisplayRotation): boolean { + return rotation === 90 || rotation === 270 +} + +export function resolveScopeCanvasLayout( + viewportWidth: number, + viewportHeight: number, + devicePixelRatio: number, + rotation: ScopeDisplayRotation, +): ScopeCanvasLayout { + const viewportCssWidth = Math.max(1, Math.floor(viewportWidth)) + const viewportCssHeight = Math.max(1, Math.floor(viewportHeight)) + const dpr = devicePixelRatio > 0 && Number.isFinite(devicePixelRatio) + ? devicePixelRatio + : 1 + const cssWidth = isQuarterTurn(rotation) ? viewportCssHeight : viewportCssWidth + const cssHeight = isQuarterTurn(rotation) ? viewportCssWidth : viewportCssHeight + + return { + viewportCssWidth, + viewportCssHeight, + cssWidth, + cssHeight, + pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)), + pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)), + dpr, + } +} + +export function measureScopeCanvasLayout( + container: HTMLElement, + rotation: ScopeDisplayRotation, +): ScopeCanvasLayout { + const rect = container.getBoundingClientRect() + return resolveScopeCanvasLayout( + rect.width, + rect.height, + window.devicePixelRatio || 1, + rotation, + ) +} + +export function isSameScopeCanvasLayout( + left: ScopeCanvasLayout | null, + right: ScopeCanvasLayout | null, +): boolean { + if (!left || !right) return false + + return left.viewportCssWidth === right.viewportCssWidth + && left.viewportCssHeight === right.viewportCssHeight + && left.cssWidth === right.cssWidth + && left.cssHeight === right.cssHeight + && left.pixelWidth === right.pixelWidth + && left.pixelHeight === right.pixelHeight + && left.dpr === right.dpr +} + +export function getScopeCanvasTransformStyle( + rotation: ScopeDisplayRotation, + mirrorHorizontal: boolean, +): CSSProperties { + return { + position: 'absolute', + top: '50%', + left: '50%', + display: 'block', + transformOrigin: 'center center', + transform: `translate(-50%, -50%) rotate(${rotation}deg) scaleX(${mirrorHorizontal ? -1 : 1})`, + } +} + +export function transformNormalizedScopePoint( + point: NormalizedScopePoint, + rotation: ScopeDisplayRotation, + mirrorHorizontal: boolean, +): NormalizedScopePoint { + const x = mirrorHorizontal ? 1 - point.x : point.x + const y = point.y + + switch (rotation) { + case 90: + return { x: 1 - y, y: x } + case 180: + return { x: 1 - x, y: 1 - y } + case 270: + return { x: y, y: 1 - x } + case 0: + default: + return { x, y } + } +} diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index b21fe8c..65f6835 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -137,6 +137,30 @@ function buildProfileBaselineSignature(profile: Profile | null): string | undefi }) } +function normalizePersistedProfileBaselineSignature(raw: string): string | null { + try { + const parsed = JSON.parse(raw) as unknown + if (typeof parsed !== 'object' || parsed === null) return null + const candidate = parsed as Record + const normalizedPopouts = normalizeScopePopouts(candidate.scopePopouts) + const scopePopouts = SCOPE_KINDS.reduce((acc, kind) => { + acc[kind] = { poppedOut: normalizedPopouts[kind]?.poppedOut === true } + return acc + }, {} as Record) + + return JSON.stringify({ + name: typeof candidate.name === 'string' ? candidate.name : DEFAULT_PROFILE_NAME, + scopeOrder: normalizeScopeOrder(candidate.scopeOrder), + hiddenScopes: normalizeHiddenScopes(candidate.hiddenScopes), + widthWeights: normalizeWidthWeights(candidate.widthWeights), + scopeSettings: mergeScopeSettings(candidate.scopeSettings), + scopePopouts, + }) + } catch { + return null + } +} + function restoreBaselineScopePopoutBounds( scopePopouts: ScopePopoutStateMap, baseline: Profile | null, @@ -274,7 +298,9 @@ function canRestorePersistedWorkingState( return false } - return state.profileBaselineSignature === buildProfileBaselineSignature(activeProfile) + const activeSignature = buildProfileBaselineSignature(activeProfile) + return state.profileBaselineSignature === activeSignature + || normalizePersistedProfileBaselineSignature(state.profileBaselineSignature) === activeSignature } function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null { diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 918084c..baf8e84 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -9,19 +9,21 @@ import { type ProfileLocalMetadata, type PrismProfileFile, type PrismProfileFileScopePopoutMap, - type PrismProfileFileV3, + type PrismProfileFileV4, 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 { clampSpectrogramTiltDbPerOctave } from '../types/spectrogram' import { isVUMeterNeedleChannels, sanitizeVUReferenceDbfs } from '../types/vumeter' import { clampWaveformScrollSpeed } from '../types/waveform' +import { + normalizeScopeDisplayRotation, + normalizeScopeMirrorHorizontal, + type ScopeDisplayRotation, +} from '../types/scopeTransform' export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS] @@ -58,6 +60,18 @@ function normalizeWaveformScrollSpeed(value: unknown, legacyProfileFileScale: bo return clampWaveformScrollSpeed(value ?? DEFAULT_SCOPE_SETTINGS.waveform.scrollSpeed) } +function normalizeDisplayTransform( + raw: { rotation?: unknown; mirrorHorizontal?: unknown }, + legacyRotation?: ScopeDisplayRotation, +): { rotation: ScopeDisplayRotation; mirrorHorizontal: boolean } { + return { + rotation: raw.rotation === undefined + ? (legacyRotation ?? 0) + : normalizeScopeDisplayRotation(raw.rotation), + mirrorHorizontal: normalizeScopeMirrorHorizontal(raw.mirrorHorizontal), + } +} + export function createDefaultScopePopouts(): ScopePopoutStateMap { return SCOPE_KINDS.reduce((acc, kind) => { acc[kind] = { poppedOut: false } @@ -160,6 +174,12 @@ export function mergeScopeSettings( const rawSpectrogram: Partial = typeof parsed.spectrogram === 'object' && parsed.spectrogram !== null ? parsed.spectrogram : {} + const rawSpectrogramWithLegacy = rawSpectrogram as Partial & { orientation?: unknown } + const { orientation: legacySpectrogramOrientation, ...rawSpectrogramSettings } = rawSpectrogramWithLegacy + const legacySpectrogramRotation: ScopeDisplayRotation = legacySpectrogramOrientation === 'vertical' ? 90 : 0 + const rawOscilloscope: Partial = typeof parsed.oscilloscope === 'object' && parsed.oscilloscope !== null + ? parsed.oscilloscope + : {} const rawWaveform: Partial = typeof parsed.waveform === 'object' && parsed.waveform !== null ? parsed.waveform : {} @@ -177,16 +197,19 @@ export function mergeScopeSettings( spectrum: { ...DEFAULT_SCOPE_SETTINGS.spectrum, ...rawSpectrum, + ...normalizeDisplayTransform(rawSpectrum), peakInfoMode: normalizeSpectrumPeakInfoMode(rawSpectrum.peakInfoMode), }, - oscilloscope: { ...DEFAULT_SCOPE_SETTINGS.oscilloscope, ...(parsed.oscilloscope ?? {}) }, + oscilloscope: { + ...DEFAULT_SCOPE_SETTINGS.oscilloscope, + ...rawOscilloscope, + ...normalizeDisplayTransform(rawOscilloscope), + }, vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) }, spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, - ...rawSpectrogram, - orientation: isSpectrogramOrientation(rawSpectrogram.orientation) - ? rawSpectrogram.orientation - : DEFAULT_SCOPE_SETTINGS.spectrogram.orientation, + ...rawSpectrogramSettings, + ...normalizeDisplayTransform(rawSpectrogram, legacySpectrogramRotation), tiltDbPerOctave: clampSpectrogramTiltDbPerOctave( rawSpectrogram.tiltDbPerOctave ?? DEFAULT_SCOPE_SETTINGS.spectrogram.tiltDbPerOctave ), @@ -208,6 +231,7 @@ export function mergeScopeSettings( }, waveform: { ...DEFAULT_SCOPE_SETTINGS.waveform, + ...normalizeDisplayTransform(rawWaveform), mode: rawWaveform.mode === 'stereo' || rawWaveform.mode === 'mono' ? rawWaveform.mode : DEFAULT_SCOPE_SETTINGS.waveform.mode, @@ -288,7 +312,7 @@ export function normalizeProfileFile( raw: unknown, fallbackId: string, fallbackName = DEFAULT_PROFILE_NAME, -) : PrismProfileFileV3 { +) : PrismProfileFileV4 { const parsed = typeof raw === 'object' && raw !== null ? raw as Partial : {} @@ -314,7 +338,7 @@ export function normalizeProfileFile( } } -export function profileToFileData(id: string, profile: Profile): PrismProfileFileV3 { +export function profileToFileData(id: string, profile: Profile): PrismProfileFileV4 { const normalized = normalizeProfile(profile, profile.name) return { diff --git a/src/types/profile.ts b/src/types/profile.ts index d0200bc..66c9b93 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 = 3 +export const PROFILE_FILE_VERSION = 4 export const PROFILE_LOCAL_STATE_FORMAT = 'prism-profile-local' export const PROFILE_LOCAL_STATE_VERSION = 1 export const LEGACY_PROFILE_MIGRATION_VERSION = 1 @@ -52,6 +52,19 @@ export interface PrismProfileFileV2 { } export interface PrismProfileFileV3 { + format: typeof PROFILE_FILE_FORMAT + version: 3 + id: string + name: string + themeId?: string | null + scopeOrder: ScopeKind[] + hiddenScopes: ScopeKind[] + widthWeights: Record + scopeSettings: ScopeSettings + scopePopouts: PrismProfileFileScopePopoutMap +} + +export interface PrismProfileFileV4 { format: typeof PROFILE_FILE_FORMAT version: typeof PROFILE_FILE_VERSION id: string @@ -64,7 +77,7 @@ export interface PrismProfileFileV3 { scopePopouts: PrismProfileFileScopePopoutMap } -export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 +export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 | PrismProfileFileV4 export interface ProfileLocalMetadata { windowBounds?: WindowBounds diff --git a/src/types/scope.ts b/src/types/scope.ts index bd29b0d..f79fd19 100644 --- a/src/types/scope.ts +++ b/src/types/scope.ts @@ -10,6 +10,12 @@ export type ScopeKind = export type AudioScopeKind = Exclude +export type TransformableScopeKind = + | 'spectrum' + | 'oscilloscope' + | 'spectrogram' + | 'waveform' + export const SCOPE_KINDS: ScopeKind[] = [ 'spectrum', 'oscilloscope', @@ -31,6 +37,17 @@ export const AUDIO_SCOPE_KINDS: AudioScopeKind[] = [ 'waveform', ] +export const TRANSFORMABLE_SCOPE_KINDS: TransformableScopeKind[] = [ + 'spectrum', + 'oscilloscope', + 'spectrogram', + 'waveform', +] + +export function isTransformableScopeKind(value: ScopeKind): value is TransformableScopeKind { + return TRANSFORMABLE_SCOPE_KINDS.includes(value as TransformableScopeKind) +} + export function isAudioScopeKind(value: unknown): value is AudioScopeKind { return typeof value === 'string' && AUDIO_SCOPE_KINDS.includes(value as AudioScopeKind) } diff --git a/src/types/scopeTransform.ts b/src/types/scopeTransform.ts new file mode 100644 index 0000000..03106d5 --- /dev/null +++ b/src/types/scopeTransform.ts @@ -0,0 +1,23 @@ +export type ScopeDisplayRotation = 0 | 90 | 180 | 270 + +export interface ScopeDisplayTransformSettings { + rotation: ScopeDisplayRotation + mirrorHorizontal: boolean +} + +export const SCOPE_DISPLAY_ROTATIONS: readonly ScopeDisplayRotation[] = [0, 90, 180, 270] +export const DEFAULT_SCOPE_DISPLAY_ROTATION: ScopeDisplayRotation = 0 +export const DEFAULT_SCOPE_MIRROR_HORIZONTAL = false + +export function isScopeDisplayRotation(value: unknown): value is ScopeDisplayRotation { + return typeof value === 'number' + && SCOPE_DISPLAY_ROTATIONS.includes(value as ScopeDisplayRotation) +} + +export function normalizeScopeDisplayRotation(value: unknown): ScopeDisplayRotation { + return isScopeDisplayRotation(value) ? value : DEFAULT_SCOPE_DISPLAY_ROTATION +} + +export function normalizeScopeMirrorHorizontal(value: unknown): boolean { + return typeof value === 'boolean' ? value : DEFAULT_SCOPE_MIRROR_HORIZONTAL +} diff --git a/src/types/settings.ts b/src/types/settings.ts index ad5a8e1..752b8c6 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,19 +1,22 @@ import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope' import { DEFAULT_SPECTROGRAM_CONTRAST, - DEFAULT_SPECTROGRAM_ORIENTATION, DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, type SpectrogramClarityMode, - type SpectrogramOrientation, type SpectrogramScaleMode, } 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, DEFAULT_WAVEFORM_SCROLL_SPEED, type WaveformMode } from './waveform' import { DEFAULT_SPECTRUM_PEAK_INFO_MODE, type SpectrumPeakInfoMode } from './spectrum' +import { + DEFAULT_SCOPE_DISPLAY_ROTATION, + DEFAULT_SCOPE_MIRROR_HORIZONTAL, + type ScopeDisplayTransformSettings, +} from './scopeTransform' export interface ScopeSettings { - spectrum: { + spectrum: ScopeDisplayTransformSettings & { fftSize: number tiltDbPerOctave: number heatmap: boolean @@ -25,7 +28,7 @@ export interface ScopeSettings { showSideLine: boolean peakInfoMode: SpectrumPeakInfoMode } - oscilloscope: { + oscilloscope: ScopeDisplayTransformSettings & { pitchLock: boolean underfillEnabled: boolean showGrid: boolean @@ -38,14 +41,13 @@ export interface ScopeSettings { persistence: number lineWidth: number } - spectrogram: { + spectrogram: ScopeDisplayTransformSettings & { fftSize: number tiltDbPerOctave: number scrollSpeed: number contrast: number clarityMode: SpectrogramClarityMode scaleMode: SpectrogramScaleMode - orientation: SpectrogramOrientation colorScheme: 'heat' | 'mono' } vumeter: { @@ -58,7 +60,7 @@ export interface ScopeSettings { mode: LUFSMeterMode readout: LUFSMeterReadout } - waveform: { + waveform: ScopeDisplayTransformSettings & { mode: WaveformMode scrollSpeed: number multiband: boolean @@ -74,13 +76,13 @@ export interface ScopeSettings { } export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { - spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE }, - oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, + spectrum: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE }, + oscilloscope: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, - 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' }, + spectrogram: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', 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: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false }, + waveform: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false }, nowPlaying: { showCoverArt: true, showTitle: true, diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 38f3a57..08d2ed8 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -51,7 +51,8 @@ function createProfile(name: string): Profile { profile.scopeSettings.spectrum.showSideLine = true profile.scopeSettings.spectrum.heatmapSmoothing = 0.67 profile.scopeSettings.spectrogram.colorScheme = 'mono' - profile.scopeSettings.spectrogram.orientation = 'vertical' + profile.scopeSettings.spectrogram.rotation = 90 + profile.scopeSettings.spectrogram.mirrorHorizontal = true return profile } @@ -67,7 +68,9 @@ test('profile file serialization excludes geometry and round-trips with local me 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.scopeSettings.spectrogram.orientation, 'vertical') + assert.equal(file.scopeSettings.spectrogram.rotation, 90) + assert.equal(file.scopeSettings.spectrogram.mirrorHorizontal, true) + assert.equal('orientation' in file.scopeSettings.spectrogram, false) assert.equal(file.scopeOrder.includes('nowPlaying'), false) assert.equal(file.hiddenScopes.includes('nowPlaying'), true) assert.equal(file.widthWeights.nowPlaying, 1) @@ -78,7 +81,8 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(restored.scopeSettings.spectrum.showSideLine, true) assert.equal(restored.scopeSettings.spectrum.heatmapSmoothing, 0.67) assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono') - assert.equal(restored.scopeSettings.spectrogram.orientation, 'vertical') + assert.equal(restored.scopeSettings.spectrogram.rotation, 90) + assert.equal(restored.scopeSettings.spectrogram.mirrorHorizontal, true) assert.equal(restored.scopeSettings.nowPlaying.showControls, true) }) @@ -114,22 +118,48 @@ test('profile file waveform speed migration preserves legacy scroll feel', () => assert.equal(legacyMissingSpeed.scopeSettings.waveform.scrollSpeed, 1) }) -test('mergeScopeSettings defaults missing or invalid spectrogram orientation to horizontal', () => { +test('mergeScopeSettings migrates legacy spectrogram orientation and validates display transforms', () => { const vertical = mergeScopeSettings({ spectrogram: { orientation: 'vertical', }, }) - const invalid = mergeScopeSettings({ + const horizontal = mergeScopeSettings({ spectrogram: { - orientation: 'diagonal', + orientation: 'horizontal', }, }) + const explicit = mergeScopeSettings({ + spectrogram: { + orientation: 'vertical', + rotation: 270, + mirrorHorizontal: true, + }, + spectrum: { + rotation: 180, + mirrorHorizontal: true, + }, + }) + const invalid = mergeScopeSettings({ + spectrogram: { rotation: 45, mirrorHorizontal: 'yes' }, + oscilloscope: { rotation: -90, mirrorHorizontal: 1 }, + waveform: { rotation: '90', mirrorHorizontal: null }, + }) const missing = mergeScopeSettings({}) - assert.equal(vertical.spectrogram.orientation, 'vertical') - assert.equal(invalid.spectrogram.orientation, 'horizontal') - assert.equal(missing.spectrogram.orientation, 'horizontal') + assert.equal(vertical.spectrogram.rotation, 90) + assert.equal(horizontal.spectrogram.rotation, 0) + assert.equal(explicit.spectrogram.rotation, 270) + assert.equal(explicit.spectrogram.mirrorHorizontal, true) + assert.equal(explicit.spectrum.rotation, 180) + assert.equal(explicit.spectrum.mirrorHorizontal, true) + assert.equal(invalid.spectrogram.rotation, 0) + assert.equal(invalid.spectrogram.mirrorHorizontal, false) + assert.equal(invalid.oscilloscope.rotation, 0) + assert.equal(invalid.waveform.rotation, 0) + assert.equal(missing.spectrogram.rotation, 0) + assert.equal(missing.spectrogram.mirrorHorizontal, false) + assert.equal('orientation' in vertical.spectrogram, false) }) test('mergeScopeSettings defaults missing or invalid VU needle channel settings to stereo', () => { @@ -361,6 +391,36 @@ test('legacy profile files with themeId import successfully and ignore embedded } }) +test('version 3 profiles remain importable and migrate vertical spectrograms to 90 degrees', async () => { + const harness = await createHarness() + + try { + const base = profileToFileData('profile_v3', createDefaultProfile('Version 3')) + const { rotation: _rotation, mirrorHorizontal: _mirrorHorizontal, ...legacySpectrogram } = base.scopeSettings.spectrogram + const legacyPath = join(harness.rootDir, 'version-3.prsm') + await writeFile(legacyPath, `${JSON.stringify({ + ...base, + version: 3, + scopeSettings: { + ...base.scopeSettings, + spectrogram: { + ...legacySpectrogram, + orientation: 'vertical', + }, + }, + }, null, 2)}\n`, 'utf8') + + const snapshot = await harness.library.importProfileFromPath(legacyPath) + const imported = snapshot.profiles.profile_v3 + assert.ok(imported) + assert.equal(imported.scopeSettings.spectrogram.rotation, 90) + assert.equal(imported.scopeSettings.spectrogram.mirrorHorizontal, false) + assert.equal('orientation' in imported.scopeSettings.spectrogram, false) + } finally { + await harness.cleanup() + } +}) + test('legacy migration writes managed files, preserves active profile, and stores local-only geometry', async () => { const harness = await createHarness() diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index b942731..1923c03 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -51,6 +51,10 @@ import { useThemeStore } from '../src/renderer/stores/themeStore' import { resolveThemeCreditDetails, resolveThemeOptionLabel } from '../src/renderer/components/BottomBar' import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule' import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection' +import { + resolveScopeCanvasLayout, + transformNormalizedScopePoint, +} from '../src/renderer/scopeCanvasTransform' import { applyInputGainToStereoSamples, inputGainDbToLinear, @@ -200,6 +204,7 @@ function installFakeTimeouts(hidden = false): { globalWithWindow.window = { ...globalThis, + localStorage: (globalThis as GlobalWithStorage).localStorage, electronAPI: { platform: 'darwin', windowCapabilities: resolveWindowCapabilities({ platform: 'darwin' }), @@ -345,9 +350,9 @@ function installFakeLocalStorage(): { const storage = new Map() let setCount = 0 const globalWithStorage = globalThis as GlobalWithStorage - const previousLocalStorage = globalWithStorage.localStorage + const previousLocalStorageDescriptor = Object.getOwnPropertyDescriptor(globalWithStorage, 'localStorage') - globalWithStorage.localStorage = { + const fakeLocalStorage = { getItem(key: string): string | null { return storage.get(key) ?? null }, @@ -368,6 +373,12 @@ function installFakeLocalStorage(): { return storage.size }, } as Storage + Object.defineProperty(globalWithStorage, 'localStorage', { + configurable: true, + enumerable: true, + value: fakeLocalStorage, + writable: true, + }) return { getSetCount: () => setCount, @@ -378,12 +389,12 @@ function installFakeLocalStorage(): { storage.set(key, value) }, restore(): void { - if (previousLocalStorage === undefined) { + if (!previousLocalStorageDescriptor) { delete globalWithStorage.localStorage return } - globalWithStorage.localStorage = previousLocalStorage + Object.defineProperty(globalWithStorage, 'localStorage', previousLocalStorageDescriptor) }, } } @@ -1218,10 +1229,16 @@ function renderSpectrogramColumnImage(options: Partial, valu try { const state = spectrogram as unknown as { ensureColumnBuffers: (height: number) => void - shiftAndPaintColumn: (values: Float32Array) => void + shiftAndPaintColumns: ( + display: Float32Array, + heat: Float32Array, + columnCount: number, + rowCount: number, + ) => void } state.ensureColumnBuffers(values.length) - state.shiftAndPaintColumn(Float32Array.from(values)) + const column = Float32Array.from(values) + state.shiftAndPaintColumns(column, column, 1, values.length) return recorder.imageDataWrites.at(-1)?.data ?? [] } finally { spectrogram.dispose() @@ -1254,10 +1271,16 @@ function renderSpectrogramShift( try { const state = spectrogram as unknown as { ensureColumnBuffers: (height: number) => void - shiftAndPaintColumn: (values: Float32Array) => void + shiftAndPaintColumns: ( + display: Float32Array, + heat: Float32Array, + columnCount: number, + rowCount: number, + ) => void } state.ensureColumnBuffers(values.length) - state.shiftAndPaintColumn(Float32Array.from(values)) + const column = Float32Array.from(values) + state.shiftAndPaintColumns(column, column, 1, values.length) return recorder } finally { spectrogram.dispose() @@ -1560,6 +1583,45 @@ test('analyzer layout locks the loudness meter width', () => { ) }) +test('scope canvas layout swaps logical dimensions for quarter-turn rotations', () => { + const horizontal = resolveScopeCanvasLayout(640, 360, 2, 0) + assert.deepEqual(horizontal, { + viewportCssWidth: 640, + viewportCssHeight: 360, + cssWidth: 640, + cssHeight: 360, + pixelWidth: 1280, + pixelHeight: 720, + dpr: 2, + }) + + const vertical = resolveScopeCanvasLayout(640, 360, 2, 90) + assert.deepEqual(vertical, { + viewportCssWidth: 640, + viewportCssHeight: 360, + cssWidth: 360, + cssHeight: 640, + pixelWidth: 720, + pixelHeight: 1280, + dpr: 2, + }) + assert.deepEqual(resolveScopeCanvasLayout(640, 360, 2, 270), vertical) +}) + +test('scope point transforms mirror the source axis before clockwise rotation', () => { + const point = { x: 0.2, y: 0.3 } + + assert.deepEqual(transformNormalizedScopePoint(point, 0, false), { x: 0.2, y: 0.3 }) + assert.deepEqual(transformNormalizedScopePoint(point, 90, false), { x: 0.7, y: 0.2 }) + assert.deepEqual(transformNormalizedScopePoint(point, 180, false), { x: 0.8, y: 0.7 }) + assert.deepEqual(transformNormalizedScopePoint(point, 270, false), { x: 0.3, y: 0.8 }) + assert.deepEqual(transformNormalizedScopePoint(point, 0, true), { x: 0.8, y: 0.3 }) + assert.deepEqual(transformNormalizedScopePoint(point, 90, true), { x: 0.7, y: 0.8 }) + const mirrored270 = transformNormalizedScopePoint(point, 270, true) + assert.equal(mirrored270.x, 0.3) + assertAlmostEqual(mirrored270.y, 0.2, 1e-12, 'mirrored 270-degree y coordinate') +}) + test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => { const profile = createDefaultProfile('Default') profile.scopeSettings.spectrum.showSideLine = true @@ -2631,7 +2693,7 @@ test('Vectorscope keeps the original linear projection behavior', () => { test('scopeSettingsToOptions forwards themed backgrounds and track colors to spectrogram, VU, and LUFS modules', () => { const profile = createDefaultProfile('Default') - profile.scopeSettings.spectrogram.orientation = 'vertical' + profile.scopeSettings.spectrogram.rotation = 90 profile.scopeSettings.spectrogram.tiltDbPerOctave = 5.2 profile.scopeSettings.lufsmeter.readout = 'shortTerm' profile.scopeSettings.vumeter.needleChannels = 'combined' @@ -2647,7 +2709,7 @@ test('scopeSettingsToOptions forwards themed backgrounds and track colors to spe const spectrogram = scopeSettingsToOptions('spectrogram', profile.scopeSettings.spectrogram, theme.spectrogram) assert.equal(spectrogram.backgroundColor, 'rgb(6, 7, 8)') - assert.equal(spectrogram.orientation, 'vertical') + assert.equal(spectrogram.orientation, 'horizontal') assert.equal(spectrogram.tiltDbPerOctave, 5.2) const vumeter = scopeSettingsToOptions('vumeter', profile.scopeSettings.vumeter, theme.vumeter) @@ -2788,13 +2850,18 @@ test('scopeSummary includes spectrum peak mode when enabled', () => { assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak Follow') }) -test('scopeSummary includes spectrogram orientation', () => { +test('scopeSummary includes visual-scope rotation and mirroring', () => { const profile = createDefaultProfile('Default') - assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), 'HORIZONTAL · LOG · sharper') + assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '0° · LOG · sharper') - profile.scopeSettings.spectrogram.orientation = 'vertical' - assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), 'VERTICAL · LOG · sharper') + profile.scopeSettings.spectrogram.rotation = 270 + profile.scopeSettings.spectrogram.mirrorHorizontal = true + assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '270° · LOG · sharper · Mirror') + + profile.scopeSettings.spectrum.rotation = 90 + profile.scopeSettings.spectrum.mirrorHorizontal = true + assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · R90° · Mirror') }) test('scopeSummary summarizes now playing field visibility', () => { @@ -3528,6 +3595,58 @@ test('initializeProfiles restores persisted dirty window bounds while keeping th } }) +test('initializeProfiles migrates legacy spectrogram working state without discarding it', async () => { + const previousSettingsState = useSettingsStore.getState() + const fakeStorage = installFakeLocalStorage() + const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) + profile.scopeSettings.spectrogram.rotation = 90 + const fakeWindow = installFakeElectronWindow({ + getProfileSnapshot: async () => ({ + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { [DEFAULT_PROFILE_ID]: profile }, + }), + getWindowBounds: async () => null, + }) + + try { + useSettingsStore.getState().applyExternalProfileSnapshot({ + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { [DEFAULT_PROFILE_ID]: profile }, + }) + const rawStored = fakeStorage.getItem('prism:settings') + assert.ok(rawStored) + const stored = JSON.parse(rawStored) as Record + const signature = JSON.parse(stored.profileBaselineSignature as string) as Record + const signatureSettings = signature.scopeSettings as Record> + const workingSettings = stored.scopeSettings as Record> + + delete signatureSettings.spectrogram.rotation + delete signatureSettings.spectrogram.mirrorHorizontal + signatureSettings.spectrogram.orientation = 'vertical' + delete workingSettings.spectrogram.rotation + delete workingSettings.spectrogram.mirrorHorizontal + workingSettings.spectrogram.orientation = 'horizontal' + fakeStorage.setItem('prism:settings', JSON.stringify({ + ...stored, + profileBaselineSignature: JSON.stringify(signature), + scopeSettings: workingSettings, + })) + useSettingsStore.setState(previousSettingsState) + + await useSettingsStore.getState().initializeProfiles() + + const state = useSettingsStore.getState() + assert.equal(state.savedProfileBaseline?.scopeSettings.spectrogram.rotation, 90) + assert.equal(state.scopeSettings.spectrogram.rotation, 0) + assert.equal(state.scopeSettings.spectrogram.mirrorHorizontal, false) + assert.equal(state.hasUnsavedProfileChanges, true) + } finally { + useSettingsStore.setState(previousSettingsState) + fakeWindow.restore() + fakeStorage.restore() + } +}) + test('initializeProfiles ignores persisted geometry on native Wayland and keeps the saved profile clean', async () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage()