diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index fa84a84..d468faf 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -57,6 +57,7 @@ function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE) Source/PluginEditor.cpp ${PRISM_NATIVE_DIR}/spectrum.cpp ${PRISM_NATIVE_DIR}/oscilloscope.cpp + ${PRISM_NATIVE_DIR}/vumeter.cpp ${PRISM_NATIVE_DIR}/dsp_utils.cpp) target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR}) @@ -94,3 +95,4 @@ endfunction() 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") diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp index e97dfb4..01c476e 100644 --- a/plugin/Source/PluginEditor.cpp +++ b/plugin/Source/PluginEditor.cpp @@ -1,6 +1,7 @@ #include "PluginEditor.h" #include "SpectrumEngine.h" #include "OscilloscopeEngine.h" +#include "VUMeterEngine.h" #include #if ! PRISM_USE_DEV_SERVER @@ -18,7 +19,9 @@ namespace std::unique_ptr makeEngine() { -#if defined(PRISM_SCOPE_OSCILLOSCOPE) && PRISM_SCOPE_OSCILLOSCOPE +#if defined(PRISM_SCOPE_VUMETER) && PRISM_SCOPE_VUMETER + return std::make_unique(); +#elif defined(PRISM_SCOPE_OSCILLOSCOPE) && PRISM_SCOPE_OSCILLOSCOPE return std::make_unique(); #else return std::make_unique(); diff --git a/plugin/Source/VUMeterEngine.h b/plugin/Source/VUMeterEngine.h new file mode 100644 index 0000000..fa5e2b2 --- /dev/null +++ b/plugin/Source/VUMeterEngine.h @@ -0,0 +1,54 @@ +#pragma once + +#include "ScopeEngine.h" +#include "vumeter.h" // reused, unmodified, from native/src + +/** + * VU meter engine. Pushes stereo audio into the reused `Visualizer::VUMeterAnalyzer` + * (RMS integration + ballistics + peak hold + correlation, all sample-accurate) and + * emits the resulting scalar snapshot each frame. No base64 needed — the frame is a + * handful of numbers. getSnapshot() advances peak decay on the steady clock, so the + * meter still settles when audio momentarily stops. + */ +class VUMeterEngine : public ScopeEngine +{ +public: + const char* scopeId() const override { return "vumeter"; } + juce::Identifier frameEventId() const override { return frameId; } + + void setSampleRate(double sampleRate) override + { + vu.setSampleRate((float) sampleRate); + } + + void configure(const juce::var&) override + { + // VU settings (mode/orientation/needleChannels/referenceDb) are render-side only. + } + + void process(const float* left, const float* right, int numSamples) override + { + if (numSamples <= 0) + return; + vu.pushSamples(left, right, (size_t) numSamples); + } + + juce::var buildFrame(double sampleRate) override + { + const auto snap = vu.getSnapshot(); + auto* obj = new juce::DynamicObject(); + obj->setProperty("sampleRate", sampleRate); + obj->setProperty("vuLDb", snap.vuLDb); + obj->setProperty("vuRDb", snap.vuRDb); + obj->setProperty("barLDb", snap.barLDb); + obj->setProperty("barRDb", snap.barRDb); + obj->setProperty("peakLDb", snap.peakLDb); + obj->setProperty("peakRDb", snap.peakRDb); + obj->setProperty("correlation", snap.correlation); + return juce::var(obj); + } + +private: + const juce::Identifier frameId { "vumeterFrame" }; + Visualizer::VUMeterAnalyzer vu; +}; diff --git a/src/plugin-ui/BridgeVUMeterAnalyzer.ts b/src/plugin-ui/BridgeVUMeterAnalyzer.ts new file mode 100644 index 0000000..427795c --- /dev/null +++ b/src/plugin-ui/BridgeVUMeterAnalyzer.ts @@ -0,0 +1,34 @@ +import type { VUMeterNativeAnalyzer, VUMeterNativeSnapshot } from '../renderer/audio/native' + +/** + * Drop-in `VUMeterNativeAnalyzer` for the plugin webview. + * + * The VU DSP (RMS integration, ballistics, peak hold, correlation) runs in the + * C++ plugin, which pushes a finished scalar snapshot each frame. This shim caches + * that snapshot and serves it through the interface `VUMeter` consumes, so the + * visualizer renders it unchanged. `pushSamples` is a no-op (audio never flows + * through the webview). + */ +export class BridgeVUMeterAnalyzer implements VUMeterNativeAnalyzer { + private snapshot: VUMeterNativeSnapshot | null = null + + /** Called by the bridge whenever the host emits a new VU frame. */ + setSnapshot(snapshot: VUMeterNativeSnapshot): void { + this.snapshot = snapshot + } + + isAvailable(): boolean { + return true + } + + setSampleRate(_sampleRate: number): void {} + pushSamples(_left: Float32Array, _right: Float32Array): void {} + + getSnapshot(): VUMeterNativeSnapshot | null { + return this.snapshot + } + + reset(): void { + this.snapshot = null + } +} diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts index a3c30ba..7ad940f 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] : [] } + // VU meter reads 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 }> { + return [] + } + getSampleRate(): number { return this.sessionState.sampleRate } diff --git a/src/plugin-ui/VUMeterScope.tsx b/src/plugin-ui/VUMeterScope.tsx new file mode 100644 index 0000000..e291b46 --- /dev/null +++ b/src/plugin-ui/VUMeterScope.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef, type JSX } from 'react' +import { VUMeter } from '../renderer/visualizers/VUMeter' +import type { ScopeSettings } from '../types/settings' +import type { ResolvedVUMeterTheme } from '../types/theme' +import type { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer' +import type { PluginWebViewDataSource } from './PluginWebViewDataSource' +import { vumeterSettingsToOptions } from './vumeterOptions' + +interface VUMeterScopeProps { + dataSource: PluginWebViewDataSource + nativeAnalyzer: BridgeVUMeterAnalyzer + settings: ScopeSettings['vumeter'] + theme: ResolvedVUMeterTheme +} + +export default function VUMeterScope({ + dataSource, + nativeAnalyzer, + settings, + theme, +}: VUMeterScopeProps): 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 VUMeter(canvas, { + ...vumeterSettingsToOptions(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(vumeterSettingsToOptions(settings, theme)) + }, [settings, theme]) + + return ( +
+ +
+ ) +} diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts index a897e7d..0ac2270 100644 --- a/src/plugin-ui/juceBridge.ts +++ b/src/plugin-ui/juceBridge.ts @@ -166,6 +166,93 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v } } +// --------------------------------------------------------------------------- +// VU meter frames (event "vumeterFrame": scalar snapshot, no base64). + +export interface VUMeterFrame { + sampleRate: number + vuLDb: number + vuRDb: number + barLDb: number + barRDb: number + peakLDb: number + peakRDb: number + correlation: number +} + +function decodeVUMeterFrame(payload: unknown): VUMeterFrame | null { + if (typeof payload !== 'object' || payload === null) return null + const p = payload as Record + const num = (key: string, fallback: number): number => + typeof p[key] === 'number' && Number.isFinite(p[key]) ? (p[key] as number) : fallback + return { + sampleRate: num('sampleRate', 48000) > 0 ? num('sampleRate', 48000) : 48000, + vuLDb: num('vuLDb', -60), + vuRDb: num('vuRDb', -60), + barLDb: num('barLDb', -60), + barRDb: num('barRDb', -60), + peakLDb: num('peakLDb', -60), + peakRDb: num('peakRDb', -60), + correlation: num('correlation', 0), + } +} + +export interface VUMeterBridgeHandlers { + onFrame: (frame: VUMeterFrame) => void + onConnected?: (usingMock: boolean) => void +} + +export function connectVUMeterBridge(handlers: VUMeterBridgeHandlers): () => 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 VU meter (browser dev mode)') + let phase = 0 + const tick = (): void => { + if (disposed) return + phase += 0.04 + const level = (offset: number): number => -40 + (Math.sin(phase + offset) * 0.5 + 0.5) * 42 + const vuL = level(0) + const vuR = level(0.7) + handlers.onFrame({ + sampleRate: 48000, + vuLDb: vuL, + vuRDb: vuR, + barLDb: vuL, + barRDb: vuR, + peakLDb: vuL + 3, + peakRDb: vuR + 3, + correlation: Math.sin(phase * 0.3), + }) + mockRaf = requestAnimationFrame(tick) + } + mockRaf = requestAnimationFrame(tick) + } + + void ensureBackend().then((backend) => { + if (disposed) return + if (backend) { + listenerId = backend.addEventListener('vumeterFrame', (payload) => { + const frame = decodeVUMeterFrame(payload) + if (frame) handlers.onFrame(frame) + }) + handlers.onConnected?.(false) + console.log('[prism-plugin] connected to JUCE host (vumeter)') + } 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 eb5306c..8126fdb 100644 --- a/src/plugin-ui/main.tsx +++ b/src/plugin-ui/main.tsx @@ -5,10 +5,12 @@ import './styles.css' import ScopeApp from './ScopeApp' import SpectrumScope from './SpectrumScope' import OscilloscopeScope from './OscilloscopeScope' +import VUMeterScope from './VUMeterScope' import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer' import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer' +import { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer' import { PluginWebViewDataSource } from './PluginWebViewDataSource' -import { connectOscilloscopeBridge, connectSpectrumBridge } from './juceBridge' +import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge } 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"]). @@ -23,6 +25,30 @@ function getScopeKind(): string { const dataSource = new PluginWebViewDataSource() function buildApp(): JSX.Element { + if (getScopeKind() === 'vumeter') { + const analyzer = new BridgeVUMeterAnalyzer() + connectVUMeterBridge({ + onFrame: (frame) => { + analyzer.setSnapshot(frame) + dataSource.setSampleRate(frame.sampleRate) + dataSource.setPlaying(true) + }, + }) + return ( + ( + + )} + /> + ) + } + if (getScopeKind() === 'oscilloscope') { const analyzer = new BridgeOscilloscopeAnalyzer() connectOscilloscopeBridge({ diff --git a/src/plugin-ui/vumeterOptions.ts b/src/plugin-ui/vumeterOptions.ts new file mode 100644 index 0000000..807f2bf --- /dev/null +++ b/src/plugin-ui/vumeterOptions.ts @@ -0,0 +1,29 @@ +import type { ScopeSettings } from '../types/settings' +import type { ResolvedVUMeterTheme } from '../types/theme' +import type { VUMeterOptions } from '../renderer/visualizers/VUMeter' + +/** + * Map Prism's VU meter settings + resolved theme to VUMeter options. + * Mirrors the `vumeter` case of `scopeSettingsToOptions` in ScopeModule.tsx. + */ +export function vumeterSettingsToOptions( + settings: ScopeSettings['vumeter'], + theme: ResolvedVUMeterTheme, +): VUMeterOptions { + return { + backgroundColor: theme.background, + lineColor: theme.level, + trackColor: theme.track, + peakColor: theme.peak, + clipColor: theme.clip, + scaleColor: theme.scale, + labelColor: theme.labels, + needleLeftColor: theme.needleLeft, + needleRightColor: theme.needleRight, + needleCombinedColor: theme.needleCombined, + mode: settings.mode, + orientation: settings.orientation, + needleChannels: settings.needleChannels, + referenceDb: settings.referenceDb, + } +}