mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-20 12:29:51 +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)}.`)
|
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)}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
|||||||
@@ -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
@@ -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), '0° · 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user