significantly improved and more accurate vectorscope

This commit is contained in:
Boof2015
2026-08-16 20:52:50 -04:00
parent 3e95cbfc28
commit 04f254512c
21 changed files with 682 additions and 335 deletions
+3
View File
@@ -124,6 +124,9 @@ jobs:
- name: Test native spectrogram accuracy - name: Test native spectrogram accuracy
run: npm run test:spectrogram-native run: npm run test:spectrogram-native
- name: Test native vectorscope calibration
run: npm run test:vectorscope-native
- name: Build and test Prism TUI - name: Build and test Prism TUI
run: npm run test:tui run: npm run test:tui
+3 -1
View File
@@ -20,7 +20,7 @@ Seven visualizers driven by a native C++ analysis engine:
- **Spectrum Analyzer** — FFT frequency display with heatmap and fill modes, configurable FFT size, spectral tilt, Log/Mel/Linear scaling, and Extended (default, 10 Hzup to 24 kHz) or Audible (20 Hz20 kHz) ranges - **Spectrum Analyzer** — FFT frequency display with heatmap and fill modes, configurable FFT size, spectral tilt, Log/Mel/Linear scaling, and Extended (default, 10 Hzup to 24 kHz) or Audible (20 Hz20 kHz) ranges
- **Oscilloscope** — Time-domain waveform with a pitch-lock mode that syncs the display to the fundamental frequency - **Oscilloscope** — Time-domain waveform with a pitch-lock mode that syncs the display to the fundamental frequency
- **Vectorscope** — Stereo phase visualization in five display modes (Lissajous, polar, linear) with optional multiband RGB split - **Vectorscope** — Stereo phase visualization in XY, folded/bipolar Polar, and folded/bipolar M/S Linear modes, with manual dB zoom and optional multiband RGB split
- **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, Extended (default) or Audible ranges, optional adaptive frequency guides, stereo-energy analysis, a legacy-style Focused mode, and detail-preserving frequency reassignment in Sharp/Sharper modes - **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, Extended (default) or Audible ranges, optional adaptive frequency guides, stereo-energy analysis, a legacy-style Focused mode, and detail-preserving frequency reassignment in Sharp/Sharper modes
- **VU Meter** — Classic loudness metering in needle or bar style, horizontal or vertical - **VU Meter** — Classic loudness metering in needle or bar style, horizontal or vertical
- **Loudness Meter** — Compact LUFS metering following ITU-R BS.1770 with fast stereo peak activity - **Loudness Meter** — Compact LUFS metering following ITU-R BS.1770 with fast stereo peak activity
@@ -30,6 +30,8 @@ Seven visualizers driven by a native C++ analysis engine:
Every scope is independently configurable. Drag and resize them into whatever layout makes sense, pop any scope out into its own window, and pin windows on top so they stay visible while you work. Every scope is independently configurable. Drag and resize them into whatever layout makes sense, pop any scope out into its own window, and pin windows on top so they stay visible while you work.
Vectorscope boundaries use exact per-channel sample-peak references in XY (square) and M/S Linear (diamond). Polar retains Prism's classic amplitude-compressed radial scaling so quieter stereo structure stays readable; its circle is labeled as a radial reference instead of a per-channel dBFS limit. Zoom changes the relevant labeled reference without changing the underlying samples. Folded modes rotate negative-Mid samples into the upper half instead of discarding them. The subtly shaded regions beyond the L/R channel guides show instantaneous Side-dominant samples; use the VU or Loudness Meter correlation reading to judge sustained mono compatibility.
## DAW Plugins ## DAW Plugins
Every Prism scope also ships as a DAW plugin. Drop a **Spectrum**, **Oscilloscope**, **Vectorscope**, **Spectrogram**, **VU Meter**, **Loudness Meter**, or **Waveform** onto any track and it analyzes that track's audio in real time, the same analysis engine and the same interface as the desktop app. Every Prism scope also ships as a DAW plugin. Drop a **Spectrum**, **Oscilloscope**, **Vectorscope**, **Spectrogram**, **VU Meter**, **Loudness Meter**, or **Waveform** onto any track and it analyzes that track's audio in real time, the same analysis engine and the same interface as the desktop app.
+3 -26
View File
@@ -22,23 +22,11 @@ Vectorscope::Vectorscope()
highRightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f); highRightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
points_.reserve(1024); points_.reserve(1024);
// Cascaded lowpass at 8kHz, Butterworth (Q=0.707)
// Two stages per channel = 4th order = 24 dB/oct rolloff
// Removes HF noise that causes erratic Lissajous motion
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
multibandSplitter_.configure(sampleRate_); multibandSplitter_.configure(sampleRate_);
} }
void Vectorscope::setSampleRate(float sampleRate) { void Vectorscope::setSampleRate(float sampleRate) {
sampleRate_ = sampleRate; sampleRate_ = sampleRate;
// Redesign all filters with new sample rate
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
multibandSplitter_.configure(sampleRate_); multibandSplitter_.configure(sampleRate_);
} }
@@ -53,15 +41,8 @@ void Vectorscope::pushSamples(
size_t length size_t length
) { ) {
for (size_t i = 0; i < length; i++) { for (size_t i = 0; i < length; i++) {
// Apply cascaded lowpass filtering leftBuffer_[writePos_] = leftChannel[i];
float filteredL = leftLowpass1_.process(leftChannel[i]); rightBuffer_[writePos_] = rightChannel[i];
filteredL = leftLowpass2_.process(filteredL);
float filteredR = rightLowpass1_.process(rightChannel[i]);
filteredR = rightLowpass2_.process(filteredR);
leftBuffer_[writePos_] = filteredL;
rightBuffer_[writePos_] = filteredR;
writePos_ = (writePos_ + 1) % VECTORSCOPE_BUFFER_SIZE; writePos_ = (writePos_ + 1) % VECTORSCOPE_BUFFER_SIZE;
if (validSamples_ < VECTORSCOPE_BUFFER_SIZE) { if (validSamples_ < VECTORSCOPE_BUFFER_SIZE) {
@@ -128,7 +109,7 @@ const std::vector<VectorscopePoint>& Vectorscope::process(
const float* rightChannel, const float* rightChannel,
size_t length size_t length
) { ) {
// Push through the filtering pipeline // Push through the full-band point pipeline
pushSamples(leftChannel, rightChannel, length); pushSamples(leftChannel, rightChannel, length);
// Build legacy output from buffer // Build legacy output from buffer
@@ -157,10 +138,6 @@ void Vectorscope::reset() {
std::fill(midRightBuffer_.begin(), midRightBuffer_.end(), 0.0f); std::fill(midRightBuffer_.begin(), midRightBuffer_.end(), 0.0f);
std::fill(highLeftBuffer_.begin(), highLeftBuffer_.end(), 0.0f); std::fill(highLeftBuffer_.begin(), highLeftBuffer_.end(), 0.0f);
std::fill(highRightBuffer_.begin(), highRightBuffer_.end(), 0.0f); std::fill(highRightBuffer_.begin(), highRightBuffer_.end(), 0.0f);
leftLowpass1_.reset();
leftLowpass2_.reset();
rightLowpass1_.reset();
rightLowpass2_.reset();
multibandSplitter_.reset(); multibandSplitter_.reset();
points_.clear(); points_.clear();
} }
+1 -7
View File
@@ -1,6 +1,5 @@
#pragma once #pragma once
#include "dsp_utils.h"
#include "multiband.h" #include "multiband.h"
#include <vector> #include <vector>
#include <cstddef> #include <cstddef>
@@ -52,7 +51,7 @@ private:
size_t writePos_; size_t writePos_;
size_t validSamples_; size_t validSamples_;
// Circular buffers for filtered L/R // Circular buffers for full-band L/R
std::vector<float> leftBuffer_; std::vector<float> leftBuffer_;
std::vector<float> rightBuffer_; std::vector<float> rightBuffer_;
std::vector<float> lowLeftBuffer_; std::vector<float> lowLeftBuffer_;
@@ -62,11 +61,6 @@ private:
std::vector<float> highLeftBuffer_; std::vector<float> highLeftBuffer_;
std::vector<float> highRightBuffer_; std::vector<float> highRightBuffer_;
// Cascaded lowpass filters (4th order Butterworth at 8kHz per channel)
DSP::BiquadFilter leftLowpass1_;
DSP::BiquadFilter leftLowpass2_;
DSP::BiquadFilter rightLowpass1_;
DSP::BiquadFilter rightLowpass2_;
MultibandSplitter multibandSplitter_; MultibandSplitter multibandSplitter_;
size_t multibandWritePos_; size_t multibandWritePos_;
size_t multibandValidSamples_; size_t multibandValidSamples_;
+1
View File
@@ -25,6 +25,7 @@
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
"test:spectrum-native": "node --test test/spectrum-native.test.mjs", "test:spectrum-native": "node --test test/spectrum-native.test.mjs",
"test:spectrogram-native": "node --test test/spectrogram-native.test.mjs", "test:spectrogram-native": "node --test test/spectrogram-native.test.mjs",
"test:vectorscope-native": "node --test test/vectorscope-native.test.mjs",
"test:tui": "node scripts/build/build-tui.cjs --test", "test:tui": "node scripts/build/build-tui.cjs --test",
"test:build-metadata": "node scripts/run-build-metadata-tests.mjs", "test:build-metadata": "node scripts/run-build-metadata-tests.mjs",
"test:updates": "node scripts/run-update-tests.mjs", "test:updates": "node scripts/run-update-tests.mjs",
+1 -1
View File
@@ -6,7 +6,7 @@
/** /**
* Vectorscope engine. Pushes stereo audio into the reused `Visualizer::Vectorscope` * Vectorscope engine. Pushes stereo audio into the reused `Visualizer::Vectorscope`
* (lowpass-filtered L/R + a 3-band split, both in circular buffers) and emits the * (full-band L/R + a 3-band split, both in circular buffers) and emits the
* most recent display points each frame. Two layouts share the buffers: the standard * most recent display points each frame. Two layouts share the buffers: the standard
* X/Y point cloud and the multiband (low/mid/high) cloud. Both buffers are kept warm * X/Y point cloud and the multiband (low/mid/high) cloud. Both buffers are kept warm
* so toggling is instant; the active layout (from the `multiband` setting) is flagged * so toggling is instant; the active layout (from the `multiband` setting) is flagged
+1 -1
View File
@@ -3,7 +3,7 @@ import type { VectorscopeNativeAnalyzer, VectorscopeMultibandPointsResult } from
/** /**
* Drop-in `VectorscopeNativeAnalyzer` for the plugin webview. * Drop-in `VectorscopeNativeAnalyzer` for the plugin webview.
* *
* The vectorscope DSP (channel lowpass + 3-band split, circular buffers) runs in * The vectorscope DSP (full-band channels + 3-band split, circular buffers) runs in
* the C++ plugin, which pushes the most recent display points each frame — either * the C++ plugin, which pushes the most recent display points each frame — either
* a standard X/Y cloud or a multiband (6 floats/point: lowL,lowR,midL,midR,highL, * a standard X/Y cloud or a multiband (6 floats/point: lowL,lowR,midL,midR,highL,
* highR) cloud, depending on the active mode. This shim caches whichever arrived * highR) cloud, depending on the active mode. This shim caches whichever arrived
+2
View File
@@ -16,12 +16,14 @@ export function vectorscopeSettingsToOptions(
gridMajorColor: theme.guides, gridMajorColor: theme.guides,
gridMinorColor: theme.guidesSecondary, gridMinorColor: theme.guidesSecondary,
labelColor: theme.labels, labelColor: theme.labels,
phaseRiskColor: theme.phaseRisk,
bandColors: { bandColors: {
low: theme.bandLow, low: theme.bandLow,
mid: theme.bandMid, mid: theme.bandMid,
high: theme.bandHigh, high: theme.bandHigh,
}, },
mode: settings.mode, mode: settings.mode,
zoomDb: settings.zoomDb,
multiband: settings.multiband, multiband: settings.multiband,
showGrid: settings.showGrid, showGrid: settings.showGrid,
persistence: settings.persistence, persistence: settings.persistence,
+2
View File
@@ -218,12 +218,14 @@ export function scopeSettingsToOptions(
gridMajorColor: t.guides, gridMajorColor: t.guides,
gridMinorColor: t.guidesSecondary, gridMinorColor: t.guidesSecondary,
labelColor: t.labels, labelColor: t.labels,
phaseRiskColor: t.phaseRisk,
bandColors: { bandColors: {
low: t.bandLow, low: t.bandLow,
mid: t.bandMid, mid: t.bandMid,
high: t.bandHigh, high: t.bandHigh,
}, },
mode: s.mode, mode: s.mode,
zoomDb: s.zoomDb,
multiband: s.multiband, multiband: s.multiband,
showGrid: s.showGrid, showGrid: s.showGrid,
persistence: s.persistence, persistence: s.persistence,
@@ -26,20 +26,26 @@ import {
MIN_WAVEFORM_SCROLL_SPEED, MIN_WAVEFORM_SCROLL_SPEED,
WAVEFORM_SCROLL_SPEED_STEP, WAVEFORM_SCROLL_SPEED_STEP,
} from '../../types/waveform' } from '../../types/waveform'
import {
formatVectorscopeZoomDb,
MAX_VECTORSCOPE_ZOOM_DB,
MIN_VECTORSCOPE_ZOOM_DB,
VECTORSCOPE_ZOOM_STEP_DB,
} from '../../types/vectorscope'
import ThemedSelect from './ThemedSelect' import ThemedSelect from './ThemedSelect'
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string { function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
switch (mode) { switch (mode) {
case 'lissajous': case 'lissajous':
return 'Lissajous' return 'XY (L/R)'
case 'polar-unipolar': case 'polar-unipolar':
return 'Polar Uni' return 'Polar (Folded)'
case 'polar-bipolar': case 'polar-bipolar':
return 'Polar Bi' return 'Polar (Bipolar)'
case 'linear-unipolar': case 'linear-unipolar':
return 'Linear Uni' return 'M/S Linear (Folded)'
case 'linear-bipolar': case 'linear-bipolar':
return 'Linear Bi' return 'M/S Linear (Bipolar)'
} }
} }
@@ -100,9 +106,10 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
} }
case 'vectorscope': { case 'vectorscope': {
const scopeSettings = settings as ScopeSettings['vectorscope'] const scopeSettings = settings as ScopeSettings['vectorscope']
return scopeSettings.multiband const parts = [vectorscopeModeLabel(scopeSettings.mode)]
? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB` if (scopeSettings.zoomDb !== 0) parts.push(`Zoom ${formatVectorscopeZoomDb(scopeSettings.zoomDb)}`)
: vectorscopeModeLabel(scopeSettings.mode) if (scopeSettings.multiband) parts.push('RGB')
return parts.join(' · ')
} }
case 'spectrogram': { case 'spectrogram': {
const scopeSettings = settings as ScopeSettings['spectrogram'] const scopeSettings = settings as ScopeSettings['spectrogram']
@@ -488,13 +495,24 @@ export default function ScopeSettingsSection({
value={current.mode} value={current.mode}
onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })} onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })}
> >
<option value="lissajous">Lissajous</option> <option value="lissajous">XY (L/R)</option>
<option value="polar-unipolar">Polar (Uni)</option> <option value="polar-unipolar">Polar (Folded)</option>
<option value="polar-bipolar">Polar (Bi)</option> <option value="polar-bipolar">Polar (Bipolar)</option>
<option value="linear-unipolar">Linear (Uni)</option> <option value="linear-unipolar">M/S Linear (Folded)</option>
<option value="linear-bipolar">Linear (Bi)</option> <option value="linear-bipolar">M/S Linear (Bipolar)</option>
</SelectControl> </SelectControl>
<RangeControl
label="Zoom"
value={current.zoomDb}
valueLabel={formatVectorscopeZoomDb(current.zoomDb)}
min={MIN_VECTORSCOPE_ZOOM_DB}
max={MAX_VECTORSCOPE_ZOOM_DB}
step={VECTORSCOPE_ZOOM_STEP_DB}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { zoomDb: value })}
/>
<ToggleGroup label="Overlays"> <ToggleGroup label="Overlays">
<ToggleChip <ToggleChip
label="RGB" label="RGB"
+22 -7
View File
@@ -9,6 +9,7 @@ import { MultibandSplitter, MultibandBuffer, createMultibandChunk, type Multiban
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 { normalizeVectorscopeZoomDb } from '../../types/vectorscope'
export type VectorscopeMode = 'lissajous' | 'polar-unipolar' | 'polar-bipolar' | 'linear-unipolar' | 'linear-bipolar' export type VectorscopeMode = 'lissajous' | 'polar-unipolar' | 'polar-bipolar' | 'linear-unipolar' | 'linear-bipolar'
@@ -24,6 +25,7 @@ export interface VectorscopeOptions {
gridMajorColor?: string gridMajorColor?: string
gridMinorColor?: string gridMinorColor?: string
labelColor?: string labelColor?: string
phaseRiskColor?: string
bandColors?: { bandColors?: {
low: string low: string
mid: string mid: string
@@ -32,6 +34,7 @@ export interface VectorscopeOptions {
persistence?: number persistence?: number
displayPoints?: number displayPoints?: number
mode?: VectorscopeMode mode?: VectorscopeMode
zoomDb?: number
multiband?: boolean multiband?: boolean
dataSource?: VectorscopeDataSource dataSource?: VectorscopeDataSource
frameScheduler?: FrameScheduler frameScheduler?: FrameScheduler
@@ -48,6 +51,7 @@ const defaultOptions: ResolvedVectorscopeOptions = {
gridMajorColor: 'rgba(255, 255, 255, 0.1)', gridMajorColor: 'rgba(255, 255, 255, 0.1)',
gridMinorColor: 'rgba(255, 255, 255, 0.05)', gridMinorColor: 'rgba(255, 255, 255, 0.05)',
labelColor: 'rgba(255, 255, 255, 0.1)', labelColor: 'rgba(255, 255, 255, 0.1)',
phaseRiskColor: 'rgb(255, 191, 0)',
bandColors: { bandColors: {
low: '#ff4444', low: '#ff4444',
mid: '#44dd44', mid: '#44dd44',
@@ -56,6 +60,7 @@ const defaultOptions: ResolvedVectorscopeOptions = {
persistence: 0.10, persistence: 0.10,
displayPoints: 4096, displayPoints: 4096,
mode: 'lissajous', mode: 'lissajous',
zoomDb: 0,
multiband: false, multiband: false,
} }
@@ -97,7 +102,11 @@ export class Vectorscope {
this.ctx = ctx this.ctx = ctx
const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options
this.options = { ...defaultOptions, ...optionOverrides } this.options = {
...defaultOptions,
...optionOverrides,
zoomDb: normalizeVectorscopeZoomDb(optionOverrides.zoomDb ?? defaultOptions.zoomDb),
}
this.dataSource = dataSource ?? defaultVectorscopeDataSource this.dataSource = dataSource ?? defaultVectorscopeDataSource
this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeVectorscope : nativeAnalyzer this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeVectorscope : nativeAnalyzer
this.frameLoop = new VisualizerFrameLoop({ this.frameLoop = new VisualizerFrameLoop({
@@ -165,9 +174,14 @@ export class Vectorscope {
setOptions(options: Partial<VectorscopeOptions>): void { setOptions(options: Partial<VectorscopeOptions>): void {
const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options
const nextOptions: ResolvedVectorscopeOptions = { ...this.options, ...optionUpdates } const nextOptions: ResolvedVectorscopeOptions = {
...this.options,
...optionUpdates,
zoomDb: normalizeVectorscopeZoomDb(optionUpdates.zoomDb ?? this.options.zoomDb),
}
const multibandChanged = nextOptions.multiband !== this.options.multiband const multibandChanged = nextOptions.multiband !== this.options.multiband
const modeChanged = nextOptions.mode !== this.options.mode const modeChanged = nextOptions.mode !== this.options.mode
const zoomChanged = nextOptions.zoomDb !== this.options.zoomDb
this.options = nextOptions this.options = nextOptions
let shouldResetDisplay = false let shouldResetDisplay = false
if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) { if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) {
@@ -181,7 +195,7 @@ export class Vectorscope {
this.subscribeToSessionChanges() this.subscribeToSessionChanges()
shouldResetDisplay = true shouldResetDisplay = true
} }
if (multibandChanged || modeChanged) { if (multibandChanged || modeChanged || zoomChanged) {
shouldResetDisplay = true shouldResetDisplay = true
} }
if (shouldResetDisplay) { if (shouldResetDisplay) {
@@ -291,7 +305,9 @@ export class Vectorscope {
options.gridMajorColor, options.gridMajorColor,
options.gridMinorColor, options.gridMinorColor,
options.labelColor, options.labelColor,
options.phaseRiskColor,
options.mode, options.mode,
options.zoomDb,
].join(':') ].join(':')
if (this.staticLayerKey === key) { if (this.staticLayerKey === key) {
@@ -317,6 +333,8 @@ export class Vectorscope {
options.gridMinorColor, options.gridMinorColor,
options.labelColor, options.labelColor,
options.mode, options.mode,
options.phaseRiskColor,
options.zoomDb,
dpr, dpr,
) )
} }
@@ -561,10 +579,7 @@ export class Vectorscope {
scale: number, scale: number,
dotSize: number, dotSize: number,
): void { ): void {
const point = transformPoint(left, right, mode) const point = transformPoint(left, right, mode, this.options.zoomDb)
if (!point) {
return
}
const { dx, dy } = point const { dx, dy } = point
const px = centerX + dx * scale const px = centerX + dx * scale
+255 -253
View File
@@ -1,9 +1,14 @@
import type { VectorscopeMode } from './Vectorscope' import type { VectorscopeMode } from './Vectorscope'
import { multiplyColorAlpha } from '../utils/color' import { multiplyColorAlpha } from '../utils/color'
import {
formatVectorscopeReferenceDbfs,
normalizeVectorscopeZoomDb,
vectorscopeZoomDbToGain,
} from '../../types/vectorscope'
const INV_SQRT2 = 1 / Math.sqrt(2) const INV_SQRT2 = Math.SQRT1_2
const COS45 = Math.SQRT2 / 2 // 0.7071... const COS45 = Math.SQRT1_2
const OVERFLOW_BOUNDARY_STEP = 0.25 const PHASE_RISK_FILL_ALPHA = 0.08
export interface VectorscopeLayout { export interface VectorscopeLayout {
centerX: number centerX: number
@@ -11,22 +16,24 @@ export interface VectorscopeLayout {
radius: number radius: number
} }
export interface VectorscopePoint {
dx: number
dy: number
}
/** /**
* Compute the center point and radius for a vectorscope mode. * Compute the center point and calibrated outer-reference radius for a mode.
* * Folded modes only need the positive-Mid half of their geometry.
* Unipolar modes place the center at the bottom when height-limited. When
* width-limited, the visible semicircle/triangle is centered vertically.
* Bipolar and Lissajous center in the canvas.
*/ */
export function getVectorscopeLayout( export function getVectorscopeLayout(
width: number, width: number,
height: number, height: number,
mode: VectorscopeMode mode: VectorscopeMode,
): VectorscopeLayout { ): VectorscopeLayout {
const centerX = width / 2 const centerX = width / 2
const isUnipolar = mode === 'polar-unipolar' || mode === 'linear-unipolar' const isFolded = mode === 'polar-unipolar' || mode === 'linear-unipolar'
if (isUnipolar) { if (isFolded) {
const margin = height * 0.04 const margin = height * 0.04
const availableHeight = height - margin const availableHeight = height - margin
const halfWidth = width / 2 const halfWidth = width / 2
@@ -38,345 +45,347 @@ export function getVectorscopeLayout(
return { centerX, centerY, radius } return { centerX, centerY, radius }
} }
// Bipolar / Lissajous: centered
const radius = Math.min(width, height) / 2 * 0.9 const radius = Math.min(width, height) / 2 * 0.9
return { centerX, centerY: height / 2, radius } return { centerX, centerY: height / 2, radius }
} }
/** Opposite-sign instantaneous channel samples are Side-dominant. */
export function isVectorscopePhaseRisk(left: number, right: number): boolean {
return Number.isFinite(left) && Number.isFinite(right) && left * right < 0
}
/** /**
* Transform raw L/R sample values into display coordinates based on mode. * Project raw L/R samples into normalized coordinates.
* Returns null for points filtered out by unipolar modes (mid < 0).
* *
* dx/dy are in normalized space: positive dx = right, positive dy = up. * XY uses raw channel amplitude. Linear modes use peak-normalized M/S, whose
* Caller maps to canvas: canvasX = centerX + dx * scale, canvasY = centerY - dy * scale. * exact legal per-channel boundary is |Mid| + |Side| = 1. Polar modes retain
* * Prism's classic amplitude-compressed radial presentation so quiet stereo
* Polar modes apply sqrt amplitude scaling so points follow the circular * detail remains readable instead of collapsing toward the origin. Folded
* contours instead of forming diamond/linear patterns. * modes rotate negative-Mid points through the origin instead of dropping
* half of the waveform.
*/ */
export function transformPoint( export function transformPoint(
L: number, left: number,
R: number, right: number,
mode: VectorscopeMode mode: VectorscopeMode,
): { dx: number; dy: number } | null { zoomDb: number = 0,
): VectorscopePoint {
const gain = vectorscopeZoomDbToGain(zoomDb)
if (mode === 'lissajous') { if (mode === 'lissajous') {
return { dx: R, dy: L } return { dx: right * gain, dy: left * gain }
}
// M/S transform (45° rotation), normalized to preserve amplitude range
const mid = (L + R) * INV_SQRT2
const side = (R - L) * INV_SQRT2
// Unipolar: filter out negative mid (anti-phase / lower half)
const isUnipolar = mode === 'polar-unipolar' || mode === 'linear-unipolar'
if (isUnipolar && mid < 0) {
return null
} }
const isFolded = mode === 'polar-unipolar' || mode === 'linear-unipolar'
const isPolar = mode === 'polar-unipolar' || mode === 'polar-bipolar' const isPolar = mode === 'polar-unipolar' || mode === 'polar-bipolar'
if (isPolar) { if (isPolar) {
// Amplitude-compressed radial scaling: pushes points toward circular contours. // Preserve the original Polar presentation: an orthonormal M/S rotation
// Power < 1 compresses dynamic range — lower = more circular. // followed by strong radial expansion. Apply zoom before the curve so the
// 0.5 = sqrt (mild), 0.33 = cube root (moderate), 0.25 = fourth root (strong) // unit circle remains the selected radial reference.
const ampSq = mid * mid + side * side let mid = (left + right) * INV_SQRT2
if (ampSq < 1e-12) { let side = (right - left) * INV_SQRT2
if (isFolded && mid < 0) {
mid = -mid
side = -side
}
const amplitude = Math.hypot(mid, side)
if (amplitude < 1e-12) {
return { dx: 0, dy: 0 } return { dx: 0, dy: 0 }
} }
const amp = Math.sqrt(ampSq)
const scaledAmp = Math.pow(amp, 0.35) const shapedAmplitude = Math.pow(amplitude * gain, 0.35)
const factor = scaledAmp / amp return {
return { dx: side * factor, dy: mid * factor } dx: side / amplitude * shapedAmplitude,
dy: mid / amplitude * shapedAmplitude,
}
} }
// Linear modes: direct M/S Cartesian mapping let mid = (left + right) / 2
return { dx: side, dy: mid } let side = (right - left) / 2
if (isFolded && mid < 0) {
mid = -mid
side = -side
}
return { dx: side * gain, dy: mid * gain }
} }
function getOverflowBoundaryLayout( function fillPhaseRiskRegions(
width: number, ctx: CanvasRenderingContext2D,
height: number, layout: VectorscopeLayout,
mode: VectorscopeMode, mode: VectorscopeMode,
): VectorscopeLayout | null { phaseRiskColor: string,
): void {
const { centerX, centerY, radius } = layout
ctx.fillStyle = multiplyColorAlpha(phaseRiskColor, PHASE_RISK_FILL_ALPHA)
if (mode === 'lissajous') { if (mode === 'lissajous') {
return null ctx.fillRect(centerX - radius, centerY - radius, radius, radius)
ctx.fillRect(centerX, centerY, radius, radius)
return
} }
const layout = getVectorscopeLayout(width, height, mode) if (mode === 'polar-unipolar') {
const maxXRadius = Math.min(layout.centerX, width - layout.centerX) ctx.beginPath()
const maxYRadius = mode === 'polar-unipolar' || mode === 'linear-unipolar' ctx.moveTo(centerX, centerY)
? layout.centerY ctx.arc(centerX, centerY, radius, Math.PI, Math.PI * 1.25, false)
: Math.min(layout.centerY, height - layout.centerY) ctx.closePath()
const maxRadius = Math.min(maxXRadius, maxYRadius) * 0.98 ctx.fill()
// Continue the existing quarter-step grid spacing, then clamp to the
// drawable area if the next full step would fall off-canvas.
const overflowRadius = Math.min(maxRadius, layout.radius * (1 + OVERFLOW_BOUNDARY_STEP))
if (overflowRadius <= layout.radius + 1) { ctx.beginPath()
return null ctx.moveTo(centerX, centerY)
ctx.arc(centerX, centerY, radius, Math.PI * 1.75, Math.PI * 2, false)
ctx.closePath()
ctx.fill()
return
} }
return { ...layout, radius: overflowRadius } if (mode === 'polar-bipolar') {
for (const [startAngle, endAngle] of [
[-Math.PI / 4, Math.PI / 4],
[Math.PI * 3 / 4, Math.PI * 5 / 4],
] as const) {
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.arc(centerX, centerY, radius, startAngle, endAngle, false)
ctx.closePath()
ctx.fill()
}
return
}
const halfRadius = radius / 2
const sidePolygons = mode === 'linear-unipolar'
? [
[[centerX, centerY], [centerX - radius, centerY], [centerX - halfRadius, centerY - halfRadius]],
[[centerX, centerY], [centerX + halfRadius, centerY - halfRadius], [centerX + radius, centerY]],
]
: [
[[centerX, centerY], [centerX - halfRadius, centerY - halfRadius], [centerX - radius, centerY], [centerX - halfRadius, centerY + halfRadius]],
[[centerX, centerY], [centerX + halfRadius, centerY + halfRadius], [centerX + radius, centerY], [centerX + halfRadius, centerY - halfRadius]],
]
for (const polygon of sidePolygons) {
ctx.beginPath()
ctx.moveTo(polygon[0][0], polygon[0][1])
for (let index = 1; index < polygon.length; index += 1) {
ctx.lineTo(polygon[index][0], polygon[index][1])
}
ctx.closePath()
ctx.fill()
}
}
function drawReferenceLabel(
ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout,
labelColor: string,
zoomDb: number,
dpr: number,
radial: boolean = false,
): void {
const { centerX, centerY, radius } = layout
ctx.fillStyle = labelColor
ctx.font = `${9 * dpr}px monospace`
ctx.textAlign = 'right'
ctx.textBaseline = 'bottom'
const referenceLabel = formatVectorscopeReferenceDbfs(zoomDb)
ctx.fillText(
radial ? referenceLabel.replace(' dBFS', ' dB radial') : referenceLabel,
centerX + radius - 4 * dpr,
centerY + radius - 4 * dpr,
)
} }
/**
* Draw the Lissajous grid: crosshairs + box boundary.
*/
export function drawLissajousGrid( export function drawLissajousGrid(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout, layout: VectorscopeLayout,
gridMajorColor: string, gridMajorColor: string,
gridMinorColor: string, gridMinorColor: string,
labelColor: string, labelColor: string,
dpr: number phaseRiskColor: string,
zoomDb: number,
dpr: number,
): void { ): void {
const { centerX, centerY, radius } = layout const { centerX, centerY, radius } = layout
fillPhaseRiskRegions(ctx, layout, 'lissajous', phaseRiskColor)
ctx.strokeStyle = gridMajorColor ctx.strokeStyle = gridMajorColor
ctx.lineWidth = dpr ctx.lineWidth = dpr
ctx.strokeRect(centerX - radius, centerY - radius, radius * 2, radius * 2)
// Outer box
ctx.strokeRect(
centerX - radius,
centerY - radius,
radius * 2,
radius * 2
)
// Vertical crosshair
ctx.beginPath() ctx.beginPath()
ctx.moveTo(centerX, centerY - radius) ctx.moveTo(centerX, centerY - radius)
ctx.lineTo(centerX, centerY + radius) ctx.lineTo(centerX, centerY + radius)
ctx.stroke()
// Horizontal crosshair
ctx.beginPath()
ctx.moveTo(centerX - radius, centerY) ctx.moveTo(centerX - radius, centerY)
ctx.lineTo(centerX + radius, centerY) ctx.lineTo(centerX + radius, centerY)
ctx.stroke() ctx.stroke()
// Diagonal guides (dimmer)
ctx.strokeStyle = gridMinorColor || multiplyColorAlpha(gridMajorColor, 0.5) ctx.strokeStyle = gridMinorColor || multiplyColorAlpha(gridMajorColor, 0.5)
ctx.beginPath() ctx.beginPath()
ctx.moveTo(centerX - radius, centerY - radius) ctx.moveTo(centerX - radius, centerY - radius)
ctx.lineTo(centerX + radius, centerY + radius) ctx.lineTo(centerX + radius, centerY + radius)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(centerX + radius, centerY - radius) ctx.moveTo(centerX + radius, centerY - radius)
ctx.lineTo(centerX - radius, centerY + radius) ctx.lineTo(centerX - radius, centerY + radius)
ctx.stroke() ctx.stroke()
// Labels
ctx.fillStyle = labelColor ctx.fillStyle = labelColor
ctx.font = `${10 * dpr}px monospace` ctx.font = `${10 * dpr}px monospace`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center' ctx.textAlign = 'center'
ctx.fillText('L', centerX, centerY - radius - 6 * dpr) ctx.fillText('L', centerX, centerY - radius + 9 * dpr)
ctx.fillText('R', centerX + radius + 12 * dpr, centerY + 4 * dpr) ctx.fillText('R', centerX + radius - 9 * dpr, centerY)
ctx.fillText('M', centerX + radius * 0.72, centerY - radius * 0.72)
ctx.fillText('S', centerX - radius * 0.72, centerY - radius * 0.72)
drawReferenceLabel(ctx, layout, labelColor, zoomDb, dpr)
} }
function drawDashedOuterBoundary( function drawChannelGuides(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout, layout: VectorscopeLayout,
mode: VectorscopeMode, unipolar: boolean,
color: string, ): void {
const { centerX, centerY, radius } = layout
if (unipolar) {
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.lineTo(centerX - radius * COS45, centerY - radius * COS45)
ctx.moveTo(centerX, centerY)
ctx.lineTo(centerX + radius * COS45, centerY - radius * COS45)
ctx.stroke()
return
}
ctx.beginPath()
ctx.moveTo(centerX - radius * COS45, centerY - radius * COS45)
ctx.lineTo(centerX + radius * COS45, centerY + radius * COS45)
ctx.moveTo(centerX + radius * COS45, centerY - radius * COS45)
ctx.lineTo(centerX - radius * COS45, centerY + radius * COS45)
ctx.stroke()
}
function drawMsLabels(
ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout,
labelColor: string,
unipolar: boolean,
channelGuideCoordinate: number,
dpr: number, dpr: number,
): void { ): void {
const { centerX, centerY, radius } = layout const { centerX, centerY, radius } = layout
const dashLength = Math.max(2, Math.round(4 * dpr)) ctx.fillStyle = labelColor
const gapLength = Math.max(2, Math.round(3 * dpr)) ctx.font = `${10 * dpr}px monospace`
ctx.textAlign = 'center'
ctx.strokeStyle = color ctx.textBaseline = 'middle'
ctx.lineWidth = dpr ctx.fillText('M+', centerX, centerY - radius + 9 * dpr)
ctx.setLineDash([dashLength, gapLength]) ctx.fillText('S', centerX - radius + 16 * dpr, centerY)
ctx.fillText('S+', centerX + radius - 16 * dpr, centerY)
switch (mode) { ctx.fillText('L', centerX - radius * channelGuideCoordinate - 9 * dpr, centerY - radius * channelGuideCoordinate - 4 * dpr)
case 'polar-unipolar': ctx.fillText('R', centerX + radius * channelGuideCoordinate + 9 * dpr, centerY - radius * channelGuideCoordinate - 4 * dpr)
ctx.beginPath() if (!unipolar) {
ctx.arc(centerX, centerY, radius, Math.PI, 0, false) ctx.fillText('M', centerX, centerY + radius - 9 * dpr)
ctx.stroke()
break
case 'polar-bipolar':
ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
ctx.stroke()
break
case 'linear-unipolar':
ctx.beginPath()
ctx.moveTo(centerX, centerY - radius)
ctx.lineTo(centerX - radius, centerY)
ctx.lineTo(centerX + radius, centerY)
ctx.closePath()
ctx.stroke()
break
case 'linear-bipolar':
ctx.beginPath()
ctx.moveTo(centerX, centerY - radius)
ctx.lineTo(centerX + radius, centerY)
ctx.lineTo(centerX, centerY + radius)
ctx.lineTo(centerX - radius, centerY)
ctx.closePath()
ctx.stroke()
break
default:
break
} }
ctx.setLineDash([])
} }
/**
* Draw the Polar (Scaled) grid: concentric circles + crosshairs.
*/
export function drawPolarGrid( export function drawPolarGrid(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout, layout: VectorscopeLayout,
gridMajorColor: string, gridMajorColor: string,
gridMinorColor: string, gridMinorColor: string,
labelColor: string, labelColor: string,
phaseRiskColor: string,
unipolar: boolean, unipolar: boolean,
dpr: number zoomDb: number,
dpr: number,
): void { ): void {
const { centerX, centerY, radius } = layout const { centerX, centerY, radius } = layout
fillPhaseRiskRegions(ctx, layout, unipolar ? 'polar-unipolar' : 'polar-bipolar', phaseRiskColor)
ctx.strokeStyle = gridMajorColor for (const ringScale of [0.25, 0.5, 0.75, 1]) {
ctx.lineWidth = dpr ctx.strokeStyle = ringScale === 1 ? gridMajorColor : gridMinorColor
ctx.lineWidth = dpr
// Concentric circles (or semicircles for unipolar)
const rings = [0.25, 0.5, 0.75, 1.0]
for (const scale of rings) {
ctx.beginPath() ctx.beginPath()
if (unipolar) { ctx.arc(centerX, centerY, radius * ringScale, unipolar ? Math.PI : 0, unipolar ? 0 : Math.PI * 2, false)
ctx.arc(centerX, centerY, radius * scale, Math.PI, 0, false)
} else {
ctx.arc(centerX, centerY, radius * scale, 0, Math.PI * 2)
}
ctx.stroke() ctx.stroke()
} }
// Vertical crosshair (mono axis) ctx.strokeStyle = gridMinorColor
ctx.beginPath() ctx.beginPath()
ctx.moveTo(centerX, centerY - radius) ctx.moveTo(centerX, centerY - radius)
if (unipolar) { ctx.lineTo(centerX, unipolar ? centerY : centerY + radius)
ctx.lineTo(centerX, centerY)
} else {
ctx.lineTo(centerX, centerY + radius)
}
ctx.stroke()
// Horizontal crosshair (side axis)
ctx.beginPath()
ctx.moveTo(centerX - radius, centerY) ctx.moveTo(centerX - radius, centerY)
ctx.lineTo(centerX + radius, centerY) ctx.lineTo(centerX + radius, centerY)
ctx.stroke() ctx.stroke()
// Diagonal guides (L and R channel axes) — dimmer ctx.strokeStyle = gridMajorColor
ctx.strokeStyle = gridMinorColor || multiplyColorAlpha(gridMajorColor, 0.5) drawChannelGuides(ctx, layout, unipolar)
drawMsLabels(ctx, layout, labelColor, unipolar, COS45, dpr)
if (unipolar) { drawReferenceLabel(ctx, layout, labelColor, zoomDb, dpr, true)
ctx.beginPath() }
ctx.moveTo(centerX, centerY)
ctx.lineTo(centerX - radius * COS45, centerY - radius * COS45) function drawLinearChannelGuides(
ctx.stroke() ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout,
ctx.beginPath() unipolar: boolean,
ctx.moveTo(centerX, centerY) ): void {
ctx.lineTo(centerX + radius * COS45, centerY - radius * COS45) const { centerX, centerY, radius } = layout
ctx.stroke() const halfRadius = radius / 2
} else { ctx.beginPath()
ctx.beginPath() if (unipolar) {
ctx.moveTo(centerX - radius * COS45, centerY - radius * COS45) ctx.moveTo(centerX, centerY)
ctx.lineTo(centerX + radius * COS45, centerY + radius * COS45) ctx.lineTo(centerX - halfRadius, centerY - halfRadius)
ctx.stroke() ctx.moveTo(centerX, centerY)
ctx.lineTo(centerX + halfRadius, centerY - halfRadius)
ctx.beginPath() } else {
ctx.moveTo(centerX + radius * COS45, centerY - radius * COS45) ctx.moveTo(centerX - halfRadius, centerY - halfRadius)
ctx.lineTo(centerX - radius * COS45, centerY + radius * COS45) ctx.lineTo(centerX + halfRadius, centerY + halfRadius)
ctx.stroke() ctx.moveTo(centerX + halfRadius, centerY - halfRadius)
} ctx.lineTo(centerX - halfRadius, centerY + halfRadius)
}
// Labels ctx.stroke()
ctx.fillStyle = labelColor
ctx.font = `${10 * dpr}px monospace`
ctx.textAlign = 'center'
ctx.fillText('+', centerX, centerY - radius - 6 * dpr)
ctx.fillText('L', centerX - radius * COS45 - 10 * dpr, centerY - radius * COS45 - 4 * dpr)
ctx.fillText('R', centerX + radius * COS45 + 10 * dpr, centerY - radius * COS45 - 4 * dpr)
if (!unipolar) {
ctx.fillText('-', centerX, centerY + radius + 14 * dpr)
}
} }
/**
* Draw the Linear grid: diamond/triangle guides.
*/
export function drawLinearGrid( export function drawLinearGrid(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
layout: VectorscopeLayout, layout: VectorscopeLayout,
gridMajorColor: string, gridMajorColor: string,
_gridMinorColor: string, gridMinorColor: string,
labelColor: string, labelColor: string,
phaseRiskColor: string,
unipolar: boolean, unipolar: boolean,
dpr: number zoomDb: number,
dpr: number,
): void { ): void {
const { centerX, centerY, radius } = layout const { centerX, centerY, radius } = layout
fillPhaseRiskRegions(ctx, layout, unipolar ? 'linear-unipolar' : 'linear-bipolar', phaseRiskColor)
ctx.strokeStyle = gridMajorColor for (const ringScale of [0.25, 0.5, 0.75, 1]) {
ctx.lineWidth = dpr const ringRadius = radius * ringScale
ctx.strokeStyle = ringScale === 1 ? gridMajorColor : gridMinorColor
const scales = [0.25, 0.5, 0.75, 1.0] ctx.lineWidth = dpr
for (const scale of scales) {
const r = radius * scale
ctx.beginPath() ctx.beginPath()
if (unipolar) { ctx.moveTo(centerX, centerY - ringRadius)
ctx.moveTo(centerX, centerY - r) // top (mono) ctx.lineTo(centerX + ringRadius, centerY)
ctx.lineTo(centerX - r, centerY) // left (L) if (!unipolar) ctx.lineTo(centerX, centerY + ringRadius)
ctx.lineTo(centerX + r, centerY) // right (R) ctx.lineTo(centerX - ringRadius, centerY)
ctx.closePath() ctx.closePath()
} else {
ctx.moveTo(centerX, centerY - r) // top (mono)
ctx.lineTo(centerX + r, centerY) // right (R)
ctx.lineTo(centerX, centerY + r) // bottom (anti-phase)
ctx.lineTo(centerX - r, centerY) // left (L)
ctx.closePath()
}
ctx.stroke() ctx.stroke()
} }
// Vertical crosshair ctx.strokeStyle = gridMinorColor
ctx.beginPath() ctx.beginPath()
ctx.moveTo(centerX, centerY - radius) ctx.moveTo(centerX, centerY - radius)
if (unipolar) { ctx.lineTo(centerX, unipolar ? centerY : centerY + radius)
ctx.lineTo(centerX, centerY)
} else {
ctx.lineTo(centerX, centerY + radius)
}
ctx.stroke()
// Horizontal crosshair
ctx.beginPath()
ctx.moveTo(centerX - radius, centerY) ctx.moveTo(centerX - radius, centerY)
ctx.lineTo(centerX + radius, centerY) ctx.lineTo(centerX + radius, centerY)
ctx.stroke() ctx.stroke()
// Labels ctx.strokeStyle = gridMajorColor
ctx.fillStyle = labelColor drawLinearChannelGuides(ctx, layout, unipolar)
ctx.font = `${10 * dpr}px monospace` drawMsLabels(ctx, layout, labelColor, unipolar, 0.5, dpr)
ctx.textAlign = 'center' drawReferenceLabel(ctx, layout, labelColor, zoomDb, dpr)
ctx.fillText('+', centerX, centerY - radius - 6 * dpr)
ctx.fillText('L', centerX - radius - 12 * dpr, centerY + 4 * dpr)
ctx.fillText('R', centerX + radius + 12 * dpr, centerY + 4 * dpr)
if (!unipolar) {
ctx.fillText('-', centerX, centerY + radius + 14 * dpr)
}
} }
/**
* Draw the appropriate grid for a given vectorscope mode.
*/
export function drawVectorscopeGridForMode( export function drawVectorscopeGridForMode(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
width: number, width: number,
@@ -385,31 +394,24 @@ export function drawVectorscopeGridForMode(
gridMinorColor: string, gridMinorColor: string,
labelColor: string, labelColor: string,
mode: VectorscopeMode, mode: VectorscopeMode,
dpr: number = 1 phaseRiskColor: string = 'rgb(255, 191, 0)',
zoomDb: number = 0,
dpr: number = 1,
): void { ): void {
const layout = getVectorscopeLayout(width, height, mode) const layout = getVectorscopeLayout(width, height, mode)
const overflowLayout = getOverflowBoundaryLayout(width, height, mode) const normalizedZoomDb = normalizeVectorscopeZoomDb(zoomDb)
const outerBoundaryColor = multiplyColorAlpha(gridMajorColor, 1.25)
switch (mode) { switch (mode) {
case 'lissajous': case 'lissajous':
drawLissajousGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, dpr) drawLissajousGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, phaseRiskColor, normalizedZoomDb, dpr)
break break
case 'polar-unipolar': case 'polar-unipolar':
drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, true, dpr)
break
case 'polar-bipolar': case 'polar-bipolar':
drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, false, dpr) drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, phaseRiskColor, mode === 'polar-unipolar', normalizedZoomDb, dpr)
break break
case 'linear-unipolar': case 'linear-unipolar':
drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, true, dpr)
break
case 'linear-bipolar': case 'linear-bipolar':
drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, false, dpr) drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, phaseRiskColor, mode === 'linear-unipolar', normalizedZoomDb, dpr)
break break
} }
if (overflowLayout) {
drawDashedOuterBoundary(ctx, overflowLayout, mode, outerBoundaryColor, dpr)
}
} }
+9 -1
View File
@@ -33,6 +33,7 @@ import {
normalizeScopeMirrorHorizontal, normalizeScopeMirrorHorizontal,
type ScopeDisplayRotation, type ScopeDisplayRotation,
} from '../types/scopeTransform' } from '../types/scopeTransform'
import { normalizeVectorscopeZoomDb } from '../types/vectorscope'
export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS] export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS]
@@ -189,6 +190,9 @@ export function mergeScopeSettings(
const rawOscilloscope: Partial<ScopeSettings['oscilloscope']> = typeof parsed.oscilloscope === 'object' && parsed.oscilloscope !== null const rawOscilloscope: Partial<ScopeSettings['oscilloscope']> = typeof parsed.oscilloscope === 'object' && parsed.oscilloscope !== null
? parsed.oscilloscope ? parsed.oscilloscope
: {} : {}
const rawVectorscope: Partial<ScopeSettings['vectorscope']> = typeof parsed.vectorscope === 'object' && parsed.vectorscope !== null
? parsed.vectorscope
: {}
const rawWaveform: Partial<ScopeSettings['waveform']> = typeof parsed.waveform === 'object' && parsed.waveform !== null const rawWaveform: Partial<ScopeSettings['waveform']> = typeof parsed.waveform === 'object' && parsed.waveform !== null
? parsed.waveform ? parsed.waveform
: {} : {}
@@ -216,7 +220,11 @@ export function mergeScopeSettings(
...rawOscilloscope, ...rawOscilloscope,
...normalizeDisplayTransform(rawOscilloscope), ...normalizeDisplayTransform(rawOscilloscope),
}, },
vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) }, vectorscope: {
...DEFAULT_SCOPE_SETTINGS.vectorscope,
...rawVectorscope,
zoomDb: normalizeVectorscopeZoomDb(rawVectorscope.zoomDb),
},
spectrogram: { spectrogram: {
...DEFAULT_SCOPE_SETTINGS.spectrogram, ...DEFAULT_SCOPE_SETTINGS.spectrogram,
...rawSpectrogramSettings, ...rawSpectrogramSettings,
+3
View File
@@ -118,6 +118,7 @@ const OSCILLOSCOPE_SCHEMA = {
const VECTORSCOPE_SCHEMA = { const VECTORSCOPE_SCHEMA = {
background: 'background', background: 'background',
trace: 'trace', trace: 'trace',
phase_risk: 'phaseRisk',
band_low: 'bandLow', band_low: 'bandLow',
band_mid: 'bandMid', band_mid: 'bandMid',
band_high: 'bandHigh', band_high: 'bandHigh',
@@ -1387,6 +1388,7 @@ export function createTemplateThemeFile(): string {
const vectorscopeSection = commentExampleTokens(serializeSection('Vectorscope', { const vectorscopeSection = commentExampleTokens(serializeSection('Vectorscope', {
...base.vectorscope, ...base.vectorscope,
background: resolved.vectorscope.background, background: resolved.vectorscope.background,
phaseRisk: resolved.vectorscope.phaseRisk,
guides: resolved.vectorscope.guides, guides: resolved.vectorscope.guides,
labels: resolved.vectorscope.labels, labels: resolved.vectorscope.labels,
}, VECTORSCOPE_SCHEMA as SectionSchema<Record<string, string | undefined>>)) }, VECTORSCOPE_SCHEMA as SectionSchema<Record<string, string | undefined>>))
@@ -1689,6 +1691,7 @@ function resolveVectorscopeTheme(
const guides = theme.vectorscope.guides ?? scopes.guides const guides = theme.vectorscope.guides ?? scopes.guides
return { return {
trace: theme.vectorscope.trace ?? app.accent, trace: theme.vectorscope.trace ?? app.accent,
phaseRisk: theme.vectorscope.phaseRisk ?? app.warning,
guides, guides,
guidesSecondary: multiplyAlpha(guides, 0.5), guidesSecondary: multiplyAlpha(guides, 0.5),
labels: theme.vectorscope.labels ?? guides, labels: theme.vectorscope.labels ?? guides,
+3 -1
View File
@@ -20,6 +20,7 @@ import {
DEFAULT_SCOPE_MIRROR_HORIZONTAL, DEFAULT_SCOPE_MIRROR_HORIZONTAL,
type ScopeDisplayTransformSettings, type ScopeDisplayTransformSettings,
} from './scopeTransform' } from './scopeTransform'
import { DEFAULT_VECTORSCOPE_ZOOM_DB } from './vectorscope'
export interface ScopeSettings { export interface ScopeSettings {
spectrum: ScopeDisplayTransformSettings & { spectrum: ScopeDisplayTransformSettings & {
@@ -44,6 +45,7 @@ export interface ScopeSettings {
} }
vectorscope: { vectorscope: {
mode: VectorscopeMode mode: VectorscopeMode
zoomDb: number
multiband: boolean multiband: boolean
showGrid: boolean showGrid: boolean
persistence: number persistence: number
@@ -88,7 +90,7 @@ export interface ScopeSettings {
export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
spectrum: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, scaleMode: DEFAULT_FREQUENCY_SCALE_MODE, frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE }, spectrum: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, scaleMode: DEFAULT_FREQUENCY_SCALE_MODE, frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE },
oscilloscope: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, oscilloscope: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 },
vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, vectorscope: { mode: 'lissajous', zoomDb: DEFAULT_VECTORSCOPE_ZOOM_DB, multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 },
spectrogram: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, showGrid: true, colorScheme: 'heat' }, spectrogram: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, showGrid: true, colorScheme: 'heat' },
vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS }, vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS },
lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT }, lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT },
+2
View File
@@ -77,6 +77,7 @@ export interface ThemeOscilloscopeTokens {
export interface ThemeVectorscopeTokens { export interface ThemeVectorscopeTokens {
background?: string background?: string
trace?: string trace?: string
phaseRisk?: string
bandLow?: string bandLow?: string
bandMid?: string bandMid?: string
bandHigh?: string bandHigh?: string
@@ -268,6 +269,7 @@ export interface ResolvedOscilloscopeTheme {
export interface ResolvedVectorscopeTheme { export interface ResolvedVectorscopeTheme {
trace: string trace: string
phaseRisk: string
guides: string guides: string
guidesSecondary: string guidesSecondary: string
labels: string labels: string
+30
View File
@@ -0,0 +1,30 @@
export const MIN_VECTORSCOPE_ZOOM_DB = -12
export const MAX_VECTORSCOPE_ZOOM_DB = 24
export const VECTORSCOPE_ZOOM_STEP_DB = 1
export const DEFAULT_VECTORSCOPE_ZOOM_DB = 0
export function normalizeVectorscopeZoomDb(value: unknown): number {
const numeric = typeof value === 'number' ? value : Number.NaN
if (!Number.isFinite(numeric)) return DEFAULT_VECTORSCOPE_ZOOM_DB
const clamped = Math.min(MAX_VECTORSCOPE_ZOOM_DB, Math.max(MIN_VECTORSCOPE_ZOOM_DB, numeric))
return Math.round(clamped / VECTORSCOPE_ZOOM_STEP_DB) * VECTORSCOPE_ZOOM_STEP_DB
}
export function vectorscopeZoomDbToGain(value: unknown): number {
return 10 ** (normalizeVectorscopeZoomDb(value) / 20)
}
export function vectorscopeReferenceDbfs(value: unknown): number {
return -normalizeVectorscopeZoomDb(value)
}
export function formatVectorscopeZoomDb(value: unknown): string {
const zoomDb = normalizeVectorscopeZoomDb(value)
return `${zoomDb > 0 ? '+' : ''}${zoomDb} dB`
}
export function formatVectorscopeReferenceDbfs(value: unknown): string {
const referenceDbfs = vectorscopeReferenceDbfs(value)
return `${referenceDbfs > 0 ? '+' : ''}${referenceDbfs} dBFS`
}
+13
View File
@@ -52,6 +52,7 @@ function createProfile(name: string): Profile {
profile.scopeSettings.spectrum.heatmapSmoothing = 0.67 profile.scopeSettings.spectrum.heatmapSmoothing = 0.67
profile.scopeSettings.spectrum.scaleMode = 'mel' profile.scopeSettings.spectrum.scaleMode = 'mel'
profile.scopeSettings.spectrum.frequencyRangeMode = 'audible' profile.scopeSettings.spectrum.frequencyRangeMode = 'audible'
profile.scopeSettings.vectorscope.zoomDb = 6
profile.scopeSettings.spectrogram.colorScheme = 'mono' profile.scopeSettings.spectrogram.colorScheme = 'mono'
profile.scopeSettings.spectrogram.clarityMode = 'focused' profile.scopeSettings.spectrogram.clarityMode = 'focused'
profile.scopeSettings.spectrogram.scaleMode = 'linear' profile.scopeSettings.spectrogram.scaleMode = 'linear'
@@ -76,6 +77,7 @@ test('profile file serialization excludes geometry and round-trips with local me
assert.equal(file.scopeSettings.spectrum.heatmapSmoothing, 0.67) assert.equal(file.scopeSettings.spectrum.heatmapSmoothing, 0.67)
assert.equal(file.scopeSettings.spectrum.scaleMode, 'mel') assert.equal(file.scopeSettings.spectrum.scaleMode, 'mel')
assert.equal(file.scopeSettings.spectrum.frequencyRangeMode, 'audible') assert.equal(file.scopeSettings.spectrum.frequencyRangeMode, 'audible')
assert.equal(file.scopeSettings.vectorscope.zoomDb, 6)
assert.equal(file.scopeSettings.spectrogram.scaleMode, 'linear') assert.equal(file.scopeSettings.spectrogram.scaleMode, 'linear')
assert.equal(file.scopeSettings.spectrogram.clarityMode, 'focused') assert.equal(file.scopeSettings.spectrogram.clarityMode, 'focused')
assert.equal(file.scopeSettings.spectrogram.frequencyRangeMode, 'extended') assert.equal(file.scopeSettings.spectrogram.frequencyRangeMode, 'extended')
@@ -94,6 +96,7 @@ test('profile file serialization excludes geometry and round-trips with local me
assert.equal(restored.scopeSettings.spectrum.heatmapSmoothing, 0.67) assert.equal(restored.scopeSettings.spectrum.heatmapSmoothing, 0.67)
assert.equal(restored.scopeSettings.spectrum.scaleMode, 'mel') assert.equal(restored.scopeSettings.spectrum.scaleMode, 'mel')
assert.equal(restored.scopeSettings.spectrum.frequencyRangeMode, 'audible') assert.equal(restored.scopeSettings.spectrum.frequencyRangeMode, 'audible')
assert.equal(restored.scopeSettings.vectorscope.zoomDb, 6)
assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono') assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono')
assert.equal(restored.scopeSettings.spectrogram.clarityMode, 'focused') assert.equal(restored.scopeSettings.spectrogram.clarityMode, 'focused')
assert.equal(restored.scopeSettings.spectrogram.scaleMode, 'linear') assert.equal(restored.scopeSettings.spectrogram.scaleMode, 'linear')
@@ -202,6 +205,16 @@ test('mergeScopeSettings defaults missing or invalid VU needle channel settings
assert.equal(missing.vumeter.needleChannels, 'stereo') assert.equal(missing.vumeter.needleChannels, 'stereo')
}) })
test('mergeScopeSettings normalizes vectorscope zoom without a profile migration', () => {
assert.equal(mergeScopeSettings({ vectorscope: { zoomDb: 6.49 } }).vectorscope.zoomDb, 6)
assert.equal(mergeScopeSettings({ vectorscope: { zoomDb: 6.5 } }).vectorscope.zoomDb, 7)
assert.equal(mergeScopeSettings({ vectorscope: { zoomDb: -99 } }).vectorscope.zoomDb, -12)
assert.equal(mergeScopeSettings({ vectorscope: { zoomDb: 99 } }).vectorscope.zoomDb, 24)
assert.equal(mergeScopeSettings({ vectorscope: { zoomDb: 'loud' } }).vectorscope.zoomDb, 0)
assert.equal(mergeScopeSettings({ vectorscope: {} }).vectorscope.zoomDb, 0)
assert.equal(mergeScopeSettings({}).vectorscope.zoomDb, 0)
})
test('mergeScopeSettings normalizes frequency scales, ranges, clarity, and supported spectrogram speeds', () => { test('mergeScopeSettings normalizes frequency scales, ranges, clarity, and supported spectrogram speeds', () => {
const valid = mergeScopeSettings({ const valid = mergeScopeSettings({
spectrum: { scaleMode: 'mel', frequencyRangeMode: 'extended' }, spectrum: { scaleMode: 'mel', frequencyRangeMode: 'extended' },
+237 -24
View File
@@ -119,6 +119,7 @@ import { decodeSpectrumFrame } from '../src/plugin-ui/juceBridge'
import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay' import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay'
import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions' import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions'
import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions' import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions'
import { vectorscopeSettingsToOptions } from '../src/plugin-ui/vectorscopeOptions'
import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram' import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram'
import { Vectorscope } from '../src/renderer/visualizers/Vectorscope' import { Vectorscope } from '../src/renderer/visualizers/Vectorscope'
import { Waveform } from '../src/renderer/visualizers/Waveform' import { Waveform } from '../src/renderer/visualizers/Waveform'
@@ -135,6 +136,8 @@ import {
import { import {
drawVectorscopeGridForMode, drawVectorscopeGridForMode,
getVectorscopeLayout, getVectorscopeLayout,
isVectorscopePhaseRisk,
transformPoint,
} from '../src/renderer/visualizers/vectorscopeGrids' } from '../src/renderer/visualizers/vectorscopeGrids'
import { import {
MultibandBuffer, MultibandBuffer,
@@ -155,6 +158,11 @@ import {
normalizedPositionAtFrequency, normalizedPositionAtFrequency,
type FrequencyScaleMode, type FrequencyScaleMode,
} from '../src/types/frequencyScale' } from '../src/types/frequencyScale'
import {
formatVectorscopeReferenceDbfs,
normalizeVectorscopeZoomDb,
vectorscopeZoomDbToGain,
} from '../src/types/vectorscope'
type WindowWithRaf = typeof globalThis & Pick<Window, 'requestAnimationFrame' | 'cancelAnimationFrame'> type WindowWithRaf = typeof globalThis & Pick<Window, 'requestAnimationFrame' | 'cancelAnimationFrame'>
type FakeElectronAPI = { type FakeElectronAPI = {
@@ -613,6 +621,10 @@ interface FakeCanvasRecorder {
lineDash: number[] lineDash: number[]
}> }>
lineDashes: number[][] lineDashes: number[][]
fills: Array<{
commands: Array<{ kind: 'moveTo' | 'lineTo'; x: number; y: number }>
fillStyle: string
}>
imageDataWrites: Array<{ x: number; y: number; width: number; height: number; data: number[] }> imageDataWrites: Array<{ x: number; y: number; width: number; height: number; data: number[] }>
drawImageCalls: Array<{ compositeOperation: GlobalCompositeOperation; args: unknown[] }> drawImageCalls: Array<{ compositeOperation: GlobalCompositeOperation; args: unknown[] }>
} }
@@ -625,6 +637,7 @@ function createFakeCanvasRecorder(): FakeCanvasRecorder {
strokeRects: [], strokeRects: [],
arcs: [], arcs: [],
lineDashes: [], lineDashes: [],
fills: [],
imageDataWrites: [], imageDataWrites: [],
drawImageCalls: [], drawImageCalls: [],
} }
@@ -650,7 +663,9 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca
currentPath = [] currentPath = []
}, },
closePath() {}, closePath() {},
fill() {}, fill() {
recorder?.fills.push({ commands: [...currentPath], fillStyle: currentFillStyle })
},
moveTo(x: number, y: number) { moveTo(x: number, y: number) {
currentPath.push({ kind: 'moveTo', x, y }) currentPath.push({ kind: 'moveTo', x, y })
}, },
@@ -3321,9 +3336,11 @@ test('scopeSettingsToOptions wires waveform stereo mode into analyzer options',
test('scopeSettingsToOptions forwards shared scope background and guides to oscilloscope and vectorscope', () => { test('scopeSettingsToOptions forwards shared scope background and guides to oscilloscope and vectorscope', () => {
const profile = createDefaultProfile('Default') const profile = createDefaultProfile('Default')
profile.scopeSettings.vectorscope.zoomDb = 6
const authoredTheme = createDefaultTheme() const authoredTheme = createDefaultTheme()
authoredTheme.scopes.background = 'rgb(3, 4, 5)' authoredTheme.scopes.background = 'rgb(3, 4, 5)'
authoredTheme.scopes.guides = 'rgba(120, 130, 140, 0.2)' authoredTheme.scopes.guides = 'rgba(120, 130, 140, 0.2)'
authoredTheme.vectorscope.phaseRisk = 'rgb(200, 100, 50)'
const theme = resolveTheme(authoredTheme) const theme = resolveTheme(authoredTheme)
const oscilloscope = scopeSettingsToOptions('oscilloscope', profile.scopeSettings.oscilloscope, theme.oscilloscope) const oscilloscope = scopeSettingsToOptions('oscilloscope', profile.scopeSettings.oscilloscope, theme.oscilloscope)
@@ -3336,6 +3353,12 @@ test('scopeSettingsToOptions forwards shared scope background and guides to osci
assert.equal(vectorscope.gridMajorColor, theme.vectorscope.guides) assert.equal(vectorscope.gridMajorColor, theme.vectorscope.guides)
assert.equal(vectorscope.gridMinorColor, theme.vectorscope.guidesSecondary) assert.equal(vectorscope.gridMinorColor, theme.vectorscope.guidesSecondary)
assert.equal(vectorscope.labelColor, theme.vectorscope.labels) assert.equal(vectorscope.labelColor, theme.vectorscope.labels)
assert.equal(vectorscope.phaseRiskColor, 'rgb(200, 100, 50)')
assert.equal(vectorscope.zoomDb, 6)
const pluginVectorscope = vectorscopeSettingsToOptions(profile.scopeSettings.vectorscope, theme.vectorscope)
assert.equal(pluginVectorscope.phaseRiskColor, vectorscope.phaseRiskColor)
assert.equal(pluginVectorscope.zoomDb, vectorscope.zoomDb)
}) })
test('Oscilloscope projects raw sample amplitude without renderer gain', () => { test('Oscilloscope projects raw sample amplitude without renderer gain', () => {
@@ -3412,7 +3435,7 @@ test('Vectorscope preserves height-limited and bipolar layout behavior', () => {
} }
}) })
test('vectorscope adds a subtle dashed outer boundary without changing the base graph', () => { test('vectorscope grids use calibrated boundaries, phase-risk shading, and honest labels', () => {
const lissajousRecorder = createFakeCanvasRecorder() const lissajousRecorder = createFakeCanvasRecorder()
drawVectorscopeGridForMode( drawVectorscopeGridForMode(
createFakeCanvasContext(lissajousRecorder), createFakeCanvasContext(lissajousRecorder),
@@ -3422,12 +3445,24 @@ test('vectorscope adds a subtle dashed outer boundary without changing the base
'rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.05)',
'rgba(255, 255, 255, 0.2)', 'rgba(255, 255, 255, 0.2)',
'lissajous', 'lissajous',
'rgb(255, 191, 0)',
6,
) )
assert.equal( assert.equal(
lissajousRecorder.lineDashes.some((segments) => segments.length > 0), lissajousRecorder.lineDashes.some((segments) => segments.length > 0),
false, false,
'lissajous should not render an outer headroom boundary', 'XY should not render an arbitrary overflow boundary',
) )
assert.equal(lissajousRecorder.strokeRects.length, 1)
assert.equal(lissajousRecorder.strokeRects[0]?.width, getVectorscopeLayout(320, 180, 'lissajous').radius * 2)
assert.equal(
lissajousRecorder.fillRects.filter(({ fillStyle }) => fillStyle === 'rgba(255, 191, 0, 0.08)').length,
2,
'XY should shade its two opposite-sign quadrants',
)
assert.equal(lissajousRecorder.fillTexts.some(({ text }) => text === '-6 dBFS'), true)
assert.equal(lissajousRecorder.fillTexts.some(({ text }) => text === 'M'), true)
assert.equal(lissajousRecorder.fillTexts.some(({ text }) => text === 'S'), true)
const linearRecorder = createFakeCanvasRecorder() const linearRecorder = createFakeCanvasRecorder()
drawVectorscopeGridForMode( drawVectorscopeGridForMode(
@@ -3438,12 +3473,18 @@ test('vectorscope adds a subtle dashed outer boundary without changing the base
'rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.05)',
'rgba(255, 255, 255, 0.2)', 'rgba(255, 255, 255, 0.2)',
'linear-bipolar', 'linear-bipolar',
'rgb(255, 191, 0)',
) )
assert.equal( assert.equal(
linearRecorder.lineDashes.some((segments) => segments.length > 0), linearRecorder.lineDashes.some((segments) => segments.length > 0),
true, false,
'linear mode should render a dashed outer max boundary', 'linear mode should only use its exact outer diamond',
) )
assert.equal(linearRecorder.fills.filter(({ fillStyle }) => fillStyle === 'rgba(255, 191, 0, 0.08)').length, 2)
assert.equal(linearRecorder.fillTexts.some(({ text }) => text === 'M+'), true)
assert.equal(linearRecorder.fillTexts.some(({ text }) => text === 'M'), true)
assert.equal(linearRecorder.fillTexts.some(({ text }) => text === 'S'), true)
assert.equal(linearRecorder.fillTexts.some(({ text }) => text === 'S+'), true)
const polarRecorder = createFakeCanvasRecorder() const polarRecorder = createFakeCanvasRecorder()
drawVectorscopeGridForMode( drawVectorscopeGridForMode(
@@ -3454,31 +3495,115 @@ test('vectorscope adds a subtle dashed outer boundary without changing the base
'rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.05)',
'rgba(255, 255, 255, 0.2)', 'rgba(255, 255, 255, 0.2)',
'polar-bipolar', 'polar-bipolar',
'rgb(255, 191, 0)',
) )
const polarLayout = getVectorscopeLayout(320, 180, 'polar-bipolar') const polarLayout = getVectorscopeLayout(320, 180, 'polar-bipolar')
const dashedPolarArc = polarRecorder.arcs.find((arc) => arc.lineDash.length > 0)
const expectedPolarOverflowRadius = Math.min(
Math.min(polarLayout.centerX, 320 - polarLayout.centerX, polarLayout.centerY, 180 - polarLayout.centerY) * 0.98,
polarLayout.radius * 1.25,
)
assert.equal( assert.equal(
polarRecorder.lineDashes.some((segments) => segments.length > 0), polarRecorder.lineDashes.some((segments) => segments.length > 0),
true, false,
'polar mode should render a dashed outer boundary', 'polar mode should not render an arbitrary overflow circle',
)
assert.ok(
dashedPolarArc && dashedPolarArc.radius > polarLayout.radius,
'polar dashed boundary should sit outside the existing graph',
) )
assertAlmostEqual( assertAlmostEqual(
dashedPolarArc?.radius ?? 0, Math.max(...polarRecorder.arcs.map(({ radius }) => radius)),
expectedPolarOverflowRadius, polarLayout.radius,
1e-6, 1e-6,
'polar dashed boundary should follow the next relative grid step, clamped to the canvas', 'the outer radial-reference circle should equal the layout radius',
) )
assert.equal(polarRecorder.fills.filter(({ fillStyle }) => fillStyle === 'rgba(255, 191, 0, 0.08)').length, 2)
assert.equal(polarRecorder.fillTexts.some(({ text }) => text === '0 dB radial'), true)
}) })
test('Vectorscope keeps the original linear projection behavior', () => { test('vectorscope projections calibrate XY and M/S Linear while preserving classic Polar shaping', () => {
const assertPoint = (
actual: { dx: number; dy: number },
expected: { dx: number; dy: number },
message: string,
): void => {
assertAlmostEqual(actual.dx, expected.dx, 1e-9, `${message} x`)
assertAlmostEqual(actual.dy, expected.dy, 1e-9, `${message} y`)
}
assertPoint(transformPoint(0.5, -0.25, 'lissajous'), { dx: -0.25, dy: 0.5 }, 'XY')
assertPoint(transformPoint(1, 1, 'linear-bipolar'), { dx: 0, dy: 1 }, 'linear mono')
assertPoint(transformPoint(1, 0, 'linear-bipolar'), { dx: -0.5, dy: 0.5 }, 'linear left-only')
assertPoint(transformPoint(0, 1, 'linear-bipolar'), { dx: 0.5, dy: 0.5 }, 'linear right-only')
assertPoint(transformPoint(1, -1, 'linear-bipolar'), { dx: -1, dy: 0 }, 'linear anti-phase')
assertPoint(transformPoint(-1, -1, 'linear-bipolar'), { dx: 0, dy: -1 }, 'linear inverted mono')
assertPoint(transformPoint(0.75, -0.25, 'linear-bipolar'), { dx: -0.5, dy: 0.25 }, 'linear unequal')
const polarLeft = transformPoint(1, 0, 'polar-bipolar')
assertAlmostEqual(polarLeft.dx, -Math.SQRT1_2, 1e-9, 'polar left-only x')
assertAlmostEqual(polarLeft.dy, Math.SQRT1_2, 1e-9, 'polar left-only y')
assertAlmostEqual(Math.hypot(polarLeft.dx, polarLeft.dy), 1, 1e-9, 'polar left-only radius')
const polarUnequal = transformPoint(0.75, -0.25, 'polar-bipolar')
assertAlmostEqual(
Math.hypot(polarUnequal.dx, polarUnequal.dy),
Math.pow(Math.hypot(0.75, -0.25), 0.35),
1e-9,
'polar unequal radius retains classic shaping',
)
for (const amplitude of [0.25, 0.5, 1]) {
const xy = transformPoint(amplitude, amplitude, 'lissajous')
const linear = transformPoint(amplitude, amplitude, 'linear-bipolar')
const polar = transformPoint(amplitude, amplitude, 'polar-bipolar')
assertAlmostEqual(Math.max(Math.abs(xy.dx), Math.abs(xy.dy)), amplitude, 1e-9, `XY amplitude ${amplitude}`)
assertAlmostEqual(Math.abs(linear.dx) + Math.abs(linear.dy), amplitude, 1e-9, `linear amplitude ${amplitude}`)
assertAlmostEqual(
Math.hypot(polar.dx, polar.dy),
Math.pow(Math.hypot(amplitude, amplitude), 0.35),
1e-9,
`classic polar amplitude ${amplitude}`,
)
}
for (const mode of ['polar-unipolar', 'linear-unipolar'] as const) {
assertPoint(
transformPoint(-0.8, -0.2, mode),
transformPoint(0.8, 0.2, mode),
`${mode} antipodal fold`,
)
}
})
test('vectorscope zoom maps the labeled per-channel and Polar radial references', () => {
for (const zoomDb of [-12, 0, 24]) {
const inputReference = 10 ** (-zoomDb / 20)
assertAlmostEqual(vectorscopeZoomDbToGain(zoomDb) * inputReference, 1, 1e-12, `zoom ${zoomDb}`)
for (const mode of ['lissajous', 'linear-unipolar', 'linear-bipolar'] as const) {
const point = transformPoint(inputReference, inputReference, mode, zoomDb)
const boundaryValue = mode === 'lissajous'
? Math.max(Math.abs(point.dx), Math.abs(point.dy))
: Math.abs(point.dx) + Math.abs(point.dy)
assertAlmostEqual(boundaryValue, 1, 1e-9, `${mode} at zoom ${zoomDb}`)
}
for (const mode of ['polar-unipolar', 'polar-bipolar'] as const) {
const point = transformPoint(inputReference, 0, mode, zoomDb)
assertAlmostEqual(Math.hypot(point.dx, point.dy), 1, 1e-9, `${mode} radial reference at zoom ${zoomDb}`)
}
}
assert.equal(normalizeVectorscopeZoomDb(6.49), 6)
assert.equal(normalizeVectorscopeZoomDb(6.5), 7)
assert.equal(normalizeVectorscopeZoomDb(-99), -12)
assert.equal(normalizeVectorscopeZoomDb(99), 24)
assert.equal(normalizeVectorscopeZoomDb('invalid'), 0)
assert.equal(formatVectorscopeReferenceDbfs(6), '-6 dBFS')
assert.equal(formatVectorscopeReferenceDbfs(-6), '+6 dBFS')
})
test('vectorscope phase-risk classification treats channel guides as safe boundaries', () => {
assert.equal(isVectorscopePhaseRisk(1, 1), false)
assert.equal(isVectorscopePhaseRisk(-1, -1), false)
assert.equal(isVectorscopePhaseRisk(1, -1), true)
assert.equal(isVectorscopePhaseRisk(-0.25, 0.75), true)
assert.equal(isVectorscopePhaseRisk(1, 0), false)
assert.equal(isVectorscopePhaseRisk(0, -1), false)
})
test('native and JavaScript vectorscope paths project identical channel samples', () => {
const dom = installFakeCanvasDom() const dom = installFakeCanvasDom()
const dataSource = { const dataSource = {
getPendingVectorscopeSamples: () => [], getPendingVectorscopeSamples: () => [],
@@ -3486,7 +3611,71 @@ test('Vectorscope keeps the original linear projection behavior', () => {
isPlaying: () => false, isPlaying: () => false,
subscribeToSessionChanges: () => () => {}, subscribeToSessionChanges: () => () => {},
} }
const vectorscope = new Vectorscope(createFakeCanvas(), { dataSource }) const vectorscope = new Vectorscope(createFakeCanvas(), { dataSource, nativeAnalyzer: null, zoomDb: 3 })
try {
const state = vectorscope as unknown as {
drawPoints: (
ctx: CanvasRenderingContext2D,
x: Float32Array,
y: Float32Array,
count: number,
centerX: number,
centerY: number,
scale: number,
) => void
drawFallbackPoints: (
ctx: CanvasRenderingContext2D,
samples: Array<{ left: Float32Array; right: Float32Array }>,
centerX: number,
centerY: number,
scale: number,
) => void
options: { mode: 'polar-unipolar' }
}
state.options.mode = 'polar-unipolar'
const left = -0.8
const right = -0.2
const nativeRecorder = createFakeCanvasRecorder()
const fallbackRecorder = createFakeCanvasRecorder()
state.drawPoints(
createFakeCanvasContext(nativeRecorder),
new Float32Array([right]),
new Float32Array([left]),
1,
100,
100,
80,
)
state.drawFallbackPoints(
createFakeCanvasContext(fallbackRecorder),
[{ left: new Float32Array([left]), right: new Float32Array([right]) }],
100,
100,
80,
)
assert.equal(nativeRecorder.fillRects.length, 1)
assert.equal(fallbackRecorder.fillRects.length, 1)
assertAlmostEqual(nativeRecorder.fillRects[0]?.x ?? 0, fallbackRecorder.fillRects[0]?.x ?? 0, 1e-6, 'path parity x')
assertAlmostEqual(nativeRecorder.fillRects[0]?.y ?? 0, fallbackRecorder.fillRects[0]?.y ?? 0, 1e-6, 'path parity y')
} finally {
vectorscope.dispose()
dom.restore()
}
})
test('Vectorscope applies live calibrated zoom and clears the previous projection', () => {
const dom = installFakeCanvasDom()
const nativeAnalyzer = createFakeVectorscopeNativeAnalyzer()
const dataSource = {
getPendingVectorscopeSamples: () => [],
getSampleRate: () => 48000,
isPlaying: () => false,
subscribeToSessionChanges: () => () => {},
}
const vectorscope = new Vectorscope(createFakeCanvas(), { dataSource, nativeAnalyzer })
try { try {
const state = vectorscope as unknown as { const state = vectorscope as unknown as {
@@ -3501,12 +3690,16 @@ test('Vectorscope keeps the original linear projection behavior', () => {
dotSize: number, dotSize: number,
) => void ) => void
getProjectionScale: (radius: number) => number getProjectionScale: (radius: number) => number
options: { zoomDb: number }
} }
const layout = getVectorscopeLayout(320, 180, 'linear-bipolar') const layout = getVectorscopeLayout(320, 180, 'linear-bipolar')
const recorder = createFakeCanvasRecorder() const recorder = createFakeCanvasRecorder()
const ctx = createFakeCanvasContext(recorder) const ctx = createFakeCanvasContext(recorder)
const scale = state.getProjectionScale(layout.radius) const scale = state.getProjectionScale(layout.radius)
vectorscope.setOptions({ zoomDb: 6.4 })
assert.equal(state.options.zoomDb, 6)
assert.equal(nativeAnalyzer.resetCount, 1)
state.drawProjectedDot(ctx, -1, 1, 'linear-bipolar', layout.centerX, layout.centerY, scale, 2) state.drawProjectedDot(ctx, -1, 1, 'linear-bipolar', layout.centerX, layout.centerY, scale, 2)
assert.equal(recorder.fillRects.length, 1) assert.equal(recorder.fillRects.length, 1)
@@ -3515,15 +3708,15 @@ test('Vectorscope keeps the original linear projection behavior', () => {
const projectedCenterY = rect.y + rect.height / 2 const projectedCenterY = rect.y + rect.height / 2
assertAlmostEqual( assertAlmostEqual(
projectedCenterX, projectedCenterX,
layout.centerX + layout.radius * Math.SQRT2, layout.centerX + layout.radius * vectorscopeZoomDbToGain(6),
1e-6, 1e-6,
'linear projection should preserve the original unscaled mapping', 'linear projection should apply the normalized zoom gain',
) )
assertAlmostEqual( assertAlmostEqual(
projectedCenterY, projectedCenterY,
layout.centerY, layout.centerY,
1e-6, 1e-6,
'linear overs peak should stay on the side axis', 'anti-phase projection should stay on the Side axis',
) )
} finally { } finally {
vectorscope.dispose() vectorscope.dispose()
@@ -3677,6 +3870,26 @@ test('scopeSummary includes only waveform display modes', () => {
assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), 'Stereo · RGB') assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), 'Stereo · RGB')
}) })
test('scopeSummary exposes explicit vectorscope geometry and zoom names', () => {
const profile = createDefaultProfile('Default')
assert.equal(scopeSummary('vectorscope', profile.scopeSettings.vectorscope), 'XY (L/R)')
profile.scopeSettings.vectorscope.mode = 'polar-unipolar'
profile.scopeSettings.vectorscope.zoomDb = 6
profile.scopeSettings.vectorscope.multiband = true
assert.equal(
scopeSummary('vectorscope', profile.scopeSettings.vectorscope),
'Polar (Folded) · Zoom +6 dB · RGB',
)
profile.scopeSettings.vectorscope.mode = 'linear-bipolar'
assert.equal(
scopeSummary('vectorscope', profile.scopeSettings.vectorscope),
'M/S Linear (Bipolar) · Zoom +6 dB · RGB',
)
})
test('scopeSummary includes loudness readout source', () => { test('scopeSummary includes loudness readout source', () => {
const profile = createDefaultProfile('Default') const profile = createDefaultProfile('Default')
+5
View File
@@ -63,6 +63,7 @@ test('theme files round-trip and keep grouped sections intact', () => {
theme.controls.flatControls = 'true' theme.controls.flatControls = 'true'
theme.scopes.background = '#030712' theme.scopes.background = '#030712'
theme.spectrum.heatMid = 'rgb(200, 50, 120)' theme.spectrum.heatMid = 'rgb(200, 50, 120)'
theme.vectorscope.phaseRisk = 'rgb(240, 120, 40)'
theme.vumeter.track = '#111827' theme.vumeter.track = '#111827'
theme.vumeter.needleLeft = 'rgb(70, 80, 90)' theme.vumeter.needleLeft = 'rgb(70, 80, 90)'
@@ -76,6 +77,7 @@ test('theme files round-trip and keep grouped sections intact', () => {
assert.match(serialized, /\[Scopes\]/) assert.match(serialized, /\[Scopes\]/)
assert.match(serialized, /flat_controls = true/) assert.match(serialized, /flat_controls = true/)
assert.match(serialized, /needle_left = 70, 80, 90/) assert.match(serialized, /needle_left = 70, 80, 90/)
assert.match(serialized, /phase_risk = 240, 120, 40/)
assert.doesNotMatch(serialized, /^id = /m) assert.doesNotMatch(serialized, /^id = /m)
assert.doesNotMatch(serialized, /^name = /m) assert.doesNotMatch(serialized, /^name = /m)
@@ -85,6 +87,7 @@ test('theme files round-trip and keep grouped sections intact', () => {
assert.equal(parsed.controls.flatControls, 'true') assert.equal(parsed.controls.flatControls, 'true')
assert.equal(parsed.scopes.background, 'rgb(3, 7, 18)') assert.equal(parsed.scopes.background, 'rgb(3, 7, 18)')
assert.equal(parsed.spectrum.heatMid, 'rgb(200, 50, 120)') assert.equal(parsed.spectrum.heatMid, 'rgb(200, 50, 120)')
assert.equal(parsed.vectorscope.phaseRisk, 'rgb(240, 120, 40)')
assert.equal(parsed.vumeter.track, 'rgb(17, 24, 39)') assert.equal(parsed.vumeter.track, 'rgb(17, 24, 39)')
assert.equal(parsed.vumeter.needleLeft, 'rgb(70, 80, 90)') assert.equal(parsed.vumeter.needleLeft, 'rgb(70, 80, 90)')
assert.equal(parsed.nowPlaying.background, theme.nowPlaying.background) assert.equal(parsed.nowPlaying.background, theme.nowPlaying.background)
@@ -316,6 +319,7 @@ test('createTemplateThemeFile presents a simplified recommended theme layout', (
assert.match(template, /^\[Controls\]$/m) assert.match(template, /^\[Controls\]$/m)
assert.match(template, /^\[Scopes\]$/m) assert.match(template, /^\[Scopes\]$/m)
assert.match(template, /^\[Spectrum\]$/m) assert.match(template, /^\[Spectrum\]$/m)
assert.match(template, /^# phase_risk = 255, 191, 0$/m)
assert.match(template, /^# flat_controls = false$/m) assert.match(template, /^# flat_controls = false$/m)
assert.match(template, /^# toolbar_bg = 4, 8, 12, 199$/m) assert.match(template, /^# toolbar_bg = 4, 8, 12, 199$/m)
assert.equal(parsed.app.accent, 'rgb(56, 189, 248)') assert.equal(parsed.app.accent, 'rgb(56, 189, 248)')
@@ -335,6 +339,7 @@ test('parsed template keeps module colors and backgrounds derived from starter a
assert.equal(resolved.spectrum.line, 'rgb(74, 222, 128)') assert.equal(resolved.spectrum.line, 'rgb(74, 222, 128)')
assert.equal(resolved.oscilloscope.line, 'rgb(74, 222, 128)') assert.equal(resolved.oscilloscope.line, 'rgb(74, 222, 128)')
assert.equal(resolved.vectorscope.trace, 'rgb(74, 222, 128)') assert.equal(resolved.vectorscope.trace, 'rgb(74, 222, 128)')
assert.equal(resolved.vectorscope.phaseRisk, resolved.interface.warning)
assert.equal(resolved.waveform.line, 'rgb(74, 222, 128)') assert.equal(resolved.waveform.line, 'rgb(74, 222, 128)')
assert.equal(resolved.interface.scopeBackground, 'rgb(3, 7, 18)') assert.equal(resolved.interface.scopeBackground, 'rgb(3, 7, 18)')
assert.equal(resolved.spectrum.background, 'rgb(3, 7, 18)') assert.equal(resolved.spectrum.background, 'rgb(3, 7, 18)')
+55
View File
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import test from 'node:test'
const require = createRequire(import.meta.url)
const { vectorscope } = require('../native/build/Release/visualizer_dsp.node')
function createHighFrequencyStereo(frequencyHz, sampleRate, length) {
const left = new Float32Array(length)
const right = new Float32Array(length)
for (let index = 0; index < length; index += 1) {
const phase = 2 * Math.PI * frequencyHz * index / sampleRate
left[index] = 0.75 * Math.sin(phase)
right[index] = -0.4 * Math.cos(phase)
}
return { left, right }
}
function assertArrayAlmostEqual(actual, expected, tolerance, message) {
assert.equal(actual.length, expected.length, `${message} length`)
for (let index = 0; index < expected.length; index += 1) {
assert.ok(
Math.abs(actual[index] - expected[index]) <= tolerance,
`${message} sample ${index}: expected ${expected[index]} +/- ${tolerance}, got ${actual[index]}`,
)
}
}
test('native vectorscope preserves full-band channel samples above the former 8 kHz cutoff', () => {
const length = 512
for (const sampleRate of [44100, 48000, 96000]) {
const frequencyHz = 12000
const { left, right } = createHighFrequencyStereo(frequencyHz, sampleRate, length)
vectorscope.setSampleRate(sampleRate)
vectorscope.reset()
vectorscope.pushSamples(left, right)
const result = vectorscope.getPoints(length)
assert.equal(result.count, length)
assertArrayAlmostEqual(result.x, right, 1e-7, `${sampleRate}Hz right/X`)
assertArrayAlmostEqual(result.y, left, 1e-7, `${sampleRate}Hz left/Y`)
}
})
test('native vectorscope legacy process keeps the renderer-facing X/Y channel shape', () => {
const { left, right } = createHighFrequencyStereo(15000, 48000, 256)
vectorscope.setSampleRate(48000)
vectorscope.reset()
const result = vectorscope.process(left, right)
assertArrayAlmostEqual(result.x, right, 1e-7, 'legacy right/X')
assertArrayAlmostEqual(result.y, left, 1e-7, 'legacy left/Y')
})