mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-19 20:09:43 +02:00
significantly improved and more accurate vectorscope
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- **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
|
- **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.
|
||||||
|
|||||||
@@ -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,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_;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
}
|
||||||
@@ -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
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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)')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user