mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
VU VST
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "PluginEditor.h"
|
||||
#include "SpectrumEngine.h"
|
||||
#include "OscilloscopeEngine.h"
|
||||
#include "VUMeterEngine.h"
|
||||
#include <cstring>
|
||||
|
||||
#if ! PRISM_USE_DEV_SERVER
|
||||
@@ -18,7 +19,9 @@ namespace
|
||||
|
||||
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>();
|
||||
#else
|
||||
return std::make_unique<SpectrumEngine>();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }).
|
||||
|
||||
|
||||
+27
-1
@@ -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 (
|
||||
<ScopeApp
|
||||
kind="vumeter"
|
||||
renderScope={(settings, theme) => (
|
||||
<VUMeterScope
|
||||
settings={settings}
|
||||
theme={theme.vumeter}
|
||||
dataSource={dataSource}
|
||||
nativeAnalyzer={analyzer}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (getScopeKind() === 'oscilloscope') {
|
||||
const analyzer = new BridgeOscilloscopeAnalyzer()
|
||||
connectOscilloscopeBridge({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user