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
|
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")
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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] : []
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }).
|
// Oscilloscope frames (event "oscilloscopeFrame": { sampleRate, samples, pitch }).
|
||||||
|
|
||||||
|
|||||||
+27
-1
@@ -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({
|
||||||
|
|||||||
@@ -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