scope rotation

This commit is contained in:
Boof2015
2026-07-10 21:08:29 -04:00
parent 4a9b325532
commit 7a1a799266
20 changed files with 695 additions and 191 deletions
+6 -1
View File
@@ -351,7 +351,12 @@ export class FileBackedProfileLibrary {
throw new Error(`Unsupported profile format in ${basename(filePath)}.`) 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)}.`) throw new Error(`Unsupported profile version in ${basename(filePath)}.`)
} }
+19 -9
View File
@@ -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 { Oscilloscope } from '../renderer/visualizers/Oscilloscope'
import type { ScopeSettings } from '../types/settings' import type { ScopeSettings } from '../types/settings'
import type { ResolvedOscilloscopeTheme } from '../types/theme' import type { ResolvedOscilloscopeTheme } from '../types/theme'
import type { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer' import type { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource' import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions' import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions'
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
interface OscilloscopeScopeProps { interface OscilloscopeScopeProps {
dataSource: PluginWebViewDataSource dataSource: PluginWebViewDataSource
@@ -22,6 +24,9 @@ export default function OscilloscopeScope({
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Oscilloscope | null>(null) const vizRef = useRef<Oscilloscope | null>(null)
const rotationRef = useRef(settings.rotation)
const applySizeRef = useRef<(() => void) | null>(null)
rotationRef.current = settings.rotation
useEffect(() => { useEffect(() => {
const container = containerRef.current const container = containerRef.current
@@ -36,16 +41,12 @@ export default function OscilloscopeScope({
vizRef.current = viz vizRef.current = viz
const applySize = (): void => { const applySize = (): void => {
const rect = container.getBoundingClientRect() const { changed } = applyPluginScopeCanvasLayout(container, canvas, rotationRef.current)
const dpr = window.devicePixelRatio || 1 if (changed) {
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
viz.resize() viz.resize()
} }
} }
applySizeRef.current = applySize
applySize() applySize()
viz.start() viz.start()
@@ -54,6 +55,7 @@ export default function OscilloscopeScope({
return () => { return () => {
observer.disconnect() observer.disconnect()
applySizeRef.current = null
viz.dispose() viz.dispose()
vizRef.current = null vizRef.current = null
} }
@@ -65,9 +67,17 @@ export default function OscilloscopeScope({
vizRef.current?.setOptions(oscilloscopeSettingsToOptions(settings, theme)) vizRef.current?.setOptions(oscilloscopeSettingsToOptions(settings, theme))
}, [settings, theme]) }, [settings, theme])
useLayoutEffect(() => {
applySizeRef.current?.()
}, [settings.rotation])
return ( return (
<div ref={containerRef} className="spectrum-scope"> <div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" /> <canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
</div> </div>
) )
} }
+24 -8
View File
@@ -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 { Spectrogram } from '../renderer/visualizers/Spectrogram'
import type { ScopeSettings } from '../types/settings' import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrogramTheme } from '../types/theme' import type { ResolvedSpectrogramTheme } from '../types/theme'
@@ -6,6 +6,8 @@ import type { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource' import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { spectrogramSettingsToOptions } from './spectrogramOptions' import { spectrogramSettingsToOptions } from './spectrogramOptions'
import { resolveScrollingCanvasSize } from './scrollingCanvas' import { resolveScrollingCanvasSize } from './scrollingCanvas'
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
interface SpectrogramScopeProps { interface SpectrogramScopeProps {
dataSource: PluginWebViewDataSource dataSource: PluginWebViewDataSource
@@ -23,6 +25,9 @@ export default function SpectrogramScope({
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Spectrogram | null>(null) const vizRef = useRef<Spectrogram | null>(null)
const rotationRef = useRef(settings.rotation)
const applySizeRef = useRef<(() => void) | null>(null)
rotationRef.current = settings.rotation
useEffect(() => { useEffect(() => {
const container = containerRef.current const container = containerRef.current
@@ -37,15 +42,17 @@ export default function SpectrogramScope({
vizRef.current = viz vizRef.current = viz
const applySize = (): void => { const applySize = (): void => {
const rect = container.getBoundingClientRect() const { changed } = applyPluginScopeCanvasLayout(
const dpr = window.devicePixelRatio || 1 container,
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr) canvas,
if (canvas.width !== width || canvas.height !== height) { rotationRef.current,
canvas.width = width resolveScrollingCanvasSize,
canvas.height = height )
if (changed) {
viz.resize() viz.resize()
} }
} }
applySizeRef.current = applySize
applySize() applySize()
viz.start() viz.start()
@@ -54,6 +61,7 @@ export default function SpectrogramScope({
return () => { return () => {
observer.disconnect() observer.disconnect()
applySizeRef.current = null
viz.dispose() viz.dispose()
vizRef.current = null vizRef.current = null
} }
@@ -65,9 +73,17 @@ export default function SpectrogramScope({
vizRef.current?.setOptions(spectrogramSettingsToOptions(settings, theme)) vizRef.current?.setOptions(spectrogramSettingsToOptions(settings, theme))
}, [settings, theme]) }, [settings, theme])
useLayoutEffect(() => {
applySizeRef.current?.()
}, [settings.rotation])
return ( return (
<div ref={containerRef} className="spectrum-scope"> <div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" /> <canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
</div> </div>
) )
} }
+26 -8
View File
@@ -6,10 +6,11 @@ import type { SpectrumPeakInfo } from '../types/spectrum'
import type { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer' import type { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource' import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { spectrumSettingsToOptions } from './spectrumOptions' import { spectrumSettingsToOptions } from './spectrumOptions'
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
import { import {
formatSpectrumPeakDb, formatSpectrumPeakDb,
formatSpectrumPeakFrequency, formatSpectrumPeakFrequency,
measureCanvasResizeState,
resolveFollowingPeakOverlayStyle, resolveFollowingPeakOverlayStyle,
type CanvasResizeState, type CanvasResizeState,
type SizeMeasurement, type SizeMeasurement,
@@ -32,11 +33,14 @@ export default function SpectrumScope({
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const analyzerRef = useRef<SpectrumAnalyzer | null>(null) const analyzerRef = useRef<SpectrumAnalyzer | null>(null)
const resizeStateRef = useRef<CanvasResizeState | null>(null) const resizeStateRef = useRef<CanvasResizeState | null>(null)
const rotationRef = useRef(settings.rotation)
const applySizeRef = useRef<(() => void) | null>(null)
const peakOverlayRef = useRef<HTMLDivElement | null>(null) const peakOverlayRef = useRef<HTMLDivElement | null>(null)
const [peak, setPeak] = useState<SpectrumPeakInfo | null>(null) const [peak, setPeak] = useState<SpectrumPeakInfo | null>(null)
const [overlaySize, setOverlaySize] = useState<SizeMeasurement | null>(null) const [overlaySize, setOverlaySize] = useState<SizeMeasurement | null>(null)
const peakMode = settings.peakInfoMode const peakMode = settings.peakInfoMode
rotationRef.current = settings.rotation
// Create the analyzer once per data source / shim. // Create the analyzer once per data source / shim.
useEffect(() => { useEffect(() => {
@@ -54,14 +58,13 @@ export default function SpectrumScope({
analyzerRef.current = analyzer analyzerRef.current = analyzer
const applySize = (): void => { const applySize = (): void => {
const state = measureCanvasResizeState(container) const { changed, layout } = applyPluginScopeCanvasLayout(container, canvas, rotationRef.current)
resizeStateRef.current = state resizeStateRef.current = layout
if (canvas.width !== state.pixelWidth || canvas.height !== state.pixelHeight) { if (changed) {
canvas.width = state.pixelWidth
canvas.height = state.pixelHeight
analyzer.resize() analyzer.resize()
} }
} }
applySizeRef.current = applySize
applySize() applySize()
analyzer.start() analyzer.start()
@@ -70,6 +73,7 @@ export default function SpectrumScope({
return () => { return () => {
observer.disconnect() observer.disconnect()
applySizeRef.current = null
analyzer.dispose() analyzer.dispose()
analyzerRef.current = null analyzerRef.current = null
} }
@@ -87,6 +91,10 @@ export default function SpectrumScope({
}) })
}, [settings, theme]) }, [settings, theme])
useLayoutEffect(() => {
applySizeRef.current?.()
}, [settings.rotation])
// Measure the overlay so "following" placement can avoid the screen edges. // Measure the overlay so "following" placement can avoid the screen edges.
useLayoutEffect(() => { useLayoutEffect(() => {
const overlay = peakOverlayRef.current const overlay = peakOverlayRef.current
@@ -107,12 +115,22 @@ export default function SpectrumScope({
const showPeak = peakMode !== 'off' && peak !== null const showPeak = peakMode !== 'off' && peak !== null
const overlayStyle: CSSProperties | undefined = const overlayStyle: CSSProperties | undefined =
peakMode === 'following' && peak peakMode === 'following' && peak
? resolveFollowingPeakOverlayStyle(peak, resizeStateRef.current, overlaySize) ? resolveFollowingPeakOverlayStyle(
peak,
resizeStateRef.current,
overlaySize,
settings.rotation,
settings.mirrorHorizontal,
)
: undefined : undefined
return ( return (
<div ref={containerRef} className="spectrum-scope"> <div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" /> <canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
{showPeak && peak && ( {showPeak && peak && (
<div <div
ref={peakMode === 'following' ? peakOverlayRef : null} ref={peakMode === 'following' ? peakOverlayRef : null}
+24 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, type JSX } from 'react' import { useEffect, useLayoutEffect, useRef, type JSX } from 'react'
import { Waveform } from '../renderer/visualizers/Waveform' import { Waveform } from '../renderer/visualizers/Waveform'
import type { ScopeSettings } from '../types/settings' import type { ScopeSettings } from '../types/settings'
import type { ResolvedWaveformTheme } from '../types/theme' import type { ResolvedWaveformTheme } from '../types/theme'
@@ -6,6 +6,8 @@ import type { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource' import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { waveformSettingsToOptions } from './waveformOptions' import { waveformSettingsToOptions } from './waveformOptions'
import { resolveScrollingCanvasSize } from './scrollingCanvas' import { resolveScrollingCanvasSize } from './scrollingCanvas'
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
interface WaveformScopeProps { interface WaveformScopeProps {
dataSource: PluginWebViewDataSource dataSource: PluginWebViewDataSource
@@ -23,6 +25,9 @@ export default function WaveformScope({
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Waveform | null>(null) const vizRef = useRef<Waveform | null>(null)
const rotationRef = useRef(settings.rotation)
const applySizeRef = useRef<(() => void) | null>(null)
rotationRef.current = settings.rotation
useEffect(() => { useEffect(() => {
const container = containerRef.current const container = containerRef.current
@@ -37,15 +42,17 @@ export default function WaveformScope({
vizRef.current = viz vizRef.current = viz
const applySize = (): void => { const applySize = (): void => {
const rect = container.getBoundingClientRect() const { changed } = applyPluginScopeCanvasLayout(
const dpr = window.devicePixelRatio || 1 container,
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr) canvas,
if (canvas.width !== width || canvas.height !== height) { rotationRef.current,
canvas.width = width resolveScrollingCanvasSize,
canvas.height = height )
if (changed) {
viz.resize() viz.resize()
} }
} }
applySizeRef.current = applySize
applySize() applySize()
viz.start() viz.start()
@@ -54,6 +61,7 @@ export default function WaveformScope({
return () => { return () => {
observer.disconnect() observer.disconnect()
applySizeRef.current = null
viz.dispose() viz.dispose()
vizRef.current = null vizRef.current = null
} }
@@ -65,9 +73,17 @@ export default function WaveformScope({
vizRef.current?.setOptions(waveformSettingsToOptions(settings, theme)) vizRef.current?.setOptions(waveformSettingsToOptions(settings, theme))
}, [settings, theme]) }, [settings, theme])
useLayoutEffect(() => {
applySizeRef.current?.()
}, [settings.rotation])
return ( return (
<div ref={containerRef} className="spectrum-scope"> <div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" /> <canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
</div> </div>
) )
} }
+23 -24
View File
@@ -1,5 +1,11 @@
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
import type { SpectrumPeakInfo } from '../types/spectrum' 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 * 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. * copy (pure functions) so the plugin doesn't import the heavy ScopeModule.
*/ */
export interface CanvasResizeState { export type CanvasResizeState = ScopeCanvasLayout
cssWidth: number
cssHeight: number
pixelWidth: number
pixelHeight: number
dpr: number
}
export interface SizeMeasurement { export interface SizeMeasurement {
width: number width: number
@@ -45,25 +45,19 @@ export function formatSpectrumPeakFrequency(value: number): string {
return `${value.toFixed(2)}Hz` return `${value.toFixed(2)}Hz`
} }
export function measureCanvasResizeState(container: HTMLElement): CanvasResizeState { export function measureCanvasResizeState(
const rect = container.getBoundingClientRect() container: HTMLElement,
const cssWidth = Math.max(1, Math.floor(rect.width)) rotation: ScopeDisplayRotation = 0,
const cssHeight = Math.max(1, Math.floor(rect.height)) ): CanvasResizeState {
const dpr = window.devicePixelRatio || 1 return measureScopeCanvasLayout(container, rotation)
return {
cssWidth,
cssHeight,
pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)),
pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)),
dpr,
}
} }
export function resolveFollowingPeakOverlayStyle( export function resolveFollowingPeakOverlayStyle(
peakInfo: SpectrumPeakInfo, peakInfo: SpectrumPeakInfo,
resizeState: CanvasResizeState | null, resizeState: CanvasResizeState | null,
overlaySize: SizeMeasurement | null, overlaySize: SizeMeasurement | null,
rotation: ScopeDisplayRotation = 0,
mirrorHorizontal = false,
): CSSProperties { ): CSSProperties {
if (!resizeState) { if (!resizeState) {
return { return {
@@ -72,12 +66,17 @@ export function resolveFollowingPeakOverlayStyle(
} }
} }
const width = resizeState.cssWidth const width = resizeState.viewportCssWidth
const height = resizeState.cssHeight const height = resizeState.viewportCssHeight
const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX
const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX
const peakX = peakInfo.normalizedX * width const transformedPeak = transformNormalizedScopePoint(
const peakY = peakInfo.normalizedY * height { x: peakInfo.normalizedX, y: peakInfo.normalizedY },
rotation,
mirrorHorizontal,
)
const peakX = transformedPeak.x * width
const peakY = transformedPeak.y * height
const maxLeft = Math.max( const maxLeft = Math.max(
SPECTRUM_PEAK_OVERLAY_MARGIN_PX, SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX, width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
+34
View File
@@ -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 }
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function spectrogramSettingsToOptions(
contrast: settings.contrast, contrast: settings.contrast,
clarityMode: settings.clarityMode, clarityMode: settings.clarityMode,
scaleMode: settings.scaleMode, scaleMode: settings.scaleMode,
orientation: settings.orientation, orientation: 'horizontal',
colorScheme: settings.colorScheme, colorScheme: settings.colorScheme,
} }
} }
+2 -10
View File
@@ -3,21 +3,13 @@ import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
import type { ScopeKind } from '../types/scope' import type { ScopeKind } from '../types/scope'
import type { PrismResolvedTheme } from '../types/theme' import type { PrismResolvedTheme } from '../types/theme'
import { createBundledThemes, createDefaultTheme, parseThemeFileContent, resolveTheme } from '../shared/themeState' import { createBundledThemes, createDefaultTheme, parseThemeFileContent, resolveTheme } from '../shared/themeState'
import { mergeScopeSettings as normalizeAllScopeSettings } from '../shared/profileState'
import { emitToHost, onHostEvent } from './juceBridge' import { emitToHost, onHostEvent } from './juceBridge'
const DEFAULT_THEME = resolveTheme(createDefaultTheme()) const DEFAULT_THEME = resolveTheme(createDefaultTheme())
function mergeScopeSettings<K extends ScopeKind>(kind: K, raw: unknown): ScopeSettings[K] { function mergeScopeSettings<K extends ScopeKind>(kind: K, raw: unknown): ScopeSettings[K] {
const defaults = DEFAULT_SCOPE_SETTINGS[kind] as Record<string, unknown> return normalizeAllScopeSettings({ [kind]: raw })[kind]
if (typeof raw !== 'object' || raw === null) return { ...defaults } as ScopeSettings[K]
const parsed = raw as Record<string, unknown>
const next: Record<string, unknown> = { ...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]
} }
function resolveAppTheme(themeId: string, themeFile: string): PrismResolvedTheme { function resolveAppTheme(themeId: string, themeFile: string): PrismResolvedTheme {
+46 -59
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react' 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 { ScopeSettings } from '../../types/settings'
import type { ScopeDisplayRotation } from '../../types/scopeTransform'
import type { import type {
PrismResolvedTheme, PrismResolvedTheme,
ResolvedAstraTheme, ResolvedAstraTheme,
@@ -25,6 +26,13 @@ import { VUMeter, type VUMeterDataSource } from '../visualizers/VUMeter'
import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter' import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter'
import { Waveform, type WaveformDataSource } from '../visualizers/Waveform' import { Waveform, type WaveformDataSource } from '../visualizers/Waveform'
import type { FrameScheduler } from '../visualizers/frameScheduler' import type { FrameScheduler } from '../visualizers/frameScheduler'
import {
getScopeCanvasTransformStyle,
isSameScopeCanvasLayout,
measureScopeCanvasLayout,
transformNormalizedScopePoint,
type ScopeCanvasLayout,
} from '../scopeCanvasTransform'
type ScopeModuleTheme = type ScopeModuleTheme =
| ResolvedSpectrumTheme | ResolvedSpectrumTheme
@@ -59,14 +67,6 @@ interface Visualizer {
setOptions(options: Record<string, unknown>): void setOptions(options: Record<string, unknown>): void
} }
interface CanvasResizeState {
cssWidth: number
cssHeight: number
pixelWidth: number
pixelHeight: number
dpr: number
}
const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10 const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10
const SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX = 248 const SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX = 248
const SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX = 42 const SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX = 42
@@ -106,8 +106,10 @@ function formatSpectrumPeakFrequency(value: number): string {
function resolveFollowingPeakOverlayStyle( function resolveFollowingPeakOverlayStyle(
peakInfo: SpectrumPeakInfo, peakInfo: SpectrumPeakInfo,
resizeState: CanvasResizeState | null, resizeState: ScopeCanvasLayout | null,
overlaySize: SizeMeasurement | null, overlaySize: SizeMeasurement | null,
rotation: ScopeDisplayRotation,
mirrorHorizontal: boolean,
): CSSProperties { ): CSSProperties {
if (!resizeState) { if (!resizeState) {
return { return {
@@ -116,12 +118,17 @@ function resolveFollowingPeakOverlayStyle(
} }
} }
const width = resizeState.cssWidth const width = resizeState.viewportCssWidth
const height = resizeState.cssHeight const height = resizeState.viewportCssHeight
const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX
const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX
const peakX = peakInfo.normalizedX * width const transformedPeak = transformNormalizedScopePoint(
const peakY = peakInfo.normalizedY * height { x: peakInfo.normalizedX, y: peakInfo.normalizedY },
rotation,
mirrorHorizontal,
)
const peakX = transformedPeak.x * width
const peakY = transformedPeak.y * height
const maxLeft = Math.max( const maxLeft = Math.max(
SPECTRUM_PEAK_OVERLAY_MARGIN_PX, SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
width - overlayWidth - 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( export function scopeSettingsToOptions(
kind: ScopeKind, kind: ScopeKind,
settings: ScopeSettings[ScopeKind], settings: ScopeSettings[ScopeKind],
@@ -253,7 +230,7 @@ export function scopeSettingsToOptions(
contrast: s.contrast, contrast: s.contrast,
clarityMode: s.clarityMode, clarityMode: s.clarityMode,
scaleMode: s.scaleMode, scaleMode: s.scaleMode,
orientation: s.orientation, orientation: 'horizontal',
colorScheme: s.colorScheme, colorScheme: s.colorScheme,
} }
} }
@@ -411,8 +388,8 @@ export default function ScopeModule({
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const visualizerRef = useRef<Visualizer | null>(null) const visualizerRef = useRef<Visualizer | null>(null)
const initializedRef = useRef(false) const initializedRef = useRef(false)
const pendingResizeRef = useRef<CanvasResizeState | null>(null) const pendingResizeRef = useRef<ScopeCanvasLayout | null>(null)
const appliedResizeRef = useRef<CanvasResizeState | null>(null) const appliedResizeRef = useRef<ScopeCanvasLayout | null>(null)
const resizeFrameRef = useRef<number | null>(null) const resizeFrameRef = useRef<number | null>(null)
const snapshotCanvasRef = useRef<HTMLCanvasElement | null>(null) const snapshotCanvasRef = useRef<HTMLCanvasElement | null>(null)
const peakOverlayRef = useRef<HTMLDivElement | null>(null) const peakOverlayRef = useRef<HTMLDivElement | null>(null)
@@ -424,6 +401,12 @@ export default function ScopeModule({
const windowBgAlpha = useWindowBackgroundAlpha() const windowBgAlpha = useWindowBackgroundAlpha()
const mySettings = settings ?? storeSettings const mySettings = settings ?? storeSettings
const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind) 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' const spectrumPeakMode = scopeKind === 'spectrum'
? (mySettings as ScopeSettings['spectrum']).peakInfoMode ? (mySettings as ScopeSettings['spectrum']).peakInfoMode
: 'off' : 'off'
@@ -516,7 +499,9 @@ export default function ScopeModule({
initializedRef.current = false initializedRef.current = false
setSpectrumPeakInfo(null) 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(() => { useEffect(() => {
if (!visualizerRef.current || !initializedRef.current) return if (!visualizerRef.current || !initializedRef.current) return
@@ -537,7 +522,7 @@ export default function ScopeModule({
visualizerRef.current.setOptions(opts) visualizerRef.current.setOptions(opts)
}, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, mySettings, myTheme, scopeKind, windowBgAlpha]) }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, mySettings, myTheme, scopeKind, windowBgAlpha])
useEffect(() => { useLayoutEffect(() => {
const container = containerRef.current const container = containerRef.current
const canvas = canvasRef.current const canvas = canvasRef.current
if (!container || !canvas) return if (!container || !canvas) return
@@ -552,7 +537,7 @@ export default function ScopeModule({
const applyResize = (): void => { const applyResize = (): void => {
resizeFrameRef.current = null resizeFrameRef.current = null
const nextResize = pendingResizeRef.current const nextResize = pendingResizeRef.current
if (!nextResize || isSameCanvasResizeState(appliedResizeRef.current, nextResize)) { if (!nextResize || isSameScopeCanvasLayout(appliedResizeRef.current, nextResize)) {
return return
} }
@@ -593,7 +578,7 @@ export default function ScopeModule({
} }
const scheduleResize = (): void => { const scheduleResize = (): void => {
pendingResizeRef.current = measureCanvasResizeState(container) pendingResizeRef.current = measureScopeCanvasLayout(container, rotation)
if (resizeFrameRef.current !== null) { if (resizeFrameRef.current !== null) {
return return
} }
@@ -603,7 +588,7 @@ export default function ScopeModule({
}) })
} }
pendingResizeRef.current = measureCanvasResizeState(container) pendingResizeRef.current = measureScopeCanvasLayout(container, rotation)
applyResize() applyResize()
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
@@ -621,12 +606,18 @@ export default function ScopeModule({
appliedResizeRef.current = null appliedResizeRef.current = null
snapshotCanvasRef.current = null snapshotCanvasRef.current = null
} }
}, []) }, [rotation])
const spectrumPeakOverlayStyle = scopeKind === 'spectrum' const spectrumPeakOverlayStyle = scopeKind === 'spectrum'
&& spectrumPeakMode === 'following' && spectrumPeakMode === 'following'
&& spectrumPeakInfo && spectrumPeakInfo
? resolveFollowingPeakOverlayStyle(spectrumPeakInfo, appliedResizeRef.current, peakOverlaySize) ? resolveFollowingPeakOverlayStyle(
spectrumPeakInfo,
appliedResizeRef.current,
peakOverlaySize,
rotation,
mirrorHorizontal,
)
: undefined : undefined
return ( return (
@@ -643,11 +634,7 @@ export default function ScopeModule({
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
position: 'absolute', ...getScopeCanvasTransformStyle(rotation, mirrorHorizontal),
top: 0,
left: 0,
width: '100%',
height: '100%',
}} }}
/> />
{scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && ( {scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
@@ -1,7 +1,8 @@
import { useState, type CSSProperties, type JSX, type ReactNode } from 'react' import { useState, type CSSProperties, type JSX, type ReactNode } from 'react'
import type { ScopeKind } from '../../types/scope' 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 type { ScopeSettings } from '../../types/settings'
import { SCOPE_DISPLAY_ROTATIONS, type ScopeDisplayRotation } from '../../types/scopeTransform'
import { import {
MAX_SPECTROGRAM_CONTRAST, MAX_SPECTROGRAM_CONTRAST,
MAX_SPECTROGRAM_TILT_DB_PER_OCTAVE, MAX_SPECTROGRAM_TILT_DB_PER_OCTAVE,
@@ -62,6 +63,15 @@ function nowPlayingVisibleLabels(settings: ScopeSettings['nowPlaying']): string[
return labels 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 { export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
switch (kind) { switch (kind) {
case 'spectrum': { case 'spectrum': {
@@ -76,12 +86,15 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
} else if (scopeSettings.peakInfoMode === 'following') { } else if (scopeSettings.peakInfoMode === 'following') {
parts.push('Peak Follow') parts.push('Peak Follow')
} }
return parts.join(' · ') return appendTransformSummary(parts, scopeSettings)
} }
case 'oscilloscope': { case 'oscilloscope': {
const scopeSettings = settings as ScopeSettings['oscilloscope'] const scopeSettings = settings as ScopeSettings['oscilloscope']
const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run' const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run'
return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode return appendTransformSummary(
scopeSettings.underfillEnabled ? [mode, 'Fill'] : [mode],
scopeSettings,
)
} }
case 'vectorscope': { case 'vectorscope': {
const scopeSettings = settings as ScopeSettings['vectorscope'] const scopeSettings = settings as ScopeSettings['vectorscope']
@@ -91,7 +104,12 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
} }
case 'spectrogram': { case 'spectrogram': {
const scopeSettings = settings as ScopeSettings['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': { case 'vumeter': {
const scopeSettings = settings as ScopeSettings['vumeter'] const scopeSettings = settings as ScopeSettings['vumeter']
@@ -112,7 +130,7 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
if (scopeSettings.multiband) { if (scopeSettings.multiband) {
summary.push('RGB') summary.push('RGB')
} }
return summary.join(' · ') return appendTransformSummary(summary, scopeSettings)
} }
case 'nowPlaying': { case 'nowPlaying': {
const visible = nowPlayingVisibleLabels(settings as ScopeSettings['nowPlaying']) const visible = nowPlayingVisibleLabels(settings as ScopeSettings['nowPlaying'])
@@ -515,15 +533,6 @@ export default function ScopeSettingsSection({
<option value="linear">Linear</option> <option value="linear">Linear</option>
</SelectControl> </SelectControl>
<SelectControl
label="Orientation"
value={current.orientation}
onChange={(value) => onUpdate('spectrogram', { orientation: value as ScopeSettings['spectrogram']['orientation'] })}
>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</SelectControl>
<SelectControl <SelectControl
label="Clarity" label="Clarity"
value={current.clarityMode} value={current.clarityMode}
@@ -721,6 +730,31 @@ export default function ScopeSettingsSection({
</ToggleGroup> </ToggleGroup>
) )
})()} })()}
{isTransformableScopeKind(kind) && (() => {
const current = settings as ScopeSettings[typeof kind]
return (
<>
<SelectControl
label="Rotate"
value={current.rotation}
onChange={(value) => onUpdate(kind, { rotation: Number(value) as ScopeDisplayRotation })}
>
{SCOPE_DISPLAY_ROTATIONS.map((rotation) => (
<option key={rotation} value={rotation}>{rotation}°</option>
))}
</SelectControl>
<ToggleGroup label="Transform">
<ToggleChip
label="Mirror Horizontal"
active={current.mirrorHorizontal}
onClick={() => onUpdate(kind, { mirrorHorizontal: !current.mirrorHorizontal })}
/>
</ToggleGroup>
</>
)
})()}
</div> </div>
</section> </section>
) )
+109
View File
@@ -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 }
}
}
+27 -1
View File
@@ -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<string, unknown>
const normalizedPopouts = normalizeScopePopouts(candidate.scopePopouts)
const scopePopouts = SCOPE_KINDS.reduce((acc, kind) => {
acc[kind] = { poppedOut: normalizedPopouts[kind]?.poppedOut === true }
return acc
}, {} as Record<ScopeKind, { poppedOut: boolean }>)
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( function restoreBaselineScopePopoutBounds(
scopePopouts: ScopePopoutStateMap, scopePopouts: ScopePopoutStateMap,
baseline: Profile | null, baseline: Profile | null,
@@ -274,7 +298,9 @@ function canRestorePersistedWorkingState(
return false return false
} }
return state.profileBaselineSignature === buildProfileBaselineSignature(activeProfile) const activeSignature = buildProfileBaselineSignature(activeProfile)
return state.profileBaselineSignature === activeSignature
|| normalizePersistedProfileBaselineSignature(state.profileBaselineSignature) === activeSignature
} }
function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null { function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null {
+36 -12
View File
@@ -9,19 +9,21 @@ import {
type ProfileLocalMetadata, type ProfileLocalMetadata,
type PrismProfileFile, type PrismProfileFile,
type PrismProfileFileScopePopoutMap, type PrismProfileFileScopePopoutMap,
type PrismProfileFileV3, type PrismProfileFileV4,
type PrismProfileLocalStateV1, type PrismProfileLocalStateV1,
} from '../types/profile' } from '../types/profile'
import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } from '../types/scope' import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } from '../types/scope'
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings' import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
import { isLUFSMeterReadout } from '../types/lufsmeter' import { isLUFSMeterReadout } from '../types/lufsmeter'
import { normalizeSpectrumPeakInfoMode } from '../types/spectrum' import { normalizeSpectrumPeakInfoMode } from '../types/spectrum'
import { import { clampSpectrogramTiltDbPerOctave } from '../types/spectrogram'
clampSpectrogramTiltDbPerOctave,
isSpectrogramOrientation,
} from '../types/spectrogram'
import { isVUMeterNeedleChannels, sanitizeVUReferenceDbfs } from '../types/vumeter' import { isVUMeterNeedleChannels, sanitizeVUReferenceDbfs } from '../types/vumeter'
import { clampWaveformScrollSpeed } from '../types/waveform' 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_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS] 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) 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 { export function createDefaultScopePopouts(): ScopePopoutStateMap {
return SCOPE_KINDS.reduce((acc, kind) => { return SCOPE_KINDS.reduce((acc, kind) => {
acc[kind] = { poppedOut: false } acc[kind] = { poppedOut: false }
@@ -160,6 +174,12 @@ export function mergeScopeSettings(
const rawSpectrogram: Partial<ScopeSettings['spectrogram']> = typeof parsed.spectrogram === 'object' && parsed.spectrogram !== null const rawSpectrogram: Partial<ScopeSettings['spectrogram']> = typeof parsed.spectrogram === 'object' && parsed.spectrogram !== null
? parsed.spectrogram ? parsed.spectrogram
: {} : {}
const rawSpectrogramWithLegacy = rawSpectrogram as Partial<ScopeSettings['spectrogram']> & { orientation?: unknown }
const { orientation: legacySpectrogramOrientation, ...rawSpectrogramSettings } = rawSpectrogramWithLegacy
const legacySpectrogramRotation: ScopeDisplayRotation = legacySpectrogramOrientation === 'vertical' ? 90 : 0
const rawOscilloscope: Partial<ScopeSettings['oscilloscope']> = typeof parsed.oscilloscope === 'object' && parsed.oscilloscope !== null
? parsed.oscilloscope
: {}
const rawWaveform: Partial<ScopeSettings['waveform']> = typeof parsed.waveform === 'object' && parsed.waveform !== null const rawWaveform: Partial<ScopeSettings['waveform']> = typeof parsed.waveform === 'object' && parsed.waveform !== null
? parsed.waveform ? parsed.waveform
: {} : {}
@@ -177,16 +197,19 @@ export function mergeScopeSettings(
spectrum: { spectrum: {
...DEFAULT_SCOPE_SETTINGS.spectrum, ...DEFAULT_SCOPE_SETTINGS.spectrum,
...rawSpectrum, ...rawSpectrum,
...normalizeDisplayTransform(rawSpectrum),
peakInfoMode: normalizeSpectrumPeakInfoMode(rawSpectrum.peakInfoMode), 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 ?? {}) }, vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) },
spectrogram: { spectrogram: {
...DEFAULT_SCOPE_SETTINGS.spectrogram, ...DEFAULT_SCOPE_SETTINGS.spectrogram,
...rawSpectrogram, ...rawSpectrogramSettings,
orientation: isSpectrogramOrientation(rawSpectrogram.orientation) ...normalizeDisplayTransform(rawSpectrogram, legacySpectrogramRotation),
? rawSpectrogram.orientation
: DEFAULT_SCOPE_SETTINGS.spectrogram.orientation,
tiltDbPerOctave: clampSpectrogramTiltDbPerOctave( tiltDbPerOctave: clampSpectrogramTiltDbPerOctave(
rawSpectrogram.tiltDbPerOctave ?? DEFAULT_SCOPE_SETTINGS.spectrogram.tiltDbPerOctave rawSpectrogram.tiltDbPerOctave ?? DEFAULT_SCOPE_SETTINGS.spectrogram.tiltDbPerOctave
), ),
@@ -208,6 +231,7 @@ export function mergeScopeSettings(
}, },
waveform: { waveform: {
...DEFAULT_SCOPE_SETTINGS.waveform, ...DEFAULT_SCOPE_SETTINGS.waveform,
...normalizeDisplayTransform(rawWaveform),
mode: rawWaveform.mode === 'stereo' || rawWaveform.mode === 'mono' mode: rawWaveform.mode === 'stereo' || rawWaveform.mode === 'mono'
? rawWaveform.mode ? rawWaveform.mode
: DEFAULT_SCOPE_SETTINGS.waveform.mode, : DEFAULT_SCOPE_SETTINGS.waveform.mode,
@@ -288,7 +312,7 @@ export function normalizeProfileFile(
raw: unknown, raw: unknown,
fallbackId: string, fallbackId: string,
fallbackName = DEFAULT_PROFILE_NAME, fallbackName = DEFAULT_PROFILE_NAME,
) : PrismProfileFileV3 { ) : PrismProfileFileV4 {
const parsed = typeof raw === 'object' && raw !== null const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<PrismProfileFile> ? raw as Partial<PrismProfileFile>
: {} : {}
@@ -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) const normalized = normalizeProfile(profile, profile.name)
return { return {
+15 -2
View File
@@ -3,7 +3,7 @@ import type { ScopeKind } from './scope'
import type { ScopeSettings } from './settings' import type { ScopeSettings } from './settings'
export const PROFILE_FILE_FORMAT = 'prism-profile' 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_FORMAT = 'prism-profile-local'
export const PROFILE_LOCAL_STATE_VERSION = 1 export const PROFILE_LOCAL_STATE_VERSION = 1
export const LEGACY_PROFILE_MIGRATION_VERSION = 1 export const LEGACY_PROFILE_MIGRATION_VERSION = 1
@@ -52,6 +52,19 @@ export interface PrismProfileFileV2 {
} }
export interface PrismProfileFileV3 { export interface PrismProfileFileV3 {
format: typeof PROFILE_FILE_FORMAT
version: 3
id: string
name: string
themeId?: string | null
scopeOrder: ScopeKind[]
hiddenScopes: ScopeKind[]
widthWeights: Record<ScopeKind, number>
scopeSettings: ScopeSettings
scopePopouts: PrismProfileFileScopePopoutMap
}
export interface PrismProfileFileV4 {
format: typeof PROFILE_FILE_FORMAT format: typeof PROFILE_FILE_FORMAT
version: typeof PROFILE_FILE_VERSION version: typeof PROFILE_FILE_VERSION
id: string id: string
@@ -64,7 +77,7 @@ export interface PrismProfileFileV3 {
scopePopouts: PrismProfileFileScopePopoutMap scopePopouts: PrismProfileFileScopePopoutMap
} }
export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 | PrismProfileFileV4
export interface ProfileLocalMetadata { export interface ProfileLocalMetadata {
windowBounds?: WindowBounds windowBounds?: WindowBounds
+17
View File
@@ -10,6 +10,12 @@ export type ScopeKind =
export type AudioScopeKind = Exclude<ScopeKind, 'nowPlaying'> export type AudioScopeKind = Exclude<ScopeKind, 'nowPlaying'>
export type TransformableScopeKind =
| 'spectrum'
| 'oscilloscope'
| 'spectrogram'
| 'waveform'
export const SCOPE_KINDS: ScopeKind[] = [ export const SCOPE_KINDS: ScopeKind[] = [
'spectrum', 'spectrum',
'oscilloscope', 'oscilloscope',
@@ -31,6 +37,17 @@ export const AUDIO_SCOPE_KINDS: AudioScopeKind[] = [
'waveform', '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 { export function isAudioScopeKind(value: unknown): value is AudioScopeKind {
return typeof value === 'string' && AUDIO_SCOPE_KINDS.includes(value as AudioScopeKind) return typeof value === 'string' && AUDIO_SCOPE_KINDS.includes(value as AudioScopeKind)
} }
+23
View File
@@ -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
}
+13 -11
View File
@@ -1,19 +1,22 @@
import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope' import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope'
import { import {
DEFAULT_SPECTROGRAM_CONTRAST, DEFAULT_SPECTROGRAM_CONTRAST,
DEFAULT_SPECTROGRAM_ORIENTATION,
DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE,
type SpectrogramClarityMode, type SpectrogramClarityMode,
type SpectrogramOrientation,
type SpectrogramScaleMode, type SpectrogramScaleMode,
} from './spectrogram' } from './spectrogram'
import { DEFAULT_VU_REFERENCE_DBFS, type VUMeterMode, type VUMeterNeedleChannels, type VUMeterOrientation } from './vumeter' 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_LUFS_METER_READOUT, type LUFSMeterMode, type LUFSMeterReadout } from './lufsmeter'
import { DEFAULT_WAVEFORM_MODE, DEFAULT_WAVEFORM_SCROLL_SPEED, 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' 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 { export interface ScopeSettings {
spectrum: { spectrum: ScopeDisplayTransformSettings & {
fftSize: number fftSize: number
tiltDbPerOctave: number tiltDbPerOctave: number
heatmap: boolean heatmap: boolean
@@ -25,7 +28,7 @@ export interface ScopeSettings {
showSideLine: boolean showSideLine: boolean
peakInfoMode: SpectrumPeakInfoMode peakInfoMode: SpectrumPeakInfoMode
} }
oscilloscope: { oscilloscope: ScopeDisplayTransformSettings & {
pitchLock: boolean pitchLock: boolean
underfillEnabled: boolean underfillEnabled: boolean
showGrid: boolean showGrid: boolean
@@ -38,14 +41,13 @@ export interface ScopeSettings {
persistence: number persistence: number
lineWidth: number lineWidth: number
} }
spectrogram: { spectrogram: ScopeDisplayTransformSettings & {
fftSize: number fftSize: number
tiltDbPerOctave: number tiltDbPerOctave: number
scrollSpeed: number scrollSpeed: number
contrast: number contrast: number
clarityMode: SpectrogramClarityMode clarityMode: SpectrogramClarityMode
scaleMode: SpectrogramScaleMode scaleMode: SpectrogramScaleMode
orientation: SpectrogramOrientation
colorScheme: 'heat' | 'mono' colorScheme: 'heat' | 'mono'
} }
vumeter: { vumeter: {
@@ -58,7 +60,7 @@ export interface ScopeSettings {
mode: LUFSMeterMode mode: LUFSMeterMode
readout: LUFSMeterReadout readout: LUFSMeterReadout
} }
waveform: { waveform: ScopeDisplayTransformSettings & {
mode: WaveformMode mode: WaveformMode
scrollSpeed: number scrollSpeed: number
multiband: boolean multiband: boolean
@@ -74,13 +76,13 @@ export interface ScopeSettings {
} }
export const DEFAULT_SCOPE_SETTINGS: 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 }, 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: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, 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 }, 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 }, vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS },
lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT }, 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: { nowPlaying: {
showCoverArt: true, showCoverArt: true,
showTitle: true, showTitle: true,
+69 -9
View File
@@ -51,7 +51,8 @@ function createProfile(name: string): Profile {
profile.scopeSettings.spectrum.showSideLine = true profile.scopeSettings.spectrum.showSideLine = true
profile.scopeSettings.spectrum.heatmapSmoothing = 0.67 profile.scopeSettings.spectrum.heatmapSmoothing = 0.67
profile.scopeSettings.spectrogram.colorScheme = 'mono' profile.scopeSettings.spectrogram.colorScheme = 'mono'
profile.scopeSettings.spectrogram.orientation = 'vertical' profile.scopeSettings.spectrogram.rotation = 90
profile.scopeSettings.spectrogram.mirrorHorizontal = true
return profile 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.equal(JSON.stringify(file).includes('inputGainDb'), false)
assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true }) assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true })
assert.equal(file.scopeSettings.spectrum.heatmapSmoothing, 0.67) 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.scopeOrder.includes('nowPlaying'), false)
assert.equal(file.hiddenScopes.includes('nowPlaying'), true) assert.equal(file.hiddenScopes.includes('nowPlaying'), true)
assert.equal(file.widthWeights.nowPlaying, 1) 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.showSideLine, true)
assert.equal(restored.scopeSettings.spectrum.heatmapSmoothing, 0.67) assert.equal(restored.scopeSettings.spectrum.heatmapSmoothing, 0.67)
assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono') 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) 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) 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({ const vertical = mergeScopeSettings({
spectrogram: { spectrogram: {
orientation: 'vertical', orientation: 'vertical',
}, },
}) })
const invalid = mergeScopeSettings({ const horizontal = mergeScopeSettings({
spectrogram: { 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({}) const missing = mergeScopeSettings({})
assert.equal(vertical.spectrogram.orientation, 'vertical') assert.equal(vertical.spectrogram.rotation, 90)
assert.equal(invalid.spectrogram.orientation, 'horizontal') assert.equal(horizontal.spectrogram.rotation, 0)
assert.equal(missing.spectrogram.orientation, 'horizontal') 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', () => { 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 () => { test('legacy migration writes managed files, preserves active profile, and stores local-only geometry', async () => {
const harness = await createHarness() const harness = await createHarness()
+133 -14
View File
@@ -51,6 +51,10 @@ import { useThemeStore } from '../src/renderer/stores/themeStore'
import { resolveThemeCreditDetails, resolveThemeOptionLabel } from '../src/renderer/components/BottomBar' import { resolveThemeCreditDetails, resolveThemeOptionLabel } from '../src/renderer/components/BottomBar'
import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule' import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule'
import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection' import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection'
import {
resolveScopeCanvasLayout,
transformNormalizedScopePoint,
} from '../src/renderer/scopeCanvasTransform'
import { import {
applyInputGainToStereoSamples, applyInputGainToStereoSamples,
inputGainDbToLinear, inputGainDbToLinear,
@@ -200,6 +204,7 @@ function installFakeTimeouts(hidden = false): {
globalWithWindow.window = { globalWithWindow.window = {
...globalThis, ...globalThis,
localStorage: (globalThis as GlobalWithStorage).localStorage,
electronAPI: { electronAPI: {
platform: 'darwin', platform: 'darwin',
windowCapabilities: resolveWindowCapabilities({ platform: 'darwin' }), windowCapabilities: resolveWindowCapabilities({ platform: 'darwin' }),
@@ -345,9 +350,9 @@ function installFakeLocalStorage(): {
const storage = new Map<string, string>() const storage = new Map<string, string>()
let setCount = 0 let setCount = 0
const globalWithStorage = globalThis as GlobalWithStorage const globalWithStorage = globalThis as GlobalWithStorage
const previousLocalStorage = globalWithStorage.localStorage const previousLocalStorageDescriptor = Object.getOwnPropertyDescriptor(globalWithStorage, 'localStorage')
globalWithStorage.localStorage = { const fakeLocalStorage = {
getItem(key: string): string | null { getItem(key: string): string | null {
return storage.get(key) ?? null return storage.get(key) ?? null
}, },
@@ -368,6 +373,12 @@ function installFakeLocalStorage(): {
return storage.size return storage.size
}, },
} as Storage } as Storage
Object.defineProperty(globalWithStorage, 'localStorage', {
configurable: true,
enumerable: true,
value: fakeLocalStorage,
writable: true,
})
return { return {
getSetCount: () => setCount, getSetCount: () => setCount,
@@ -378,12 +389,12 @@ function installFakeLocalStorage(): {
storage.set(key, value) storage.set(key, value)
}, },
restore(): void { restore(): void {
if (previousLocalStorage === undefined) { if (!previousLocalStorageDescriptor) {
delete globalWithStorage.localStorage delete globalWithStorage.localStorage
return return
} }
globalWithStorage.localStorage = previousLocalStorage Object.defineProperty(globalWithStorage, 'localStorage', previousLocalStorageDescriptor)
}, },
} }
} }
@@ -1218,10 +1229,16 @@ function renderSpectrogramColumnImage(options: Partial<SpectrogramOptions>, valu
try { try {
const state = spectrogram as unknown as { const state = spectrogram as unknown as {
ensureColumnBuffers: (height: number) => void ensureColumnBuffers: (height: number) => void
shiftAndPaintColumn: (values: Float32Array) => void shiftAndPaintColumns: (
display: Float32Array,
heat: Float32Array,
columnCount: number,
rowCount: number,
) => void
} }
state.ensureColumnBuffers(values.length) 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 ?? [] return recorder.imageDataWrites.at(-1)?.data ?? []
} finally { } finally {
spectrogram.dispose() spectrogram.dispose()
@@ -1254,10 +1271,16 @@ function renderSpectrogramShift(
try { try {
const state = spectrogram as unknown as { const state = spectrogram as unknown as {
ensureColumnBuffers: (height: number) => void ensureColumnBuffers: (height: number) => void
shiftAndPaintColumn: (values: Float32Array) => void shiftAndPaintColumns: (
display: Float32Array,
heat: Float32Array,
columnCount: number,
rowCount: number,
) => void
} }
state.ensureColumnBuffers(values.length) state.ensureColumnBuffers(values.length)
state.shiftAndPaintColumn(Float32Array.from(values)) const column = Float32Array.from(values)
state.shiftAndPaintColumns(column, column, 1, values.length)
return recorder return recorder
} finally { } finally {
spectrogram.dispose() 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', () => { test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => {
const profile = createDefaultProfile('Default') const profile = createDefaultProfile('Default')
profile.scopeSettings.spectrum.showSideLine = true 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', () => { test('scopeSettingsToOptions forwards themed backgrounds and track colors to spectrogram, VU, and LUFS modules', () => {
const profile = createDefaultProfile('Default') const profile = createDefaultProfile('Default')
profile.scopeSettings.spectrogram.orientation = 'vertical' profile.scopeSettings.spectrogram.rotation = 90
profile.scopeSettings.spectrogram.tiltDbPerOctave = 5.2 profile.scopeSettings.spectrogram.tiltDbPerOctave = 5.2
profile.scopeSettings.lufsmeter.readout = 'shortTerm' profile.scopeSettings.lufsmeter.readout = 'shortTerm'
profile.scopeSettings.vumeter.needleChannels = 'combined' 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) const spectrogram = scopeSettingsToOptions('spectrogram', profile.scopeSettings.spectrogram, theme.spectrogram)
assert.equal(spectrogram.backgroundColor, 'rgb(6, 7, 8)') assert.equal(spectrogram.backgroundColor, 'rgb(6, 7, 8)')
assert.equal(spectrogram.orientation, 'vertical') assert.equal(spectrogram.orientation, 'horizontal')
assert.equal(spectrogram.tiltDbPerOctave, 5.2) assert.equal(spectrogram.tiltDbPerOctave, 5.2)
const vumeter = scopeSettingsToOptions('vumeter', profile.scopeSettings.vumeter, theme.vumeter) 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') 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') const profile = createDefaultProfile('Default')
assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), 'HORIZONTAL · LOG · sharper') assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), ' · LOG · sharper')
profile.scopeSettings.spectrogram.orientation = 'vertical' profile.scopeSettings.spectrogram.rotation = 270
assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), 'VERTICAL · LOG · sharper') 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', () => { 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<string, unknown>
const signature = JSON.parse(stored.profileBaselineSignature as string) as Record<string, unknown>
const signatureSettings = signature.scopeSettings as Record<string, Record<string, unknown>>
const workingSettings = stored.scopeSettings as Record<string, Record<string, unknown>>
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 () => { test('initializeProfiles ignores persisted geometry on native Wayland and keeps the saved profile clean', async () => {
const previousSettingsState = useSettingsStore.getState() const previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage() const fakeStorage = installFakeLocalStorage()