This commit is contained in:
Boof2015
2026-05-27 18:55:41 -04:00
parent b877541882
commit ddae07c985
9 changed files with 316 additions and 2 deletions
+2
View File
@@ -57,6 +57,7 @@ function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE)
Source/PluginEditor.cpp Source/PluginEditor.cpp
${PRISM_NATIVE_DIR}/spectrum.cpp ${PRISM_NATIVE_DIR}/spectrum.cpp
${PRISM_NATIVE_DIR}/oscilloscope.cpp ${PRISM_NATIVE_DIR}/oscilloscope.cpp
${PRISM_NATIVE_DIR}/vumeter.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp) ${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR}) target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR})
@@ -94,3 +95,4 @@ endfunction()
add_prism_scope(PrismSpectrum "Prism Spectrum" Pspc "") add_prism_scope(PrismSpectrum "Prism Spectrum" Pspc "")
add_prism_scope(PrismOscilloscope "Prism Oscilloscope" Posc "PRISM_SCOPE_OSCILLOSCOPE=1") add_prism_scope(PrismOscilloscope "Prism Oscilloscope" Posc "PRISM_SCOPE_OSCILLOSCOPE=1")
add_prism_scope(PrismVUMeter "Prism VU Meter" Pvum "PRISM_SCOPE_VUMETER=1")
+4 -1
View File
@@ -1,6 +1,7 @@
#include "PluginEditor.h" #include "PluginEditor.h"
#include "SpectrumEngine.h" #include "SpectrumEngine.h"
#include "OscilloscopeEngine.h" #include "OscilloscopeEngine.h"
#include "VUMeterEngine.h"
#include <cstring> #include <cstring>
#if ! PRISM_USE_DEV_SERVER #if ! PRISM_USE_DEV_SERVER
@@ -18,7 +19,9 @@ namespace
std::unique_ptr<ScopeEngine> makeEngine() std::unique_ptr<ScopeEngine> makeEngine()
{ {
#if defined(PRISM_SCOPE_OSCILLOSCOPE) && PRISM_SCOPE_OSCILLOSCOPE #if defined(PRISM_SCOPE_VUMETER) && PRISM_SCOPE_VUMETER
return std::make_unique<VUMeterEngine>();
#elif defined(PRISM_SCOPE_OSCILLOSCOPE) && PRISM_SCOPE_OSCILLOSCOPE
return std::make_unique<OscilloscopeEngine>(); return std::make_unique<OscilloscopeEngine>();
#else #else
return std::make_unique<SpectrumEngine>(); return std::make_unique<SpectrumEngine>();
+54
View File
@@ -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;
};
+34
View File
@@ -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
}
}
+6
View File
@@ -52,6 +52,12 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource {
return this.sessionState.capturing ? [this.sentinel] : [] 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 { getSampleRate(): number {
return this.sessionState.sampleRate return this.sessionState.sampleRate
} }
+73
View File
@@ -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<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<VUMeter | null>(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 (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+87
View File
@@ -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<string, unknown>
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 }). // Oscilloscope frames (event "oscilloscopeFrame": { sampleRate, samples, pitch }).
+27 -1
View File
@@ -5,10 +5,12 @@ import './styles.css'
import ScopeApp from './ScopeApp' import ScopeApp from './ScopeApp'
import SpectrumScope from './SpectrumScope' import SpectrumScope from './SpectrumScope'
import OscilloscopeScope from './OscilloscopeScope' import OscilloscopeScope from './OscilloscopeScope'
import VUMeterScope from './VUMeterScope'
import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer' import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer' import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
import { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer'
import { PluginWebViewDataSource } from './PluginWebViewDataSource' 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. // The C++ plugin tells us which scope it is via JUCE initialisation data.
// JUCE stores each value as an array (e.g. prismScope = ["oscilloscope"]). // JUCE stores each value as an array (e.g. prismScope = ["oscilloscope"]).
@@ -23,6 +25,30 @@ function getScopeKind(): string {
const dataSource = new PluginWebViewDataSource() const dataSource = new PluginWebViewDataSource()
function buildApp(): JSX.Element { 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 (
<ScopeApp
kind="vumeter"
renderScope={(settings, theme) => (
<VUMeterScope
settings={settings}
theme={theme.vumeter}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
if (getScopeKind() === 'oscilloscope') { if (getScopeKind() === 'oscilloscope') {
const analyzer = new BridgeOscilloscopeAnalyzer() const analyzer = new BridgeOscilloscopeAnalyzer()
connectOscilloscopeBridge({ connectOscilloscopeBridge({
+29
View File
@@ -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,
}
}