diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10d96d8..a78671d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,6 +124,9 @@ jobs: - name: Test native spectrogram accuracy run: npm run test:spectrogram-native + - name: Test native vectorscope calibration + run: npm run test:vectorscope-native + - name: Build and test Prism TUI run: npm run test:tui diff --git a/README.md b/README.md index 76b9662..dcb4588 100644 --- a/README.md +++ b/README.md @@ -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 Hz–up to 24 kHz) or Audible (20 Hz–20 kHz) ranges - **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 - **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 @@ -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. +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 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. diff --git a/native/src/vectorscope.cpp b/native/src/vectorscope.cpp index dea93e5..b229b5b 100644 --- a/native/src/vectorscope.cpp +++ b/native/src/vectorscope.cpp @@ -22,23 +22,11 @@ Vectorscope::Vectorscope() highRightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f); 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_); } void Vectorscope::setSampleRate(float 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_); } @@ -53,15 +41,8 @@ void Vectorscope::pushSamples( size_t length ) { for (size_t i = 0; i < length; i++) { - // Apply cascaded lowpass filtering - float filteredL = leftLowpass1_.process(leftChannel[i]); - filteredL = leftLowpass2_.process(filteredL); - - float filteredR = rightLowpass1_.process(rightChannel[i]); - filteredR = rightLowpass2_.process(filteredR); - - leftBuffer_[writePos_] = filteredL; - rightBuffer_[writePos_] = filteredR; + leftBuffer_[writePos_] = leftChannel[i]; + rightBuffer_[writePos_] = rightChannel[i]; writePos_ = (writePos_ + 1) % VECTORSCOPE_BUFFER_SIZE; if (validSamples_ < VECTORSCOPE_BUFFER_SIZE) { @@ -128,7 +109,7 @@ const std::vector& Vectorscope::process( const float* rightChannel, size_t length ) { - // Push through the filtering pipeline + // Push through the full-band point pipeline pushSamples(leftChannel, rightChannel, length); // Build legacy output from buffer @@ -157,10 +138,6 @@ void Vectorscope::reset() { std::fill(midRightBuffer_.begin(), midRightBuffer_.end(), 0.0f); std::fill(highLeftBuffer_.begin(), highLeftBuffer_.end(), 0.0f); std::fill(highRightBuffer_.begin(), highRightBuffer_.end(), 0.0f); - leftLowpass1_.reset(); - leftLowpass2_.reset(); - rightLowpass1_.reset(); - rightLowpass2_.reset(); multibandSplitter_.reset(); points_.clear(); } diff --git a/native/src/vectorscope.h b/native/src/vectorscope.h index 061a6ea..0d6b7f7 100644 --- a/native/src/vectorscope.h +++ b/native/src/vectorscope.h @@ -1,6 +1,5 @@ #pragma once -#include "dsp_utils.h" #include "multiband.h" #include #include @@ -52,7 +51,7 @@ private: size_t writePos_; size_t validSamples_; - // Circular buffers for filtered L/R + // Circular buffers for full-band L/R std::vector leftBuffer_; std::vector rightBuffer_; std::vector lowLeftBuffer_; @@ -62,11 +61,6 @@ private: std::vector highLeftBuffer_; std::vector 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_; size_t multibandWritePos_; size_t multibandValidSamples_; diff --git a/package.json b/package.json index 312977c..e0ab537 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "test:spectrum-native": "node --test test/spectrum-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:build-metadata": "node scripts/run-build-metadata-tests.mjs", "test:updates": "node scripts/run-update-tests.mjs", diff --git a/plugin/Source/VectorscopeEngine.h b/plugin/Source/VectorscopeEngine.h index 3e3696b..bd9af1a 100644 --- a/plugin/Source/VectorscopeEngine.h +++ b/plugin/Source/VectorscopeEngine.h @@ -6,7 +6,7 @@ /** * 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 * 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 diff --git a/src/plugin-ui/BridgeVectorscopeAnalyzer.ts b/src/plugin-ui/BridgeVectorscopeAnalyzer.ts index be78ef7..44f1509 100644 --- a/src/plugin-ui/BridgeVectorscopeAnalyzer.ts +++ b/src/plugin-ui/BridgeVectorscopeAnalyzer.ts @@ -3,7 +3,7 @@ import type { VectorscopeNativeAnalyzer, VectorscopeMultibandPointsResult } from /** * 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 * 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 diff --git a/src/plugin-ui/vectorscopeOptions.ts b/src/plugin-ui/vectorscopeOptions.ts index 5c6c4b6..fcce286 100644 --- a/src/plugin-ui/vectorscopeOptions.ts +++ b/src/plugin-ui/vectorscopeOptions.ts @@ -16,12 +16,14 @@ export function vectorscopeSettingsToOptions( gridMajorColor: theme.guides, gridMinorColor: theme.guidesSecondary, labelColor: theme.labels, + phaseRiskColor: theme.phaseRisk, bandColors: { low: theme.bandLow, mid: theme.bandMid, high: theme.bandHigh, }, mode: settings.mode, + zoomDb: settings.zoomDb, multiband: settings.multiband, showGrid: settings.showGrid, persistence: settings.persistence, diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 7001397..ccd9796 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -218,12 +218,14 @@ export function scopeSettingsToOptions( gridMajorColor: t.guides, gridMinorColor: t.guidesSecondary, labelColor: t.labels, + phaseRiskColor: t.phaseRisk, bandColors: { low: t.bandLow, mid: t.bandMid, high: t.bandHigh, }, mode: s.mode, + zoomDb: s.zoomDb, multiband: s.multiband, showGrid: s.showGrid, persistence: s.persistence, diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 9e409e6..52780ae 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -26,20 +26,26 @@ import { MIN_WAVEFORM_SCROLL_SPEED, WAVEFORM_SCROLL_SPEED_STEP, } from '../../types/waveform' +import { + formatVectorscopeZoomDb, + MAX_VECTORSCOPE_ZOOM_DB, + MIN_VECTORSCOPE_ZOOM_DB, + VECTORSCOPE_ZOOM_STEP_DB, +} from '../../types/vectorscope' import ThemedSelect from './ThemedSelect' function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string { switch (mode) { case 'lissajous': - return 'Lissajous' + return 'XY (L/R)' case 'polar-unipolar': - return 'Polar Uni' + return 'Polar (Folded)' case 'polar-bipolar': - return 'Polar Bi' + return 'Polar (Bipolar)' case 'linear-unipolar': - return 'Linear Uni' + return 'M/S Linear (Folded)' 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': { const scopeSettings = settings as ScopeSettings['vectorscope'] - return scopeSettings.multiband - ? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB` - : vectorscopeModeLabel(scopeSettings.mode) + const parts = [vectorscopeModeLabel(scopeSettings.mode)] + if (scopeSettings.zoomDb !== 0) parts.push(`Zoom ${formatVectorscopeZoomDb(scopeSettings.zoomDb)}`) + if (scopeSettings.multiband) parts.push('RGB') + return parts.join(' · ') } case 'spectrogram': { const scopeSettings = settings as ScopeSettings['spectrogram'] @@ -488,13 +495,24 @@ export default function ScopeSettingsSection({ value={current.mode} onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })} > - - - - - + + + + + + onUpdate('vectorscope', { zoomDb: value })} + /> + ): void { 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 modeChanged = nextOptions.mode !== this.options.mode + const zoomChanged = nextOptions.zoomDb !== this.options.zoomDb this.options = nextOptions let shouldResetDisplay = false if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) { @@ -181,7 +195,7 @@ export class Vectorscope { this.subscribeToSessionChanges() shouldResetDisplay = true } - if (multibandChanged || modeChanged) { + if (multibandChanged || modeChanged || zoomChanged) { shouldResetDisplay = true } if (shouldResetDisplay) { @@ -291,7 +305,9 @@ export class Vectorscope { options.gridMajorColor, options.gridMinorColor, options.labelColor, + options.phaseRiskColor, options.mode, + options.zoomDb, ].join(':') if (this.staticLayerKey === key) { @@ -317,6 +333,8 @@ export class Vectorscope { options.gridMinorColor, options.labelColor, options.mode, + options.phaseRiskColor, + options.zoomDb, dpr, ) } @@ -561,10 +579,7 @@ export class Vectorscope { scale: number, dotSize: number, ): void { - const point = transformPoint(left, right, mode) - if (!point) { - return - } + const point = transformPoint(left, right, mode, this.options.zoomDb) const { dx, dy } = point const px = centerX + dx * scale diff --git a/src/renderer/visualizers/vectorscopeGrids.ts b/src/renderer/visualizers/vectorscopeGrids.ts index 3318b5d..ea5e5c9 100644 --- a/src/renderer/visualizers/vectorscopeGrids.ts +++ b/src/renderer/visualizers/vectorscopeGrids.ts @@ -1,9 +1,14 @@ import type { VectorscopeMode } from './Vectorscope' import { multiplyColorAlpha } from '../utils/color' +import { + formatVectorscopeReferenceDbfs, + normalizeVectorscopeZoomDb, + vectorscopeZoomDbToGain, +} from '../../types/vectorscope' -const INV_SQRT2 = 1 / Math.sqrt(2) -const COS45 = Math.SQRT2 / 2 // 0.7071... -const OVERFLOW_BOUNDARY_STEP = 0.25 +const INV_SQRT2 = Math.SQRT1_2 +const COS45 = Math.SQRT1_2 +const PHASE_RISK_FILL_ALPHA = 0.08 export interface VectorscopeLayout { centerX: number @@ -11,22 +16,24 @@ export interface VectorscopeLayout { radius: number } +export interface VectorscopePoint { + dx: number + dy: number +} + /** - * Compute the center point and radius for a vectorscope mode. - * - * 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. + * Compute the center point and calibrated outer-reference radius for a mode. + * Folded modes only need the positive-Mid half of their geometry. */ export function getVectorscopeLayout( width: number, height: number, - mode: VectorscopeMode + mode: VectorscopeMode, ): VectorscopeLayout { 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 availableHeight = height - margin const halfWidth = width / 2 @@ -38,345 +45,347 @@ export function getVectorscopeLayout( return { centerX, centerY, radius } } - // Bipolar / Lissajous: centered const radius = Math.min(width, height) / 2 * 0.9 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. - * Returns null for points filtered out by unipolar modes (mid < 0). + * Project raw L/R samples into normalized coordinates. * - * dx/dy are in normalized space: positive dx = right, positive dy = up. - * Caller maps to canvas: canvasX = centerX + dx * scale, canvasY = centerY - dy * scale. - * - * Polar modes apply sqrt amplitude scaling so points follow the circular - * contours instead of forming diamond/linear patterns. + * XY uses raw channel amplitude. Linear modes use peak-normalized M/S, whose + * exact legal per-channel boundary is |Mid| + |Side| = 1. Polar modes retain + * Prism's classic amplitude-compressed radial presentation so quiet stereo + * detail remains readable instead of collapsing toward the origin. Folded + * modes rotate negative-Mid points through the origin instead of dropping + * half of the waveform. */ export function transformPoint( - L: number, - R: number, - mode: VectorscopeMode -): { dx: number; dy: number } | null { + left: number, + right: number, + mode: VectorscopeMode, + zoomDb: number = 0, +): VectorscopePoint { + const gain = vectorscopeZoomDbToGain(zoomDb) + if (mode === 'lissajous') { - return { dx: R, dy: L } - } - - // 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 + return { dx: right * gain, dy: left * gain } } + const isFolded = mode === 'polar-unipolar' || mode === 'linear-unipolar' const isPolar = mode === 'polar-unipolar' || mode === 'polar-bipolar' if (isPolar) { - // Amplitude-compressed radial scaling: pushes points toward circular contours. - // Power < 1 compresses dynamic range — lower = more circular. - // 0.5 = sqrt (mild), 0.33 = cube root (moderate), 0.25 = fourth root (strong) - const ampSq = mid * mid + side * side - if (ampSq < 1e-12) { + // Preserve the original Polar presentation: an orthonormal M/S rotation + // followed by strong radial expansion. Apply zoom before the curve so the + // unit circle remains the selected radial reference. + let mid = (left + right) * INV_SQRT2 + 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 } } - const amp = Math.sqrt(ampSq) - const scaledAmp = Math.pow(amp, 0.35) - const factor = scaledAmp / amp - return { dx: side * factor, dy: mid * factor } + + const shapedAmplitude = Math.pow(amplitude * gain, 0.35) + return { + dx: side / amplitude * shapedAmplitude, + dy: mid / amplitude * shapedAmplitude, + } } - // Linear modes: direct M/S Cartesian mapping - return { dx: side, dy: mid } + let mid = (left + right) / 2 + let side = (right - left) / 2 + if (isFolded && mid < 0) { + mid = -mid + side = -side + } + return { dx: side * gain, dy: mid * gain } } -function getOverflowBoundaryLayout( - width: number, - height: number, +function fillPhaseRiskRegions( + ctx: CanvasRenderingContext2D, + layout: VectorscopeLayout, mode: VectorscopeMode, -): VectorscopeLayout | null { + phaseRiskColor: string, +): void { + const { centerX, centerY, radius } = layout + ctx.fillStyle = multiplyColorAlpha(phaseRiskColor, PHASE_RISK_FILL_ALPHA) + 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) - const maxXRadius = Math.min(layout.centerX, width - layout.centerX) - const maxYRadius = mode === 'polar-unipolar' || mode === 'linear-unipolar' - ? layout.centerY - : Math.min(layout.centerY, height - layout.centerY) - const maxRadius = Math.min(maxXRadius, maxYRadius) * 0.98 - // 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 (mode === 'polar-unipolar') { + ctx.beginPath() + ctx.moveTo(centerX, centerY) + ctx.arc(centerX, centerY, radius, Math.PI, Math.PI * 1.25, false) + ctx.closePath() + ctx.fill() - if (overflowRadius <= layout.radius + 1) { - return null + ctx.beginPath() + 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( ctx: CanvasRenderingContext2D, layout: VectorscopeLayout, gridMajorColor: string, gridMinorColor: string, labelColor: string, - dpr: number + phaseRiskColor: string, + zoomDb: number, + dpr: number, ): void { const { centerX, centerY, radius } = layout + fillPhaseRiskRegions(ctx, layout, 'lissajous', phaseRiskColor) ctx.strokeStyle = gridMajorColor 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.moveTo(centerX, centerY - radius) ctx.lineTo(centerX, centerY + radius) - ctx.stroke() - - // Horizontal crosshair - ctx.beginPath() ctx.moveTo(centerX - radius, centerY) ctx.lineTo(centerX + radius, centerY) ctx.stroke() - // Diagonal guides (dimmer) ctx.strokeStyle = gridMinorColor || multiplyColorAlpha(gridMajorColor, 0.5) - ctx.beginPath() ctx.moveTo(centerX - radius, centerY - radius) ctx.lineTo(centerX + radius, centerY + radius) - ctx.stroke() - - ctx.beginPath() ctx.moveTo(centerX + radius, centerY - radius) ctx.lineTo(centerX - radius, centerY + radius) ctx.stroke() - // Labels ctx.fillStyle = labelColor ctx.font = `${10 * dpr}px monospace` + ctx.textBaseline = 'middle' ctx.textAlign = 'center' - ctx.fillText('L', centerX, centerY - radius - 6 * dpr) - ctx.fillText('R', centerX + radius + 12 * dpr, centerY + 4 * dpr) + ctx.fillText('L', centerX, centerY - radius + 9 * 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, layout: VectorscopeLayout, - mode: VectorscopeMode, - color: string, + unipolar: boolean, +): 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, ): void { const { centerX, centerY, radius } = layout - const dashLength = Math.max(2, Math.round(4 * dpr)) - const gapLength = Math.max(2, Math.round(3 * dpr)) - - ctx.strokeStyle = color - ctx.lineWidth = dpr - ctx.setLineDash([dashLength, gapLength]) - - switch (mode) { - case 'polar-unipolar': - ctx.beginPath() - ctx.arc(centerX, centerY, radius, Math.PI, 0, false) - 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.fillStyle = labelColor + ctx.font = `${10 * dpr}px monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('M+', centerX, centerY - radius + 9 * dpr) + ctx.fillText('S−', centerX - radius + 16 * dpr, centerY) + ctx.fillText('S+', centerX + radius - 16 * dpr, centerY) + ctx.fillText('L', centerX - radius * channelGuideCoordinate - 9 * dpr, centerY - radius * channelGuideCoordinate - 4 * dpr) + ctx.fillText('R', centerX + radius * channelGuideCoordinate + 9 * dpr, centerY - radius * channelGuideCoordinate - 4 * dpr) + if (!unipolar) { + ctx.fillText('M−', centerX, centerY + radius - 9 * dpr) } - - ctx.setLineDash([]) } -/** - * Draw the Polar (Scaled) grid: concentric circles + crosshairs. - */ export function drawPolarGrid( ctx: CanvasRenderingContext2D, layout: VectorscopeLayout, gridMajorColor: string, gridMinorColor: string, labelColor: string, + phaseRiskColor: string, unipolar: boolean, - dpr: number + zoomDb: number, + dpr: number, ): void { const { centerX, centerY, radius } = layout + fillPhaseRiskRegions(ctx, layout, unipolar ? 'polar-unipolar' : 'polar-bipolar', phaseRiskColor) - ctx.strokeStyle = gridMajorColor - ctx.lineWidth = dpr - - // Concentric circles (or semicircles for unipolar) - const rings = [0.25, 0.5, 0.75, 1.0] - for (const scale of rings) { + for (const ringScale of [0.25, 0.5, 0.75, 1]) { + ctx.strokeStyle = ringScale === 1 ? gridMajorColor : gridMinorColor + ctx.lineWidth = dpr ctx.beginPath() - if (unipolar) { - ctx.arc(centerX, centerY, radius * scale, Math.PI, 0, false) - } else { - ctx.arc(centerX, centerY, radius * scale, 0, Math.PI * 2) - } + ctx.arc(centerX, centerY, radius * ringScale, unipolar ? Math.PI : 0, unipolar ? 0 : Math.PI * 2, false) ctx.stroke() } - // Vertical crosshair (mono axis) + ctx.strokeStyle = gridMinorColor ctx.beginPath() ctx.moveTo(centerX, centerY - radius) - if (unipolar) { - ctx.lineTo(centerX, centerY) - } else { - ctx.lineTo(centerX, centerY + radius) - } - ctx.stroke() - - // Horizontal crosshair (side axis) - ctx.beginPath() + ctx.lineTo(centerX, unipolar ? centerY : centerY + radius) ctx.moveTo(centerX - radius, centerY) ctx.lineTo(centerX + radius, centerY) ctx.stroke() - // Diagonal guides (L and R channel axes) — dimmer - ctx.strokeStyle = gridMinorColor || multiplyColorAlpha(gridMajorColor, 0.5) - - if (unipolar) { - ctx.beginPath() - ctx.moveTo(centerX, centerY) - ctx.lineTo(centerX - radius * COS45, centerY - radius * COS45) - ctx.stroke() - - ctx.beginPath() - ctx.moveTo(centerX, centerY) - ctx.lineTo(centerX + radius * COS45, centerY - radius * COS45) - ctx.stroke() - } else { - ctx.beginPath() - ctx.moveTo(centerX - radius * COS45, centerY - radius * COS45) - ctx.lineTo(centerX + radius * COS45, centerY + radius * COS45) - ctx.stroke() - - ctx.beginPath() - ctx.moveTo(centerX + radius * COS45, centerY - radius * COS45) - ctx.lineTo(centerX - radius * COS45, centerY + radius * COS45) - ctx.stroke() - } - - // Labels - 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) - } + ctx.strokeStyle = gridMajorColor + drawChannelGuides(ctx, layout, unipolar) + drawMsLabels(ctx, layout, labelColor, unipolar, COS45, dpr) + drawReferenceLabel(ctx, layout, labelColor, zoomDb, dpr, true) +} + +function drawLinearChannelGuides( + ctx: CanvasRenderingContext2D, + layout: VectorscopeLayout, + unipolar: boolean, +): void { + const { centerX, centerY, radius } = layout + const halfRadius = radius / 2 + ctx.beginPath() + if (unipolar) { + ctx.moveTo(centerX, centerY) + ctx.lineTo(centerX - halfRadius, centerY - halfRadius) + ctx.moveTo(centerX, centerY) + ctx.lineTo(centerX + halfRadius, centerY - halfRadius) + } else { + ctx.moveTo(centerX - halfRadius, centerY - halfRadius) + ctx.lineTo(centerX + halfRadius, centerY + halfRadius) + ctx.moveTo(centerX + halfRadius, centerY - halfRadius) + ctx.lineTo(centerX - halfRadius, centerY + halfRadius) + } + ctx.stroke() } -/** - * Draw the Linear grid: diamond/triangle guides. - */ export function drawLinearGrid( ctx: CanvasRenderingContext2D, layout: VectorscopeLayout, gridMajorColor: string, - _gridMinorColor: string, + gridMinorColor: string, labelColor: string, + phaseRiskColor: string, unipolar: boolean, - dpr: number + zoomDb: number, + dpr: number, ): void { const { centerX, centerY, radius } = layout + fillPhaseRiskRegions(ctx, layout, unipolar ? 'linear-unipolar' : 'linear-bipolar', phaseRiskColor) - ctx.strokeStyle = gridMajorColor - ctx.lineWidth = dpr - - const scales = [0.25, 0.5, 0.75, 1.0] - for (const scale of scales) { - const r = radius * scale + for (const ringScale of [0.25, 0.5, 0.75, 1]) { + const ringRadius = radius * ringScale + ctx.strokeStyle = ringScale === 1 ? gridMajorColor : gridMinorColor + ctx.lineWidth = dpr ctx.beginPath() - if (unipolar) { - ctx.moveTo(centerX, centerY - r) // top (mono) - ctx.lineTo(centerX - r, centerY) // left (L) - ctx.lineTo(centerX + r, centerY) // right (R) - 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.moveTo(centerX, centerY - ringRadius) + ctx.lineTo(centerX + ringRadius, centerY) + if (!unipolar) ctx.lineTo(centerX, centerY + ringRadius) + ctx.lineTo(centerX - ringRadius, centerY) + ctx.closePath() ctx.stroke() } - // Vertical crosshair + ctx.strokeStyle = gridMinorColor ctx.beginPath() ctx.moveTo(centerX, centerY - radius) - if (unipolar) { - ctx.lineTo(centerX, centerY) - } else { - ctx.lineTo(centerX, centerY + radius) - } - ctx.stroke() - - // Horizontal crosshair - ctx.beginPath() + ctx.lineTo(centerX, unipolar ? centerY : centerY + radius) ctx.moveTo(centerX - radius, centerY) ctx.lineTo(centerX + radius, centerY) ctx.stroke() - // Labels - ctx.fillStyle = labelColor - ctx.font = `${10 * dpr}px monospace` - ctx.textAlign = 'center' - - 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) - } + ctx.strokeStyle = gridMajorColor + drawLinearChannelGuides(ctx, layout, unipolar) + drawMsLabels(ctx, layout, labelColor, unipolar, 0.5, dpr) + drawReferenceLabel(ctx, layout, labelColor, zoomDb, dpr) } -/** - * Draw the appropriate grid for a given vectorscope mode. - */ export function drawVectorscopeGridForMode( ctx: CanvasRenderingContext2D, width: number, @@ -385,31 +394,24 @@ export function drawVectorscopeGridForMode( gridMinorColor: string, labelColor: string, mode: VectorscopeMode, - dpr: number = 1 + phaseRiskColor: string = 'rgb(255, 191, 0)', + zoomDb: number = 0, + dpr: number = 1, ): void { const layout = getVectorscopeLayout(width, height, mode) - const overflowLayout = getOverflowBoundaryLayout(width, height, mode) - const outerBoundaryColor = multiplyColorAlpha(gridMajorColor, 1.25) + const normalizedZoomDb = normalizeVectorscopeZoomDb(zoomDb) switch (mode) { case 'lissajous': - drawLissajousGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, dpr) + drawLissajousGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, phaseRiskColor, normalizedZoomDb, dpr) break case 'polar-unipolar': - drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, true, dpr) - break case 'polar-bipolar': - drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, false, dpr) + drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, phaseRiskColor, mode === 'polar-unipolar', normalizedZoomDb, dpr) break case 'linear-unipolar': - drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, true, dpr) - break case 'linear-bipolar': - drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, false, dpr) + drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, phaseRiskColor, mode === 'linear-unipolar', normalizedZoomDb, dpr) break } - - if (overflowLayout) { - drawDashedOuterBoundary(ctx, overflowLayout, mode, outerBoundaryColor, dpr) - } } diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index c39beda..e36c67c 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -33,6 +33,7 @@ import { normalizeScopeMirrorHorizontal, type ScopeDisplayRotation, } from '../types/scopeTransform' +import { normalizeVectorscopeZoomDb } from '../types/vectorscope' export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS] @@ -189,6 +190,9 @@ export function mergeScopeSettings( const rawOscilloscope: Partial = typeof parsed.oscilloscope === 'object' && parsed.oscilloscope !== null ? parsed.oscilloscope : {} + const rawVectorscope: Partial = typeof parsed.vectorscope === 'object' && parsed.vectorscope !== null + ? parsed.vectorscope + : {} const rawWaveform: Partial = typeof parsed.waveform === 'object' && parsed.waveform !== null ? parsed.waveform : {} @@ -216,7 +220,11 @@ export function mergeScopeSettings( ...rawOscilloscope, ...normalizeDisplayTransform(rawOscilloscope), }, - vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) }, + vectorscope: { + ...DEFAULT_SCOPE_SETTINGS.vectorscope, + ...rawVectorscope, + zoomDb: normalizeVectorscopeZoomDb(rawVectorscope.zoomDb), + }, spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...rawSpectrogramSettings, diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts index bb1f7ec..a37462f 100644 --- a/src/shared/themeState.ts +++ b/src/shared/themeState.ts @@ -118,6 +118,7 @@ const OSCILLOSCOPE_SCHEMA = { const VECTORSCOPE_SCHEMA = { background: 'background', trace: 'trace', + phase_risk: 'phaseRisk', band_low: 'bandLow', band_mid: 'bandMid', band_high: 'bandHigh', @@ -1387,6 +1388,7 @@ export function createTemplateThemeFile(): string { const vectorscopeSection = commentExampleTokens(serializeSection('Vectorscope', { ...base.vectorscope, background: resolved.vectorscope.background, + phaseRisk: resolved.vectorscope.phaseRisk, guides: resolved.vectorscope.guides, labels: resolved.vectorscope.labels, }, VECTORSCOPE_SCHEMA as SectionSchema>)) @@ -1689,6 +1691,7 @@ function resolveVectorscopeTheme( const guides = theme.vectorscope.guides ?? scopes.guides return { trace: theme.vectorscope.trace ?? app.accent, + phaseRisk: theme.vectorscope.phaseRisk ?? app.warning, guides, guidesSecondary: multiplyAlpha(guides, 0.5), labels: theme.vectorscope.labels ?? guides, diff --git a/src/types/settings.ts b/src/types/settings.ts index 32774e5..6144989 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -20,6 +20,7 @@ import { DEFAULT_SCOPE_MIRROR_HORIZONTAL, type ScopeDisplayTransformSettings, } from './scopeTransform' +import { DEFAULT_VECTORSCOPE_ZOOM_DB } from './vectorscope' export interface ScopeSettings { spectrum: ScopeDisplayTransformSettings & { @@ -44,6 +45,7 @@ export interface ScopeSettings { } vectorscope: { mode: VectorscopeMode + zoomDb: number multiband: boolean showGrid: boolean persistence: number @@ -88,7 +90,7 @@ export interface 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 }, 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' }, vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS }, lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT }, diff --git a/src/types/theme.ts b/src/types/theme.ts index 60b321e..5f186a2 100644 --- a/src/types/theme.ts +++ b/src/types/theme.ts @@ -77,6 +77,7 @@ export interface ThemeOscilloscopeTokens { export interface ThemeVectorscopeTokens { background?: string trace?: string + phaseRisk?: string bandLow?: string bandMid?: string bandHigh?: string @@ -268,6 +269,7 @@ export interface ResolvedOscilloscopeTheme { export interface ResolvedVectorscopeTheme { trace: string + phaseRisk: string guides: string guidesSecondary: string labels: string diff --git a/src/types/vectorscope.ts b/src/types/vectorscope.ts new file mode 100644 index 0000000..6d61563 --- /dev/null +++ b/src/types/vectorscope.ts @@ -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` +} diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 46fa33f..b0d82d5 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -52,6 +52,7 @@ function createProfile(name: string): Profile { profile.scopeSettings.spectrum.heatmapSmoothing = 0.67 profile.scopeSettings.spectrum.scaleMode = 'mel' profile.scopeSettings.spectrum.frequencyRangeMode = 'audible' + profile.scopeSettings.vectorscope.zoomDb = 6 profile.scopeSettings.spectrogram.colorScheme = 'mono' profile.scopeSettings.spectrogram.clarityMode = 'focused' 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.scaleMode, 'mel') 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.clarityMode, 'focused') 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.scaleMode, 'mel') 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.clarityMode, 'focused') 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') }) +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', () => { const valid = mergeScopeSettings({ spectrum: { scaleMode: 'mel', frequencyRangeMode: 'extended' }, diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 3fcc667..3a10e22 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -119,6 +119,7 @@ import { decodeSpectrumFrame } from '../src/plugin-ui/juceBridge' import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay' import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions' import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions' +import { vectorscopeSettingsToOptions } from '../src/plugin-ui/vectorscopeOptions' import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram' import { Vectorscope } from '../src/renderer/visualizers/Vectorscope' import { Waveform } from '../src/renderer/visualizers/Waveform' @@ -135,6 +136,8 @@ import { import { drawVectorscopeGridForMode, getVectorscopeLayout, + isVectorscopePhaseRisk, + transformPoint, } from '../src/renderer/visualizers/vectorscopeGrids' import { MultibandBuffer, @@ -155,6 +158,11 @@ import { normalizedPositionAtFrequency, type FrequencyScaleMode, } from '../src/types/frequencyScale' +import { + formatVectorscopeReferenceDbfs, + normalizeVectorscopeZoomDb, + vectorscopeZoomDbToGain, +} from '../src/types/vectorscope' type WindowWithRaf = typeof globalThis & Pick type FakeElectronAPI = { @@ -613,6 +621,10 @@ interface FakeCanvasRecorder { lineDash: 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[] }> drawImageCalls: Array<{ compositeOperation: GlobalCompositeOperation; args: unknown[] }> } @@ -625,6 +637,7 @@ function createFakeCanvasRecorder(): FakeCanvasRecorder { strokeRects: [], arcs: [], lineDashes: [], + fills: [], imageDataWrites: [], drawImageCalls: [], } @@ -650,7 +663,9 @@ function createFakeCanvasContext(recorder: FakeCanvasRecorder | null = null): Ca currentPath = [] }, closePath() {}, - fill() {}, + fill() { + recorder?.fills.push({ commands: [...currentPath], fillStyle: currentFillStyle }) + }, moveTo(x: number, y: number) { 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', () => { const profile = createDefaultProfile('Default') + profile.scopeSettings.vectorscope.zoomDb = 6 const authoredTheme = createDefaultTheme() authoredTheme.scopes.background = 'rgb(3, 4, 5)' authoredTheme.scopes.guides = 'rgba(120, 130, 140, 0.2)' + authoredTheme.vectorscope.phaseRisk = 'rgb(200, 100, 50)' const theme = resolveTheme(authoredTheme) 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.gridMinorColor, theme.vectorscope.guidesSecondary) 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', () => { @@ -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() drawVectorscopeGridForMode( 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.2)', 'lissajous', + 'rgb(255, 191, 0)', + 6, ) assert.equal( lissajousRecorder.lineDashes.some((segments) => segments.length > 0), 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() 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.2)', 'linear-bipolar', + 'rgb(255, 191, 0)', ) assert.equal( linearRecorder.lineDashes.some((segments) => segments.length > 0), - true, - 'linear mode should render a dashed outer max boundary', + false, + '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() 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.2)', 'polar-bipolar', + 'rgb(255, 191, 0)', ) 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( polarRecorder.lineDashes.some((segments) => segments.length > 0), - true, - 'polar mode should render a dashed outer boundary', - ) - assert.ok( - dashedPolarArc && dashedPolarArc.radius > polarLayout.radius, - 'polar dashed boundary should sit outside the existing graph', + false, + 'polar mode should not render an arbitrary overflow circle', ) assertAlmostEqual( - dashedPolarArc?.radius ?? 0, - expectedPolarOverflowRadius, + Math.max(...polarRecorder.arcs.map(({ radius }) => radius)), + polarLayout.radius, 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 dataSource = { getPendingVectorscopeSamples: () => [], @@ -3486,7 +3611,71 @@ test('Vectorscope keeps the original linear projection behavior', () => { isPlaying: () => false, 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 { const state = vectorscope as unknown as { @@ -3501,12 +3690,16 @@ test('Vectorscope keeps the original linear projection behavior', () => { dotSize: number, ) => void getProjectionScale: (radius: number) => number + options: { zoomDb: number } } const layout = getVectorscopeLayout(320, 180, 'linear-bipolar') const recorder = createFakeCanvasRecorder() const ctx = createFakeCanvasContext(recorder) 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) 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 assertAlmostEqual( projectedCenterX, - layout.centerX + layout.radius * Math.SQRT2, + layout.centerX + layout.radius * vectorscopeZoomDbToGain(6), 1e-6, - 'linear projection should preserve the original unscaled mapping', + 'linear projection should apply the normalized zoom gain', ) assertAlmostEqual( projectedCenterY, layout.centerY, 1e-6, - 'linear overs peak should stay on the side axis', + 'anti-phase projection should stay on the Side axis', ) } finally { vectorscope.dispose() @@ -3677,6 +3870,26 @@ test('scopeSummary includes only waveform display modes', () => { 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', () => { const profile = createDefaultProfile('Default') diff --git a/test/theme-library.test.ts b/test/theme-library.test.ts index 7b49607..9c5a54c 100644 --- a/test/theme-library.test.ts +++ b/test/theme-library.test.ts @@ -63,6 +63,7 @@ test('theme files round-trip and keep grouped sections intact', () => { theme.controls.flatControls = 'true' theme.scopes.background = '#030712' theme.spectrum.heatMid = 'rgb(200, 50, 120)' + theme.vectorscope.phaseRisk = 'rgb(240, 120, 40)' theme.vumeter.track = '#111827' 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, /flat_controls = true/) assert.match(serialized, /needle_left = 70, 80, 90/) + assert.match(serialized, /phase_risk = 240, 120, 40/) assert.doesNotMatch(serialized, /^id = /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.scopes.background, 'rgb(3, 7, 18)') 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.needleLeft, 'rgb(70, 80, 90)') 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, /^\[Scopes\]$/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, /^# toolbar_bg = 4, 8, 12, 199$/m) 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.oscilloscope.line, '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.interface.scopeBackground, 'rgb(3, 7, 18)') assert.equal(resolved.spectrum.background, 'rgb(3, 7, 18)') diff --git a/test/vectorscope-native.test.mjs b/test/vectorscope-native.test.mjs new file mode 100644 index 0000000..3e67d96 --- /dev/null +++ b/test/vectorscope-native.test.mjs @@ -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') +})