This commit is contained in:
Boof2015
2026-05-27 19:04:44 -04:00
parent ddae07c985
commit 434b65605a
9 changed files with 320 additions and 4 deletions
+2
View File
@@ -58,6 +58,7 @@ function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE)
${PRISM_NATIVE_DIR}/spectrum.cpp
${PRISM_NATIVE_DIR}/oscilloscope.cpp
${PRISM_NATIVE_DIR}/vumeter.cpp
${PRISM_NATIVE_DIR}/lufsmeter.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR})
@@ -96,3 +97,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")
add_prism_scope(PrismLUFSMeter "Prism Loudness Meter" Pluf "PRISM_SCOPE_LUFSMETER=1")
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "ScopeEngine.h"
#include "lufsmeter.h" // reused, unmodified, from native/src
/**
* Loudness (LUFS) meter engine. Pushes stereo audio into the reused
* `Visualizer::LUFSMeterAnalyzer` (K-weighting + gated integration + the same fast
* VU/peak/correlation block) and emits its scalar snapshot each frame. Like the VU
* engine the frame is plain numbers (no base64); getSnapshot() advances peak decay
* on the steady clock, so it's safe to call every frame. configure() is a no-op —
* LUFS settings (mode/readout) are render-side.
*/
class LUFSMeterEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "lufsmeter"; }
juce::Identifier frameEventId() const override { return frameId; }
void setSampleRate(double sampleRate) override
{
lufs.setSampleRate((float) sampleRate);
}
void configure(const juce::var&) override {}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
lufs.pushSamples(left, right, (size_t) numSamples);
}
juce::var buildFrame(double sampleRate) override
{
const auto snap = lufs.getSnapshot();
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("momentaryLUFS", snap.momentaryLUFS);
obj->setProperty("shortTermLUFS", snap.shortTermLUFS);
obj->setProperty("integratedLUFS", snap.integratedLUFS);
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 { "lufsmeterFrame" };
Visualizer::LUFSMeterAnalyzer lufs;
};
+4 -1
View File
@@ -2,6 +2,7 @@
#include "SpectrumEngine.h"
#include "OscilloscopeEngine.h"
#include "VUMeterEngine.h"
#include "LUFSMeterEngine.h"
#include <cstring>
#if ! PRISM_USE_DEV_SERVER
@@ -19,7 +20,9 @@ namespace
std::unique_ptr<ScopeEngine> makeEngine()
{
#if defined(PRISM_SCOPE_VUMETER) && PRISM_SCOPE_VUMETER
#if defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER
return std::make_unique<LUFSMeterEngine>();
#elif 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>();
+34
View File
@@ -0,0 +1,34 @@
import type { LUFSMeterNativeAnalyzer, LUFSMeterNativeSnapshot } from '../renderer/audio/native'
/**
* Drop-in `LUFSMeterNativeAnalyzer` for the plugin webview.
*
* The loudness DSP (K-weighting, gated integration, fast VU/peak/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 `LUFSMeter` consumes.
* `pushSamples` is a no-op (audio never flows through the webview); `reset` clears
* only the cache (the C++ integrator keeps its own state).
*/
export class BridgeLUFSMeterAnalyzer implements LUFSMeterNativeAnalyzer {
private snapshot: LUFSMeterNativeSnapshot | null = null
/** Called by the bridge whenever the host emits a new LUFS frame. */
setSnapshot(snapshot: LUFSMeterNativeSnapshot): void {
this.snapshot = snapshot
}
isAvailable(): boolean {
return true
}
setSampleRate(_sampleRate: number): void {}
pushSamples(_left: Float32Array, _right: Float32Array): void {}
getSnapshot(): LUFSMeterNativeSnapshot | null {
return this.snapshot
}
reset(): void {
this.snapshot = null
}
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, type JSX } from 'react'
import { LUFSMeter } from '../renderer/visualizers/LUFSMeter'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedLUFSMeterTheme } from '../types/theme'
import type { BridgeLUFSMeterAnalyzer } from './BridgeLUFSMeterAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { lufsmeterSettingsToOptions } from './lufsmeterOptions'
interface LUFSMeterScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeLUFSMeterAnalyzer
settings: ScopeSettings['lufsmeter']
theme: ResolvedLUFSMeterTheme
}
export default function LUFSMeterScope({
dataSource,
nativeAnalyzer,
settings,
theme,
}: LUFSMeterScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<LUFSMeter | null>(null)
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const viz = new LUFSMeter(canvas, {
...lufsmeterSettingsToOptions(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(lufsmeterSettingsToOptions(settings, theme))
}, [settings, theme])
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+6 -2
View File
@@ -52,12 +52,16 @@ 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).
// 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 }> {
return []
}
getPendingLUFSMeterSamples(): Array<{ left: Float32Array; right: Float32Array }> {
return []
}
getSampleRate(): number {
return this.sessionState.sampleRate
}
+96
View File
@@ -253,6 +253,102 @@ export function connectVUMeterBridge(handlers: VUMeterBridgeHandlers): () => voi
}
}
// ---------------------------------------------------------------------------
// Loudness meter frames (event "lufsmeterFrame": scalar snapshot, no base64).
export interface LUFSMeterFrame {
sampleRate: number
momentaryLUFS: number
shortTermLUFS: number
integratedLUFS: number
vuLDb: number
vuRDb: number
barLDb: number
barRDb: number
peakLDb: number
peakRDb: number
correlation: number
}
function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | 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,
momentaryLUFS: num('momentaryLUFS', -70),
shortTermLUFS: num('shortTermLUFS', -70),
integratedLUFS: num('integratedLUFS', -70),
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 LUFSMeterBridgeHandlers {
onFrame: (frame: LUFSMeterFrame) => void
onConnected?: (usingMock: boolean) => void
}
export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () => 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 LUFS meter (browser dev mode)')
let phase = 0
const tick = (): void => {
if (disposed) return
phase += 0.04
const lufs = (offset: number): number => -24 + Math.sin(phase + offset) * 6
const vuL = -36 + (Math.sin(phase) * 0.5 + 0.5) * 30
const vuR = -36 + (Math.sin(phase + 0.7) * 0.5 + 0.5) * 30
handlers.onFrame({
sampleRate: 48000,
momentaryLUFS: lufs(0),
shortTermLUFS: lufs(0.5),
integratedLUFS: -23,
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('lufsmeterFrame', (payload) => {
const frame = decodeLUFSMeterFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (lufsmeter)')
} 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 }).
+23
View File
@@ -0,0 +1,23 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedLUFSMeterTheme } from '../types/theme'
import type { LUFSMeterOptions } from '../renderer/visualizers/LUFSMeter'
/**
* Map Prism's loudness meter settings + resolved theme to LUFSMeter options.
* Mirrors the `lufsmeter` case of `scopeSettingsToOptions` in ScopeModule.tsx.
*/
export function lufsmeterSettingsToOptions(
settings: ScopeSettings['lufsmeter'],
theme: ResolvedLUFSMeterTheme,
): LUFSMeterOptions {
return {
backgroundColor: theme.background,
lineColor: theme.level,
trackColor: theme.track,
targetColor: theme.target,
scaleColor: theme.scale,
labelColor: theme.labels,
mode: settings.mode,
readout: settings.readout,
}
}
+27 -1
View File
@@ -6,11 +6,13 @@ import ScopeApp from './ScopeApp'
import SpectrumScope from './SpectrumScope'
import OscilloscopeScope from './OscilloscopeScope'
import VUMeterScope from './VUMeterScope'
import LUFSMeterScope from './LUFSMeterScope'
import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
import { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer'
import { BridgeLUFSMeterAnalyzer } from './BridgeLUFSMeterAnalyzer'
import { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge } from './juceBridge'
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge } 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"]).
@@ -25,6 +27,30 @@ function getScopeKind(): string {
const dataSource = new PluginWebViewDataSource()
function buildApp(): JSX.Element {
if (getScopeKind() === 'lufsmeter') {
const analyzer = new BridgeLUFSMeterAnalyzer()
connectLUFSMeterBridge({
onFrame: (frame) => {
analyzer.setSnapshot(frame)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
return (
<ScopeApp
kind="lufsmeter"
renderScope={(settings, theme) => (
<LUFSMeterScope
settings={settings}
theme={theme.lufsmeter}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
if (getScopeKind() === 'vumeter') {
const analyzer = new BridgeVUMeterAnalyzer()
connectVUMeterBridge({