diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index f52c000..20d9a7c 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -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") diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp index da23e52..452828f 100644 --- a/plugin/Source/PluginEditor.cpp +++ b/plugin/Source/PluginEditor.cpp @@ -4,6 +4,7 @@ #include "VUMeterEngine.h" #include "LUFSMeterEngine.h" #include "VectorscopeEngine.h" +#include "SpectrogramEngine.h" #include #if ! PRISM_USE_DEV_SERVER @@ -21,7 +22,9 @@ namespace std::unique_ptr makeEngine() { -#if defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE +#if defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM + return std::make_unique(); +#elif defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE return std::make_unique(); #elif defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER return std::make_unique(); @@ -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(); diff --git a/plugin/Source/PluginEditor.h b/plugin/Source/PluginEditor.h index 763e622..28ea40c 100644 --- a/plugin/Source/PluginEditor.h +++ b/plugin/Source/PluginEditor.h @@ -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(); diff --git a/plugin/Source/ScopeEngine.h b/plugin/Source/ScopeEngine.h index 809fcaf..e7d23f7 100644 --- a/plugin/Source/ScopeEngine.h +++ b/plugin/Source/ScopeEngine.h @@ -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; diff --git a/plugin/Source/SpectrogramEngine.h b/plugin/Source/SpectrogramEngine.h new file mode 100644 index 0000000..9704a08 --- /dev/null +++ b/plugin/Source/SpectrogramEngine.h @@ -0,0 +1,121 @@ +#pragma once + +#include "ScopeEngine.h" +#include "spectrogram.h" // reused, unmodified, from native/src +#include +#include + +/** + * 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 mono; + std::vector pendingDisplay, pendingHeat; + size_t pendingColumns = 0; +}; diff --git a/src/plugin-ui/BridgeSpectrogramAnalyzer.ts b/src/plugin-ui/BridgeSpectrogramAnalyzer.ts new file mode 100644 index 0000000..d155cb3 --- /dev/null +++ b/src/plugin-ui/BridgeSpectrogramAnalyzer.ts @@ -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 + } +} diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts index d57bf0d..c2f6115 100644 --- a/src/plugin-ui/PluginWebViewDataSource.ts +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -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 }> { diff --git a/src/plugin-ui/SpectrogramScope.tsx b/src/plugin-ui/SpectrogramScope.tsx new file mode 100644 index 0000000..8fd2ac6 --- /dev/null +++ b/src/plugin-ui/SpectrogramScope.tsx @@ -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(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 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 ( +
+ +
+ ) +} diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts index b85f088..706aae3 100644 --- a/src/plugin-ui/juceBridge.ts +++ b/src/plugin-ui/juceBridge.ts @@ -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 }). diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx index 60c4e64..9707c38 100644 --- a/src/plugin-ui/main.tsx +++ b/src/plugin-ui/main.tsx @@ -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 ( + ( + + )} + /> + ) + } + if (getScopeKind() === 'vectorscope') { const analyzer = new BridgeVectorscopeAnalyzer() connectVectorscopeBridge({ diff --git a/src/plugin-ui/spectrogramOptions.ts b/src/plugin-ui/spectrogramOptions.ts new file mode 100644 index 0000000..6f29c42 --- /dev/null +++ b/src/plugin-ui/spectrogramOptions.ts @@ -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, + } +}