From 7da5f62c5e6268b761aa55d36c6b13db90dccdfd Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 27 May 2026 19:22:22 -0400 Subject: [PATCH] vectorscope VST --- plugin/CMakeLists.txt | 3 + plugin/Source/PluginEditor.cpp | 5 +- plugin/Source/VectorscopeEngine.h | 75 +++++++++++++++++ src/plugin-ui/BridgeVectorscopeAnalyzer.ts | 66 +++++++++++++++ src/plugin-ui/PluginWebViewDataSource.ts | 6 ++ src/plugin-ui/VectorscopeScope.tsx | 73 +++++++++++++++++ src/plugin-ui/juceBridge.ts | 93 ++++++++++++++++++++++ src/plugin-ui/main.tsx | 32 +++++++- src/plugin-ui/vectorscopeOptions.ts | 30 +++++++ 9 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 plugin/Source/VectorscopeEngine.h create mode 100644 src/plugin-ui/BridgeVectorscopeAnalyzer.ts create mode 100644 src/plugin-ui/VectorscopeScope.tsx create mode 100644 src/plugin-ui/vectorscopeOptions.ts diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index c2fd25e..f52c000 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -59,6 +59,8 @@ function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE) ${PRISM_NATIVE_DIR}/oscilloscope.cpp ${PRISM_NATIVE_DIR}/vumeter.cpp ${PRISM_NATIVE_DIR}/lufsmeter.cpp + ${PRISM_NATIVE_DIR}/vectorscope.cpp + ${PRISM_NATIVE_DIR}/multiband.cpp ${PRISM_NATIVE_DIR}/dsp_utils.cpp) target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR}) @@ -98,3 +100,4 @@ add_prism_scope(PrismSpectrum "Prism Spectrum" Pspc "") add_prism_scope(PrismOscilloscope "Prism Oscilloscope" Posc "PRISM_SCOPE_OSCILLOSCOPE=1") add_prism_scope(PrismVUMeter "Prism VU Meter" Pvum "PRISM_SCOPE_VUMETER=1") add_prism_scope(PrismLUFSMeter "Prism Loudness Meter" Pluf "PRISM_SCOPE_LUFSMETER=1") +add_prism_scope(PrismVectorscope "Prism Vectorscope" Pvct "PRISM_SCOPE_VECTORSCOPE=1") diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp index 46525a7..da23e52 100644 --- a/plugin/Source/PluginEditor.cpp +++ b/plugin/Source/PluginEditor.cpp @@ -3,6 +3,7 @@ #include "OscilloscopeEngine.h" #include "VUMeterEngine.h" #include "LUFSMeterEngine.h" +#include "VectorscopeEngine.h" #include #if ! PRISM_USE_DEV_SERVER @@ -20,7 +21,9 @@ namespace std::unique_ptr makeEngine() { -#if defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER +#if defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE + return std::make_unique(); +#elif defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER return std::make_unique(); #elif defined(PRISM_SCOPE_VUMETER) && PRISM_SCOPE_VUMETER return std::make_unique(); diff --git a/plugin/Source/VectorscopeEngine.h b/plugin/Source/VectorscopeEngine.h new file mode 100644 index 0000000..ccdc133 --- /dev/null +++ b/plugin/Source/VectorscopeEngine.h @@ -0,0 +1,75 @@ +#pragma once + +#include "ScopeEngine.h" +#include "vectorscope.h" // reused, unmodified, from native/src +#include + +/** + * 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 + * most recent display points each frame. Two layouts share the buffers: the standard + * X/Y point cloud and the multiband (low/mid/high) cloud. Both buffers are kept warm + * so toggling is instant; the active layout (from the `multiband` setting) is flagged + * in the frame so the webview reads the right payload. + */ +class VectorscopeEngine : public ScopeEngine +{ +public: + const char* scopeId() const override { return "vectorscope"; } + juce::Identifier frameEventId() const override { return frameId; } + + void setSampleRate(double sampleRate) override + { + vec.setSampleRate((float) sampleRate); + } + + void configure(const juce::var& settings) override + { + multiband = (bool) settings.getProperty("multiband", false); + } + + void process(const float* left, const float* right, int numSamples) override + { + if (numSamples <= 0) + return; + vec.pushSamples(left, right, (size_t) numSamples); + vec.pushMultibandSamples(left, right, (size_t) numSamples); + } + + juce::var buildFrame(double sampleRate) override + { + auto* obj = new juce::DynamicObject(); + obj->setProperty("sampleRate", sampleRate); + obj->setProperty("multiband", multiband); + + if (multiband) + { + constexpr size_t stride = Visualizer::MULTIBAND_POINT_STRIDE; + if (mbData.size() < (size_t) kDisplayPoints * stride) + mbData.resize((size_t) kDisplayPoints * stride); + const size_t count = vec.getMultibandPoints(mbData.data(), (size_t) kDisplayPoints); + obj->setProperty("count", (int) count); + obj->setProperty("data", juce::Base64::toBase64(mbData.data(), count * stride * sizeof(float))); + } + else + { + if (pointX.size() < (size_t) kDisplayPoints) + { + pointX.resize((size_t) kDisplayPoints); + pointY.resize((size_t) kDisplayPoints); + } + const size_t count = vec.getPoints(pointX.data(), pointY.data(), (size_t) kDisplayPoints); + obj->setProperty("count", (int) count); + obj->setProperty("x", juce::Base64::toBase64(pointX.data(), count * sizeof(float))); + obj->setProperty("y", juce::Base64::toBase64(pointY.data(), count * sizeof(float))); + } + return juce::var(obj); + } + +private: + static constexpr int kDisplayPoints = 4096; // matches Vectorscope.ts default + const juce::Identifier frameId { "vectorscopeFrame" }; + Visualizer::Vectorscope vec; + std::vector pointX, pointY, mbData; + bool multiband = false; +}; diff --git a/src/plugin-ui/BridgeVectorscopeAnalyzer.ts b/src/plugin-ui/BridgeVectorscopeAnalyzer.ts new file mode 100644 index 0000000..be78ef7 --- /dev/null +++ b/src/plugin-ui/BridgeVectorscopeAnalyzer.ts @@ -0,0 +1,66 @@ +import type { VectorscopeNativeAnalyzer, VectorscopeMultibandPointsResult } from '../renderer/audio/native' + +/** + * Drop-in `VectorscopeNativeAnalyzer` for the plugin webview. + * + * The vectorscope DSP (channel lowpass + 3-band split, circular buffers) runs in + * the C++ plugin, which pushes the most recent display points each frame — either + * a standard X/Y cloud or a multiband (6 floats/point: lowL,lowR,midL,midR,highL, + * highR) cloud, depending on the active mode. This shim caches whichever arrived + * and serves it through the two readout methods `Vectorscope` consumes. The push + * methods are no-ops (audio never flows through the webview). + */ +export class BridgeVectorscopeAnalyzer implements VectorscopeNativeAnalyzer { + private x: Float32Array = new Float32Array(0) + private y: Float32Array = new Float32Array(0) + private count = 0 + private mbData: Float32Array = new Float32Array(0) + private mbCount = 0 + + /** Standard X/Y point cloud from the host. */ + setStandard(x: Float32Array, y: Float32Array, count: number): void { + this.x = x + this.y = y + this.count = count + } + + /** Multiband point cloud from the host (flat, 6 floats per point). */ + setMultiband(data: Float32Array, count: number): void { + this.mbData = data + this.mbCount = count + } + + isAvailable(): boolean { + return true + } + + isMultibandAvailable(): boolean { + return true + } + + setSampleRate(_sampleRate: number): void {} + pushSamples(_left: Float32Array, _right: Float32Array): void {} + pushMultibandSamples(_left: Float32Array, _right: Float32Array): void {} + + fillPoints(xOut: Float32Array, yOut: Float32Array): number { + const count = Math.min(xOut.length, yOut.length, this.count, this.x.length, this.y.length) + if (count > 0) { + xOut.set(this.x.subarray(0, count), 0) + yOut.set(this.y.subarray(0, count), 0) + } + return count + } + + getMultibandPoints(maxPoints: number): VectorscopeMultibandPointsResult { + const count = Math.min(maxPoints, this.mbCount, Math.floor(this.mbData.length / 6)) + return { data: this.mbData, count } + } + + reset(): void { + this.x = new Float32Array(0) + this.y = new Float32Array(0) + this.count = 0 + this.mbData = new Float32Array(0) + this.mbCount = 0 + } +} diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts index bef82b9..d57bf0d 100644 --- a/src/plugin-ui/PluginWebViewDataSource.ts +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -62,6 +62,12 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource { return [] } + // Vectorscope reads the C++-pushed point cloud via fillPoints/getMultibandPoints + // each frame — no raw samples drain here, and no sentinel is needed. + getPendingVectorscopeSamples(): Array<{ left: Float32Array; right: Float32Array }> { + return [] + } + getSampleRate(): number { return this.sessionState.sampleRate } diff --git a/src/plugin-ui/VectorscopeScope.tsx b/src/plugin-ui/VectorscopeScope.tsx new file mode 100644 index 0000000..26bc865 --- /dev/null +++ b/src/plugin-ui/VectorscopeScope.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef, type JSX } from 'react' +import { Vectorscope } from '../renderer/visualizers/Vectorscope' +import type { ScopeSettings } from '../types/settings' +import type { ResolvedVectorscopeTheme } from '../types/theme' +import type { BridgeVectorscopeAnalyzer } from './BridgeVectorscopeAnalyzer' +import type { PluginWebViewDataSource } from './PluginWebViewDataSource' +import { vectorscopeSettingsToOptions } from './vectorscopeOptions' + +interface VectorscopeScopeProps { + dataSource: PluginWebViewDataSource + nativeAnalyzer: BridgeVectorscopeAnalyzer + settings: ScopeSettings['vectorscope'] + theme: ResolvedVectorscopeTheme +} + +export default function VectorscopeScope({ + dataSource, + nativeAnalyzer, + settings, + theme, +}: VectorscopeScopeProps): JSX.Element { + const containerRef = useRef(null) + const canvasRef = useRef(null) + const vizRef = useRef(null) + + useEffect(() => { + const container = containerRef.current + const canvas = canvasRef.current + if (!container || !canvas) return + + const viz = new Vectorscope(canvas, { + ...vectorscopeSettingsToOptions(settings, theme), + dataSource, + nativeAnalyzer, + }) + vizRef.current = viz + + const applySize = (): void => { + const rect = container.getBoundingClientRect() + const dpr = window.devicePixelRatio || 1 + const pixelWidth = Math.max(1, Math.floor(rect.width * dpr)) + const pixelHeight = Math.max(1, Math.floor(rect.height * dpr)) + if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { + canvas.width = pixelWidth + canvas.height = pixelHeight + viz.resize() + } + } + + applySize() + viz.start() + const observer = new ResizeObserver(applySize) + observer.observe(container) + + return () => { + observer.disconnect() + viz.dispose() + vizRef.current = null + } + // settings/theme applied via setOptions below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataSource, nativeAnalyzer]) + + useEffect(() => { + vizRef.current?.setOptions(vectorscopeSettingsToOptions(settings, theme)) + }, [settings, theme]) + + return ( +
+ +
+ ) +} diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts index 4eb3ba8..b85f088 100644 --- a/src/plugin-ui/juceBridge.ts +++ b/src/plugin-ui/juceBridge.ts @@ -349,6 +349,99 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () => } } +// --------------------------------------------------------------------------- +// Vectorscope frames (event "vectorscopeFrame"): a point cloud, either standard +// (x, y base64) or multiband (data base64, 6 floats/point), flagged by `multiband`. + +export interface VectorscopeFrame { + sampleRate: number + multiband: boolean + count: number + x?: Float32Array + y?: Float32Array + data?: Float32Array +} + +interface VectorscopeFramePayload { + sampleRate?: number + multiband?: boolean + count?: number + x?: string + y?: string + data?: string +} + +function decodeVectorscopeFrame(payload: unknown): VectorscopeFrame | null { + if (typeof payload !== 'object' || payload === null) return null + const { sampleRate, multiband, count, x, y, data } = payload as VectorscopeFramePayload + const frame: VectorscopeFrame = { + sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000, + multiband: Boolean(multiband), + count: typeof count === 'number' ? count : 0, + } + if (frame.multiband) { + if (typeof data !== 'string') return null + frame.data = base64ToFloat32Array(data) + } else { + if (typeof x !== 'string' || typeof y !== 'string') return null + frame.x = base64ToFloat32Array(x) + frame.y = base64ToFloat32Array(y) + } + return frame +} + +export interface VectorscopeBridgeHandlers { + onFrame: (frame: VectorscopeFrame) => void + onConnected?: (usingMock: boolean) => void +} + +export function connectVectorscopeBridge(handlers: VectorscopeBridgeHandlers): () => void { + let disposed = false + let listenerId: number | null = null + let mockRaf: number | null = null + + const startMock = (): void => { + handlers.onConnected?.(true) + console.warn('[prism-plugin] no JUCE host — using synthetic vectorscope (browser dev mode)') + const count = 2048 + const x = new Float32Array(count) + const y = new Float32Array(count) + let phase = 0 + const tick = (): void => { + if (disposed) return + phase += 0.03 + for (let i = 0; i < count; i += 1) { + const t = (i / count) * Math.PI * 2 + x[i] = Math.sin(t * 3 + phase) * 0.7 + y[i] = Math.sin(t * 2 + phase * 1.3) * 0.7 + } + handlers.onFrame({ sampleRate: 48000, multiband: false, count, x, y }) + mockRaf = requestAnimationFrame(tick) + } + mockRaf = requestAnimationFrame(tick) + } + + void ensureBackend().then((backend) => { + if (disposed) return + if (backend) { + listenerId = backend.addEventListener('vectorscopeFrame', (payload) => { + const frame = decodeVectorscopeFrame(payload) + if (frame) handlers.onFrame(frame) + }) + handlers.onConnected?.(false) + console.log('[prism-plugin] connected to JUCE host (vectorscope)') + } else { + startMock() + } + }) + + return () => { + disposed = true + if (mockRaf !== null) cancelAnimationFrame(mockRaf) + if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId) + } +} + // --------------------------------------------------------------------------- // Oscilloscope frames (event "oscilloscopeFrame": { sampleRate, samples, pitch }). diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx index 5a9f7e6..60c4e64 100644 --- a/src/plugin-ui/main.tsx +++ b/src/plugin-ui/main.tsx @@ -7,12 +7,14 @@ import SpectrumScope from './SpectrumScope' import OscilloscopeScope from './OscilloscopeScope' import VUMeterScope from './VUMeterScope' import LUFSMeterScope from './LUFSMeterScope' +import VectorscopeScope from './VectorscopeScope' import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer' import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer' import { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer' import { BridgeLUFSMeterAnalyzer } from './BridgeLUFSMeterAnalyzer' +import { BridgeVectorscopeAnalyzer } from './BridgeVectorscopeAnalyzer' import { PluginWebViewDataSource } from './PluginWebViewDataSource' -import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge } from './juceBridge' +import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge } from './juceBridge' // The C++ plugin tells us which scope it is via JUCE initialisation data. // JUCE stores each value as an array (e.g. prismScope = ["oscilloscope"]). @@ -27,6 +29,34 @@ function getScopeKind(): string { const dataSource = new PluginWebViewDataSource() function buildApp(): JSX.Element { + if (getScopeKind() === 'vectorscope') { + const analyzer = new BridgeVectorscopeAnalyzer() + connectVectorscopeBridge({ + onFrame: (frame) => { + if (frame.multiband && frame.data) { + analyzer.setMultiband(frame.data, frame.count) + } else if (frame.x && frame.y) { + analyzer.setStandard(frame.x, frame.y, frame.count) + } + dataSource.setSampleRate(frame.sampleRate) + dataSource.setPlaying(true) + }, + }) + return ( + ( + + )} + /> + ) + } + if (getScopeKind() === 'lufsmeter') { const analyzer = new BridgeLUFSMeterAnalyzer() connectLUFSMeterBridge({ diff --git a/src/plugin-ui/vectorscopeOptions.ts b/src/plugin-ui/vectorscopeOptions.ts new file mode 100644 index 0000000..5c6c4b6 --- /dev/null +++ b/src/plugin-ui/vectorscopeOptions.ts @@ -0,0 +1,30 @@ +import type { ScopeSettings } from '../types/settings' +import type { ResolvedVectorscopeTheme } from '../types/theme' +import type { VectorscopeOptions } from '../renderer/visualizers/Vectorscope' + +/** + * Map Prism's vectorscope settings + resolved theme to Vectorscope options. + * Mirrors the `vectorscope` case of `scopeSettingsToOptions` in ScopeModule.tsx. + */ +export function vectorscopeSettingsToOptions( + settings: ScopeSettings['vectorscope'], + theme: ResolvedVectorscopeTheme, +): VectorscopeOptions { + return { + lineColor: theme.trace, + backgroundColor: theme.background, + gridMajorColor: theme.guides, + gridMinorColor: theme.guidesSecondary, + labelColor: theme.labels, + bandColors: { + low: theme.bandLow, + mid: theme.bandMid, + high: theme.bandHigh, + }, + mode: settings.mode, + multiband: settings.multiband, + showGrid: settings.showGrid, + persistence: settings.persistence, + lineWidth: settings.lineWidth, + } +}