diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index 20d9a7c..b5fd3b1 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -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") diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp index 452828f..713e8d0 100644 --- a/plugin/Source/PluginEditor.cpp +++ b/plugin/Source/PluginEditor.cpp @@ -5,6 +5,7 @@ #include "LUFSMeterEngine.h" #include "VectorscopeEngine.h" #include "SpectrogramEngine.h" +#include "WaveformEngine.h" #include #if ! PRISM_USE_DEV_SERVER @@ -22,7 +23,9 @@ namespace std::unique_ptr makeEngine() { -#if defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM +#if defined(PRISM_SCOPE_WAVEFORM) && PRISM_SCOPE_WAVEFORM + return std::make_unique(); +#elif defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM return std::make_unique(); #elif defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE return std::make_unique(); diff --git a/plugin/Source/WaveformEngine.h b/plugin/Source/WaveformEngine.h new file mode 100644 index 0000000..acc8900 --- /dev/null +++ b/plugin/Source/WaveformEngine.h @@ -0,0 +1,97 @@ +#pragma once + +#include "ScopeEngine.h" +#include "waveform.h" // reused, unmodified, from native/src +#include +#include +#include + +/** + * 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 mono, pending; + float sampleRate = 48000.0f; + float scrollSpeed = 1.0f; + size_t samplesPerColumn = 1; + bool stereo = false; +}; diff --git a/src/plugin-ui/BridgeWaveformAnalyzer.ts b/src/plugin-ui/BridgeWaveformAnalyzer.ts new file mode 100644 index 0000000..49042d2 --- /dev/null +++ b/src/plugin-ui/BridgeWaveformAnalyzer.ts @@ -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 + } +} diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts index c2f6115..11f3eae 100644 --- a/src/plugin-ui/PluginWebViewDataSource.ts +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -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 }> { diff --git a/src/plugin-ui/WaveformScope.tsx b/src/plugin-ui/WaveformScope.tsx new file mode 100644 index 0000000..75f1dc7 --- /dev/null +++ b/src/plugin-ui/WaveformScope.tsx @@ -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(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 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 ( +
+ +
+ ) +} diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts index 706aae3..a9cead8 100644 --- a/src/plugin-ui/juceBridge.ts +++ b/src/plugin-ui/juceBridge.ts @@ -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 }). diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx index 9707c38..4cf470f 100644 --- a/src/plugin-ui/main.tsx +++ b/src/plugin-ui/main.tsx @@ -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 ( + ( + + )} + /> + ) + } + if (getScopeKind() === 'spectrogram') { const analyzer = new BridgeSpectrogramAnalyzer() connectSpectrogramBridge({ diff --git a/src/plugin-ui/waveformOptions.ts b/src/plugin-ui/waveformOptions.ts new file mode 100644 index 0000000..f260204 --- /dev/null +++ b/src/plugin-ui/waveformOptions.ts @@ -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, + } +} diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 23e8466..8249a88 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -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 {