mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
vectorscope VST
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user