Merge pull request #10 from Boof2015/VSTs

Add cross platform VSTs
This commit is contained in:
Boof2015
2026-06-03 10:21:21 -04:00
committed by GitHub
parent 180d0dbef5
commit 63ef5ffa0f
63 changed files with 5617 additions and 66 deletions
+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
}
}
@@ -0,0 +1,52 @@
import type { OscilloscopeNativeAnalyzer, OscilloscopeResult } from '../renderer/audio/native'
/**
* Drop-in `OscilloscopeNativeAnalyzer` for the plugin webview.
*
* The oscilloscope DSP (circular buffer + trigger detection) runs in the C++
* plugin, which pushes the finished, already-triggered display window each frame.
* This shim serves that window through the interface `Oscilloscope` consumes, so
* the visualizer renders it unchanged. `pushSamples` is a no-op (audio never
* flows through the webview); `processContinuous` reports the window at index 0.
*/
export class BridgeOscilloscopeAnalyzer implements OscilloscopeNativeAnalyzer {
private samples = new Float32Array(0)
private pitch = 0
/** Called by the bridge whenever the host emits a new oscilloscope frame. */
setSamples(samples: Float32Array, pitch: number): void {
if (samples.length !== this.samples.length) {
this.samples = new Float32Array(samples.length)
}
this.samples.set(samples)
this.pitch = pitch
}
isAvailable(): boolean {
return true
}
setSampleRate(_sampleRate: number): void {}
setPitchLock(_enabled: boolean): void {}
setDisplaySamples(_samples: number): void {}
pushSamples(_samples: Float32Array): void {}
processContinuous(): OscilloscopeResult {
const count = this.samples.length
// C++ already applied the trigger, so the window starts at index 0.
return { triggerIndex: 0, samplesToShow: count, detectedPitch: this.pitch, writePos: count }
}
fillSamples(_startPos: number, output: Float32Array): number {
const count = Math.min(output.length, this.samples.length)
if (count > 0) {
output.set(this.samples.subarray(0, count), 0)
}
return count
}
reset(): void {
this.samples = new Float32Array(0)
this.pitch = 0
}
}
@@ -0,0 +1,98 @@
import type { SpectrogramNativeAnalyzer, SpectrogramNativeOptions, SpectrogramNativeResult } from '../renderer/audio/native'
import { emitToHost } from './juceBridge'
const EMPTY = new Float32Array(0)
// The C++ engine emits columns every vblank (audio-driven), independently of how
// fast the webview can render them. Cap the backlog so that when the consumer falls
// behind we drop the OLDEST columns instead of accumulating unbounded latency (which
// otherwise death-spirals into stutter at high scroll speeds). One entry == one
// emitted frame, so this bounds latency to ~N display frames regardless of rate.
const MAX_QUEUED_FRAMES = 8
interface QueuedColumns {
display: Float32Array
heat: Float32Array
columnCount: number
}
/**
* Drop-in `SpectrogramNativeAnalyzer` for the plugin webview.
*
* The spectrogram DSP runs in the C++ plugin, but its output depends on the
* canvas-derived `rowCount` that only the UI knows. So this shim works in two
* directions: `configure()` forwards the full native config to C++ (event
* "prismSpectrogramConfig"), and the host streams finished display+heat columns
* back which the bridge enqueues via `pushFrame()`. `process()` ignores its audio
* argument (no samples flow through the webview) and returns all columns queued
* since the last call, concatenated into one result.
*
* Columns are only kept while their rowCount matches the rowCount the UI last
* asked for — on a resize the UI reconfigures, we drop the stale queue, and the
* C++ side catches up within a frame or two (a brief gap, never a mismatch).
*/
export class BridgeSpectrogramAnalyzer implements SpectrogramNativeAnalyzer {
private expectedRowCount = 0
private queue: QueuedColumns[] = []
private queuedColumns = 0
configure(options: SpectrogramNativeOptions): void {
if (options.rowCount !== this.expectedRowCount) {
this.expectedRowCount = options.rowCount
this.clearQueue()
}
emitToHost('prismSpectrogramConfig', options)
}
/** Called by the bridge when the host emits a spectrogram frame. */
pushFrame(display: Float32Array, heat: Float32Array, columnCount: number, rowCount: number): void {
if (rowCount !== this.expectedRowCount || columnCount <= 0) return
if (display.length < columnCount * rowCount || heat.length < columnCount * rowCount) return
this.queue.push({ display, heat, columnCount })
this.queuedColumns += columnCount
// Drop oldest backlog beyond the cap so we stay near real-time under overload.
while (this.queue.length > MAX_QUEUED_FRAMES) {
const dropped = this.queue.shift()
if (dropped) this.queuedColumns -= dropped.columnCount
}
}
/** The rowCount the UI last asked for (0 until first configure). */
getExpectedRowCount(): number {
return this.expectedRowCount
}
isAvailable(): boolean {
return true
}
process(_audioData: Float32Array): SpectrogramNativeResult {
const rowCount = this.expectedRowCount
if (this.queuedColumns === 0 || rowCount <= 0) {
return { display: EMPTY, heat: EMPTY, columnCount: 0, rowCount }
}
const total = this.queuedColumns * rowCount
const display = new Float32Array(total)
const heat = new Float32Array(total)
let offset = 0
for (const entry of this.queue) {
display.set(entry.display, offset)
heat.set(entry.heat, offset)
offset += entry.display.length
}
const columnCount = this.queuedColumns
this.clearQueue()
return { display, heat, columnCount, rowCount }
}
reset(): void {
this.clearQueue()
}
private clearQueue(): void {
this.queue = []
this.queuedColumns = 0
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { SpectrumNativeAnalyzer } from '../renderer/audio/native'
const FFT_SILENCE_DB = -100
/**
* A drop-in `SpectrumNativeAnalyzer` for the plugin webview.
*
* In the Electron app, `SpectrumAnalyzer` pushes raw samples into the N-API DSP
* addon and reads magnitudes back. There is no N-API addon inside a webview, so
* here the DSP runs in the C++ plugin instead: it computes magnitudes off the
* realtime thread and pushes them over the JUCE bridge. This shim simply caches
* the latest pushed magnitudes and serves them through the same interface
* `SpectrumAnalyzer` already consumes — so the visualizer needs no changes.
*
* `pushSamples` / `pushStereoSamples` are intentional no-ops: audio never flows
* through the webview.
*/
export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
private fftSize = 2048
private sampleRate = 48000
private magnitudes: Float32Array
private sideMagnitudes: Float32Array
constructor(fftSize = 2048) {
this.fftSize = fftSize
this.magnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
this.sideMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
}
/** Called by the bridge whenever the host emits a new frame. */
setMagnitudes(magnitudes: Float32Array, side?: Float32Array): void {
if (magnitudes.length !== this.magnitudes.length) {
this.magnitudes = new Float32Array(magnitudes.length)
}
this.magnitudes.set(magnitudes)
if (side && side.length > 0) {
if (side.length !== this.sideMagnitudes.length) {
this.sideMagnitudes = new Float32Array(side.length)
}
this.sideMagnitudes.set(side)
}
}
isAvailable(): boolean {
return true
}
setFFTSize(size: number): void {
if (size > 0 && size !== this.fftSize) {
this.fftSize = size
this.magnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
this.sideMagnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
}
}
getFFTSize(): number {
return this.fftSize
}
setSampleRate(sampleRate: number): void {
this.sampleRate = sampleRate
}
// Smoothing is applied in the C++ DSP; nothing to do on this side.
setSmoothing(_smoothing: number): void {}
pushSamples(_audioData: Float32Array): void {}
pushStereoSamples(_leftChannel: Float32Array, _rightChannel: Float32Array): void {}
fillMagnitudes(output: Float32Array): number {
const count = Math.min(output.length, this.magnitudes.length)
if (count > 0) {
output.set(this.magnitudes.subarray(0, count), 0)
}
return count
}
// The C++ side sends already-smoothed magnitudes; serve them for the raw
// request too (the heatmap path re-smooths from this in the visualizer).
fillRawMagnitudes(output: Float32Array): number {
return this.fillMagnitudes(output)
}
fillSideMagnitudes(output: Float32Array): number {
const count = Math.min(output.length, this.sideMagnitudes.length)
if (count > 0) {
output.set(this.sideMagnitudes.subarray(0, count), 0)
}
return count
}
getMagnitudes(): Float32Array {
return this.magnitudes
}
getRawMagnitudes(): Float32Array {
return this.magnitudes
}
getSideMagnitudes(): Float32Array {
return this.sideMagnitudes
}
process(_audioData: Float32Array): Float32Array {
return this.magnitudes
}
binToFrequency(bin: number): number {
return (bin * this.sampleRate) / this.fftSize
}
reset(): void {
this.magnitudes.fill(FFT_SILENCE_DB)
this.sideMagnitudes.fill(FFT_SILENCE_DB)
}
}
+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
}
}
@@ -0,0 +1,66 @@
import type { VectorscopeNativeAnalyzer, VectorscopeMultibandPointsResult } from '../renderer/audio/native'
/**
* Drop-in `VectorscopeNativeAnalyzer` for the plugin webview.
*
* The vectorscope DSP (channel lowpass + 3-band split, circular buffers) runs in
* the C++ plugin, which pushes the most recent display points each frame — either
* a standard X/Y cloud or a multiband (6 floats/point: lowL,lowR,midL,midR,highL,
* highR) cloud, depending on the active mode. This shim caches whichever arrived
* and serves it through the two readout methods `Vectorscope` consumes. The push
* methods are no-ops (audio never flows through the webview).
*/
export class BridgeVectorscopeAnalyzer implements VectorscopeNativeAnalyzer {
private x: Float32Array = new Float32Array(0)
private y: Float32Array = new Float32Array(0)
private count = 0
private mbData: Float32Array = new Float32Array(0)
private mbCount = 0
/** Standard X/Y point cloud from the host. */
setStandard(x: Float32Array, y: Float32Array, count: number): void {
this.x = x
this.y = y
this.count = count
}
/** Multiband point cloud from the host (flat, 6 floats per point). */
setMultiband(data: Float32Array, count: number): void {
this.mbData = data
this.mbCount = count
}
isAvailable(): boolean {
return true
}
isMultibandAvailable(): boolean {
return true
}
setSampleRate(_sampleRate: number): void {}
pushSamples(_left: Float32Array, _right: Float32Array): void {}
pushMultibandSamples(_left: Float32Array, _right: Float32Array): void {}
fillPoints(xOut: Float32Array, yOut: Float32Array): number {
const count = Math.min(xOut.length, yOut.length, this.count, this.x.length, this.y.length)
if (count > 0) {
xOut.set(this.x.subarray(0, count), 0)
yOut.set(this.y.subarray(0, count), 0)
}
return count
}
getMultibandPoints(maxPoints: number): VectorscopeMultibandPointsResult {
const count = Math.min(maxPoints, this.mbCount, Math.floor(this.mbData.length / 6))
return { data: this.mbData, count }
}
reset(): void {
this.x = new Float32Array(0)
this.y = new Float32Array(0)
this.count = 0
this.mbData = new Float32Array(0)
this.mbCount = 0
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { WaveformNativeAnalyzer } from '../renderer/audio/native'
interface QueuedColumns {
summaries: Float32Array
stereo: boolean
}
// The C++ engine emits columns every vblank (audio-driven), independently of how
// fast the webview renders them. Cap the backlog so an overloaded consumer drops the
// OLDEST columns rather than accumulating unbounded latency (which death-spirals into
// stutter at high scroll speeds). One entry == one emitted frame.
const MAX_QUEUED_FRAMES = 8
/**
* Drop-in `WaveformNativeAnalyzer` for the plugin webview.
*
* The waveform DSP (per-column min/max + 3-band RMS) runs in the C++ plugin, which
* pushes finished column summaries each frame — stride 10 in stereo mode, stride 5
* in mono. This shim queues them and serves whichever the visualizer asks for:
* `processStereo`/`processMono` return the queued summaries matching that mode and
* clear the queue (so a mode switch never returns mismatched-stride data, and the
* queue can't grow unbounded). `configure` is a no-op — the engine derives
* samplesPerColumn itself from the host sample rate + scroll speed.
*/
export class BridgeWaveformAnalyzer implements WaveformNativeAnalyzer {
private queue: QueuedColumns[] = []
/** Called by the bridge when the host emits a waveform frame. */
pushFrame(summaries: Float32Array, stereo: boolean): void {
if (summaries.length === 0) return
this.queue.push({ summaries, stereo })
// Drop oldest backlog beyond the cap so we stay near real-time under overload.
while (this.queue.length > MAX_QUEUED_FRAMES) {
this.queue.shift()
}
}
isAvailable(): boolean {
return true
}
configure(_sampleRate: number, _samplesPerColumn: number): void {}
processMono(_samples: Float32Array): Float32Array | null {
return this.drain(false)
}
processStereo(_left: Float32Array, _right: Float32Array): Float32Array | null {
return this.drain(true)
}
reset(): void {
this.queue = []
}
private drain(stereo: boolean): Float32Array {
const matching = this.queue.filter((entry) => entry.stereo === stereo)
this.queue = []
if (matching.length === 0) return new Float32Array(0)
if (matching.length === 1) return matching[0].summaries
let total = 0
for (const entry of matching) total += entry.summaries.length
const out = new Float32Array(total)
let offset = 0
for (const entry of matching) {
out.set(entry.summaries, offset)
offset += entry.summaries.length
}
return out
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { JSX } from 'react'
// Prism's settings icon (matches the app's scope chrome).
export default function GearIcon(): JSX.Element {
return (
<svg viewBox="0 0 118 118" width="15" height="15" aria-hidden="true">
<path d="M104.811 35.1118L102.384 30.9002C100.549 27.7151 99.6313 26.1225 98.0697 25.4874C96.5082 24.8524 94.7421 25.3535 91.2105 26.3557L85.2112 28.0456C82.9564 28.5655 80.5905 28.2706 78.5319 27.2127L76.8755 26.2571C75.1099 25.1263 73.7519 23.4591 73.0002 21.4993L71.3585 16.5955C70.2788 13.3504 69.7389 11.7279 68.4537 10.7998C67.169 9.87175 65.4619 9.87175 62.0478 9.87175H56.5667C53.1531 9.87175 51.446 9.87175 50.1608 10.7998C48.8758 11.7279 48.336 13.3504 47.2564 16.5955L45.6145 21.4993C44.8628 23.4591 43.5048 25.1263 41.7394 26.2571L40.083 27.2127C38.0242 28.2706 35.6585 28.5655 33.4037 28.0456L27.4042 26.3557C23.8724 25.3535 22.1065 24.8524 20.5451 25.4874C18.9836 26.1225 18.066 27.7151 16.2306 30.9002L13.8038 35.1118C12.0834 38.0975 11.2232 39.5903 11.3902 41.1795C11.5571 42.7687 12.7087 44.0493 15.0118 46.6106L20.0811 52.2779C21.3201 53.8464 22.1997 56.58 22.1997 59.0379C22.1997 61.4967 21.3204 64.2294 20.0812 65.7983L15.0118 71.4657C12.7087 74.0273 11.5572 75.3076 11.3902 76.8972C11.2232 78.4862 12.0834 79.9789 13.8038 82.9643L16.2306 87.1759C18.0659 90.361 18.9836 91.954 20.5451 92.5887C22.1065 93.2239 23.8724 92.7229 27.4043 91.7204L33.4035 90.0306C35.6587 89.5104 38.0248 89.8059 40.0839 90.8639L41.74 91.8197C43.5051 92.9506 44.8628 94.6173 45.6143 96.5771L47.2564 101.481C48.336 104.726 48.8758 106.349 50.1608 107.277C51.446 108.205 53.1531 108.205 56.5667 108.205H62.0478C65.4619 108.205 67.169 108.205 68.4537 107.277C69.7389 106.349 70.2788 104.726 71.3585 101.481L73.0007 96.5771C73.7519 94.6173 75.1094 92.9506 76.875 91.8197L78.5309 90.8639C80.59 89.8059 82.9559 89.5104 85.2112 90.0306L91.2105 91.7204C94.7421 92.7229 96.5082 93.2239 98.0697 92.5887C99.6313 91.954 100.549 90.361 102.384 87.1759L104.811 82.9643C106.531 79.9789 107.391 78.4862 107.225 76.8972C107.057 75.3076 105.906 74.0273 103.603 71.4657L98.5334 65.7983C97.2944 64.2294 96.4148 61.4967 96.4148 59.0379C96.4148 56.58 97.2949 53.8464 98.5334 52.2779L103.603 46.6106C105.906 44.0493 107.057 42.7687 107.225 41.1795C107.391 39.5903 106.531 38.0975 104.811 35.1118Z" fill="none" stroke="currentColor" strokeWidth="8.5" strokeLinecap="round" />
<path d="M76.3042 59C76.3042 68.5039 68.5998 76.2083 59.0959 76.2083C49.592 76.2083 41.8877 68.5039 41.8877 59C41.8877 49.4961 49.592 41.7917 59.0959 41.7917C68.5998 41.7917 76.3042 49.4961 76.3042 59Z" fill="none" stroke="currentColor" strokeWidth="8.5" />
</svg>
)
}
+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>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, type JSX } from 'react'
import { Oscilloscope } from '../renderer/visualizers/Oscilloscope'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedOscilloscopeTheme } from '../types/theme'
import type { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions'
interface OscilloscopeScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeOscilloscopeAnalyzer
settings: ScopeSettings['oscilloscope']
theme: ResolvedOscilloscopeTheme
}
export default function OscilloscopeScope({
dataSource,
nativeAnalyzer,
settings,
theme,
}: OscilloscopeScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Oscilloscope | null>(null)
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const viz = new Oscilloscope(canvas, {
...oscilloscopeSettingsToOptions(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(oscilloscopeSettingsToOptions(settings, theme))
}, [settings, theme])
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+122
View File
@@ -0,0 +1,122 @@
import type { ScopePopoutSessionState } from '../types/popout'
import type { SpectrumAnalyzerDataSource } from '../renderer/visualizers/SpectrumAnalyzer'
type SpectrumStereoChunk = { left: Float32Array; right: Float32Array }
/**
* `SpectrumAnalyzerDataSource` for the plugin webview.
*
* In Electron this source drains raw sample queues from the AudioRouter. In the
* plugin the DSP runs in C++, so there are no raw samples to drain here — the
* pending-sample getters return empty. This source's only job is to report the
* session state (sample rate + whether the host is feeding us frames) so the
* visualizer maps frequencies correctly and runs its render loop.
*
* Mirrors the seam used by ScopePopoutDataSource so the visualizer is unchanged.
*/
export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource {
private sessionState: ScopePopoutSessionState = {
sessionId: 1,
sampleRate: 48000,
channelCount: 2,
capturing: false,
backendKind: null,
}
private readonly listeners = new Set<(state: ScopePopoutSessionState) => void>()
// The DSP runs in C++ and pushes finished magnitudes (no raw samples flow
// through here). But SpectrumAnalyzer only refreshes its heatmap buffer when
// it sees "new samples arrived" (a non-empty pending queue). We therefore
// hand it a reusable sentinel chunk each frame to signal a fresh frame. Its
// length saturates `nativeBufferedSamples` so heatmap smoothing applies — the
// values are unused (the shim's pushSamples is a no-op; magnitudes come from
// fillMagnitudes). Sized to the max FFT so any fftSize saturates in one frame.
private readonly sentinel = new Float32Array(16384)
private readonly sentinelStereo: SpectrumStereoChunk = {
left: this.sentinel,
right: this.sentinel,
}
getPendingSpectrumSamples(): Float32Array[] {
return this.sessionState.capturing ? [this.sentinel] : []
}
getPendingSpectrumStereoSamples(): SpectrumStereoChunk[] {
return this.sessionState.capturing ? [this.sentinelStereo] : []
}
// Oscilloscope: same sentinel trick — the DSP runs in C++ and pushes finished
// display windows; this just advances the visualizer's warmup/"new data" gate.
getPendingOscilloscopeSamples(): Float32Array[] {
return this.sessionState.capturing ? [this.sentinel] : []
}
// Spectrogram: needs a sentinel so the visualizer calls the analyzer's process()
// each frame (it only does so per pending chunk) to drain the C++ column queue.
getPendingSpectrogramSamples(): Float32Array[] {
return this.sessionState.capturing ? [this.sentinel] : []
}
// Waveform: same sentinel trick — the visualizer calls processMono/processStereo
// per pending chunk, which drains the C++-pushed column summaries from the bridge.
getPendingWaveformSamples(): Float32Array[] {
return this.sessionState.capturing ? [this.sentinel] : []
}
getPendingWaveformStereoSamples(): SpectrumStereoChunk[] {
return this.sessionState.capturing ? [this.sentinelStereo] : []
}
// 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 []
}
// Vectorscope reads the C++-pushed point cloud via fillPoints/getMultibandPoints
// each frame — no raw samples drain here, and no sentinel is needed.
getPendingVectorscopeSamples(): Array<{ left: Float32Array; right: Float32Array }> {
return []
}
getSampleRate(): number {
return this.sessionState.sampleRate
}
isPlaying(): boolean {
return this.sessionState.capturing
}
subscribeToSessionChanges(listener: (state: ScopePopoutSessionState) => void): () => void {
this.listeners.add(listener)
listener(this.sessionState)
return () => {
this.listeners.delete(listener)
}
}
/** Called by the bridge when a host frame arrives. */
setSampleRate(sampleRate: number): void {
if (sampleRate > 0 && sampleRate !== this.sessionState.sampleRate) {
this.updateSession({ sampleRate, sessionId: this.sessionState.sessionId + 1 })
}
}
setPlaying(playing: boolean): void {
if (playing !== this.sessionState.capturing) {
this.updateSession({ capturing: playing })
}
}
private updateSession(partial: Partial<ScopePopoutSessionState>): void {
this.sessionState = { ...this.sessionState, ...partial }
for (const listener of this.listeners) {
listener(this.sessionState)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useRef, useState, type CSSProperties, type JSX, type ReactNode } from 'react'
import type { ScopeKind } from '../types/scope'
import type { ScopeSettings } from '../types/settings'
import type { PrismResolvedTheme } from '../types/theme'
import ScopeSettingsSection from '../renderer/components/ScopeSettingsSection'
import GearIcon from './GearIcon'
import { useScopeHostSync } from './useScopeHostSync'
import { emitToHost } from './juceBridge'
// Height (CSS px) of the bottom settings panel. The C++ editor grows its window by
// exactly this when settings open (and shrinks back on close) so the scope area is
// unchanged — like the desktop app. Must match `.spectrum-app__panel` in styles.css.
const PANEL_HEIGHT = 280
interface ScopeAppProps<K extends ScopeKind> {
kind: K
/** Render the scope's canvas given the current settings + resolved theme. */
renderScope: (settings: ScopeSettings[K], theme: PrismResolvedTheme) => ReactNode
}
/**
* Generic plugin shell for any scope: the scope fills the viewport, and the gear
* toggles a settings panel that opens along the bottom (like the desktop app). The
* window grows by the panel height to accommodate it, so the scope area never resizes.
*/
export default function ScopeApp<K extends ScopeKind>({ kind, renderScope }: ScopeAppProps<K>): JSX.Element {
const { settings, resolvedTheme, handleUpdate } = useScopeHostSync(kind)
const [settingsOpen, setSettingsOpen] = useState(false)
const [lockedViewportHeight, setLockedViewportHeight] = useState<number | null>(null)
const viewportRef = useRef<HTMLDivElement>(null)
useEffect(() => {
emitToHost('prismSettingsPanel', { height: settingsOpen ? PANEL_HEIGHT : 0 })
}, [settingsOpen])
const toggleSettings = (): void => {
if (settingsOpen) {
setSettingsOpen(false)
setLockedViewportHeight(null)
return
}
const rect = viewportRef.current?.getBoundingClientRect()
setLockedViewportHeight(rect && rect.height > 0 ? Math.ceil(rect.height) : null)
setSettingsOpen(true)
}
const appStyle = lockedViewportHeight === null
? undefined
: ({ '--spectrum-viewport-height': `${lockedViewportHeight}px` } as CSSProperties)
return (
<div className={`spectrum-app ${settingsOpen ? 'has-settings' : ''}`.trim()} style={appStyle}>
<div ref={viewportRef} className="spectrum-app__viewport">
{renderScope(settings, resolvedTheme)}
<button
type="button"
className={`spectrum-app__gear ${settingsOpen ? 'is-active' : ''}`.trim()}
onClick={toggleSettings}
aria-label="Settings"
title="Settings"
>
<GearIcon />
</button>
</div>
{settingsOpen && (
<div className="spectrum-app__panel">
<ScopeSettingsSection
kind={kind}
settings={settings}
onUpdate={(_k, partial) => handleUpdate(partial as unknown as Partial<ScopeSettings[K]>)}
/>
</div>
)}
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, type JSX } from 'react'
import { Spectrogram } from '../renderer/visualizers/Spectrogram'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrogramTheme } from '../types/theme'
import type { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { spectrogramSettingsToOptions } from './spectrogramOptions'
import { resolveScrollingCanvasSize } from './scrollingCanvas'
interface SpectrogramScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeSpectrogramAnalyzer
settings: ScopeSettings['spectrogram']
theme: ResolvedSpectrogramTheme
}
export default function SpectrogramScope({
dataSource,
nativeAnalyzer,
settings,
theme,
}: SpectrogramScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Spectrogram | null>(null)
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const viz = new Spectrogram(canvas, {
...spectrogramSettingsToOptions(settings, theme),
dataSource,
nativeAnalyzer,
})
vizRef.current = viz
const applySize = (): void => {
const rect = container.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
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(spectrogramSettingsToOptions(settings, theme))
}, [settings, theme])
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react'
import { SpectrumAnalyzer } from '../renderer/visualizers/SpectrumAnalyzer'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrumTheme } from '../types/theme'
import type { SpectrumPeakInfo } from '../types/spectrum'
import type { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { spectrumSettingsToOptions } from './spectrumOptions'
import {
formatSpectrumPeakDb,
formatSpectrumPeakFrequency,
measureCanvasResizeState,
resolveFollowingPeakOverlayStyle,
type CanvasResizeState,
type SizeMeasurement,
} from './peakOverlay'
interface SpectrumScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeSpectrumAnalyzer
settings: ScopeSettings['spectrum']
theme: ResolvedSpectrumTheme
}
export default function SpectrumScope({
dataSource,
nativeAnalyzer,
settings,
theme,
}: SpectrumScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const analyzerRef = useRef<SpectrumAnalyzer | null>(null)
const resizeStateRef = useRef<CanvasResizeState | null>(null)
const peakOverlayRef = useRef<HTMLDivElement | null>(null)
const [peak, setPeak] = useState<SpectrumPeakInfo | null>(null)
const [overlaySize, setOverlaySize] = useState<SizeMeasurement | null>(null)
const peakMode = settings.peakInfoMode
// Create the analyzer once per data source / shim.
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const analyzer = new SpectrumAnalyzer(canvas, {
...spectrumSettingsToOptions(settings, theme),
capturePeakInfo: settings.peakInfoMode !== 'off',
onPeakInfo: setPeak,
dataSource,
nativeAnalyzer,
})
analyzerRef.current = analyzer
const applySize = (): void => {
const state = measureCanvasResizeState(container)
resizeStateRef.current = state
if (canvas.width !== state.pixelWidth || canvas.height !== state.pixelHeight) {
canvas.width = state.pixelWidth
canvas.height = state.pixelHeight
analyzer.resize()
}
}
applySize()
analyzer.start()
const observer = new ResizeObserver(applySize)
observer.observe(container)
return () => {
observer.disconnect()
analyzer.dispose()
analyzerRef.current = null
}
// settings/theme are applied via setOptions below, not on recreation.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSource, nativeAnalyzer])
// Apply settings/theme changes live.
useEffect(() => {
if (settings.peakInfoMode === 'off') setPeak(null)
analyzerRef.current?.setOptions({
...spectrumSettingsToOptions(settings, theme),
capturePeakInfo: settings.peakInfoMode !== 'off',
onPeakInfo: setPeak,
})
}, [settings, theme])
// Measure the overlay so "following" placement can avoid the screen edges.
useLayoutEffect(() => {
const overlay = peakOverlayRef.current
if (peakMode !== 'following' || !peak || !overlay) {
setOverlaySize(null)
return
}
const measure = (): void => {
const next = { width: overlay.offsetWidth, height: overlay.offsetHeight }
setOverlaySize((prev) => (prev?.width === next.width && prev?.height === next.height ? prev : next))
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(overlay)
return () => observer.disconnect()
}, [peakMode, peak])
const showPeak = peakMode !== 'off' && peak !== null
const overlayStyle: CSSProperties | undefined =
peakMode === 'following' && peak
? resolveFollowingPeakOverlayStyle(peak, resizeStateRef.current, overlaySize)
: undefined
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
{showPeak && peak && (
<div
ref={peakMode === 'following' ? peakOverlayRef : null}
className={['scope-module__peak-info', peakMode === 'following' ? 'is-following' : 'is-corner'].join(' ')}
style={overlayStyle}
>
<span className="scope-module__peak-info-value">{formatSpectrumPeakDb(peak.db)}</span>
<span className="scope-module__peak-info-separator">/</span>
<span className="scope-module__peak-info-value">{formatSpectrumPeakFrequency(peak.frequencyHz)}</span>
<span className="scope-module__peak-info-separator">/</span>
<span className="scope-module__peak-info-value">{peak.key}</span>
</div>
)}
</div>
)
}
+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>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, type JSX } from 'react'
import { Vectorscope } from '../renderer/visualizers/Vectorscope'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedVectorscopeTheme } from '../types/theme'
import type { BridgeVectorscopeAnalyzer } from './BridgeVectorscopeAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { vectorscopeSettingsToOptions } from './vectorscopeOptions'
interface VectorscopeScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeVectorscopeAnalyzer
settings: ScopeSettings['vectorscope']
theme: ResolvedVectorscopeTheme
}
export default function VectorscopeScope({
dataSource,
nativeAnalyzer,
settings,
theme,
}: VectorscopeScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Vectorscope | null>(null)
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const viz = new Vectorscope(canvas, {
...vectorscopeSettingsToOptions(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(vectorscopeSettingsToOptions(settings, theme))
}, [settings, theme])
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, type JSX } from 'react'
import { Waveform } from '../renderer/visualizers/Waveform'
import type { ScopeSettings } from '../types/settings'
import type { ResolvedWaveformTheme } from '../types/theme'
import type { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { waveformSettingsToOptions } from './waveformOptions'
import { resolveScrollingCanvasSize } from './scrollingCanvas'
interface WaveformScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeWaveformAnalyzer
settings: ScopeSettings['waveform']
theme: ResolvedWaveformTheme
}
export default function WaveformScope({
dataSource,
nativeAnalyzer,
settings,
theme,
}: WaveformScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const vizRef = useRef<Waveform | null>(null)
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const viz = new Waveform(canvas, {
...waveformSettingsToOptions(settings, theme),
dataSource,
nativeAnalyzer,
})
vizRef.current = viz
const applySize = (): void => {
const rect = container.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
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(waveformSettingsToOptions(settings, theme))
}, [settings, theme])
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prism Spectrum</title>
<style>
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background: #000;
}
body {
overflow: hidden;
color: rgba(255, 255, 255, 0.82);
font-family: Inter, system-ui, sans-serif;
}
#prism-plugin-status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: #000;
color: rgba(255, 255, 255, 0.72);
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-align: center;
white-space: pre-wrap;
}
html.prism-plugin-mounted #prism-plugin-status {
display: none;
}
</style>
<script>
window.__PRISM_PLUGIN_ERROR__ = function (message) {
var status = document.getElementById('prism-plugin-status')
if (status) status.textContent = 'Prism plugin UI failed to load\n\n' + message
}
window.addEventListener('error', function (event) {
window.__PRISM_PLUGIN_ERROR__(
event.message || (event.error && event.error.message) || 'Unknown error'
)
})
window.addEventListener('unhandledrejection', function (event) {
var reason = event.reason
window.__PRISM_PLUGIN_ERROR__(
(reason && reason.message) || String(reason || 'Unhandled promise rejection')
)
})
</script>
</head>
<body>
<div id="root"></div>
<div id="prism-plugin-status">Loading Prism plugin UI...</div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+701
View File
@@ -0,0 +1,701 @@
/**
* Bridge between the JUCE 8 plugin host (C++) and this webview UI.
*
* - C++ -> JS: emits "spectrumFrame" (~display rate) and "prismRestoreSettings".
* - JS -> C++: emits "prismConfig" (settings + DSP params) and "prismReady".
*
* JUCE injects `window.__JUCE__` into pages loaded by the webview (including the
* Vite dev server) when native integration is enabled. When it's absent (e.g. a
* plain browser), a synthetic generator drives the UI so it's developable.
*/
export interface SpectrumFrame {
/** Host sample rate in Hz. */
sampleRate: number
/** Mid (mono) magnitudes in dB, length = fftSize/2. */
magnitudes: Float32Array
/** Side magnitudes in dB (same length); empty if unavailable. */
side: Float32Array
}
interface SpectrumFramePayload {
sampleRate?: number
magnitudes?: string
side?: string
}
type JuceBackend = {
addEventListener: (eventId: string, fn: (payload: unknown) => void) => number
removeEventListener?: (id: number) => void
emitEvent?: (eventId: string, payload: unknown) => void
}
declare global {
interface Window {
__JUCE__?: { backend?: JuceBackend; initialisationData?: unknown }
}
}
const HOST_WAIT_TIMEOUT_MS = 4000
const HOST_POLL_INTERVAL_MS = 50
/** Resolves to the JUCE backend once available, or null if no host (timeout). */
let backendPromise: Promise<JuceBackend | null> | null = null
function ensureBackend(): Promise<JuceBackend | null> {
if (backendPromise) return backendPromise
backendPromise = new Promise((resolve) => {
const existing = window.__JUCE__?.backend
if (existing && typeof existing.addEventListener === 'function') {
resolve(existing)
return
}
let waited = 0
const timer = setInterval(() => {
const backend = window.__JUCE__?.backend
if (backend && typeof backend.addEventListener === 'function') {
clearInterval(timer)
resolve(backend)
return
}
waited += HOST_POLL_INTERVAL_MS
if (waited >= HOST_WAIT_TIMEOUT_MS) {
clearInterval(timer)
resolve(null)
}
}, HOST_POLL_INTERVAL_MS)
})
return backendPromise
}
/** Fire-and-forget event to C++ (no-op when running without a host). */
export function emitToHost(eventId: string, payload: unknown): void {
void ensureBackend().then((backend) => backend?.emitEvent?.(eventId, payload))
}
/** Subscribe to a C++ event. Returns an unsubscribe function. */
export function onHostEvent(eventId: string, handler: (payload: unknown) => void): () => void {
let listenerId: number | null = null
let cancelled = false
void ensureBackend().then((backend) => {
if (!backend || cancelled) return
listenerId = backend.addEventListener(eventId, handler)
})
return () => {
cancelled = true
if (listenerId !== null) {
window.__JUCE__?.backend?.removeEventListener?.(listenerId)
}
}
}
export function base64ToFloat32Array(b64: string): Float32Array {
if (!b64) return new Float32Array(0)
const binary = atob(b64)
const byteLength = binary.length
const bytes = new Uint8Array(byteLength)
for (let i = 0; i < byteLength; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return new Float32Array(bytes.buffer, 0, byteLength >> 2)
}
function decodeFrame(payload: unknown): SpectrumFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const { sampleRate, magnitudes, side } = payload as SpectrumFramePayload
if (typeof magnitudes !== 'string' || magnitudes.length === 0) return null
return {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
magnitudes: base64ToFloat32Array(magnitudes),
side: typeof side === 'string' ? base64ToFloat32Array(side) : new Float32Array(0),
}
}
export interface SpectrumBridgeHandlers {
onFrame: (frame: SpectrumFrame) => void
onConnected?: (usingMock: boolean) => void
}
export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => 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 spectrum (browser dev mode)')
const binCount = 1024
const sampleRate = 48000
const mid = new Float32Array(binCount)
const side = new Float32Array(binCount).fill(-100)
let phase = 0
const tick = (): void => {
if (disposed) return
phase += 0.05
for (let i = 0; i < binCount; i += 1) {
const t = i / binCount
const peak1 = Math.exp(-Math.pow((t - (0.15 + 0.05 * Math.sin(phase))) * 12, 2)) * 70
const peak2 = Math.exp(-Math.pow((t - 0.5) * 18, 2)) * 50
mid[i] = -100 + peak1 + peak2 + Math.random() * 6
side[i] = -100 + peak2 * 0.4 + Math.random() * 4
}
handlers.onFrame({ sampleRate, magnitudes: mid, side })
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
}
void ensureBackend().then((backend) => {
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('spectrumFrame', (payload) => {
const frame = decodeFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host')
} else {
startMock()
}
})
return () => {
disposed = true
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
}
}
// ---------------------------------------------------------------------------
// 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)
}
}
// ---------------------------------------------------------------------------
// 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)
}
}
// ---------------------------------------------------------------------------
// Vectorscope frames (event "vectorscopeFrame"): a point cloud, either standard
// (x, y base64) or multiband (data base64, 6 floats/point), flagged by `multiband`.
export interface VectorscopeFrame {
sampleRate: number
multiband: boolean
count: number
x?: Float32Array
y?: Float32Array
data?: Float32Array
}
interface VectorscopeFramePayload {
sampleRate?: number
multiband?: boolean
count?: number
x?: string
y?: string
data?: string
}
function decodeVectorscopeFrame(payload: unknown): VectorscopeFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const { sampleRate, multiband, count, x, y, data } = payload as VectorscopeFramePayload
const frame: VectorscopeFrame = {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
multiband: Boolean(multiband),
count: typeof count === 'number' ? count : 0,
}
if (frame.multiband) {
if (typeof data !== 'string') return null
frame.data = base64ToFloat32Array(data)
} else {
if (typeof x !== 'string' || typeof y !== 'string') return null
frame.x = base64ToFloat32Array(x)
frame.y = base64ToFloat32Array(y)
}
return frame
}
export interface VectorscopeBridgeHandlers {
onFrame: (frame: VectorscopeFrame) => void
onConnected?: (usingMock: boolean) => void
}
export function connectVectorscopeBridge(handlers: VectorscopeBridgeHandlers): () => 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 vectorscope (browser dev mode)')
const count = 2048
const x = new Float32Array(count)
const y = new Float32Array(count)
let phase = 0
const tick = (): void => {
if (disposed) return
phase += 0.03
for (let i = 0; i < count; i += 1) {
const t = (i / count) * Math.PI * 2
x[i] = Math.sin(t * 3 + phase) * 0.7
y[i] = Math.sin(t * 2 + phase * 1.3) * 0.7
}
handlers.onFrame({ sampleRate: 48000, multiband: false, count, x, y })
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
}
void ensureBackend().then((backend) => {
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('vectorscopeFrame', (payload) => {
const frame = decodeVectorscopeFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (vectorscope)')
} else {
startMock()
}
})
return () => {
disposed = true
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
}
}
// ---------------------------------------------------------------------------
// Spectrogram frames (event "spectrogramFrame"): the new display+heat columns
// produced since the last frame, base64-encoded, tagged with rowCount/columnCount.
export interface SpectrogramFrame {
sampleRate: number
display: Float32Array
heat: Float32Array
columnCount: number
rowCount: number
}
interface SpectrogramFramePayload {
sampleRate?: number
display?: string
heat?: string
columnCount?: number
rowCount?: number
}
function decodeSpectrogramFrame(payload: unknown): SpectrogramFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const { sampleRate, display, heat, columnCount, rowCount } = payload as SpectrogramFramePayload
return {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
display: typeof display === 'string' ? base64ToFloat32Array(display) : new Float32Array(0),
heat: typeof heat === 'string' ? base64ToFloat32Array(heat) : new Float32Array(0),
columnCount: typeof columnCount === 'number' ? columnCount : 0,
rowCount: typeof rowCount === 'number' ? rowCount : 0,
}
}
export interface SpectrogramBridgeHandlers {
onFrame: (frame: SpectrogramFrame) => void
onConnected?: (usingMock: boolean) => void
/** Lets the dev mock size its columns to the canvas-derived rowCount. */
getRowCount?: () => number
}
export function connectSpectrogramBridge(handlers: SpectrogramBridgeHandlers): () => 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 spectrogram (browser dev mode)')
let phase = 0
const tick = (): void => {
if (disposed) return
phase += 0.08
const rowCount = handlers.getRowCount?.() ?? 0
if (rowCount > 0) {
const columnCount = 2
const display = new Float32Array(rowCount * columnCount)
const heat = new Float32Array(rowCount * columnCount)
for (let c = 0; c < columnCount; c += 1) {
for (let r = 0; r < rowCount; r += 1) {
const t = r / rowCount
const band = Math.exp(-Math.pow((t - (0.3 + 0.2 * Math.sin(phase))) * 6, 2))
const v = Math.min(1, band + Math.random() * 0.15)
display[c * rowCount + r] = v
heat[c * rowCount + r] = v
}
}
handlers.onFrame({ sampleRate: 48000, display, heat, columnCount, rowCount })
}
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
}
void ensureBackend().then((backend) => {
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('spectrogramFrame', (payload) => {
const frame = decodeSpectrogramFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (spectrogram)')
} else {
startMock()
}
})
return () => {
disposed = true
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
}
}
// ---------------------------------------------------------------------------
// Waveform frames (event "waveformFrame"): per-column summaries (base64), stride 10
// in stereo mode / stride 5 in mono, flagged by `stereo`.
export interface WaveformFrame {
sampleRate: number
stereo: boolean
columnCount: number
summaries: Float32Array
}
interface WaveformFramePayload {
sampleRate?: number
stereo?: boolean
columnCount?: number
summaries?: string
}
function decodeWaveformFrame(payload: unknown): WaveformFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const { sampleRate, stereo, columnCount, summaries } = payload as WaveformFramePayload
return {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
stereo: Boolean(stereo),
columnCount: typeof columnCount === 'number' ? columnCount : 0,
summaries: typeof summaries === 'string' ? base64ToFloat32Array(summaries) : new Float32Array(0),
}
}
export interface WaveformBridgeHandlers {
onFrame: (frame: WaveformFrame) => void
onConnected?: (usingMock: boolean) => void
}
export function connectWaveformBridge(handlers: WaveformBridgeHandlers): () => 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 waveform (browser dev mode)')
let phase = 0
const tick = (): void => {
if (disposed) return
phase += 0.12
const columns = 2
// Emit both mono (stride 5) and stereo (stride 10) so the dev mock works in
// either mode (the analyzer serves whichever the visualizer asks for).
const mono = new Float32Array(columns * 5)
const stereo = new Float32Array(columns * 10)
for (let c = 0; c < columns; c += 1) {
const amp = 0.3 + 0.6 * Math.abs(Math.sin(phase + c * 0.4))
const m = c * 5
mono[m] = -amp; mono[m + 1] = amp; mono[m + 2] = amp * 0.5; mono[m + 3] = amp * 0.7; mono[m + 4] = amp * 0.3
const s = c * 10
stereo[s] = -amp; stereo[s + 1] = amp; stereo[s + 2] = amp * 0.5; stereo[s + 3] = amp * 0.7; stereo[s + 4] = amp * 0.3
const ampR = amp * 0.85
stereo[s + 5] = -ampR; stereo[s + 6] = ampR; stereo[s + 7] = ampR * 0.5; stereo[s + 8] = ampR * 0.7; stereo[s + 9] = ampR * 0.3
}
handlers.onFrame({ sampleRate: 48000, stereo: false, columnCount: columns, summaries: mono })
handlers.onFrame({ sampleRate: 48000, stereo: true, columnCount: columns, summaries: stereo })
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
}
void ensureBackend().then((backend) => {
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('waveformFrame', (payload) => {
const frame = decodeWaveformFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (waveform)')
} 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 }).
export interface OscilloscopeFrame {
sampleRate: number
/** Already-triggered display window of time-domain samples. */
samples: Float32Array
detectedPitch: number
}
interface OscilloscopeFramePayload {
sampleRate?: number
samples?: string
pitch?: number
}
function decodeOscilloscopeFrame(payload: unknown): OscilloscopeFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const { sampleRate, samples, pitch } = payload as OscilloscopeFramePayload
if (typeof samples !== 'string' || samples.length === 0) return null
return {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
samples: base64ToFloat32Array(samples),
detectedPitch: typeof pitch === 'number' ? pitch : 0,
}
}
export interface OscilloscopeBridgeHandlers {
onFrame: (frame: OscilloscopeFrame) => void
onConnected?: (usingMock: boolean) => void
}
export function connectOscilloscopeBridge(handlers: OscilloscopeBridgeHandlers): () => 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 oscilloscope (browser dev mode)')
const count = 2048
const samples = new Float32Array(count)
let phase = 0
const tick = (): void => {
if (disposed) return
phase += 0.08
for (let i = 0; i < count; i += 1) {
const t = (i / count) * Math.PI * 2 * 3
samples[i] = Math.sin(t + phase) * 0.7 + Math.sin(t * 2 + phase) * 0.15
}
handlers.onFrame({ sampleRate: 48000, samples, detectedPitch: 220 })
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
}
void ensureBackend().then((backend) => {
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('oscilloscopeFrame', (payload) => {
const frame = decodeOscilloscopeFrame(payload)
if (frame) handlers.onFrame(frame)
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (oscilloscope)')
} else {
startMock()
}
})
return () => {
disposed = true
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
}
}
+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,
}
}
+222
View File
@@ -0,0 +1,222 @@
import { StrictMode, type JSX } from 'react'
import { createRoot } from 'react-dom/client'
import '../renderer/styles/globals.css'
import './styles.css'
import ScopeApp from './ScopeApp'
import SpectrumScope from './SpectrumScope'
import OscilloscopeScope from './OscilloscopeScope'
import VUMeterScope from './VUMeterScope'
import LUFSMeterScope from './LUFSMeterScope'
import VectorscopeScope from './VectorscopeScope'
import SpectrogramScope from './SpectrogramScope'
import WaveformScope from './WaveformScope'
import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
import { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer'
import { BridgeLUFSMeterAnalyzer } from './BridgeLUFSMeterAnalyzer'
import { BridgeVectorscopeAnalyzer } from './BridgeVectorscopeAnalyzer'
import { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
import { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
import { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge, connectSpectrogramBridge, connectWaveformBridge } 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"]).
function getScopeKind(): string {
const raw = (window as unknown as {
__JUCE__?: { initialisationData?: { prismScope?: unknown } }
}).__JUCE__?.initialisationData?.prismScope
const value = Array.isArray(raw) ? raw[0] : raw
return typeof value === 'string' ? value : 'spectrum'
}
const dataSource = new PluginWebViewDataSource()
function buildApp(): JSX.Element {
if (getScopeKind() === 'waveform') {
const analyzer = new BridgeWaveformAnalyzer()
connectWaveformBridge({
onFrame: (frame) => {
analyzer.pushFrame(frame.summaries, frame.stereo)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
return (
<ScopeApp
kind="waveform"
renderScope={(settings, theme) => (
<WaveformScope
settings={settings}
theme={theme.waveform}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
if (getScopeKind() === 'spectrogram') {
const analyzer = new BridgeSpectrogramAnalyzer()
connectSpectrogramBridge({
onFrame: (frame) => {
analyzer.pushFrame(frame.display, frame.heat, frame.columnCount, frame.rowCount)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
getRowCount: () => analyzer.getExpectedRowCount(),
})
return (
<ScopeApp
kind="spectrogram"
renderScope={(settings, theme) => (
<SpectrogramScope
settings={settings}
theme={theme.spectrogram}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
if (getScopeKind() === 'vectorscope') {
const analyzer = new BridgeVectorscopeAnalyzer()
connectVectorscopeBridge({
onFrame: (frame) => {
if (frame.multiband && frame.data) {
analyzer.setMultiband(frame.data, frame.count)
} else if (frame.x && frame.y) {
analyzer.setStandard(frame.x, frame.y, frame.count)
}
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
return (
<ScopeApp
kind="vectorscope"
renderScope={(settings, theme) => (
<VectorscopeScope
settings={settings}
theme={theme.vectorscope}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
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({
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({
onFrame: (frame) => {
analyzer.setSamples(frame.samples, frame.detectedPitch)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
return (
<ScopeApp
kind="oscilloscope"
renderScope={(settings, theme) => (
<OscilloscopeScope
settings={settings}
theme={theme.oscilloscope}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
const analyzer = new BridgeSpectrumAnalyzer(2048)
connectSpectrumBridge({
onFrame: (frame) => {
analyzer.setMagnitudes(frame.magnitudes, frame.side)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
return (
<ScopeApp
kind="spectrum"
renderScope={(settings, theme) => (
<SpectrumScope
settings={settings}
theme={theme.spectrum}
dataSource={dataSource}
nativeAnalyzer={analyzer}
/>
)}
/>
)
}
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Missing #root element')
}
try {
createRoot(rootElement).render(<StrictMode>{buildApp()}</StrictMode>)
document.documentElement.classList.add('prism-plugin-mounted')
} catch (error) {
const reporter = (window as unknown as {
__PRISM_PLUGIN_ERROR__?: (message: string) => void
}).__PRISM_PLUGIN_ERROR__
reporter?.(error instanceof Error ? error.message : String(error))
throw error
}
+24
View File
@@ -0,0 +1,24 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedOscilloscopeTheme } from '../types/theme'
import type { OscilloscopeOptions } from '../renderer/visualizers/Oscilloscope'
/**
* Map Prism's oscilloscope settings + resolved theme to Oscilloscope options.
* Mirrors the `oscilloscope` case of `scopeSettingsToOptions` in ScopeModule.tsx.
*/
export function oscilloscopeSettingsToOptions(
settings: ScopeSettings['oscilloscope'],
theme: ResolvedOscilloscopeTheme,
): OscilloscopeOptions {
return {
lineColor: theme.line,
backgroundColor: theme.background,
gridMajorColor: theme.guides,
gridMinorColor: theme.guidesSecondary,
underfillColor: theme.fill,
pitchLock: settings.pitchLock,
underfillEnabled: settings.underfillEnabled,
showGrid: settings.showGrid,
lineWidth: settings.lineWidth,
}
}
+102
View File
@@ -0,0 +1,102 @@
import type { CSSProperties } from 'react'
import type { SpectrumPeakInfo } from '../types/spectrum'
/**
* Peak-overlay positioning + formatting, mirroring ScopeModule.tsx so the plugin's
* "following" peak readout behaves exactly like the Prism app. Kept as a local
* copy (pure functions) so the plugin doesn't import the heavy ScopeModule.
*/
export interface CanvasResizeState {
cssWidth: number
cssHeight: number
pixelWidth: number
pixelHeight: number
dpr: number
}
export interface SizeMeasurement {
width: number
height: number
}
const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10
const SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX = 248
const SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX = 42
function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function formatSpectrumPeakDb(value: number): string {
if (!Number.isFinite(value)) {
return '--'
}
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dB`
}
export function formatSpectrumPeakFrequency(value: number): string {
if (!Number.isFinite(value) || value <= 0) {
return '--'
}
if (value >= 1000) {
return `${(value / 1000).toFixed(2)}kHz`
}
return `${value.toFixed(2)}Hz`
}
export function measureCanvasResizeState(container: HTMLElement): CanvasResizeState {
const rect = container.getBoundingClientRect()
const cssWidth = Math.max(1, Math.floor(rect.width))
const cssHeight = Math.max(1, Math.floor(rect.height))
const dpr = window.devicePixelRatio || 1
return {
cssWidth,
cssHeight,
pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)),
pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)),
dpr,
}
}
export function resolveFollowingPeakOverlayStyle(
peakInfo: SpectrumPeakInfo,
resizeState: CanvasResizeState | null,
overlaySize: SizeMeasurement | null,
): CSSProperties {
if (!resizeState) {
return {
left: `${SPECTRUM_PEAK_OVERLAY_MARGIN_PX}px`,
top: `${SPECTRUM_PEAK_OVERLAY_MARGIN_PX}px`,
}
}
const width = resizeState.cssWidth
const height = resizeState.cssHeight
const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX
const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX
const peakX = peakInfo.normalizedX * width
const peakY = peakInfo.normalizedY * height
const maxLeft = Math.max(
SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
)
const maxTop = Math.max(
SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
height - overlayHeight - SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
)
const canPlaceAbove = peakY - overlayHeight >= SPECTRUM_PEAK_OVERLAY_MARGIN_PX
const canPlaceBelow = peakY + overlayHeight <= height - SPECTRUM_PEAK_OVERLAY_MARGIN_PX
const left = peakX
const top = canPlaceAbove || !canPlaceBelow
? peakY - overlayHeight
: peakY
return {
left: `${clampNumber(left, SPECTRUM_PEAK_OVERLAY_MARGIN_PX, maxLeft)}px`,
top: `${clampNumber(top, SPECTRUM_PEAK_OVERLAY_MARGIN_PX, maxTop)}px`,
}
}
+30
View File
@@ -0,0 +1,30 @@
// The scrolling scopes (spectrogram + waveform) advance their waterfall by blitting
// the entire canvas onto itself every frame. That self-blit costs ~canvas area per
// frame and WKWebView's 2D canvas handles it far less efficiently than Chromium, so a
// large plugin window blows the frame budget even at low scroll speeds. Cap the
// backing-store resolution so the per-frame cost stays bounded regardless of window
// size; the canvas is CSS-stretched to fill, so it just looks slightly softer when the
// window is very large. Typical sizes stay fully crisp (they fall under the budget).
//
// Bonus for the spectrogram: rowCount is derived from the canvas height, so capping
// here also shrinks the per-column DSP work and the bridge payload.
const MAX_DEVICE_PIXELS = 2_000_000
export function resolveScrollingCanvasSize(
cssWidth: number,
cssHeight: number,
devicePixelRatio: number,
): { width: number; height: number } {
const dpr = devicePixelRatio > 0 ? devicePixelRatio : 1
let width = Math.max(1, Math.floor(cssWidth * dpr))
let height = Math.max(1, Math.floor(cssHeight * dpr))
const pixels = width * height
if (pixels > MAX_DEVICE_PIXELS) {
const scale = Math.sqrt(MAX_DEVICE_PIXELS / pixels)
width = Math.max(1, Math.floor(width * scale))
height = Math.max(1, Math.floor(height * scale))
}
return { width, height }
}
+26
View File
@@ -0,0 +1,26 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrogramTheme } from '../types/theme'
import type { SpectrogramOptions } from '../renderer/visualizers/Spectrogram'
/**
* Map Prism's spectrogram settings + resolved theme to Spectrogram options.
* Mirrors the `spectrogram` case of `scopeSettingsToOptions` in ScopeModule.tsx.
*/
export function spectrogramSettingsToOptions(
settings: ScopeSettings['spectrogram'],
theme: ResolvedSpectrogramTheme,
): SpectrogramOptions {
return {
lineColor: theme.mono,
heatColors: theme.heatColors,
backgroundColor: theme.background,
fftSize: settings.fftSize,
tiltDbPerOctave: settings.tiltDbPerOctave,
scrollSpeed: settings.scrollSpeed,
contrast: settings.contrast,
clarityMode: settings.clarityMode,
scaleMode: settings.scaleMode,
orientation: settings.orientation,
colorScheme: settings.colorScheme,
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedSpectrumTheme } from '../types/theme'
import type { SpectrumAnalyzerOptions } from '../renderer/visualizers/SpectrumAnalyzer'
/**
* Map Prism's spectrum settings + resolved theme to SpectrumAnalyzer options.
* Mirrors the `spectrum` case of `scopeSettingsToOptions` in ScopeModule.tsx —
* kept local so the spectrum plugin doesn't pull in every other visualizer.
*/
export function spectrumSettingsToOptions(
settings: ScopeSettings['spectrum'],
theme: ResolvedSpectrumTheme,
): SpectrumAnalyzerOptions {
return {
lineColor: theme.line,
secondaryLineColor: theme.sideLine,
gradientColors: theme.fillGradient,
heatColors: theme.heatColors,
heatBaseColor: theme.heatBase,
backgroundColor: theme.background,
gridColor: theme.guides,
fftSize: settings.fftSize,
tiltDbPerOctave: settings.tiltDbPerOctave,
heatmapFill: settings.heatmap,
heatmapTiltDbPerOctave: settings.heatmapTiltDbPerOctave,
heatmapSmoothing: settings.heatmapSmoothing,
showGrid: settings.showGrid,
fillGradient: settings.fillGradient,
smoothing: settings.smoothing,
showSideLine: settings.showSideLine,
}
}
+135
View File
@@ -0,0 +1,135 @@
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: #000;
font-family: 'Inter', system-ui, sans-serif;
}
.spectrum-app {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.spectrum-app.has-settings {
overflow-y: auto;
}
/* The scope fills everything above the (optional) bottom settings panel. */
.spectrum-app__viewport {
position: relative;
flex: 1 1 auto;
min-height: 0;
}
.spectrum-app.has-settings .spectrum-app__viewport {
flex: 0 0 var(--spectrum-viewport-height, 100%);
height: var(--spectrum-viewport-height, 100%);
}
.spectrum-scope {
position: absolute;
inset: 0;
}
.spectrum-scope__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
/* Settings gear — appears on hover (like the app's scope chrome). */
.spectrum-app__gear {
position: absolute;
top: 8px;
right: 8px;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: var(--text-secondary, rgba(255, 255, 255, 0.62));
background: var(--control-bg, rgba(255, 255, 255, 0.04));
border: 1px solid var(--control-border, rgba(255, 255, 255, 0.08));
border-radius: 7px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.spectrum-app:hover .spectrum-app__gear,
.spectrum-app__gear.is-active {
opacity: 1;
}
.spectrum-app__gear:hover {
color: var(--text-primary, #fff);
background: var(--control-bg-hover, rgba(255, 255, 255, 0.08));
}
.spectrum-app__gear.is-active {
color: var(--accent, #38bdf8);
border-color: var(--control-border-active, rgba(56, 189, 248, 0.4));
}
/* Settings panel along the bottom. The editor asks the host to grow by this height,
but Windows DAWs may delay or constrain that resize, so the viewport locks to its
previous height and this panel overflows instead of shrinking the scope. */
.spectrum-app__panel {
flex: 0 0 280px;
width: 100%;
overflow-y: auto;
padding: 12px;
background: var(--settings-bg-top, rgba(8, 10, 14, 0.96));
border-top: 1px solid var(--panel-outline, rgba(255, 255, 255, 0.12));
}
.spectrum-scope__peak {
position: absolute;
z-index: 4;
pointer-events: none;
display: flex;
gap: 5px;
align-items: center;
padding: 3px 8px;
border-radius: 6px;
font: 11px/1.3 'JetBrains Mono', ui-monospace, monospace;
color: var(--scope-overlay-text, rgba(255, 255, 255, 0.82));
background: var(--scope-overlay-surface, rgba(8, 12, 18, 0.82));
border: 1px solid var(--scope-overlay-border, rgba(255, 255, 255, 0.12));
}
.spectrum-scope__peak.is-corner {
top: 8px;
left: 8px;
}
.spectrum-scope__peak.is-following {
transform: translate(-50%, -120%);
}
.spectrum-scope__peak-sep {
opacity: 0.4;
}
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
import type { ScopeKind } from '../types/scope'
import type { PrismResolvedTheme } from '../types/theme'
import { createBundledThemes, createDefaultTheme, parseThemeFileContent, resolveTheme } from '../shared/themeState'
import { emitToHost, onHostEvent } from './juceBridge'
const DEFAULT_THEME = resolveTheme(createDefaultTheme())
function mergeScopeSettings<K extends ScopeKind>(kind: K, raw: unknown): ScopeSettings[K] {
const defaults = DEFAULT_SCOPE_SETTINGS[kind] as Record<string, unknown>
if (typeof raw !== 'object' || raw === null) return { ...defaults } as ScopeSettings[K]
const parsed = raw as Record<string, unknown>
const next: Record<string, unknown> = { ...defaults }
for (const key of Object.keys(defaults)) {
if (key in parsed && typeof parsed[key] === typeof defaults[key]) {
next[key] = parsed[key]
}
}
return next as ScopeSettings[K]
}
function resolveAppTheme(themeId: string, themeFile: string): PrismResolvedTheme {
try {
if (themeFile) return resolveTheme(parseThemeFileContent(themeFile, themeId || undefined))
} catch {
// fall through
}
if (themeId) {
const bundled = createBundledThemes().find((theme) => theme.name === themeId)
if (bundled) return resolveTheme(bundled)
}
return DEFAULT_THEME
}
function resolveAppScopeSettings<K extends ScopeKind>(kind: K, profileJson: string): ScopeSettings[K] {
try {
const parsed = JSON.parse(profileJson) as { scopeSettings?: Record<string, unknown> }
const scoped = parsed?.scopeSettings?.[kind]
if (scoped) return mergeScopeSettings(kind, scoped)
} catch {
// fall through
}
return { ...(DEFAULT_SCOPE_SETTINGS[kind] as object) } as ScopeSettings[K]
}
export interface ScopeHostSync<K extends ScopeKind> {
settings: ScopeSettings[K]
resolvedTheme: PrismResolvedTheme
handleUpdate: (partial: Partial<ScopeSettings[K]>) => void
}
/**
* Shared host sync for any scope plugin: applies app-default theme/settings,
* persists per-instance overrides, and reconciles precedence (per-instance DAW
* override > app settings > built-in defaults). Theme always follows the app.
*/
export function useScopeHostSync<K extends ScopeKind>(kind: K): ScopeHostSync<K> {
const [settings, setSettings] = useState<ScopeSettings[K]>(() => mergeScopeSettings(kind, undefined))
const [resolvedTheme, setResolvedTheme] = useState<PrismResolvedTheme>(DEFAULT_THEME)
const settingsRef = useRef(settings)
const hasOverride = useRef(false)
const applySettings = useCallback((next: ScopeSettings[K], persist: boolean): void => {
settingsRef.current = next
setSettings(next)
emitToHost('prismConfig', { settings: next, persist })
}, [])
useEffect(() => {
const unsubRestore = onHostEvent('prismRestoreSettings', (payload) => {
const json = (payload as { json?: unknown })?.json
if (typeof json === 'string' && json.length > 0) {
try {
hasOverride.current = true
applySettings(mergeScopeSettings(kind, JSON.parse(json)), false)
} catch {
// ignore malformed saved settings
}
}
})
const unsubDefaults = onHostEvent('prismAppDefaults', (payload) => {
const p = (payload ?? {}) as { themeId?: string; themeFile?: string; profileJson?: string }
setResolvedTheme(resolveAppTheme(p.themeId ?? '', p.themeFile ?? ''))
if (!hasOverride.current) {
applySettings(resolveAppScopeSettings(kind, p.profileJson ?? ''), false)
}
})
emitToHost('prismReady', {})
return () => {
unsubRestore()
unsubDefaults()
}
}, [kind, applySettings])
const handleUpdate = useCallback((partial: Partial<ScopeSettings[K]>): void => {
hasOverride.current = true
applySettings({ ...settingsRef.current, ...partial }, true)
}, [applySettings])
return { settings, resolvedTheme, handleUpdate }
}
+30
View File
@@ -0,0 +1,30 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedVectorscopeTheme } from '../types/theme'
import type { VectorscopeOptions } from '../renderer/visualizers/Vectorscope'
/**
* Map Prism's vectorscope settings + resolved theme to Vectorscope options.
* Mirrors the `vectorscope` case of `scopeSettingsToOptions` in ScopeModule.tsx.
*/
export function vectorscopeSettingsToOptions(
settings: ScopeSettings['vectorscope'],
theme: ResolvedVectorscopeTheme,
): VectorscopeOptions {
return {
lineColor: theme.trace,
backgroundColor: theme.background,
gridMajorColor: theme.guides,
gridMinorColor: theme.guidesSecondary,
labelColor: theme.labels,
bandColors: {
low: theme.bandLow,
mid: theme.bandMid,
high: theme.bandHigh,
},
mode: settings.mode,
multiband: settings.multiband,
showGrid: settings.showGrid,
persistence: settings.persistence,
lineWidth: settings.lineWidth,
}
}
+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,
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { ScopeSettings } from '../types/settings'
import type { ResolvedWaveformTheme } from '../types/theme'
import type { WaveformOptions } from '../renderer/visualizers/Waveform'
/**
* Map Prism's waveform settings + resolved theme to Waveform options.
* Mirrors the `waveform` case of `scopeSettingsToOptions` in ScopeModule.tsx.
*/
export function waveformSettingsToOptions(
settings: ScopeSettings['waveform'],
theme: ResolvedWaveformTheme,
): WaveformOptions {
return {
backgroundColor: theme.background,
lineColor: theme.line,
gridMajorColor: theme.guides,
gridMinorColor: theme.guidesSecondary,
bandColors: {
low: theme.bandLow,
mid: theme.bandMid,
high: theme.bandHigh,
},
mode: settings.mode,
scrollSpeed: settings.scrollSpeed,
multiband: settings.multiband,
}
}