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("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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(' ')}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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={[
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user