mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-21 21:05:12 +02:00
scope rotation
This commit is contained in:
@@ -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)}.`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Oscilloscope | null>(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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Spectrogram | null>(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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HTMLCanvasElement>(null)
|
||||
const analyzerRef = useRef<SpectrumAnalyzer | 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 [peak, setPeak] = useState<SpectrumPeakInfo | null>(null)
|
||||
const [overlaySize, setOverlaySize] = useState<SizeMeasurement | null>(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 (
|
||||
<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 && (
|
||||
<div
|
||||
ref={peakMode === 'following' ? peakOverlayRef : null}
|
||||
|
||||
@@ -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 type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedWaveformTheme } from '../types/theme'
|
||||
@@ -6,6 +6,8 @@ import type { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { waveformSettingsToOptions } from './waveformOptions'
|
||||
import { resolveScrollingCanvasSize } from './scrollingCanvas'
|
||||
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
||||
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
||||
|
||||
interface WaveformScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
@@ -23,6 +25,9 @@ export default function WaveformScope({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Waveform | null>(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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function spectrogramSettingsToOptions(
|
||||
contrast: settings.contrast,
|
||||
clarityMode: settings.clarityMode,
|
||||
scaleMode: settings.scaleMode,
|
||||
orientation: settings.orientation,
|
||||
orientation: 'horizontal',
|
||||
colorScheme: settings.colorScheme,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<K extends ScopeKind>(kind: K, raw: unknown): ScopeSettings[K] {
|
||||
const defaults = DEFAULT_SCOPE_SETTINGS[kind] as Record<string, unknown>
|
||||
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]
|
||||
return normalizeAllScopeSettings({ [kind]: raw })[kind]
|
||||
}
|
||||
|
||||
function resolveAppTheme(themeId: string, themeFile: string): PrismResolvedTheme {
|
||||
|
||||
@@ -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<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_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<HTMLCanvasElement>(null)
|
||||
const visualizerRef = useRef<Visualizer | null>(null)
|
||||
const initializedRef = useRef(false)
|
||||
const pendingResizeRef = useRef<CanvasResizeState | null>(null)
|
||||
const appliedResizeRef = useRef<CanvasResizeState | null>(null)
|
||||
const pendingResizeRef = useRef<ScopeCanvasLayout | null>(null)
|
||||
const appliedResizeRef = useRef<ScopeCanvasLayout | null>(null)
|
||||
const resizeFrameRef = useRef<number | null>(null)
|
||||
const snapshotCanvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const peakOverlayRef = useRef<HTMLDivElement | null>(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({
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
...getScopeCanvasTransformStyle(rotation, mirrorHorizontal),
|
||||
}}
|
||||
/>
|
||||
{scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
|
||||
|
||||
@@ -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({
|
||||
<option value="linear">Linear</option>
|
||||
</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
|
||||
label="Clarity"
|
||||
value={current.clarityMode}
|
||||
@@ -721,6 +730,31 @@ export default function ScopeSettingsSection({
|
||||
</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>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
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 {
|
||||
|
||||
+36
-12
@@ -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<ScopeSettings['spectrogram']> = typeof parsed.spectrogram === 'object' && parsed.spectrogram !== null
|
||||
? 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
|
||||
? 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<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)
|
||||
|
||||
return {
|
||||
|
||||
+15
-2
@@ -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<ScopeKind, number>
|
||||
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
|
||||
|
||||
@@ -10,6 +10,12 @@ export type ScopeKind =
|
||||
|
||||
export type AudioScopeKind = Exclude<ScopeKind, 'nowPlaying'>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+133
-14
@@ -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<string, string>()
|
||||
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<SpectrogramOptions>, 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<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 () => {
|
||||
const previousSettingsState = useSettingsStore.getState()
|
||||
const fakeStorage = installFakeLocalStorage()
|
||||
|
||||
Reference in New Issue
Block a user