/** * 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 /** Smoothed max(L, R) magnitudes in dBFS (same length). */ channelMax: Float32Array } interface SpectrumFramePayload { sampleRate?: number magnitudes?: string side?: string channelMax?: 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 | null = null function ensureBackend(): Promise { 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) } export function decodeSpectrumFrame(payload: unknown): SpectrumFrame | null { if (typeof payload !== 'object' || payload === null) return null const { sampleRate, magnitudes, side, channelMax } = payload as SpectrumFramePayload if (typeof magnitudes !== 'string' || magnitudes.length === 0) return null const decodedMagnitudes = base64ToFloat32Array(magnitudes) const decodedChannelMax = typeof channelMax === 'string' ? base64ToFloat32Array(channelMax) : new Float32Array(0) return { sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000, magnitudes: decodedMagnitudes, side: typeof side === 'string' ? base64ToFloat32Array(side) : new Float32Array(0), channelMax: decodedChannelMax.length > 0 ? decodedChannelMax : decodedMagnitudes, } } 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) const channelMax = new Float32Array(binCount) 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 channelMax[i] = mid[i] } handlers.onFrame({ sampleRate, magnitudes: mid, side, channelMax }) mockRaf = requestAnimationFrame(tick) } mockRaf = requestAnimationFrame(tick) } void ensureBackend().then((backend) => { if (disposed) return if (backend) { listenerId = backend.addEventListener('spectrumFrame', (payload) => { const frame = decodeSpectrumFrame(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 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 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) } }