mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-16 16:21:11 +02:00
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user