spectrogram VST

This commit is contained in:
Boof2015
2026-05-27 19:56:09 -04:00
parent 7da5f62c5e
commit 4de676a8ad
11 changed files with 453 additions and 3 deletions
+2
View File
@@ -61,6 +61,7 @@ function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE)
${PRISM_NATIVE_DIR}/lufsmeter.cpp
${PRISM_NATIVE_DIR}/vectorscope.cpp
${PRISM_NATIVE_DIR}/multiband.cpp
${PRISM_NATIVE_DIR}/spectrogram.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR})
@@ -101,3 +102,4 @@ add_prism_scope(PrismOscilloscope "Prism Oscilloscope" Posc "PRISM_SCOPE_OSCILLO
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")
add_prism_scope(PrismSpectrogram "Prism Spectrogram" Pspg "PRISM_SCOPE_SPECTROGRAM=1")
+11 -2
View File
@@ -4,6 +4,7 @@
#include "VUMeterEngine.h"
#include "LUFSMeterEngine.h"
#include "VectorscopeEngine.h"
#include "SpectrogramEngine.h"
#include <cstring>
#if ! PRISM_USE_DEV_SERVER
@@ -21,7 +22,9 @@ namespace
std::unique_ptr<ScopeEngine> makeEngine()
{
#if defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE
#if 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>();
#elif defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER
return std::make_unique<LUFSMeterEngine>();
@@ -77,7 +80,8 @@ namespace
.withNativeIntegrationEnabled()
.withInitialisationData("prismScope", juce::String(scopeId))
.withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); })
.withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); });
.withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); })
.withEventListener("prismSpectrogramConfig", [&editor](juce::var v) { editor.onScopeNativeConfig(std::move(v)); });
#if ! PRISM_USE_DEV_SERVER
options = options.withResourceProvider([](const auto& url) { return provideResource(url); });
#endif
@@ -134,6 +138,11 @@ void PrismSpectrumEditor::onPrismReady()
sendAppDefaults();
}
void PrismSpectrumEditor::onScopeNativeConfig(juce::var payload)
{
engine->configureNative(payload);
}
void PrismSpectrumEditor::pushRestoreSettings()
{
auto* obj = new juce::DynamicObject();
+3
View File
@@ -27,6 +27,9 @@ public:
void onPrismConfig(juce::var payload);
void onPrismReady();
// Scope-specific native config (e.g. the spectrogram's canvas-derived rowCount).
void onScopeNativeConfig(juce::var payload);
// Push the processor's saved settings to the UI (used on ready + on host
// state restore, to cover either ordering).
void pushRestoreSettings();
+7
View File
@@ -24,6 +24,13 @@ public:
/** Apply scope settings (the JS settings object) to the DSP. */
virtual void configure(const juce::var& settings) = 0;
/**
* Apply a scope-specific native config pushed from the UI on the
* "prismSpectrogramConfig" event. Default no-op; only scopes whose DSP needs
* canvas-derived parameters (e.g. the spectrogram's rowCount) override this.
*/
virtual void configureNative(const juce::var&) {}
/** Feed audio (called off the realtime thread). numSamples may be 0. */
virtual void process(const float* left, const float* right, int numSamples) = 0;
+121
View File
@@ -0,0 +1,121 @@
#pragma once
#include "ScopeEngine.h"
#include "spectrogram.h" // reused, unmodified, from native/src
#include <vector>
#include <algorithm>
/**
* Spectrogram engine. The reused Visualizer::SpectrogramAnalyzer runs in C++ and
* produces finished display+heat columns. Unlike the other scopes, its DSP needs
* the canvas-derived rowCount (and fft/freq/db/scale/orientation), which only the
* webview knows — the UI pushes the full native config via "prismSpectrogramConfig",
* routed here through configureNative(). process() mixes to mono and runs the DSP;
* buildFrame() emits the columns produced since the last frame (base64) tagged with
* rowCount, so the bridge can match them to the config the UI currently expects.
* configure() (scope settings) is a no-op — every DSP parameter arrives in the
* native config. The host sample rate (from setSampleRate) overrides whatever the
* UI believed, so frequency mapping is always correct.
*/
class SpectrogramEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "spectrogram"; }
juce::Identifier frameEventId() const override { return frameId; }
void setSampleRate(double sampleRate) override
{
if (sampleRate > 0.0 && (float) sampleRate != config.sampleRate)
{
config.sampleRate = (float) sampleRate;
if (hasConfig)
spectro.configure(config);
}
}
void configure(const juce::var&) override {}
void configureNative(const juce::var& opts) override
{
if (! opts.isObject())
return;
config.fftSize = (size_t) std::max(0, (int) opts.getProperty("fftSize", 4096));
config.rowCount = (size_t) std::max(0, (int) opts.getProperty("rowCount", 0));
config.minFrequency = (float) opts.getProperty("minFrequency", 20.0);
config.maxFrequency = (float) opts.getProperty("maxFrequency", 20000.0);
config.minDecibels = (float) opts.getProperty("minDecibels", -90.0);
config.maxDecibels = (float) opts.getProperty("maxDecibels", -12.0);
config.scrollSpeed = (float) opts.getProperty("scrollSpeed", 2.0);
config.contrast = (float) opts.getProperty("contrast", 1.0);
config.tiltDbPerOctave = (float) opts.getProperty("tiltDbPerOctave", 4.0);
config.clarityMode = opts.getProperty("clarityMode", "sharper").toString().toStdString();
config.scaleMode = opts.getProperty("scaleMode", "log").toString().toStdString();
config.orientation = opts.getProperty("orientation", "horizontal").toString().toStdString();
// Host rate (from setSampleRate) is authoritative; the UI may still hold a
// stale default before it has seen a frame. Only fall back to the UI value
// if we have not yet learned the host rate.
if (config.sampleRate <= 0.0f)
config.sampleRate = (float) opts.getProperty("sampleRate", 48000.0);
hasConfig = config.rowCount > 0 && config.fftSize > 0;
if (hasConfig)
spectro.configure(config);
// The config changed: drop any half-built column batch so the next frame
// starts clean at the new rowCount.
pendingDisplay.clear();
pendingHeat.clear();
pendingColumns = 0;
}
void process(const float* left, const float* right, int numSamples) override
{
if (! hasConfig || numSamples <= 0)
return;
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]);
auto result = spectro.process(mono.data(), (size_t) numSamples);
if (result.columnCount > 0 && result.rowCount == config.rowCount)
{
pendingDisplay.insert(pendingDisplay.end(), result.display.begin(), result.display.end());
pendingHeat.insert(pendingHeat.end(), result.heat.begin(), result.heat.end());
pendingColumns += result.columnCount;
}
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("rowCount", (int) config.rowCount);
obj->setProperty("columnCount", (int) pendingColumns);
if (pendingColumns > 0)
{
obj->setProperty("display", juce::Base64::toBase64(pendingDisplay.data(), pendingDisplay.size() * sizeof(float)));
obj->setProperty("heat", juce::Base64::toBase64(pendingHeat.data(), pendingHeat.size() * sizeof(float)));
}
else
{
obj->setProperty("display", juce::String());
obj->setProperty("heat", juce::String());
}
pendingDisplay.clear();
pendingHeat.clear();
pendingColumns = 0;
return juce::var(obj);
}
private:
const juce::Identifier frameId { "spectrogramFrame" };
Visualizer::SpectrogramAnalyzer spectro;
Visualizer::SpectrogramConfig config;
bool hasConfig = false;
std::vector<float> mono;
std::vector<float> pendingDisplay, pendingHeat;
size_t pendingColumns = 0;
};
@@ -0,0 +1,83 @@
import type { SpectrogramNativeAnalyzer, SpectrogramNativeOptions, SpectrogramNativeResult } from '../renderer/audio/native'
import { emitToHost } from './juceBridge'
const EMPTY = new Float32Array(0)
/**
* Drop-in `SpectrogramNativeAnalyzer` for the plugin webview.
*
* The spectrogram DSP runs in the C++ plugin, but its output depends on the
* canvas-derived `rowCount` that only the UI knows. So this shim works in two
* directions: `configure()` forwards the full native config to C++ (event
* "prismSpectrogramConfig"), and the host streams finished display+heat columns
* back which the bridge enqueues via `pushFrame()`. `process()` ignores its audio
* argument (no samples flow through the webview) and returns all columns queued
* since the last call, concatenated into one result.
*
* Columns are only kept while their rowCount matches the rowCount the UI last
* asked for — on a resize the UI reconfigures, we drop the stale queue, and the
* C++ side catches up within a frame or two (a brief gap, never a mismatch).
*/
export class BridgeSpectrogramAnalyzer implements SpectrogramNativeAnalyzer {
private expectedRowCount = 0
private queuedDisplay: Float32Array[] = []
private queuedHeat: Float32Array[] = []
private queuedColumns = 0
configure(options: SpectrogramNativeOptions): void {
if (options.rowCount !== this.expectedRowCount) {
this.expectedRowCount = options.rowCount
this.clearQueue()
}
emitToHost('prismSpectrogramConfig', options)
}
/** Called by the bridge when the host emits a spectrogram frame. */
pushFrame(display: Float32Array, heat: Float32Array, columnCount: number, rowCount: number): void {
if (rowCount !== this.expectedRowCount || columnCount <= 0) return
if (display.length < columnCount * rowCount || heat.length < columnCount * rowCount) return
this.queuedDisplay.push(display)
this.queuedHeat.push(heat)
this.queuedColumns += columnCount
}
/** The rowCount the UI last asked for (0 until first configure). */
getExpectedRowCount(): number {
return this.expectedRowCount
}
isAvailable(): boolean {
return true
}
process(_audioData: Float32Array): SpectrogramNativeResult {
const rowCount = this.expectedRowCount
if (this.queuedColumns === 0 || rowCount <= 0) {
return { display: EMPTY, heat: EMPTY, columnCount: 0, rowCount }
}
const total = this.queuedColumns * rowCount
const display = new Float32Array(total)
const heat = new Float32Array(total)
let offset = 0
for (let i = 0; i < this.queuedDisplay.length; i += 1) {
display.set(this.queuedDisplay[i], offset)
heat.set(this.queuedHeat[i], offset)
offset += this.queuedDisplay[i].length
}
const columnCount = this.queuedColumns
this.clearQueue()
return { display, heat, columnCount, rowCount }
}
reset(): void {
this.clearQueue()
}
private clearQueue(): void {
this.queuedDisplay = []
this.queuedHeat = []
this.queuedColumns = 0
}
}
+6
View File
@@ -52,6 +52,12 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource {
return this.sessionState.capturing ? [this.sentinel] : []
}
// Spectrogram: needs a sentinel so the visualizer calls the analyzer's process()
// each frame (it only does so per pending chunk) to drain the C++ column queue.
getPendingSpectrogramSamples(): Float32Array[] {
return this.sessionState.capturing ? [this.sentinel] : []
}
// 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 { Spectrogram } from '../renderer/visualizers/Spectrogram'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrogramTheme } from '../types/theme'
import type { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { spectrogramSettingsToOptions } from './spectrogramOptions'
interface SpectrogramScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeSpectrogramAnalyzer
settings: ScopeSettings['spectrogram']
theme: ResolvedSpectrogramTheme
}
export default function SpectrogramScope({
dataSource,
nativeAnalyzer,
settings,
theme,
}: SpectrogramScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Spectrogram | null>(null)
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const viz = new Spectrogram(canvas, {
...spectrogramSettingsToOptions(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(spectrogramSettingsToOptions(settings, theme))
}, [settings, theme])
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+93
View File
@@ -442,6 +442,99 @@ export function connectVectorscopeBridge(handlers: VectorscopeBridgeHandlers): (
}
}
// ---------------------------------------------------------------------------
// Spectrogram frames (event "spectrogramFrame"): the new display+heat columns
// produced since the last frame, base64-encoded, tagged with rowCount/columnCount.
export interface SpectrogramFrame {
sampleRate: number
display: Float32Array
heat: Float32Array
columnCount: number
rowCount: number
}
interface SpectrogramFramePayload {
sampleRate?: number
display?: string
heat?: string
columnCount?: number
rowCount?: number
}
function decodeSpectrogramFrame(payload: unknown): SpectrogramFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const { sampleRate, display, heat, columnCount, rowCount } = payload as SpectrogramFramePayload
return {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
display: typeof display === 'string' ? base64ToFloat32Array(display) : new Float32Array(0),
heat: typeof heat === 'string' ? base64ToFloat32Array(heat) : new Float32Array(0),
columnCount: typeof columnCount === 'number' ? columnCount : 0,
rowCount: typeof rowCount === 'number' ? rowCount : 0,
}
}
export interface SpectrogramBridgeHandlers {
onFrame: (frame: SpectrogramFrame) => void
onConnected?: (usingMock: boolean) => void
/** Lets the dev mock size its columns to the canvas-derived rowCount. */
getRowCount?: () => number
}
export function connectSpectrogramBridge(handlers: SpectrogramBridgeHandlers): () => 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 spectrogram (browser dev mode)')
let phase = 0
const tick = (): void => {
if (disposed) return
phase += 0.08
const rowCount = handlers.getRowCount?.() ?? 0
if (rowCount > 0) {
const columnCount = 2
const display = new Float32Array(rowCount * columnCount)
const heat = new Float32Array(rowCount * columnCount)
for (let c = 0; c < columnCount; c += 1) {
for (let r = 0; r < rowCount; r += 1) {
const t = r / rowCount
const band = Math.exp(-Math.pow((t - (0.3 + 0.2 * Math.sin(phase))) * 6, 2))
const v = Math.min(1, band + Math.random() * 0.15)
display[c * rowCount + r] = v
heat[c * rowCount + r] = v
}
}
handlers.onFrame({ sampleRate: 48000, display, heat, columnCount, rowCount })
}
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
}
void ensureBackend().then((backend) => {
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('spectrogramFrame', (payload) => {
const frame = decodeSpectrogramFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (spectrogram)')
} 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 }).
+28 -1
View File
@@ -8,13 +8,15 @@ import OscilloscopeScope from './OscilloscopeScope'
import VUMeterScope from './VUMeterScope'
import LUFSMeterScope from './LUFSMeterScope'
import VectorscopeScope from './VectorscopeScope'
import SpectrogramScope from './SpectrogramScope'
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 { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge } from './juceBridge'
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge, connectSpectrogramBridge } 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"]).
@@ -29,6 +31,31 @@ function getScopeKind(): string {
const dataSource = new PluginWebViewDataSource()
function buildApp(): JSX.Element {
if (getScopeKind() === 'spectrogram') {
const analyzer = new BridgeSpectrogramAnalyzer()
connectSpectrogramBridge({
onFrame: (frame) => {
analyzer.pushFrame(frame.display, frame.heat, frame.columnCount, frame.rowCount)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
getRowCount: () => analyzer.getExpectedRowCount(),
})
return (
<ScopeApp
kind="spectrogram"
renderScope={(settings, theme) => (
<SpectrogramScope
settings={settings}
theme={theme.spectrogram}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
if (getScopeKind() === 'vectorscope') {
const analyzer = new BridgeVectorscopeAnalyzer()
connectVectorscopeBridge({
+26
View File
@@ -0,0 +1,26 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrogramTheme } from '../types/theme'
import type { SpectrogramOptions } from '../renderer/visualizers/Spectrogram'
/**
* Map Prism's spectrogram settings + resolved theme to Spectrogram options.
* Mirrors the `spectrogram` case of `scopeSettingsToOptions` in ScopeModule.tsx.
*/
export function spectrogramSettingsToOptions(
settings: ScopeSettings['spectrogram'],
theme: ResolvedSpectrogramTheme,
): SpectrogramOptions {
return {
lineColor: theme.mono,
heatColors: theme.heatColors,
backgroundColor: theme.background,
fftSize: settings.fftSize,
tiltDbPerOctave: settings.tiltDbPerOctave,
scrollSpeed: settings.scrollSpeed,
contrast: settings.contrast,
clarityMode: settings.clarityMode,
scaleMode: settings.scaleMode,
orientation: settings.orientation,
colorScheme: settings.colorScheme,
}
}