diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp index ef80da8..f63424b 100644 --- a/plugin/Source/PluginEditor.cpp +++ b/plugin/Source/PluginEditor.cpp @@ -277,6 +277,7 @@ namespace .withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); }) .withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); }) .withEventListener("prismSpectrogramConfig", [&editor](juce::var v) { editor.onScopeNativeConfig(std::move(v)); }) + .withEventListener("prismScopeMeasurement", [&editor](juce::var v) { editor.onScopeMeasurement(std::move(v)); }) .withEventListener("prismSettingsPanel", [&editor](juce::var v) { editor.onSettingsPanel(std::move(v)); }); #if JUCE_WINDOWS @@ -605,6 +606,11 @@ void PrismSpectrumEditor::onScopeNativeConfig(juce::var payload) engine->configureNative(payload); } +void PrismSpectrumEditor::onScopeMeasurement(juce::var payload) +{ + engine->setMeasurementActive((bool) payload.getProperty("active", false)); +} + void PrismSpectrumEditor::pushRestoreSettings() { if (webView == nullptr) diff --git a/plugin/Source/PluginEditor.h b/plugin/Source/PluginEditor.h index fa4809c..a3980c5 100644 --- a/plugin/Source/PluginEditor.h +++ b/plugin/Source/PluginEditor.h @@ -36,6 +36,9 @@ public: // Scope-specific native config (e.g. the spectrogram's canvas-derived rowCount). void onScopeNativeConfig(juce::var payload); + // Momentary crosshair state; Spectrum uses it for temporary DSP smoothing. + void onScopeMeasurement(juce::var payload); + // The UI's settings panel opened/closed along the bottom. Grow/shrink the editor // height by exactly the panel height so the scope area is unchanged (the window // accommodates the panel, like the app). 0 = closed. diff --git a/plugin/Source/ScopeEngine.h b/plugin/Source/ScopeEngine.h index 4401a17..3558067 100644 --- a/plugin/Source/ScopeEngine.h +++ b/plugin/Source/ScopeEngine.h @@ -41,6 +41,9 @@ public: */ virtual void configureNative(const juce::var&) {} + /** Temporary UI interaction state. Most scopes do not need DSP changes. */ + virtual void setMeasurementActive(bool) {} + /** Feed audio (called off the realtime thread). numSamples may be 0. */ virtual void process(const float* left, const float* right, int numSamples) = 0; diff --git a/plugin/Source/SpectrumEngine.h b/plugin/Source/SpectrumEngine.h index 5766cff..5c1db0b 100644 --- a/plugin/Source/SpectrumEngine.h +++ b/plugin/Source/SpectrumEngine.h @@ -22,7 +22,18 @@ public: if (fftSize > 0 && (size_t) fftSize != spectrum.getFFTSize()) spectrum.setFFTSize((size_t) fftSize); - spectrum.setSmoothing((float) (double) settings.getProperty("smoothing", 0.9)); + configuredSmoothing = juce::jlimit(0.0f, 0.99f, + (float) (double) settings.getProperty("smoothing", 0.9)); + applySmoothing(); + } + + void setMeasurementActive(bool active) override + { + if (measurementActive == active) + return; + + measurementActive = active; + applySmoothing(); } void process(const float* left, const float* right, int numSamples) override @@ -44,6 +55,14 @@ public: } private: + void applySmoothing() + { + constexpr float measurementSmoothing = 0.97f; + spectrum.setSmoothing(measurementActive + ? juce::jmax(configuredSmoothing, measurementSmoothing) + : configuredSmoothing); + } + static juce::String toBase64(const std::vector& data) { return data.empty() ? juce::String() @@ -52,4 +71,6 @@ private: const juce::Identifier frameId { "spectrumFrame" }; Visualizer::Spectrum spectrum { 2048 }; + float configuredSmoothing = 0.9f; + bool measurementActive = false; }; diff --git a/src/plugin-ui/OscilloscopeScope.tsx b/src/plugin-ui/OscilloscopeScope.tsx index 0238320..39bef8c 100644 --- a/src/plugin-ui/OscilloscopeScope.tsx +++ b/src/plugin-ui/OscilloscopeScope.tsx @@ -7,6 +7,10 @@ import type { PluginWebViewDataSource } from './PluginWebViewDataSource' import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions' import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform' import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout' +import { + ScopeMeasurementOverlay, + useScopeMeasurement, +} from '../renderer/components/ScopeMeasurementOverlay' interface OscilloscopeScopeProps { dataSource: PluginWebViewDataSource @@ -27,6 +31,13 @@ export default function OscilloscopeScope({ const rotationRef = useRef(settings.rotation) const applySizeRef = useRef<(() => void) | null>(null) rotationRef.current = settings.rotation + const measurementController = useScopeMeasurement({ + containerRef, + enabled: true, + rotation: settings.rotation, + mirrorHorizontal: settings.mirrorHorizontal, + getSource: () => vizRef.current, + }) useEffect(() => { const container = containerRef.current @@ -72,12 +83,20 @@ export default function OscilloscopeScope({ }, [settings.rotation]) return ( -
+
+
) } diff --git a/src/plugin-ui/SpectrogramScope.tsx b/src/plugin-ui/SpectrogramScope.tsx index fde8003..e729079 100644 --- a/src/plugin-ui/SpectrogramScope.tsx +++ b/src/plugin-ui/SpectrogramScope.tsx @@ -8,6 +8,10 @@ import { spectrogramSettingsToOptions } from './spectrogramOptions' import { resolveScrollingCanvasSize } from './scrollingCanvas' import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform' import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout' +import { + ScopeMeasurementOverlay, + useScopeMeasurement, +} from '../renderer/components/ScopeMeasurementOverlay' interface SpectrogramScopeProps { dataSource: PluginWebViewDataSource @@ -28,6 +32,13 @@ export default function SpectrogramScope({ const rotationRef = useRef(settings.rotation) const applySizeRef = useRef<(() => void) | null>(null) rotationRef.current = settings.rotation + const measurementController = useScopeMeasurement({ + containerRef, + enabled: true, + rotation: settings.rotation, + mirrorHorizontal: settings.mirrorHorizontal, + getSource: () => vizRef.current, + }) useEffect(() => { const container = containerRef.current @@ -78,12 +89,20 @@ export default function SpectrogramScope({ }, [settings.rotation]) return ( -
+
+
) } diff --git a/src/plugin-ui/SpectrumScope.tsx b/src/plugin-ui/SpectrumScope.tsx index d55c85c..dc914f2 100644 --- a/src/plugin-ui/SpectrumScope.tsx +++ b/src/plugin-ui/SpectrumScope.tsx @@ -8,6 +8,11 @@ import type { PluginWebViewDataSource } from './PluginWebViewDataSource' import { spectrumSettingsToOptions } from './spectrumOptions' import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform' import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout' +import { + ScopeMeasurementOverlay, + useScopeMeasurement, +} from '../renderer/components/ScopeMeasurementOverlay' +import { emitToHost } from './juceBridge' import { formatSpectrumPeakDbfs, formatSpectrumPeakFrequency, @@ -41,6 +46,16 @@ export default function SpectrumScope({ const peakMode = settings.peakInfoMode rotationRef.current = settings.rotation + const measurementController = useScopeMeasurement({ + containerRef, + enabled: true, + rotation: settings.rotation, + mirrorHorizontal: settings.mirrorHorizontal, + getSource: () => analyzerRef.current, + onActiveChange: (active) => { + emitToHost('prismScopeMeasurement', { active }) + }, + }) // Create the analyzer once per data source / shim. useEffect(() => { @@ -125,13 +140,21 @@ export default function SpectrumScope({ : undefined return ( -
+
- {showPeak && peak && ( + + {!measurementController.active && showPeak && peak && (
void) | null>(null) rotationRef.current = settings.rotation + const measurementController = useScopeMeasurement({ + containerRef, + enabled: true, + rotation: settings.rotation, + mirrorHorizontal: settings.mirrorHorizontal, + getSource: () => vizRef.current, + }) useEffect(() => { const container = containerRef.current @@ -78,12 +89,20 @@ export default function WaveformScope({ }, [settings.rotation]) return ( -
+
+
) } diff --git a/src/renderer/components/ScopeMeasurementOverlay.tsx b/src/renderer/components/ScopeMeasurementOverlay.tsx new file mode 100644 index 0000000..6b1afa5 --- /dev/null +++ b/src/renderer/components/ScopeMeasurementOverlay.tsx @@ -0,0 +1,221 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type JSX, + type PointerEvent as ReactPointerEvent, + type RefObject, +} from 'react' +import type { ScopeDisplayRotation } from '../../types/scopeTransform' +import { + resolveMeasurementReadoutPosition, + resolveMeasurementSourcePoint, + type ActiveScopeMeasurement, + type ScopeMeasurementSource, +} from '../scopeMeasurement' + +interface ScopeMeasurementControllerOptions { + containerRef: RefObject + getSource: () => ScopeMeasurementSource | null + enabled: boolean + rotation: ScopeDisplayRotation + mirrorHorizontal: boolean + onActiveChange?: (active: boolean) => void +} + +interface ScopeMeasurementPointerBindings { + onPointerDown: (event: ReactPointerEvent) => void + onPointerMove: (event: ReactPointerEvent) => void + onPointerUp: (event: ReactPointerEvent) => void + onPointerCancel: (event: ReactPointerEvent) => void + onLostPointerCapture: (event: ReactPointerEvent) => void +} + +export interface ScopeMeasurementController { + active: boolean + measurement: ActiveScopeMeasurement | null + pointerBindings: ScopeMeasurementPointerBindings +} + +export function useScopeMeasurement({ + containerRef, + getSource, + enabled, + rotation, + mirrorHorizontal, + onActiveChange, +}: ScopeMeasurementControllerOptions): ScopeMeasurementController { + const [measurement, setMeasurement] = useState(null) + const activePointerIdRef = useRef(null) + const getSourceRef = useRef(getSource) + const onActiveChangeRef = useRef(onActiveChange) + const transformRef = useRef({ rotation, mirrorHorizontal }) + getSourceRef.current = getSource + onActiveChangeRef.current = onActiveChange + transformRef.current = { rotation, mirrorHorizontal } + + const setActive = useCallback((active: boolean): void => { + getSourceRef.current()?.setMeasurementActive?.(active) + onActiveChangeRef.current?.(active) + }, []) + + const endMeasurement = useCallback((): void => { + const pointerId = activePointerIdRef.current + if (pointerId === null) return + activePointerIdRef.current = null + const container = containerRef.current + if (container?.hasPointerCapture(pointerId)) { + container.releasePointerCapture(pointerId) + } + setMeasurement(null) + setActive(false) + }, [containerRef, setActive]) + + const updateMeasurement = useCallback((event: ReactPointerEvent): void => { + const container = containerRef.current + const source = getSourceRef.current() + if (!container || !source) return + const rect = container.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + const viewportPoint = { + x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), + y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)), + } + const sourcePoint = resolveMeasurementSourcePoint( + viewportPoint, + transformRef.current.rotation, + transformRef.current.mirrorHorizontal, + ) + setMeasurement({ + pointerId: event.pointerId, + viewportPoint, + sourcePoint, + measurement: source.getMeasurementAt(sourcePoint), + }) + }, [containerRef]) + + const handlePointerDown = useCallback((event: ReactPointerEvent): void => { + if (!enabled || event.altKey || event.button !== 0 || !event.isPrimary || activePointerIdRef.current !== null) return + if (!getSourceRef.current()) return + event.preventDefault() + activePointerIdRef.current = event.pointerId + event.currentTarget.setPointerCapture(event.pointerId) + setActive(true) + updateMeasurement(event) + }, [enabled, setActive, updateMeasurement]) + + const handlePointerMove = useCallback((event: ReactPointerEvent): void => { + if (activePointerIdRef.current !== event.pointerId) return + updateMeasurement(event) + }, [updateMeasurement]) + + const handlePointerEnd = useCallback((event: ReactPointerEvent): void => { + if (activePointerIdRef.current !== event.pointerId) return + endMeasurement() + }, [endMeasurement]) + + const handleLostPointerCapture = useCallback((event: ReactPointerEvent): void => { + if (activePointerIdRef.current === event.pointerId) endMeasurement() + }, [endMeasurement]) + + useEffect(() => { + if (!enabled) endMeasurement() + }, [enabled, endMeasurement]) + + useEffect(() => { + window.addEventListener('blur', endMeasurement) + return () => { + window.removeEventListener('blur', endMeasurement) + endMeasurement() + } + }, [endMeasurement]) + + return { + active: measurement !== null, + measurement, + pointerBindings: { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerEnd, + onPointerCancel: handlePointerEnd, + onLostPointerCapture: handleLostPointerCapture, + }, + } +} + +interface ScopeMeasurementOverlayProps { + containerRef: RefObject + measurement: ActiveScopeMeasurement | null +} + +interface Size { + width: number + height: number +} + +export function ScopeMeasurementOverlay({ + containerRef, + measurement, +}: ScopeMeasurementOverlayProps): JSX.Element | null { + const readoutRef = useRef(null) + const [readoutSize, setReadoutSize] = useState({ width: 260, height: 30 }) + const [viewportSize, setViewportSize] = useState({ width: 1, height: 1 }) + const active = measurement !== null + + useLayoutEffect(() => { + if (!active) return + const readout = readoutRef.current + const container = containerRef.current + if (!readout || !container) return + const measure = (): void => { + const nextReadoutSize = { width: readout.offsetWidth, height: readout.offsetHeight } + setReadoutSize((previous) => previous.width === nextReadoutSize.width + && previous.height === nextReadoutSize.height + ? previous + : nextReadoutSize) + const rect = container.getBoundingClientRect() + const nextViewportSize = { width: rect.width, height: rect.height } + setViewportSize((previous) => previous.width === nextViewportSize.width + && previous.height === nextViewportSize.height + ? previous + : nextViewportSize) + } + measure() + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) + observer?.observe(readout) + observer?.observe(container) + return () => observer?.disconnect() + }, [active, containerRef]) + + if (!measurement) return null + + const x = measurement.viewportPoint.x * viewportSize.width + const y = measurement.viewportPoint.y * viewportSize.height + const readoutPosition = resolveMeasurementReadoutPosition( + { x, y }, + viewportSize, + readoutSize, + ) + const readoutStyle: CSSProperties = { + left: `${readoutPosition.left}px`, + top: `${readoutPosition.top}px`, + } + + return ( + + ) +} diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 8e7ee65..9e70326 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -26,6 +26,11 @@ 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 { + ScopeMeasurementOverlay, + useScopeMeasurement, +} from './ScopeMeasurementOverlay' +import type { ScopeMeasurementSource } from '../scopeMeasurement' import { getScopeCanvasTransformStyle, isSameScopeCanvasLayout, @@ -65,6 +70,8 @@ interface Visualizer { dispose(): void resize(): void setOptions(options: Record): void + getMeasurementAt?: ScopeMeasurementSource['getMeasurementAt'] + setMeasurementActive?: ScopeMeasurementSource['setMeasurementActive'] } const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10 @@ -414,6 +421,22 @@ export default function ScopeModule({ const handleSpectrumPeakInfo = useCallback((nextPeakInfo: SpectrumPeakInfo | null): void => { setSpectrumPeakInfo(nextPeakInfo) }, []) + const measurementEnabled = scopeKind === 'spectrum' + || scopeKind === 'spectrogram' + || scopeKind === 'oscilloscope' + || scopeKind === 'waveform' + const measurementController = useScopeMeasurement({ + containerRef, + enabled: measurementEnabled, + rotation, + mirrorHorizontal, + getSource: () => { + const visualizer = visualizerRef.current + return visualizer?.getMeasurementAt + ? visualizer as ScopeMeasurementSource + : null + }, + }) useEffect(() => { if (!captureSpectrumPeakInfo) { @@ -622,8 +645,13 @@ export default function ScopeModule({ return (
- {scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && ( + + {!measurementController.active && scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
= 1000 + ? `${(frequencyHz / 1000).toFixed(2)}kHz` + : `${frequencyHz.toFixed(2)}Hz` +} + +export function formatMeasurementPitch(frequencyHz: number): string { + return formatSpectrumPitchInfo(resolveSpectrumPitchInfo(frequencyHz)) +} + +export function formatMeasurementDb(value: number, suffix = 'dB'): string { + if (value === Number.NEGATIVE_INFINITY) return `-∞${suffix}` + if (!Number.isFinite(value)) return `--${suffix}` + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}${suffix}` +} + +export function amplitudeToDbfs(amplitude: number): number { + const magnitude = Math.abs(amplitude) + return magnitude > 0 ? 20 * Math.log10(magnitude) : Number.NEGATIVE_INFINITY +} + +export function formatMeasurementAmplitude(amplitude: number): string { + if (!Number.isFinite(amplitude)) return '--' + return `${amplitude >= 0 ? '+' : ''}${amplitude.toFixed(3)}` +} + +export function formatMeasurementTime(seconds: number, historical = false): string { + if (!Number.isFinite(seconds) || seconds < 0) return '--' + const suffix = historical ? ' ago' : '' + if (seconds < 1) { + return `${(seconds * 1000).toFixed(2)}ms${suffix}` + } + return `${seconds.toFixed(2)}s${suffix}` +} + +export function resolveSpectrumMeasurement( + point: NormalizedScopePoint, + options: { + sampleRate: number + minFrequency: number + maxFrequency: number + minDecibels: number + maxDecibels: number + scaleType: 'linear' | 'log' + }, +): ScopeMeasurement { + const range = clampFrequencyRange(options.sampleRate, options.minFrequency, options.maxFrequency) + const frequencyHz = frequencyAtNormalizedPosition( + point.x, + range.minFrequency, + range.maxFrequency, + options.scaleType, + ) + const db = options.maxDecibels - clamp01(point.y) * (options.maxDecibels - options.minDecibels) + return { + values: [ + formatMeasurementDb(db), + formatMeasurementFrequency(frequencyHz), + formatMeasurementPitch(frequencyHz), + ], + } +} + +export function resolveSpectrogramMeasurement( + point: NormalizedScopePoint, + options: { + sampleRate: number + minFrequency: number + maxFrequency: number + scaleMode: SpectrogramScaleMode + fftSize: number + scrollSpeed: number + canvasPixelWidth: number + }, +): ScopeMeasurement { + const range = clampFrequencyRange(options.sampleRate, options.minFrequency, options.maxFrequency) + const frequencyHz = frequencyAtNormalizedPosition( + 1 - point.y, + range.minFrequency, + range.maxFrequency, + options.scaleMode, + ) + const hopDivisor = Math.max(2, Math.min(64, Math.round(8 * options.scrollSpeed))) + const hopSize = Math.max(1, Math.floor(options.fftSize / hopDivisor)) + const pixelsAgo = (1 - clamp01(point.x)) * Math.max(0, options.canvasPixelWidth - 1) + const secondsAgo = (pixelsAgo * hopSize) / Math.max(1, options.sampleRate) + return { + values: [ + formatMeasurementTime(secondsAgo, true), + formatMeasurementFrequency(frequencyHz), + formatMeasurementPitch(frequencyHz), + ], + } +} + +export function resolveOscilloscopeMeasurement( + point: NormalizedScopePoint, + sampleRate: number, + displaySamples: number, +): ScopeMeasurement { + const timeSeconds = clamp01(point.x) * Math.max(0, displaySamples - 1) / Math.max(1, sampleRate) + const amplitude = 1 - 2 * clamp01(point.y) + return { + values: [ + formatMeasurementTime(timeSeconds), + formatMeasurementAmplitude(amplitude), + formatMeasurementDb(amplitudeToDbfs(amplitude), 'dBFS'), + ], + } +} + +export function resolveWaveformMeasurement( + point: NormalizedScopePoint, + options: { + mode: WaveformMode + scrollSpeed: number + canvasPixelWidth: number + }, +): ScopeMeasurement { + const x = clamp01(point.x) + const y = clamp01(point.y) + const pixelsAgo = (1 - x) * Math.max(0, options.canvasPixelWidth - 1) + const secondsAgo = pixelsAgo / (WAVEFORM_BASE_PIXELS_PER_SECOND * Math.max(0.01, options.scrollSpeed)) + const stereo = options.mode === 'stereo' + const channel = stereo ? (y < 0.5 ? 'L' : 'R') : null + const laneY = stereo ? (y < 0.5 ? y * 2 : (y - 0.5) * 2) : y + const amplitude = (0.5 - laneY) / (0.5 * WAVEFORM_DISPLAY_MARGIN) + const values = [ + formatMeasurementTime(secondsAgo, true), + formatMeasurementAmplitude(amplitude), + formatMeasurementDb(amplitudeToDbfs(amplitude), 'dBFS'), + ] + if (channel) values.unshift(channel) + return { values } +} + +export function resolveMeasurementSourcePoint( + viewportPoint: NormalizedScopePoint, + rotation: ScopeDisplayRotation, + mirrorHorizontal: boolean, +): NormalizedScopePoint { + const sourcePoint = inverseTransformNormalizedScopePoint(viewportPoint, rotation, mirrorHorizontal) + return { + x: clamp01(sourcePoint.x), + y: clamp01(sourcePoint.y), + } +} + +export function resolveMeasurementReadoutPosition( + pointer: { x: number; y: number }, + viewport: { width: number; height: number }, + overlay: { width: number; height: number }, + margin = 8, + gap = 12, +): { left: number; top: number } { + const maxLeft = Math.max(margin, viewport.width - overlay.width - margin) + const maxTop = Math.max(margin, viewport.height - overlay.height - margin) + const preferredLeft = pointer.x + gap + overlay.width <= viewport.width - margin + ? pointer.x + gap + : pointer.x - gap - overlay.width + const preferredTop = pointer.y + gap + overlay.height <= viewport.height - margin + ? pointer.y + gap + : pointer.y - gap - overlay.height + return { + left: Math.max(margin, Math.min(maxLeft, preferredLeft)), + top: Math.max(margin, Math.min(maxTop, preferredTop)), + } +} diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 0236e3f..95c4e5d 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -783,6 +783,84 @@ button.toolbar__version:hover { height: 100%; } +.scope-measurement-surface { + cursor: crosshair; + touch-action: none; +} + +.scope-measurement-surface.is-measuring { + user-select: none; +} + +.scope-measurement { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + pointer-events: none; +} + +.scope-measurement__line { + position: absolute; + display: block; + background: var(--scope-overlay-text); + opacity: 0.58; + box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.5); +} + +.scope-measurement__line--vertical { + top: 0; + bottom: 0; + width: 1px; + transform: translateX(-0.5px); +} + +.scope-measurement__line--horizontal { + left: 0; + right: 0; + height: 1px; + transform: translateY(-0.5px); +} + +.scope-measurement__readout { + position: absolute; + max-width: calc(100% - 16px); + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 9px; + padding: 6px 10px; + border: 1px solid var(--scope-overlay-border); + border-radius: 5px; + background: var(--scope-overlay-bg); + color: var(--scope-overlay-text); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.2); +} + +.scope-measurement__item { + display: inline-flex; + align-items: center; + gap: 9px; + min-width: 0; + white-space: nowrap; +} + +.scope-measurement__value, +.scope-measurement__separator { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + line-height: 1; +} + +.scope-measurement__value { + font-weight: 600; + letter-spacing: 0.01em; +} + +.scope-measurement__separator { + opacity: 0.4; +} + .scope-module__peak-info { position: absolute; z-index: 3; diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts index d42933b..9381b01 100644 --- a/src/renderer/visualizers/Oscilloscope.ts +++ b/src/renderer/visualizers/Oscilloscope.ts @@ -9,6 +9,11 @@ import { colorToRgbChannels, multiplyColorAlpha } from '../utils/color' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' +import { + resolveOscilloscopeMeasurement, + type ScopeMeasurement, +} from '../scopeMeasurement' +import type { NormalizedScopePoint } from '../scopeCanvasTransform' export interface OscilloscopeDataSource extends VisualizerSessionSource { getPendingOscilloscopeSamples: () => Float32Array[] @@ -187,6 +192,15 @@ export class Oscilloscope { this.invalidate() } + getMeasurementAt(point: NormalizedScopePoint): ScopeMeasurement { + const sampleRate = Math.max(1, this.dataSource.getSampleRate()) + return resolveOscilloscopeMeasurement( + point, + sampleRate, + getNormalizedOscilloscopeDisplaySamples(sampleRate), + ) + } + private ensureRenderBuffer(size: number): Float32Array { if (this.renderBuffer.length !== size) { this.renderBuffer = new Float32Array(size) diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index f943cd5..06c87e1 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -31,6 +31,11 @@ import { HEAT_MID_DB, normalizeHeatDb, } from './heatScale' +import { + resolveSpectrogramMeasurement, + type ScopeMeasurement, +} from '../scopeMeasurement' +import type { NormalizedScopePoint } from '../scopeCanvasTransform' export interface SpectrogramDataSource extends VisualizerSessionSource { getPendingSpectrogramSamples: () => Float32Array[] @@ -331,6 +336,18 @@ export class Spectrogram { this.invalidate() } + getMeasurementAt(point: NormalizedScopePoint): ScopeMeasurement { + return resolveSpectrogramMeasurement(point, { + sampleRate: Math.max(1, this.dataSource.getSampleRate()), + minFrequency: this.options.minFrequency, + maxFrequency: this.options.maxFrequency, + scaleMode: this.options.scaleMode, + fftSize: this.options.fftSize, + scrollSpeed: this.options.scrollSpeed, + canvasPixelWidth: this.canvas.width, + }) + } + private getFrequencyPixelCount(width: number, height: number): number { return this.options.orientation === 'vertical' ? width : height } diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index 7394090..921f323 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -18,6 +18,12 @@ import { HEAT_MID_DB, normalizeHeatDb, } from './heatScale' +import { + SPECTRUM_MEASUREMENT_SMOOTHING, + resolveSpectrumMeasurement, + type ScopeMeasurement, +} from '../scopeMeasurement' +import type { NormalizedScopePoint } from '../scopeCanvasTransform' type SpectrumStereoChunk = { left: Float32Array @@ -246,6 +252,7 @@ export class SpectrumAnalyzer { private primaryPointDbfs = new Float32Array(0) private primaryPointFrequency = new Float32Array(0) private lastSelectedPeakInfo: SpectrumPeakInfo | null = null + private measurementActive = false constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) { this.canvas = canvas @@ -357,7 +364,29 @@ export class SpectrumAnalyzer { private getNativeSmoothing(): number { const base = clampSmoothing(this.options.smoothing) const fftRatio = Math.max(0.5, this.options.fftSize / 2048) - return clampSmoothing(Math.pow(base, fftRatio)) + const configured = clampSmoothing(Math.pow(base, fftRatio)) + return this.measurementActive + ? Math.max(configured, SPECTRUM_MEASUREMENT_SMOOTHING) + : configured + } + + getMeasurementAt(point: NormalizedScopePoint): ScopeMeasurement { + return resolveSpectrumMeasurement(point, { + sampleRate: this.sampleRate, + minFrequency: this.options.minFrequency, + maxFrequency: this.options.maxFrequency, + minDecibels: this.options.minDecibels, + maxDecibels: this.options.maxDecibels, + scaleType: this.options.scaleType, + }) + } + + setMeasurementActive(active: boolean): void { + if (this.measurementActive === active) return + this.measurementActive = active + if (this.isNativeAvailable()) { + this.nativeAnalyzer?.setSmoothing(this.getNativeSmoothing()) + } } private resetState(): void { @@ -1251,6 +1280,7 @@ export class SpectrumAnalyzer { } this.resetAnalyzerBuffers() this.lastSampleRate = 0 + this.measurementActive = false this.emitPeakInfo(null) } } diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 0106c32..6f32b64 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -11,6 +11,13 @@ import { type WaveformMode, } from '../../types/waveform' import { MultibandSplitter, createMultibandChunk, type MultibandChunk } from './multibandSplitter' +import { + WAVEFORM_BASE_PIXELS_PER_SECOND, + WAVEFORM_DISPLAY_MARGIN, + resolveWaveformMeasurement, + type ScopeMeasurement, +} from '../scopeMeasurement' +import type { NormalizedScopePoint } from '../scopeCanvasTransform' export interface WaveformStereoChunk { left: Float32Array @@ -62,9 +69,6 @@ const MULTIBAND_DOMINANCE_SENSITIVITY = 5 const MULTIBAND_FOCUSED_BLEND = 0.68 const MULTIBAND_FILL_ALPHA = 0.72 const MULTIBAND_EDGE_ALPHA = 1.0 -const BASE_PIXELS_PER_SECOND = 128 -const DISPLAY_MARGIN = 0.95 - const defaultWaveformDataSource: WaveformDataSource = { getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(), getPendingWaveformStereoSamples: () => audioRouter.flushPendingWaveformStereoSamples(), @@ -168,7 +172,7 @@ export class Waveform { private recomputeSamplesPerColumn(): void { const sampleRate = Math.max(1, this.dataSource.getSampleRate()) - const pixelsPerSecond = BASE_PIXELS_PER_SECOND * this.options.scrollSpeed + const pixelsPerSecond = WAVEFORM_BASE_PIXELS_PER_SECOND * this.options.scrollSpeed const next = Math.max(1, Math.round(sampleRate / pixelsPerSecond)) if (next !== this.samplesPerColumn) { this.samplesPerColumn = next @@ -251,6 +255,14 @@ export class Waveform { this.invalidate() } + getMeasurementAt(point: NormalizedScopePoint): ScopeMeasurement { + return resolveWaveformMeasurement(point, { + mode: this.options.mode, + scrollSpeed: this.options.scrollSpeed, + canvasPixelWidth: this.canvas.width, + }) + } + private computeMinMax(samples: Float32Array): { min: number; max: number } { if (this.columnAccumulatorPos === 0) { return { min: 0, max: 0 } @@ -388,7 +400,7 @@ export class Waveform { const scaledMin = Math.max(-1, Math.min(1, min)) const scaledMax = Math.max(-1, Math.min(1, max)) const centerY = laneTop + (laneHeight / 2) - const displayHalfHeight = (laneHeight / 2) * DISPLAY_MARGIN + const displayHalfHeight = (laneHeight / 2) * WAVEFORM_DISPLAY_MARGIN const yTop = Math.round(centerY - scaledMax * displayHalfHeight) const yBottom = Math.round(centerY - scaledMin * displayHalfHeight) const lineHeight = Math.max(1, yBottom - yTop) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index c0e6332..9799205 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -52,9 +52,19 @@ import { resolveThemeCreditDetails, resolveThemeOptionLabel } from '../src/rende import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule' import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection' import { + inverseTransformNormalizedScopePoint, resolveScopeCanvasLayout, transformNormalizedScopePoint, } from '../src/renderer/scopeCanvasTransform' +import { + SPECTRUM_MEASUREMENT_SMOOTHING, + frequencyAtNormalizedPosition, + resolveMeasurementReadoutPosition, + resolveOscilloscopeMeasurement, + resolveSpectrogramMeasurement, + resolveSpectrumMeasurement, + resolveWaveformMeasurement, +} from '../src/renderer/scopeMeasurement' import { applyInputGainToStereoSamples, inputGainDbToLinear, @@ -800,6 +810,7 @@ interface FakeSpectrumNativeAnalyzer extends SpectrumNativeAnalyzer { fillSideMagnitudes: number fillChannelMaxMagnitudes: number resets: number + smoothingValues: number[] } } @@ -887,6 +898,7 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { fillSideMagnitudes: 0, fillChannelMaxMagnitudes: 0, resets: 0, + smoothingValues: [], } const resize = (size: number): void => { @@ -1009,6 +1021,7 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { }, setSmoothing: (nextSmoothing) => { smoothing = Math.min(0.99, Math.max(0, nextSmoothing)) + calls.smoothingValues.push(nextSmoothing) }, pushSamples: (audioData) => { calls.monoPushes.push(new Float32Array(audioData)) @@ -1663,6 +1676,108 @@ test('scope point transforms mirror the source axis before clockwise rotation', assertAlmostEqual(mirrored270.y, 0.2, 1e-12, 'mirrored 270-degree y coordinate') }) +test('scope point inverse transforms round-trip every rotation and mirror state', () => { + const point = { x: 0.217, y: 0.683 } + for (const rotation of [0, 90, 180, 270] as const) { + for (const mirrorHorizontal of [false, true]) { + const viewportPoint = transformNormalizedScopePoint(point, rotation, mirrorHorizontal) + const restored = inverseTransformNormalizedScopePoint(viewportPoint, rotation, mirrorHorizontal) + assertAlmostEqual(restored.x, point.x, 1e-12, `${rotation}° mirror=${mirrorHorizontal} x`) + assertAlmostEqual(restored.y, point.y, 1e-12, `${rotation}° mirror=${mirrorHorizontal} y`) + } + } +}) + +test('scope measurement helpers resolve MiniMeters-style cursor axis values', () => { + const spectrumX = Math.log10(1000 / 20) / Math.log10(20000 / 20) + const spectrum = resolveSpectrumMeasurement( + { x: spectrumX, y: 0.5 }, + { + sampleRate: 48000, + minFrequency: 20, + maxFrequency: 20000, + minDecibels: -90, + maxDecibels: -10, + scaleType: 'log', + }, + ) + assert.deepEqual(spectrum.values.slice(0, 2), ['-50.00dB', '1.00kHz']) + assert.match(spectrum.values[2] ?? '', /^B5 /) + + const nyquistLimited = resolveSpectrumMeasurement( + { x: 1, y: 0 }, + { + sampleRate: 8000, + minFrequency: 20, + maxFrequency: 20000, + minDecibels: -90, + maxDecibels: -10, + scaleType: 'log', + }, + ) + assert.equal(nyquistLimited.values[1], '4.00kHz') + + const oscilloscope = resolveOscilloscopeMeasurement({ x: 0.5, y: 0.25 }, 48000, 2048) + assert.deepEqual(oscilloscope.values, ['21.32ms', '+0.500', '-6.02dBFS']) + + const waveform = resolveWaveformMeasurement( + { x: 0, y: 0.025 }, + { mode: 'mono', scrollSpeed: 1, canvasPixelWidth: 129 }, + ) + assert.deepEqual(waveform.values, ['1.00s ago', '+1.000', '+0.00dBFS']) + + const stereoWaveform = resolveWaveformMeasurement( + { x: 1, y: 0.75 }, + { mode: 'stereo', scrollSpeed: 1, canvasPixelWidth: 129 }, + ) + assert.deepEqual(stereoWaveform.values, ['R', '0.00ms ago', '+0.000', '-∞dBFS']) +}) + +test('spectrogram measurement follows scale mode and rendered history speed', () => { + assert.equal(frequencyAtNormalizedPosition(0.5, 20, 20000, 'linear'), 10010) + assertAlmostEqual( + frequencyAtNormalizedPosition(0.5, 20, 20000, 'log'), + Math.sqrt(20 * 20000), + 1e-9, + 'log midpoint', + ) + const melMidpoint = frequencyAtNormalizedPosition(0.5, 20, 20000, 'mel') + assert.ok(melMidpoint > 1000 && melMidpoint < 10000) + + const measurement = resolveSpectrogramMeasurement( + { x: 0.5, y: 0 }, + { + sampleRate: 48000, + minFrequency: 20, + maxFrequency: 20000, + scaleMode: 'log', + fftSize: 4096, + scrollSpeed: 2, + canvasPixelWidth: 101, + }, + ) + assert.deepEqual(measurement.values.slice(0, 2), ['266.67ms ago', '20.00kHz']) +}) + +test('measurement readout flips and clamps at viewport edges', () => { + assert.deepEqual( + resolveMeasurementReadoutPosition( + { x: 190, y: 90 }, + { width: 200, height: 100 }, + { width: 80, height: 24 }, + ), + { left: 98, top: 54 }, + ) + assert.deepEqual( + resolveMeasurementReadoutPosition( + { x: 2, y: 2 }, + { width: 60, height: 30 }, + { width: 80, height: 40 }, + ), + { left: 8, top: 8 }, + ) +}) + test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => { const profile = createDefaultProfile('Default') profile.scopeSettings.spectrum.showSideLine = true @@ -1679,6 +1794,50 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer assert.equal(options.gridColor, theme.spectrum.guides) }) +test('SpectrumAnalyzer applies and restores transient measurement smoothing without resetting', () => { + const dom = installFakeCanvasDom() + const nativeAnalyzer = createFakeSpectrumNativeAnalyzer() + const dataSource = { + getPendingSpectrumSamples: () => [], + getPendingSpectrumStereoSamples: () => [], + getSampleRate: () => 48000, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + smoothing: 0.9, + dataSource, + nativeAnalyzer, + }) + + try { + const resetsBeforeMeasurement = nativeAnalyzer.calls.resets + analyzer.setMeasurementActive(true) + assert.equal(nativeAnalyzer.calls.smoothingValues.at(-1), SPECTRUM_MEASUREMENT_SMOOTHING) + assert.equal(nativeAnalyzer.calls.resets, resetsBeforeMeasurement) + + analyzer.setMeasurementActive(false) + assertAlmostEqual( + nativeAnalyzer.calls.smoothingValues.at(-1) ?? 0, + 0.9, + 1e-12, + 'configured smoothing should be restored', + ) + + analyzer.setOptions({ smoothing: 0.99 }) + analyzer.setMeasurementActive(true) + assertAlmostEqual( + nativeAnalyzer.calls.smoothingValues.at(-1) ?? 0, + 0.99, + 1e-12, + 'higher user smoothing should be preserved', + ) + } finally { + analyzer.dispose() + dom.restore() + } +}) + test('SpectrumAnalyzer side line uses native stereo spectrum without draining mono samples', () => { const dom = installFakeCanvasDom() const nativeAnalyzer = createFakeSpectrumNativeAnalyzer()