waveform VST

This commit is contained in:
Boof2015
2026-05-27 23:44:30 -04:00
parent 4de676a8ad
commit 8006764608
10 changed files with 399 additions and 6 deletions
+2
View File
@@ -62,6 +62,7 @@ function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE)
${PRISM_NATIVE_DIR}/vectorscope.cpp
${PRISM_NATIVE_DIR}/multiband.cpp
${PRISM_NATIVE_DIR}/spectrogram.cpp
${PRISM_NATIVE_DIR}/waveform.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR})
@@ -103,3 +104,4 @@ add_prism_scope(PrismVUMeter "Prism VU Meter" Pvum "PRISM_SCOPE_VUMETER
add_prism_scope(PrismLUFSMeter "Prism Loudness Meter" Pluf "PRISM_SCOPE_LUFSMETER=1")
add_prism_scope(PrismVectorscope "Prism Vectorscope" Pvct "PRISM_SCOPE_VECTORSCOPE=1")
add_prism_scope(PrismSpectrogram "Prism Spectrogram" Pspg "PRISM_SCOPE_SPECTROGRAM=1")
add_prism_scope(PrismWaveform "Prism Waveform" Pwav "PRISM_SCOPE_WAVEFORM=1")
+4 -1
View File
@@ -5,6 +5,7 @@
#include "LUFSMeterEngine.h"
#include "VectorscopeEngine.h"
#include "SpectrogramEngine.h"
#include "WaveformEngine.h"
#include <cstring>
#if ! PRISM_USE_DEV_SERVER
@@ -22,7 +23,9 @@ namespace
std::unique_ptr<ScopeEngine> makeEngine()
{
#if defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM
#if defined(PRISM_SCOPE_WAVEFORM) && PRISM_SCOPE_WAVEFORM
return std::make_unique<WaveformEngine>();
#elif defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM
return std::make_unique<SpectrogramEngine>();
#elif defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE
return std::make_unique<VectorscopeEngine>();
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include "ScopeEngine.h"
#include "waveform.h" // reused, unmodified, from native/src
#include <vector>
#include <cmath>
#include <algorithm>
/**
* Waveform engine. Runs the reused Visualizer::WaveformMultibandAnalyzer, which
* summarizes audio into per-column min/max + 3-band RMS. Unlike the spectrogram, the
* column width (samplesPerColumn) is a pure function of sampleRate and scrollSpeed —
* sampleRate / (128 * scrollSpeed), the same formula the renderer uses — so the engine
* derives it itself; no canvas round-trip. mode ('stereo' vs 'mono') selects
* processStereo (stride 10) vs processMono (stride 5), flagged in the frame. multiband
* is render-only (the analyzer always emits the band RMS columns use for coloring).
*/
class WaveformEngine : public ScopeEngine
{
public:
WaveformEngine() { reconfigure(); }
const char* scopeId() const override { return "waveform"; }
juce::Identifier frameEventId() const override { return frameId; }
void setSampleRate(double sr) override
{
if (sr > 0.0 && (float) sr != sampleRate)
{
sampleRate = (float) sr;
reconfigure();
}
}
void configure(const juce::var& settings) override
{
stereo = settings.getProperty("mode", "mono").toString() == "stereo";
const auto speed = (float) settings.getProperty("scrollSpeed", (double) scrollSpeed);
if (speed > 0.0f)
scrollSpeed = speed;
reconfigure();
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
if (stereo)
{
const auto& cols = wave.processStereo(left, right, (size_t) numSamples);
pending.insert(pending.end(), cols.begin(), cols.end());
}
else
{
if ((int) mono.size() < numSamples)
mono.resize((size_t) numSamples);
for (int i = 0; i < numSamples; ++i)
mono[(size_t) i] = 0.5f * (left[i] + right[i]);
const auto& cols = wave.processMono(mono.data(), (size_t) numSamples);
pending.insert(pending.end(), cols.begin(), cols.end());
}
}
juce::var buildFrame(double sr) override
{
const size_t stride = stereo ? Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE
: Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE;
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sr);
obj->setProperty("stereo", stereo);
obj->setProperty("columnCount", (int) (pending.size() / stride));
if (! pending.empty())
obj->setProperty("summaries", juce::Base64::toBase64(pending.data(), pending.size() * sizeof(float)));
else
obj->setProperty("summaries", juce::String());
pending.clear();
return juce::var(obj);
}
private:
void reconfigure()
{
const float pps = 128.0f * std::max(0.01f, scrollSpeed);
samplesPerColumn = (size_t) std::max(1L, std::lround(sampleRate / pps));
wave.configure(sampleRate, samplesPerColumn);
pending.clear();
}
const juce::Identifier frameId { "waveformFrame" };
Visualizer::WaveformMultibandAnalyzer wave;
std::vector<float> mono, pending;
float sampleRate = 48000.0f;
float scrollSpeed = 1.0f;
size_t samplesPerColumn = 1;
bool stereo = false;
};
+62
View File
@@ -0,0 +1,62 @@
import type { WaveformNativeAnalyzer } from '../renderer/audio/native'
interface QueuedColumns {
summaries: Float32Array
stereo: boolean
}
/**
* Drop-in `WaveformNativeAnalyzer` for the plugin webview.
*
* The waveform DSP (per-column min/max + 3-band RMS) runs in the C++ plugin, which
* pushes finished column summaries each frame — stride 10 in stereo mode, stride 5
* in mono. This shim queues them and serves whichever the visualizer asks for:
* `processStereo`/`processMono` return the queued summaries matching that mode and
* clear the queue (so a mode switch never returns mismatched-stride data, and the
* queue can't grow unbounded). `configure` is a no-op — the engine derives
* samplesPerColumn itself from the host sample rate + scroll speed.
*/
export class BridgeWaveformAnalyzer implements WaveformNativeAnalyzer {
private queue: QueuedColumns[] = []
/** Called by the bridge when the host emits a waveform frame. */
pushFrame(summaries: Float32Array, stereo: boolean): void {
if (summaries.length === 0) return
this.queue.push({ summaries, stereo })
}
isAvailable(): boolean {
return true
}
configure(_sampleRate: number, _samplesPerColumn: number): void {}
processMono(_samples: Float32Array): Float32Array | null {
return this.drain(false)
}
processStereo(_left: Float32Array, _right: Float32Array): Float32Array | null {
return this.drain(true)
}
reset(): void {
this.queue = []
}
private drain(stereo: boolean): Float32Array {
const matching = this.queue.filter((entry) => entry.stereo === stereo)
this.queue = []
if (matching.length === 0) return new Float32Array(0)
if (matching.length === 1) return matching[0].summaries
let total = 0
for (const entry of matching) total += entry.summaries.length
const out = new Float32Array(total)
let offset = 0
for (const entry of matching) {
out.set(entry.summaries, offset)
offset += entry.summaries.length
}
return out
}
}
+10
View File
@@ -58,6 +58,16 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource {
return this.sessionState.capturing ? [this.sentinel] : []
}
// Waveform: same sentinel trick — the visualizer calls processMono/processStereo
// per pending chunk, which drains the C++-pushed column summaries from the bridge.
getPendingWaveformSamples(): Float32Array[] {
return this.sessionState.capturing ? [this.sentinel] : []
}
getPendingWaveformStereoSamples(): SpectrumStereoChunk[] {
return this.sessionState.capturing ? [this.sentinelStereo] : []
}
// VU + loudness meters read the C++-pushed snapshot from the bridge analyzer
// every frame, so there are no raw samples to drain here (no sentinel needed).
getPendingVUMeterSamples(): Array<{ left: Float32Array; right: Float32Array }> {
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, type JSX } from 'react'
import { Waveform } from '../renderer/visualizers/Waveform'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedWaveformTheme } from '../types/theme'
import type { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { waveformSettingsToOptions } from './waveformOptions'
interface WaveformScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeWaveformAnalyzer
settings: ScopeSettings['waveform']
theme: ResolvedWaveformTheme
}
export default function WaveformScope({
dataSource,
nativeAnalyzer,
settings,
theme,
}: WaveformScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Waveform | null>(null)
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const viz = new Waveform(canvas, {
...waveformSettingsToOptions(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(waveformSettingsToOptions(settings, theme))
}, [settings, theme])
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+88
View File
@@ -535,6 +535,94 @@ export function connectSpectrogramBridge(handlers: SpectrogramBridgeHandlers): (
}
}
// ---------------------------------------------------------------------------
// Waveform frames (event "waveformFrame"): per-column summaries (base64), stride 10
// in stereo mode / stride 5 in mono, flagged by `stereo`.
export interface WaveformFrame {
sampleRate: number
stereo: boolean
columnCount: number
summaries: Float32Array
}
interface WaveformFramePayload {
sampleRate?: number
stereo?: boolean
columnCount?: number
summaries?: string
}
function decodeWaveformFrame(payload: unknown): WaveformFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const { sampleRate, stereo, columnCount, summaries } = payload as WaveformFramePayload
return {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
stereo: Boolean(stereo),
columnCount: typeof columnCount === 'number' ? columnCount : 0,
summaries: typeof summaries === 'string' ? base64ToFloat32Array(summaries) : new Float32Array(0),
}
}
export interface WaveformBridgeHandlers {
onFrame: (frame: WaveformFrame) => void
onConnected?: (usingMock: boolean) => void
}
export function connectWaveformBridge(handlers: WaveformBridgeHandlers): () => 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 waveform (browser dev mode)')
let phase = 0
const tick = (): void => {
if (disposed) return
phase += 0.12
const columns = 2
// Emit both mono (stride 5) and stereo (stride 10) so the dev mock works in
// either mode (the analyzer serves whichever the visualizer asks for).
const mono = new Float32Array(columns * 5)
const stereo = new Float32Array(columns * 10)
for (let c = 0; c < columns; c += 1) {
const amp = 0.3 + 0.6 * Math.abs(Math.sin(phase + c * 0.4))
const m = c * 5
mono[m] = -amp; mono[m + 1] = amp; mono[m + 2] = amp * 0.5; mono[m + 3] = amp * 0.7; mono[m + 4] = amp * 0.3
const s = c * 10
stereo[s] = -amp; stereo[s + 1] = amp; stereo[s + 2] = amp * 0.5; stereo[s + 3] = amp * 0.7; stereo[s + 4] = amp * 0.3
const ampR = amp * 0.85
stereo[s + 5] = -ampR; stereo[s + 6] = ampR; stereo[s + 7] = ampR * 0.5; stereo[s + 8] = ampR * 0.7; stereo[s + 9] = ampR * 0.3
}
handlers.onFrame({ sampleRate: 48000, stereo: false, columnCount: columns, summaries: mono })
handlers.onFrame({ sampleRate: 48000, stereo: true, columnCount: columns, summaries: stereo })
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
}
void ensureBackend().then((backend) => {
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('waveformFrame', (payload) => {
const frame = decodeWaveformFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (waveform)')
} 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 }).
+27 -1
View File
@@ -9,14 +9,16 @@ import VUMeterScope from './VUMeterScope'
import LUFSMeterScope from './LUFSMeterScope'
import VectorscopeScope from './VectorscopeScope'
import SpectrogramScope from './SpectrogramScope'
import WaveformScope from './WaveformScope'
import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
import { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer'
import { BridgeLUFSMeterAnalyzer } from './BridgeLUFSMeterAnalyzer'
import { BridgeVectorscopeAnalyzer } from './BridgeVectorscopeAnalyzer'
import { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
import { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
import { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge, connectSpectrogramBridge } from './juceBridge'
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge, connectSpectrogramBridge, connectWaveformBridge } 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"]).
@@ -31,6 +33,30 @@ function getScopeKind(): string {
const dataSource = new PluginWebViewDataSource()
function buildApp(): JSX.Element {
if (getScopeKind() === 'waveform') {
const analyzer = new BridgeWaveformAnalyzer()
connectWaveformBridge({
onFrame: (frame) => {
analyzer.pushFrame(frame.summaries, frame.stereo)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
return (
<ScopeApp
kind="waveform"
renderScope={(settings, theme) => (
<WaveformScope
settings={settings}
theme={theme.waveform}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
if (getScopeKind() === 'spectrogram') {
const analyzer = new BridgeSpectrogramAnalyzer()
connectSpectrogramBridge({
+27
View File
@@ -0,0 +1,27 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedWaveformTheme } from '../types/theme'
import type { WaveformOptions } from '../renderer/visualizers/Waveform'
/**
* Map Prism's waveform settings + resolved theme to Waveform options.
* Mirrors the `waveform` case of `scopeSettingsToOptions` in ScopeModule.tsx.
*/
export function waveformSettingsToOptions(
settings: ScopeSettings['waveform'],
theme: ResolvedWaveformTheme,
): WaveformOptions {
return {
backgroundColor: theme.background,
lineColor: theme.line,
gridMajorColor: theme.guides,
gridMinorColor: theme.guidesSecondary,
bandColors: {
low: theme.bandLow,
mid: theme.bandMid,
high: theme.bandHigh,
},
mode: settings.mode,
scrollSpeed: settings.scrollSpeed,
multiband: settings.multiband,
}
}
+9 -4
View File
@@ -495,7 +495,7 @@ export class Waveform {
}
private processMonoChunk(chunk: Float32Array, width: number, height: number): void {
if (this.useNativeMultiband()) {
if (this.useNativeAnalyzer()) {
if (this.processNativeMonoChunk(chunk, width, height)) {
return
}
@@ -540,7 +540,7 @@ export class Waveform {
const leftSamples = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length)
const rightSamples = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length)
if (this.useNativeMultiband()) {
if (this.useNativeAnalyzer()) {
if (this.processNativeStereoChunk(leftSamples, rightSamples, width, height)) {
return
}
@@ -590,8 +590,13 @@ export class Waveform {
}
}
private useNativeMultiband(): boolean {
return this.options.multiband && Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false
private useNativeAnalyzer(): boolean {
// The native analyzer computes per-column min/max (and band RMS) identically to
// the JS sample loop, so prefer it whenever available — for plain mode it just
// colors columns with lineColor. The JS path below remains the fallback when no
// native analyzer is present (and is what feeds raw samples in the Electron app
// when the addon is unavailable).
return Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false
}
private processNativeMonoChunk(chunk: Float32Array, width: number, height: number): boolean {