measurement crosshair overlay

This commit is contained in:
Boof2015
2026-07-21 01:39:48 -04:00
parent bddebefd54
commit 1fcabb2b32
18 changed files with 984 additions and 14 deletions
+6
View File
@@ -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)
+3
View File
@@ -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.
+3
View File
@@ -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;
+22 -1
View File
@@ -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<float>& 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;
};
+20 -1
View File
@@ -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 (
<div ref={containerRef} className="spectrum-scope">
<div
ref={containerRef}
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
{...measurementController.pointerBindings}
>
<canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
</div>
)
}
+20 -1
View File
@@ -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 (
<div ref={containerRef} className="spectrum-scope">
<div
ref={containerRef}
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
{...measurementController.pointerBindings}
>
<canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
</div>
)
}
+25 -2
View File
@@ -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 (
<div ref={containerRef} className="spectrum-scope">
<div
ref={containerRef}
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
{...measurementController.pointerBindings}
>
<canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
{showPeak && peak && (
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
{!measurementController.active && showPeak && peak && (
<div
ref={peakMode === 'following' ? peakOverlayRef : null}
className={['scope-module__peak-info', peakMode === 'following' ? 'is-following' : 'is-corner'].join(' ')}
+20 -1
View File
@@ -8,6 +8,10 @@ import { waveformSettingsToOptions } from './waveformOptions'
import { resolveScrollingCanvasSize } from './scrollingCanvas'
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
import {
ScopeMeasurementOverlay,
useScopeMeasurement,
} from '../renderer/components/ScopeMeasurementOverlay'
interface WaveformScopeProps {
dataSource: PluginWebViewDataSource
@@ -28,6 +32,13 @@ export default function WaveformScope({
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 WaveformScope({
}, [settings.rotation])
return (
<div ref={containerRef} className="spectrum-scope">
<div
ref={containerRef}
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
{...measurementController.pointerBindings}
>
<canvas
ref={canvasRef}
className="spectrum-scope__canvas"
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
/>
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
</div>
)
}
@@ -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<HTMLDivElement | null>
getSource: () => ScopeMeasurementSource | null
enabled: boolean
rotation: ScopeDisplayRotation
mirrorHorizontal: boolean
onActiveChange?: (active: boolean) => void
}
interface ScopeMeasurementPointerBindings {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void
onLostPointerCapture: (event: ReactPointerEvent<HTMLDivElement>) => 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<ActiveScopeMeasurement | null>(null)
const activePointerIdRef = useRef<number | null>(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<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>): void => {
if (activePointerIdRef.current !== event.pointerId) return
updateMeasurement(event)
}, [updateMeasurement])
const handlePointerEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (activePointerIdRef.current !== event.pointerId) return
endMeasurement()
}, [endMeasurement])
const handleLostPointerCapture = useCallback((event: ReactPointerEvent<HTMLDivElement>): 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<HTMLDivElement | null>
measurement: ActiveScopeMeasurement | null
}
interface Size {
width: number
height: number
}
export function ScopeMeasurementOverlay({
containerRef,
measurement,
}: ScopeMeasurementOverlayProps): JSX.Element | null {
const readoutRef = useRef<HTMLDivElement>(null)
const [readoutSize, setReadoutSize] = useState<Size>({ width: 260, height: 30 })
const [viewportSize, setViewportSize] = useState<Size>({ 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 (
<div className="scope-measurement" aria-hidden="true">
<span className="scope-measurement__line scope-measurement__line--vertical" style={{ left: `${measurement.viewportPoint.x * 100}%` }} />
<span className="scope-measurement__line scope-measurement__line--horizontal" style={{ top: `${measurement.viewportPoint.y * 100}%` }} />
<div ref={readoutRef} className="scope-measurement__readout" style={readoutStyle}>
{measurement.measurement.values.map((value, index) => (
<span key={`${index}:${value}`} className="scope-measurement__item">
{index > 0 && <span className="scope-measurement__separator">|</span>}
<span className="scope-measurement__value">{value}</span>
</span>
))}
</div>
</div>
)
}
+34 -2
View File
@@ -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<string, unknown>): 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 (
<div
className="scope-module"
className={[
'scope-module',
measurementEnabled ? 'scope-measurement-surface' : '',
measurementController.active ? 'is-measuring' : '',
].filter(Boolean).join(' ')}
ref={containerRef}
{...measurementController.pointerBindings}
style={{
minWidth: 0,
height: '100%',
@@ -637,7 +665,11 @@ export default function ScopeModule({
...getScopeCanvasTransformStyle(rotation, mirrorHorizontal),
}}
/>
{scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
<ScopeMeasurementOverlay
containerRef={containerRef}
measurement={measurementController.measurement}
/>
{!measurementController.active && scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
<div
ref={spectrumPeakMode === 'following' ? peakOverlayRef : null}
className={[
+34
View File
@@ -107,3 +107,37 @@ export function transformNormalizedScopePoint(
return { x, y }
}
}
export function inverseTransformNormalizedScopePoint(
point: NormalizedScopePoint,
rotation: ScopeDisplayRotation,
mirrorHorizontal: boolean,
): NormalizedScopePoint {
let mirroredX: number
let y: number
switch (rotation) {
case 90:
mirroredX = point.y
y = 1 - point.x
break
case 180:
mirroredX = 1 - point.x
y = 1 - point.y
break
case 270:
mirroredX = 1 - point.y
y = point.x
break
case 0:
default:
mirroredX = point.x
y = point.y
break
}
return {
x: mirrorHorizontal ? 1 - mirroredX : mirroredX,
y,
}
}
+260
View File
@@ -0,0 +1,260 @@
import type { ScopeDisplayRotation } from '../types/scopeTransform'
import {
formatSpectrumPitchInfo,
resolveSpectrumPitchInfo,
} from '../types/spectrum'
import type { SpectrogramScaleMode } from '../types/spectrogram'
import type { WaveformMode } from '../types/waveform'
import {
inverseTransformNormalizedScopePoint,
type NormalizedScopePoint,
} from './scopeCanvasTransform'
export type MeasurableScopeKind = 'spectrum' | 'spectrogram' | 'oscilloscope' | 'waveform'
export interface ScopeMeasurement {
values: string[]
}
export interface ScopeMeasurementSource {
getMeasurementAt(point: NormalizedScopePoint): ScopeMeasurement
setMeasurementActive?(active: boolean): void
}
export interface ActiveScopeMeasurement {
pointerId: number
viewportPoint: NormalizedScopePoint
sourcePoint: NormalizedScopePoint
measurement: ScopeMeasurement
}
export const SPECTRUM_MEASUREMENT_SMOOTHING = 0.97
export const WAVEFORM_BASE_PIXELS_PER_SECOND = 128
export const WAVEFORM_DISPLAY_MARGIN = 0.95
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value))
}
function clampFrequencyRange(
sampleRate: number,
minFrequency: number,
maxFrequency: number,
): { minFrequency: number; maxFrequency: number } {
const nyquist = Math.max(1, sampleRate) / 2
const min = Math.max(1, Math.min(minFrequency, nyquist))
return {
minFrequency: min,
maxFrequency: Math.max(min + 1, Math.min(maxFrequency, nyquist)),
}
}
function hzToMelSlaney(frequencyHz: number): number {
const fSp = 200 / 3
const minLogHz = 1000
const minLogMel = minLogHz / fSp
const logStep = Math.log(6.4) / 27
return frequencyHz < minLogHz
? frequencyHz / fSp
: minLogMel + (Math.log(frequencyHz / minLogHz) / logStep)
}
function melToHzSlaney(mel: number): number {
const fSp = 200 / 3
const minLogHz = 1000
const minLogMel = minLogHz / fSp
const logStep = Math.log(6.4) / 27
return mel < minLogMel
? mel * fSp
: minLogHz * Math.exp(logStep * (mel - minLogMel))
}
export function frequencyAtNormalizedPosition(
position: number,
minFrequency: number,
maxFrequency: number,
scaleMode: 'linear' | 'log' | 'mel',
): number {
const t = clamp01(position)
if (scaleMode === 'linear') {
return minFrequency + t * (maxFrequency - minFrequency)
}
if (scaleMode === 'mel') {
const melMin = hzToMelSlaney(minFrequency)
const melMax = hzToMelSlaney(maxFrequency)
return melToHzSlaney(melMin + t * (melMax - melMin))
}
const logMin = Math.log10(minFrequency)
const logMax = Math.log10(maxFrequency)
return 10 ** (logMin + t * (logMax - logMin))
}
export function formatMeasurementFrequency(frequencyHz: number): string {
if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return '--'
return frequencyHz >= 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)),
}
}
+78
View File
@@ -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;
+14
View File
@@ -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)
+17
View File
@@ -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
}
+31 -1
View File
@@ -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)
}
}
+17 -5
View File
@@ -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)
+159
View File
@@ -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()