mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
measurement crosshair overlay
This commit is contained in:
@@ -277,6 +277,7 @@ namespace
|
|||||||
.withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); })
|
.withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); })
|
||||||
.withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); })
|
.withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); })
|
||||||
.withEventListener("prismSpectrogramConfig", [&editor](juce::var v) { editor.onScopeNativeConfig(std::move(v)); })
|
.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)); });
|
.withEventListener("prismSettingsPanel", [&editor](juce::var v) { editor.onSettingsPanel(std::move(v)); });
|
||||||
|
|
||||||
#if JUCE_WINDOWS
|
#if JUCE_WINDOWS
|
||||||
@@ -605,6 +606,11 @@ void PrismSpectrumEditor::onScopeNativeConfig(juce::var payload)
|
|||||||
engine->configureNative(payload);
|
engine->configureNative(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PrismSpectrumEditor::onScopeMeasurement(juce::var payload)
|
||||||
|
{
|
||||||
|
engine->setMeasurementActive((bool) payload.getProperty("active", false));
|
||||||
|
}
|
||||||
|
|
||||||
void PrismSpectrumEditor::pushRestoreSettings()
|
void PrismSpectrumEditor::pushRestoreSettings()
|
||||||
{
|
{
|
||||||
if (webView == nullptr)
|
if (webView == nullptr)
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ public:
|
|||||||
// Scope-specific native config (e.g. the spectrogram's canvas-derived rowCount).
|
// Scope-specific native config (e.g. the spectrogram's canvas-derived rowCount).
|
||||||
void onScopeNativeConfig(juce::var payload);
|
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
|
// 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
|
// height by exactly the panel height so the scope area is unchanged (the window
|
||||||
// accommodates the panel, like the app). 0 = closed.
|
// accommodates the panel, like the app). 0 = closed.
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ public:
|
|||||||
*/
|
*/
|
||||||
virtual void configureNative(const juce::var&) {}
|
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. */
|
/** Feed audio (called off the realtime thread). numSamples may be 0. */
|
||||||
virtual void process(const float* left, const float* right, int numSamples) = 0;
|
virtual void process(const float* left, const float* right, int numSamples) = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,18 @@ public:
|
|||||||
if (fftSize > 0 && (size_t) fftSize != spectrum.getFFTSize())
|
if (fftSize > 0 && (size_t) fftSize != spectrum.getFFTSize())
|
||||||
spectrum.setFFTSize((size_t) fftSize);
|
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
|
void process(const float* left, const float* right, int numSamples) override
|
||||||
@@ -44,6 +55,14 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
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)
|
static juce::String toBase64(const std::vector<float>& data)
|
||||||
{
|
{
|
||||||
return data.empty() ? juce::String()
|
return data.empty() ? juce::String()
|
||||||
@@ -52,4 +71,6 @@ private:
|
|||||||
|
|
||||||
const juce::Identifier frameId { "spectrumFrame" };
|
const juce::Identifier frameId { "spectrumFrame" };
|
||||||
Visualizer::Spectrum spectrum { 2048 };
|
Visualizer::Spectrum spectrum { 2048 };
|
||||||
|
float configuredSmoothing = 0.9f;
|
||||||
|
bool measurementActive = false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
|||||||
import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions'
|
import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions'
|
||||||
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
||||||
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
||||||
|
import {
|
||||||
|
ScopeMeasurementOverlay,
|
||||||
|
useScopeMeasurement,
|
||||||
|
} from '../renderer/components/ScopeMeasurementOverlay'
|
||||||
|
|
||||||
interface OscilloscopeScopeProps {
|
interface OscilloscopeScopeProps {
|
||||||
dataSource: PluginWebViewDataSource
|
dataSource: PluginWebViewDataSource
|
||||||
@@ -27,6 +31,13 @@ export default function OscilloscopeScope({
|
|||||||
const rotationRef = useRef(settings.rotation)
|
const rotationRef = useRef(settings.rotation)
|
||||||
const applySizeRef = useRef<(() => void) | null>(null)
|
const applySizeRef = useRef<(() => void) | null>(null)
|
||||||
rotationRef.current = settings.rotation
|
rotationRef.current = settings.rotation
|
||||||
|
const measurementController = useScopeMeasurement({
|
||||||
|
containerRef,
|
||||||
|
enabled: true,
|
||||||
|
rotation: settings.rotation,
|
||||||
|
mirrorHorizontal: settings.mirrorHorizontal,
|
||||||
|
getSource: () => vizRef.current,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
@@ -72,12 +83,20 @@ export default function OscilloscopeScope({
|
|||||||
}, [settings.rotation])
|
}, [settings.rotation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="spectrum-scope">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
|
||||||
|
{...measurementController.pointerBindings}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="spectrum-scope__canvas"
|
className="spectrum-scope__canvas"
|
||||||
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
||||||
/>
|
/>
|
||||||
|
<ScopeMeasurementOverlay
|
||||||
|
containerRef={containerRef}
|
||||||
|
measurement={measurementController.measurement}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { spectrogramSettingsToOptions } from './spectrogramOptions'
|
|||||||
import { resolveScrollingCanvasSize } from './scrollingCanvas'
|
import { resolveScrollingCanvasSize } from './scrollingCanvas'
|
||||||
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
||||||
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
||||||
|
import {
|
||||||
|
ScopeMeasurementOverlay,
|
||||||
|
useScopeMeasurement,
|
||||||
|
} from '../renderer/components/ScopeMeasurementOverlay'
|
||||||
|
|
||||||
interface SpectrogramScopeProps {
|
interface SpectrogramScopeProps {
|
||||||
dataSource: PluginWebViewDataSource
|
dataSource: PluginWebViewDataSource
|
||||||
@@ -28,6 +32,13 @@ export default function SpectrogramScope({
|
|||||||
const rotationRef = useRef(settings.rotation)
|
const rotationRef = useRef(settings.rotation)
|
||||||
const applySizeRef = useRef<(() => void) | null>(null)
|
const applySizeRef = useRef<(() => void) | null>(null)
|
||||||
rotationRef.current = settings.rotation
|
rotationRef.current = settings.rotation
|
||||||
|
const measurementController = useScopeMeasurement({
|
||||||
|
containerRef,
|
||||||
|
enabled: true,
|
||||||
|
rotation: settings.rotation,
|
||||||
|
mirrorHorizontal: settings.mirrorHorizontal,
|
||||||
|
getSource: () => vizRef.current,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
@@ -78,12 +89,20 @@ export default function SpectrogramScope({
|
|||||||
}, [settings.rotation])
|
}, [settings.rotation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="spectrum-scope">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
|
||||||
|
{...measurementController.pointerBindings}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="spectrum-scope__canvas"
|
className="spectrum-scope__canvas"
|
||||||
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
||||||
/>
|
/>
|
||||||
|
<ScopeMeasurementOverlay
|
||||||
|
containerRef={containerRef}
|
||||||
|
measurement={measurementController.measurement}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
|||||||
import { spectrumSettingsToOptions } from './spectrumOptions'
|
import { spectrumSettingsToOptions } from './spectrumOptions'
|
||||||
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
||||||
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
||||||
|
import {
|
||||||
|
ScopeMeasurementOverlay,
|
||||||
|
useScopeMeasurement,
|
||||||
|
} from '../renderer/components/ScopeMeasurementOverlay'
|
||||||
|
import { emitToHost } from './juceBridge'
|
||||||
import {
|
import {
|
||||||
formatSpectrumPeakDbfs,
|
formatSpectrumPeakDbfs,
|
||||||
formatSpectrumPeakFrequency,
|
formatSpectrumPeakFrequency,
|
||||||
@@ -41,6 +46,16 @@ export default function SpectrumScope({
|
|||||||
|
|
||||||
const peakMode = settings.peakInfoMode
|
const peakMode = settings.peakInfoMode
|
||||||
rotationRef.current = settings.rotation
|
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.
|
// Create the analyzer once per data source / shim.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,13 +140,21 @@ export default function SpectrumScope({
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="spectrum-scope">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
|
||||||
|
{...measurementController.pointerBindings}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="spectrum-scope__canvas"
|
className="spectrum-scope__canvas"
|
||||||
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
||||||
/>
|
/>
|
||||||
{showPeak && peak && (
|
<ScopeMeasurementOverlay
|
||||||
|
containerRef={containerRef}
|
||||||
|
measurement={measurementController.measurement}
|
||||||
|
/>
|
||||||
|
{!measurementController.active && showPeak && peak && (
|
||||||
<div
|
<div
|
||||||
ref={peakMode === 'following' ? peakOverlayRef : null}
|
ref={peakMode === 'following' ? peakOverlayRef : null}
|
||||||
className={['scope-module__peak-info', peakMode === 'following' ? 'is-following' : 'is-corner'].join(' ')}
|
className={['scope-module__peak-info', peakMode === 'following' ? 'is-following' : 'is-corner'].join(' ')}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { waveformSettingsToOptions } from './waveformOptions'
|
|||||||
import { resolveScrollingCanvasSize } from './scrollingCanvas'
|
import { resolveScrollingCanvasSize } from './scrollingCanvas'
|
||||||
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
||||||
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
||||||
|
import {
|
||||||
|
ScopeMeasurementOverlay,
|
||||||
|
useScopeMeasurement,
|
||||||
|
} from '../renderer/components/ScopeMeasurementOverlay'
|
||||||
|
|
||||||
interface WaveformScopeProps {
|
interface WaveformScopeProps {
|
||||||
dataSource: PluginWebViewDataSource
|
dataSource: PluginWebViewDataSource
|
||||||
@@ -28,6 +32,13 @@ export default function WaveformScope({
|
|||||||
const rotationRef = useRef(settings.rotation)
|
const rotationRef = useRef(settings.rotation)
|
||||||
const applySizeRef = useRef<(() => void) | null>(null)
|
const applySizeRef = useRef<(() => void) | null>(null)
|
||||||
rotationRef.current = settings.rotation
|
rotationRef.current = settings.rotation
|
||||||
|
const measurementController = useScopeMeasurement({
|
||||||
|
containerRef,
|
||||||
|
enabled: true,
|
||||||
|
rotation: settings.rotation,
|
||||||
|
mirrorHorizontal: settings.mirrorHorizontal,
|
||||||
|
getSource: () => vizRef.current,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
@@ -78,12 +89,20 @@ export default function WaveformScope({
|
|||||||
}, [settings.rotation])
|
}, [settings.rotation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="spectrum-scope">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
|
||||||
|
{...measurementController.pointerBindings}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="spectrum-scope__canvas"
|
className="spectrum-scope__canvas"
|
||||||
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
||||||
/>
|
/>
|
||||||
|
<ScopeMeasurementOverlay
|
||||||
|
containerRef={containerRef}
|
||||||
|
measurement={measurementController.measurement}
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ import { VUMeter, type VUMeterDataSource } from '../visualizers/VUMeter'
|
|||||||
import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter'
|
import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter'
|
||||||
import { Waveform, type WaveformDataSource } from '../visualizers/Waveform'
|
import { Waveform, type WaveformDataSource } from '../visualizers/Waveform'
|
||||||
import type { FrameScheduler } from '../visualizers/frameScheduler'
|
import type { FrameScheduler } from '../visualizers/frameScheduler'
|
||||||
|
import {
|
||||||
|
ScopeMeasurementOverlay,
|
||||||
|
useScopeMeasurement,
|
||||||
|
} from './ScopeMeasurementOverlay'
|
||||||
|
import type { ScopeMeasurementSource } from '../scopeMeasurement'
|
||||||
import {
|
import {
|
||||||
getScopeCanvasTransformStyle,
|
getScopeCanvasTransformStyle,
|
||||||
isSameScopeCanvasLayout,
|
isSameScopeCanvasLayout,
|
||||||
@@ -65,6 +70,8 @@ interface Visualizer {
|
|||||||
dispose(): void
|
dispose(): void
|
||||||
resize(): void
|
resize(): void
|
||||||
setOptions(options: Record<string, unknown>): void
|
setOptions(options: Record<string, unknown>): void
|
||||||
|
getMeasurementAt?: ScopeMeasurementSource['getMeasurementAt']
|
||||||
|
setMeasurementActive?: ScopeMeasurementSource['setMeasurementActive']
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10
|
const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10
|
||||||
@@ -414,6 +421,22 @@ export default function ScopeModule({
|
|||||||
const handleSpectrumPeakInfo = useCallback((nextPeakInfo: SpectrumPeakInfo | null): void => {
|
const handleSpectrumPeakInfo = useCallback((nextPeakInfo: SpectrumPeakInfo | null): void => {
|
||||||
setSpectrumPeakInfo(nextPeakInfo)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!captureSpectrumPeakInfo) {
|
if (!captureSpectrumPeakInfo) {
|
||||||
@@ -622,8 +645,13 @@ export default function ScopeModule({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="scope-module"
|
className={[
|
||||||
|
'scope-module',
|
||||||
|
measurementEnabled ? 'scope-measurement-surface' : '',
|
||||||
|
measurementController.active ? 'is-measuring' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
{...measurementController.pointerBindings}
|
||||||
style={{
|
style={{
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -637,7 +665,11 @@ export default function ScopeModule({
|
|||||||
...getScopeCanvasTransformStyle(rotation, mirrorHorizontal),
|
...getScopeCanvasTransformStyle(rotation, mirrorHorizontal),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
|
<ScopeMeasurementOverlay
|
||||||
|
containerRef={containerRef}
|
||||||
|
measurement={measurementController.measurement}
|
||||||
|
/>
|
||||||
|
{!measurementController.active && scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
|
||||||
<div
|
<div
|
||||||
ref={spectrumPeakMode === 'following' ? peakOverlayRef : null}
|
ref={spectrumPeakMode === 'following' ? peakOverlayRef : null}
|
||||||
className={[
|
className={[
|
||||||
|
|||||||
@@ -107,3 +107,37 @@ export function transformNormalizedScopePoint(
|
|||||||
return { x, y }
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -783,6 +783,84 @@ button.toolbar__version:hover {
|
|||||||
height: 100%;
|
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 {
|
.scope-module__peak-info {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { colorToRgbChannels, multiplyColorAlpha } from '../utils/color'
|
|||||||
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
||||||
import { FrameScheduler } from './frameScheduler'
|
import { FrameScheduler } from './frameScheduler'
|
||||||
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
||||||
|
import {
|
||||||
|
resolveOscilloscopeMeasurement,
|
||||||
|
type ScopeMeasurement,
|
||||||
|
} from '../scopeMeasurement'
|
||||||
|
import type { NormalizedScopePoint } from '../scopeCanvasTransform'
|
||||||
|
|
||||||
export interface OscilloscopeDataSource extends VisualizerSessionSource {
|
export interface OscilloscopeDataSource extends VisualizerSessionSource {
|
||||||
getPendingOscilloscopeSamples: () => Float32Array[]
|
getPendingOscilloscopeSamples: () => Float32Array[]
|
||||||
@@ -187,6 +192,15 @@ export class Oscilloscope {
|
|||||||
this.invalidate()
|
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 {
|
private ensureRenderBuffer(size: number): Float32Array {
|
||||||
if (this.renderBuffer.length !== size) {
|
if (this.renderBuffer.length !== size) {
|
||||||
this.renderBuffer = new Float32Array(size)
|
this.renderBuffer = new Float32Array(size)
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import {
|
|||||||
HEAT_MID_DB,
|
HEAT_MID_DB,
|
||||||
normalizeHeatDb,
|
normalizeHeatDb,
|
||||||
} from './heatScale'
|
} from './heatScale'
|
||||||
|
import {
|
||||||
|
resolveSpectrogramMeasurement,
|
||||||
|
type ScopeMeasurement,
|
||||||
|
} from '../scopeMeasurement'
|
||||||
|
import type { NormalizedScopePoint } from '../scopeCanvasTransform'
|
||||||
|
|
||||||
export interface SpectrogramDataSource extends VisualizerSessionSource {
|
export interface SpectrogramDataSource extends VisualizerSessionSource {
|
||||||
getPendingSpectrogramSamples: () => Float32Array[]
|
getPendingSpectrogramSamples: () => Float32Array[]
|
||||||
@@ -331,6 +336,18 @@ export class Spectrogram {
|
|||||||
this.invalidate()
|
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 {
|
private getFrequencyPixelCount(width: number, height: number): number {
|
||||||
return this.options.orientation === 'vertical' ? width : height
|
return this.options.orientation === 'vertical' ? width : height
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import {
|
|||||||
HEAT_MID_DB,
|
HEAT_MID_DB,
|
||||||
normalizeHeatDb,
|
normalizeHeatDb,
|
||||||
} from './heatScale'
|
} from './heatScale'
|
||||||
|
import {
|
||||||
|
SPECTRUM_MEASUREMENT_SMOOTHING,
|
||||||
|
resolveSpectrumMeasurement,
|
||||||
|
type ScopeMeasurement,
|
||||||
|
} from '../scopeMeasurement'
|
||||||
|
import type { NormalizedScopePoint } from '../scopeCanvasTransform'
|
||||||
|
|
||||||
type SpectrumStereoChunk = {
|
type SpectrumStereoChunk = {
|
||||||
left: Float32Array
|
left: Float32Array
|
||||||
@@ -246,6 +252,7 @@ export class SpectrumAnalyzer {
|
|||||||
private primaryPointDbfs = new Float32Array(0)
|
private primaryPointDbfs = new Float32Array(0)
|
||||||
private primaryPointFrequency = new Float32Array(0)
|
private primaryPointFrequency = new Float32Array(0)
|
||||||
private lastSelectedPeakInfo: SpectrumPeakInfo | null = null
|
private lastSelectedPeakInfo: SpectrumPeakInfo | null = null
|
||||||
|
private measurementActive = false
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) {
|
constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) {
|
||||||
this.canvas = canvas
|
this.canvas = canvas
|
||||||
@@ -357,7 +364,29 @@ export class SpectrumAnalyzer {
|
|||||||
private getNativeSmoothing(): number {
|
private getNativeSmoothing(): number {
|
||||||
const base = clampSmoothing(this.options.smoothing)
|
const base = clampSmoothing(this.options.smoothing)
|
||||||
const fftRatio = Math.max(0.5, this.options.fftSize / 2048)
|
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 {
|
private resetState(): void {
|
||||||
@@ -1251,6 +1280,7 @@ export class SpectrumAnalyzer {
|
|||||||
}
|
}
|
||||||
this.resetAnalyzerBuffers()
|
this.resetAnalyzerBuffers()
|
||||||
this.lastSampleRate = 0
|
this.lastSampleRate = 0
|
||||||
|
this.measurementActive = false
|
||||||
this.emitPeakInfo(null)
|
this.emitPeakInfo(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
type WaveformMode,
|
type WaveformMode,
|
||||||
} from '../../types/waveform'
|
} from '../../types/waveform'
|
||||||
import { MultibandSplitter, createMultibandChunk, type MultibandChunk } from './multibandSplitter'
|
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 {
|
export interface WaveformStereoChunk {
|
||||||
left: Float32Array
|
left: Float32Array
|
||||||
@@ -62,9 +69,6 @@ const MULTIBAND_DOMINANCE_SENSITIVITY = 5
|
|||||||
const MULTIBAND_FOCUSED_BLEND = 0.68
|
const MULTIBAND_FOCUSED_BLEND = 0.68
|
||||||
const MULTIBAND_FILL_ALPHA = 0.72
|
const MULTIBAND_FILL_ALPHA = 0.72
|
||||||
const MULTIBAND_EDGE_ALPHA = 1.0
|
const MULTIBAND_EDGE_ALPHA = 1.0
|
||||||
const BASE_PIXELS_PER_SECOND = 128
|
|
||||||
const DISPLAY_MARGIN = 0.95
|
|
||||||
|
|
||||||
const defaultWaveformDataSource: WaveformDataSource = {
|
const defaultWaveformDataSource: WaveformDataSource = {
|
||||||
getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(),
|
getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(),
|
||||||
getPendingWaveformStereoSamples: () => audioRouter.flushPendingWaveformStereoSamples(),
|
getPendingWaveformStereoSamples: () => audioRouter.flushPendingWaveformStereoSamples(),
|
||||||
@@ -168,7 +172,7 @@ export class Waveform {
|
|||||||
|
|
||||||
private recomputeSamplesPerColumn(): void {
|
private recomputeSamplesPerColumn(): void {
|
||||||
const sampleRate = Math.max(1, this.dataSource.getSampleRate())
|
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))
|
const next = Math.max(1, Math.round(sampleRate / pixelsPerSecond))
|
||||||
if (next !== this.samplesPerColumn) {
|
if (next !== this.samplesPerColumn) {
|
||||||
this.samplesPerColumn = next
|
this.samplesPerColumn = next
|
||||||
@@ -251,6 +255,14 @@ export class Waveform {
|
|||||||
this.invalidate()
|
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 } {
|
private computeMinMax(samples: Float32Array): { min: number; max: number } {
|
||||||
if (this.columnAccumulatorPos === 0) {
|
if (this.columnAccumulatorPos === 0) {
|
||||||
return { min: 0, max: 0 }
|
return { min: 0, max: 0 }
|
||||||
@@ -388,7 +400,7 @@ export class Waveform {
|
|||||||
const scaledMin = Math.max(-1, Math.min(1, min))
|
const scaledMin = Math.max(-1, Math.min(1, min))
|
||||||
const scaledMax = Math.max(-1, Math.min(1, max))
|
const scaledMax = Math.max(-1, Math.min(1, max))
|
||||||
const centerY = laneTop + (laneHeight / 2)
|
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 yTop = Math.round(centerY - scaledMax * displayHalfHeight)
|
||||||
const yBottom = Math.round(centerY - scaledMin * displayHalfHeight)
|
const yBottom = Math.round(centerY - scaledMin * displayHalfHeight)
|
||||||
const lineHeight = Math.max(1, yBottom - yTop)
|
const lineHeight = Math.max(1, yBottom - yTop)
|
||||||
|
|||||||
@@ -52,9 +52,19 @@ import { resolveThemeCreditDetails, resolveThemeOptionLabel } from '../src/rende
|
|||||||
import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule'
|
import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule'
|
||||||
import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection'
|
import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection'
|
||||||
import {
|
import {
|
||||||
|
inverseTransformNormalizedScopePoint,
|
||||||
resolveScopeCanvasLayout,
|
resolveScopeCanvasLayout,
|
||||||
transformNormalizedScopePoint,
|
transformNormalizedScopePoint,
|
||||||
} from '../src/renderer/scopeCanvasTransform'
|
} from '../src/renderer/scopeCanvasTransform'
|
||||||
|
import {
|
||||||
|
SPECTRUM_MEASUREMENT_SMOOTHING,
|
||||||
|
frequencyAtNormalizedPosition,
|
||||||
|
resolveMeasurementReadoutPosition,
|
||||||
|
resolveOscilloscopeMeasurement,
|
||||||
|
resolveSpectrogramMeasurement,
|
||||||
|
resolveSpectrumMeasurement,
|
||||||
|
resolveWaveformMeasurement,
|
||||||
|
} from '../src/renderer/scopeMeasurement'
|
||||||
import {
|
import {
|
||||||
applyInputGainToStereoSamples,
|
applyInputGainToStereoSamples,
|
||||||
inputGainDbToLinear,
|
inputGainDbToLinear,
|
||||||
@@ -800,6 +810,7 @@ interface FakeSpectrumNativeAnalyzer extends SpectrumNativeAnalyzer {
|
|||||||
fillSideMagnitudes: number
|
fillSideMagnitudes: number
|
||||||
fillChannelMaxMagnitudes: number
|
fillChannelMaxMagnitudes: number
|
||||||
resets: number
|
resets: number
|
||||||
|
smoothingValues: number[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,6 +898,7 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer {
|
|||||||
fillSideMagnitudes: 0,
|
fillSideMagnitudes: 0,
|
||||||
fillChannelMaxMagnitudes: 0,
|
fillChannelMaxMagnitudes: 0,
|
||||||
resets: 0,
|
resets: 0,
|
||||||
|
smoothingValues: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const resize = (size: number): void => {
|
const resize = (size: number): void => {
|
||||||
@@ -1009,6 +1021,7 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer {
|
|||||||
},
|
},
|
||||||
setSmoothing: (nextSmoothing) => {
|
setSmoothing: (nextSmoothing) => {
|
||||||
smoothing = Math.min(0.99, Math.max(0, nextSmoothing))
|
smoothing = Math.min(0.99, Math.max(0, nextSmoothing))
|
||||||
|
calls.smoothingValues.push(nextSmoothing)
|
||||||
},
|
},
|
||||||
pushSamples: (audioData) => {
|
pushSamples: (audioData) => {
|
||||||
calls.monoPushes.push(new Float32Array(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')
|
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', () => {
|
test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => {
|
||||||
const profile = createDefaultProfile('Default')
|
const profile = createDefaultProfile('Default')
|
||||||
profile.scopeSettings.spectrum.showSideLine = true
|
profile.scopeSettings.spectrum.showSideLine = true
|
||||||
@@ -1679,6 +1794,50 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer
|
|||||||
assert.equal(options.gridColor, theme.spectrum.guides)
|
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', () => {
|
test('SpectrumAnalyzer side line uses native stereo spectrum without draining mono samples', () => {
|
||||||
const dom = installFakeCanvasDom()
|
const dom = installFakeCanvasDom()
|
||||||
const nativeAnalyzer = createFakeSpectrumNativeAnalyzer()
|
const nativeAnalyzer = createFakeSpectrumNativeAnalyzer()
|
||||||
|
|||||||
Reference in New Issue
Block a user