scope rotation

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