vectorscope VST

This commit is contained in:
Boof2015
2026-05-27 19:22:22 -04:00
parent 434b65605a
commit 7da5f62c5e
9 changed files with 381 additions and 2 deletions
+3
View File
@@ -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")
+4 -1
View File
@@ -3,6 +3,7 @@
#include "OscilloscopeEngine.h"
#include "VUMeterEngine.h"
#include "LUFSMeterEngine.h"
#include "VectorscopeEngine.h"
#include <cstring>
#if ! PRISM_USE_DEV_SERVER
@@ -20,7 +21,9 @@ namespace
std::unique_ptr<ScopeEngine> makeEngine()
{
#if defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER
#if defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE
return std::make_unique<VectorscopeEngine>();
#elif defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER
return std::make_unique<LUFSMeterEngine>();
#elif defined(PRISM_SCOPE_VUMETER) && PRISM_SCOPE_VUMETER
return std::make_unique<VUMeterEngine>();
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "ScopeEngine.h"
#include "vectorscope.h" // reused, unmodified, from native/src
#include <vector>
/**
* 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<float> pointX, pointY, mbData;
bool multiband = false;
};
@@ -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
}
}
+6
View File
@@ -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
}
+73
View File
@@ -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<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Vectorscope | null>(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 (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+93
View File
@@ -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 }).
+31 -1
View File
@@ -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 (
<ScopeApp
kind="vectorscope"
renderScope={(settings, theme) => (
<VectorscopeScope
settings={settings}
theme={theme.vectorscope}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
if (getScopeKind() === 'lufsmeter') {
const analyzer = new BridgeLUFSMeterAnalyzer()
connectLUFSMeterBridge({
+30
View File
@@ -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,
}
}