initial VST proof of concept

This commit is contained in:
Boof2015
2026-05-26 16:12:53 -04:00
parent 180d0dbef5
commit 3335b4a917
18 changed files with 1158 additions and 0 deletions
+2
View File
@@ -23,6 +23,8 @@
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
"test:build-metadata": "node scripts/run-build-metadata-tests.mjs", "test:build-metadata": "node scripts/run-build-metadata-tests.mjs",
"test:updates": "node scripts/run-update-tests.mjs", "test:updates": "node scripts/run-update-tests.mjs",
"plugin-ui:dev": "vite --config vite.plugin-ui.config.ts",
"plugin-ui:build": "vite build --config vite.plugin-ui.config.ts",
"build:native": "cd native && node-gyp rebuild", "build:native": "cd native && node-gyp rebuild",
"rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"", "rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"",
"postinstall": "npm run rebuild:native || echo 'Native build failed, will use JS fallback'", "postinstall": "npm run rebuild:native || echo 'Native build failed, will use JS fallback'",
+2
View File
@@ -0,0 +1,2 @@
build/
webview-dist/
+85
View File
@@ -0,0 +1,85 @@
cmake_minimum_required(VERSION 3.22)
project(PrismPlugins VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# JUCE: use a local checkout if provided (-DJUCE_PATH=/path/to/JUCE), otherwise
# fetch a pinned release. A local checkout makes reconfigures much faster.
if(DEFINED JUCE_PATH)
add_subdirectory(${JUCE_PATH} juce-build)
else()
include(FetchContent)
FetchContent_Declare(JUCE
GIT_REPOSITORY https://github.com/juce-framework/JUCE.git
GIT_TAG 8.0.4
GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(JUCE)
endif()
# Reused, unmodified Prism DSP (no N-API dependency).
set(PRISM_NATIVE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../native/src)
juce_add_plugin(PrismSpectrum
PRODUCT_NAME "Prism Spectrum"
COMPANY_NAME "Prism"
BUNDLE_ID com.astra.prism.spectrum
PLUGIN_MANUFACTURER_CODE Prsm
PLUGIN_CODE Pspc
FORMATS AU VST3 Standalone
IS_SYNTH FALSE
NEEDS_MIDI_INPUT FALSE
NEEDS_MIDI_OUTPUT FALSE
IS_MIDI_EFFECT FALSE
NEEDS_WEBVIEW2 FALSE # macOS uses WKWebView; flip on for Windows later
COPY_PLUGIN_AFTER_BUILD TRUE)
target_sources(PrismSpectrum PRIVATE
Source/PluginProcessor.cpp
Source/PluginEditor.cpp
${PRISM_NATIVE_DIR}/spectrum.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(PrismSpectrum PRIVATE
Source
${PRISM_NATIVE_DIR})
# macOS: private-API helper to lift WKWebView's 60fps cap (no public alternative).
if(APPLE)
target_sources(PrismSpectrum PRIVATE Source/WebViewFrameRate.mm)
set_source_files_properties(Source/WebViewFrameRate.mm PROPERTIES COMPILE_FLAGS "-fno-objc-arc")
target_link_libraries(PrismSpectrum PRIVATE "-framework WebKit")
endif()
target_compile_definitions(PrismSpectrum PRIVATE
JUCE_WEB_BROWSER=1 # enables WebBrowserComponent (WKWebView on macOS)
JUCE_USE_CURL=0
JUCE_VST3_CAN_REPLACE_VST2=0)
# UI delivery: bundled (self-contained, default) vs Vite dev server (hot reload).
option(PRISM_DEV_SERVER "Load the webview UI from the Vite dev server instead of the embedded bundle" OFF)
if(PRISM_DEV_SERVER)
target_compile_definitions(PrismSpectrum PRIVATE PRISM_USE_DEV_SERVER=1)
message(STATUS "Prism: UI from Vite dev server (run 'npm run plugin-ui:dev')")
else()
# Embed the built webview bundle (run 'npm run plugin-ui:build' first).
set(PRISM_WEBUI_DIST ${CMAKE_CURRENT_SOURCE_DIR}/webview-dist)
file(GLOB_RECURSE PRISM_WEBUI_FILES "${PRISM_WEBUI_DIST}/*")
if(NOT PRISM_WEBUI_FILES)
message(FATAL_ERROR "No webview bundle at ${PRISM_WEBUI_DIST}. Run: npm run plugin-ui:build")
endif()
juce_add_binary_data(PrismSpectrumWebUI SOURCES ${PRISM_WEBUI_FILES})
target_link_libraries(PrismSpectrum PRIVATE PrismSpectrumWebUI)
target_compile_definitions(PrismSpectrum PRIVATE PRISM_USE_DEV_SERVER=0)
message(STATUS "Prism: UI embedded from ${PRISM_WEBUI_DIST}")
endif()
target_link_libraries(PrismSpectrum PRIVATE
juce::juce_audio_utils
juce::juce_gui_extra
PRIVATE
juce::juce_recommended_config_flags
juce::juce_recommended_lto_flags
juce::juce_recommended_warning_flags)
+66
View File
@@ -0,0 +1,66 @@
# Prism Spectrum — DAW plugin POC
A proof-of-concept JUCE 8 plugin (VST3 / AU / Standalone, macOS) that renders Prism's
Spectrum scope inside a DAW. It **reuses Prism's existing C++ DSP** (`native/src/spectrum.cpp`,
`dsp_utils.cpp`) and the **existing React canvas UI** (`src/plugin-ui`, which imports
`src/renderer/visualizers/SpectrumAnalyzer.ts`).
## How it fits together
```
DAW track audio
→ processBlock (RT thread): mix to mono, write to lock-free FIFO [Source/PluginProcessor.cpp]
→ 60 Hz timer (message thread): drain FIFO → Visualizer::Spectrum [Source/PluginEditor.cpp + native/src/spectrum.cpp]
→ emit "spectrumFrame" (base64 Float32 magnitudes) to the webview
→ juceBridge.ts decodes → BridgeSpectrumAnalyzer (a SpectrumNativeAnalyzer shim)
→ SpectrumAnalyzer.ts renders to canvas (unchanged Electron code) [src/plugin-ui]
```
No DSP or allocation runs on the realtime audio thread; audio passes through unmodified.
## Build & run (macOS)
Prereqs: CMake ≥ 3.22, Xcode command-line tools, Node.
### Default: self-contained build (embedded UI)
The UI is bundled into the plugin binary and served via JUCE's resource provider —
**no dev server needed at runtime.**
```sh
npm run plugin-ui:build # build the webview bundle → plugin/webview-dist
cmake -B plugin/build -S plugin -DCMAKE_BUILD_TYPE=Release # embeds the bundle (reconfigure to pick up UI changes)
cmake --build plugin/build --config Release
```
`COPY_PLUGIN_AFTER_BUILD` installs into your user plugin folders:
- AU: `~/Library/Audio/Plug-Ins/Components/Prism Spectrum.component`
- VST3: `~/Library/Audio/Plug-Ins/VST3/Prism Spectrum.vst3`
Run the **Standalone** (`plugin/build/.../Standalone/Prism Spectrum.app`) or load the
VST3/AU on a track in Ableton / FL / Logic / Reaper, play audio, and the spectrum animates.
(To use a local JUCE checkout instead of fetching: add `-DJUCE_PATH=/path/to/JUCE`.)
### UI development: dev-server mode (hot reload)
```sh
npm run plugin-ui:dev # serve UI on :5174 with HMR
cmake -B plugin/build -S plugin -DPRISM_DEV_SERVER=ON # editor loads http://localhost:5174
cmake --build plugin/build --config Release
```
Edit React → the plugin window hot-reloads. The UI also runs in a plain browser at
`http://localhost:5174` (no JUCE host → it shows a synthetic spectrum so the UI is
developable outside a DAW). Reconfigure without `-DPRISM_DEV_SERVER=ON` to go back to embedded.
## Notes
- **Refresh rate (macOS):** frames are emitted on `juce::VBlankAttachment` (synced to the
display, adapts to 60/120/144 Hz) and the webview renders via the `display-sync`
FrameScheduler. WKWebView otherwise throttles `requestAnimationFrame` to 60 fps regardless
of display — `Source/WebViewFrameRate.mm` lifts that by disabling WebKit's private
`PreferPageRenderingUpdatesNear60FPSEnabled` feature on the live web view (no public API
exists; fine for a non-App-Store FOSS plugin). To diagnose, set `showFpsMeter` on
`<SpectrumScope/>` to overlay `render / data` fps.
- **Windows (later):** bump `NEEDS_WEBVIEW2 TRUE`; the DSP is already cross-platform. The
frame-rate workaround is macOS-only (WebView2 has its own rate behavior).
+135
View File
@@ -0,0 +1,135 @@
#include "PluginEditor.h"
#include <cstring>
#if ! PRISM_USE_DEV_SERVER
#include "BinaryData.h"
#endif
#if JUCE_MAC
#include "WebViewFrameRate.h"
#endif
namespace
{
const juce::Identifier kSpectrumFrameEvent { "spectrumFrame" };
constexpr int kDrainCapacity = 16384;
#if PRISM_USE_DEV_SERVER
// Hot-reload path: load the Vite dev server (run `npm run plugin-ui:dev`).
const juce::String kDevServerUrl { "http://localhost:5174" };
#else
// Self-contained path: serve the bundle embedded via juce_add_binary_data.
juce::String mimeForExtension(const juce::String& name)
{
if (name.endsWithIgnoreCase(".html")) return "text/html";
if (name.endsWithIgnoreCase(".js")) return "text/javascript";
if (name.endsWithIgnoreCase(".css")) return "text/css";
if (name.endsWithIgnoreCase(".svg")) return "image/svg+xml";
if (name.endsWithIgnoreCase(".json")) return "application/json";
if (name.endsWithIgnoreCase(".woff2")) return "font/woff2";
if (name.endsWithIgnoreCase(".woff")) return "font/woff";
if (name.endsWithIgnoreCase(".png")) return "image/png";
return "application/octet-stream";
}
std::optional<juce::WebBrowserComponent::Resource> provideResource(const juce::String& url)
{
// "/" -> index.html; otherwise match the request's basename against the
// embedded originals (juce_add_binary_data stores files by basename).
auto name = (url == "/") ? juce::String("index.html")
: url.fromLastOccurrenceOf("/", false, false);
name = name.upToFirstOccurrenceOf("?", false, false);
for (int i = 0; i < BinaryData::namedResourceListSize; ++i)
{
if (name == juce::String(BinaryData::originalFilenames[i]))
{
int dataSize = 0;
const char* data = BinaryData::getNamedResource(BinaryData::namedResourceList[i], dataSize);
std::vector<std::byte> bytes ((size_t) dataSize);
std::memcpy (bytes.data(), data, (size_t) dataSize);
return juce::WebBrowserComponent::Resource { std::move (bytes), mimeForExtension (name) };
}
}
return std::nullopt;
}
#endif
juce::WebBrowserComponent::Options makeWebOptions()
{
auto options = juce::WebBrowserComponent::Options{}.withNativeIntegrationEnabled();
#if ! PRISM_USE_DEV_SERVER
options = options.withResourceProvider ([] (const auto& url) { return provideResource (url); });
#endif
return options;
}
}
PrismSpectrumEditor::PrismSpectrumEditor(PrismSpectrumProcessor& p)
: juce::AudioProcessorEditor(&p),
processorRef(p),
webView(makeWebOptions())
{
drainScratch.assign((size_t) kDrainCapacity, 0.0f);
addAndMakeVisible(webView);
#if PRISM_USE_DEV_SERVER
webView.goToURL(kDevServerUrl);
#else
webView.goToURL(juce::WebBrowserComponent::getResourceProviderRoot());
#endif
setResizable(true, true);
setResizeLimits(360, 200, 4096, 4096);
setSize(900, 480);
// Drive frames at the display's refresh rate (adapts to 60/120/144 Hz).
vblank = juce::VBlankAttachment(this, [this] { renderFrame(); });
}
void PrismSpectrumEditor::resized()
{
webView.setBounds(getLocalBounds());
}
void PrismSpectrumEditor::renderFrame()
{
#if JUCE_MAC
// Once the editor is on screen, lift WKWebView's private 60fps cap so the
// canvas repaints at the display's native rate (e.g. 120Hz). Retry a few
// frames until the web view exists in the hierarchy, then stop.
if (! frameRateUncapped && uncapAttempts < 300)
{
++uncapAttempts;
if (auto* peer = getPeer())
frameRateUncapped = prismUncapWebViewFrameRate(peer->getNativeHandle());
}
#endif
const double sampleRate = processorRef.getSampleRateHz();
if (sampleRate > 0.0 && sampleRate != lastSampleRate)
{
spectrum.setSampleRate((float) sampleRate);
lastSampleRate = sampleRate;
}
const int drained = processorRef.drainSamples(drainScratch.data(), (int) drainScratch.size());
if (drained > 0)
spectrum.pushSamples(drainScratch.data(), (size_t) drained);
else
spectrum.pushSamples(nullptr, 0); // recompute so smoothing keeps decaying to silence
const auto& magnitudes = spectrum.getMagnitudes();
if (magnitudes.empty())
return;
const auto encoded = juce::Base64::toBase64(magnitudes.data(),
magnitudes.size() * sizeof(float));
auto* payload = new juce::DynamicObject();
payload->setProperty("sampleRate", sampleRate);
payload->setProperty("magnitudes", encoded);
webView.emitEventIfBrowserIsVisible(kSpectrumFrameEvent, juce::var(payload));
}
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <juce_gui_extra/juce_gui_extra.h>
#include "PluginProcessor.h"
#include "spectrum.h" // reused, unmodified, from native/src
#include <vector>
/**
* Hosts the React webview UI and bridges the reused Prism spectrum DSP to it.
*
* Driven by a VBlankAttachment (message thread, synced to the display's refresh
* rate — so it adapts to 60/120/144 Hz panels). Each vblank it drains audio
* buffered by the processor, runs Visualizer::Spectrum (native/src/spectrum.cpp),
* and emits magnitudes to the webview as a "spectrumFrame" event. The webview
* (src/plugin-ui) renders them with the existing SpectrumAnalyzer canvas code.
*/
class PrismSpectrumEditor : public juce::AudioProcessorEditor
{
public:
explicit PrismSpectrumEditor(PrismSpectrumProcessor&);
~PrismSpectrumEditor() override = default;
void resized() override;
private:
void renderFrame();
PrismSpectrumProcessor& processorRef;
Visualizer::Spectrum spectrum { 2048 };
std::vector<float> drainScratch;
double lastSampleRate = 0.0;
// One-time attempt to lift WKWebView's private 60fps cap (macOS).
bool frameRateUncapped = false;
int uncapAttempts = 0;
juce::WebBrowserComponent webView;
// Declared last so it is destroyed first — no vblank callback can fire into
// a partially-destroyed editor.
juce::VBlankAttachment vblank;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismSpectrumEditor)
};
+95
View File
@@ -0,0 +1,95 @@
#include "PluginProcessor.h"
#include "PluginEditor.h"
#include <cstring>
PrismSpectrumProcessor::PrismSpectrumProcessor()
: juce::AudioProcessor(BusesProperties()
.withInput("Input", juce::AudioChannelSet::stereo(), true)
.withOutput("Output", juce::AudioChannelSet::stereo(), true))
{
fifoBuffer.assign((size_t) fifo.getTotalSize(), 0.0f);
}
void PrismSpectrumProcessor::prepareToPlay(double sampleRate, int samplesPerBlock)
{
currentSampleRate.store(sampleRate);
monoScratch.assign((size_t) juce::jmax(samplesPerBlock, 1), 0.0f);
fifo.reset();
}
bool PrismSpectrumProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const
{
const auto& mainOut = layouts.getMainOutputChannelSet();
if (mainOut != juce::AudioChannelSet::mono() && mainOut != juce::AudioChannelSet::stereo())
return false;
// Analyzer passes audio through, so the input layout must match the output.
return mainOut == layouts.getMainInputChannelSet();
}
void PrismSpectrumProcessor::pushMonoToFifo(const float* data, int num) noexcept
{
int start1, size1, start2, size2;
fifo.prepareToWrite(num, start1, size1, start2, size2);
if (size1 > 0)
std::memcpy(fifoBuffer.data() + start1, data, (size_t) size1 * sizeof(float));
if (size2 > 0)
std::memcpy(fifoBuffer.data() + start2, data + size1, (size_t) size2 * sizeof(float));
fifo.finishedWrite(size1 + size2);
}
int PrismSpectrumProcessor::drainSamples(float* dest, int maxSamples) noexcept
{
const int num = juce::jmin(maxSamples, fifo.getNumReady());
int start1, size1, start2, size2;
fifo.prepareToRead(num, start1, size1, start2, size2);
if (size1 > 0)
std::memcpy(dest, fifoBuffer.data() + start1, (size_t) size1 * sizeof(float));
if (size2 > 0)
std::memcpy(dest + size1, fifoBuffer.data() + start2, (size_t) size2 * sizeof(float));
fifo.finishedRead(size1 + size2);
return size1 + size2;
}
void PrismSpectrumProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
{
juce::ScopedNoDenormals noDenormals;
const int numSamples = buffer.getNumSamples();
const int numChannels = buffer.getNumChannels();
if (numSamples <= 0 || numChannels <= 0)
return;
if ((int) monoScratch.size() < numSamples)
monoScratch.resize((size_t) numSamples);
if (numChannels >= 2)
{
const float* left = buffer.getReadPointer(0);
const float* right = buffer.getReadPointer(1);
for (int i = 0; i < numSamples; ++i)
monoScratch[(size_t) i] = 0.5f * (left[i] + right[i]);
}
else
{
const float* mono = buffer.getReadPointer(0);
for (int i = 0; i < numSamples; ++i)
monoScratch[(size_t) i] = mono[i];
}
pushMonoToFifo(monoScratch.data(), numSamples);
// Pure analyzer: the audio buffer is left untouched (pass-through).
juce::ignoreUnused(numChannels);
}
juce::AudioProcessorEditor* PrismSpectrumProcessor::createEditor()
{
return new PrismSpectrumEditor(*this);
}
// This creates the plugin instance, called by the JUCE plugin wrappers.
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter()
{
return new PrismSpectrumProcessor();
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <atomic>
#include <vector>
/**
* Prism Spectrum — analyzer plugin.
*
* Passes audio through unchanged. On the realtime thread (processBlock) it mixes
* the input to mono and writes it into a lock-free FIFO. The editor's timer drains
* the FIFO off the realtime thread, runs the reused Prism DSP (native/src/spectrum.cpp),
* and pushes magnitudes to the webview. No DSP or allocation happens on the audio thread.
*/
class PrismSpectrumProcessor : public juce::AudioProcessor
{
public:
PrismSpectrumProcessor();
~PrismSpectrumProcessor() override = default;
void prepareToPlay(double sampleRate, int samplesPerBlock) override;
void releaseResources() override {}
bool isBusesLayoutSupported(const BusesLayout& layouts) const override;
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
juce::AudioProcessorEditor* createEditor() override;
bool hasEditor() const override { return true; }
const juce::String getName() const override { return "Prism Spectrum"; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
bool isMidiEffect() const override { return false; }
double getTailLengthSeconds() const override { return 0.0; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock&) override {}
void setStateInformation(const void*, int) override {}
/** Latest negotiated sample rate (read from the message thread). */
double getSampleRateHz() const noexcept { return currentSampleRate.load(); }
/** Copy up to `maxSamples` of buffered mono audio into `dest`; returns count written. */
int drainSamples(float* dest, int maxSamples) noexcept;
private:
void pushMonoToFifo(const float* data, int num) noexcept;
juce::AbstractFifo fifo { 1 << 16 };
std::vector<float> fifoBuffer; // backing storage for `fifo`
std::vector<float> monoScratch; // realtime-thread mono mixdown buffer
std::atomic<double> currentSampleRate { 48000.0 };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismSpectrumProcessor)
};
+13
View File
@@ -0,0 +1,13 @@
#pragma once
/**
* macOS only. Finds the WKWebView living under the given NSView (the plugin
* editor's peer) and disables WebKit's private `PreferPageRenderingUpdatesNear60FPSEnabled`
* feature, which otherwise throttles requestAnimationFrame to 60fps regardless of
* the display refresh rate. Returns true once the feature was found and toggled.
*
* Uses private WebKit API. There is no public alternative (Apple FB16411517 is
* unresolved). Acceptable for a non-App-Store FOSS plugin; guarded by
* respondsToSelector so it degrades to a no-op if the private API changes.
*/
bool prismUncapWebViewFrameRate(void* nsViewHandle);
+62
View File
@@ -0,0 +1,62 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#include "WebViewFrameRate.h"
// Private WebKit API. +_features is a CLASS method; the enable setter is an
// instance method. Guarded by respondsToSelector so this degrades to a no-op if
// the private API changes.
@interface WKPreferences (PrismPrivate)
+ (NSArray *)_features;
- (void)_setEnabled:(BOOL)enabled forFeature:(id)feature;
@end
static WKWebView* prismFindWebView(NSView* view)
{
if (view == nil)
return nil;
if ([view isKindOfClass:[WKWebView class]])
return (WKWebView*) view;
for (NSView* sub in [view subviews])
if (WKWebView* found = prismFindWebView(sub))
return found;
return nil;
}
bool prismUncapWebViewFrameRate(void* nsViewHandle)
{
WKWebView* webView = (nsViewHandle != nullptr) ? prismFindWebView((NSView*) nsViewHandle) : nil;
// Fallback: search every app window's content view.
if (webView == nil)
for (NSWindow* win in [NSApp windows])
if ((webView = prismFindWebView([win contentView])) != nil)
break;
if (webView == nil)
return false; // not in the hierarchy yet — caller retries
WKPreferences* prefs = [[webView configuration] preferences];
if (prefs == nil
|| ! [WKPreferences respondsToSelector:@selector(_features)]
|| ! [prefs respondsToSelector:@selector(_setEnabled:forFeature:)])
return false;
// Disable WebKit's private "prefer ~60fps page rendering" throttle so the
// canvas repaints at the display's native rate (e.g. 120Hz). Applied live:
// a reload is NOT used because JUCE's resource provider doesn't re-serve on
// reload (which would blank the page). The preference syncs to the WebContent
// process and takes effect on the running page.
for (id feature in [WKPreferences _features])
{
NSString* key = nil;
@try { key = [feature valueForKey:@"key"]; }
@catch (NSException*) { key = nil; }
if ([key isEqualToString:@"PreferPageRenderingUpdatesNear60FPSEnabled"])
{
[prefs _setEnabled:NO forFeature:feature];
return true;
}
}
return false;
}
+106
View File
@@ -0,0 +1,106 @@
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
constructor(fftSize = 2048) {
this.fftSize = fftSize
this.magnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
}
/** Called by the bridge whenever the host emits a new frame. */
setMagnitudes(magnitudes: Float32Array): void {
if (magnitudes.length !== this.magnitudes.length) {
this.magnitudes = new Float32Array(magnitudes.length)
}
this.magnitudes.set(magnitudes)
}
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)
}
}
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 currently sends a single (already smoothed) magnitude array.
// Serve it for the raw/side requests too; the heatmap + side-line paths can be
// wired to dedicated host arrays in a later phase.
fillRawMagnitudes(output: Float32Array): number {
return this.fillMagnitudes(output)
}
fillSideMagnitudes(output: Float32Array): number {
const count = Math.min(output.length, this.magnitudes.length)
output.fill(FFT_SILENCE_DB, 0, count)
return count
}
getMagnitudes(): Float32Array {
return this.magnitudes
}
getRawMagnitudes(): Float32Array {
return this.magnitudes
}
getSideMagnitudes(): Float32Array | null {
return null
}
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)
}
}
+71
View File
@@ -0,0 +1,71 @@
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>()
getPendingSpectrumSamples(): Float32Array[] {
return []
}
getPendingSpectrumStereoSamples(): SpectrumStereoChunk[] {
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)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState, type JSX } from 'react'
import { SpectrumAnalyzer } from '../renderer/visualizers/SpectrumAnalyzer'
import type { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
interface SpectrumScopeProps {
dataSource: PluginWebViewDataSource
nativeAnalyzer: BridgeSpectrumAnalyzer
/** Returns the cumulative count of frames received from the host (for the FPS meter). */
getDataFrameCount?: () => number
/** Show the render/data FPS diagnostic overlay. */
showFpsMeter?: boolean
}
// POC visual defaults. A later phase can sync these to Prism's theme/settings
// (the same `scopeSettingsToOptions` mapping ScopeModule already uses).
const VISUAL_OPTIONS = {
lineColor: '#22d3ee',
lineWidth: 2,
fillGradient: true,
gradientColors: ['rgba(34, 211, 238, 0)', 'rgba(34, 211, 238, 0.25)', 'rgba(139, 92, 246, 0.45)'],
backgroundColor: 'transparent',
showGrid: true,
gridColor: 'rgba(255, 255, 255, 0.1)',
scaleType: 'log' as const,
fftSize: 2048,
minFrequency: 20,
maxFrequency: 20000,
minDecibels: -90,
maxDecibels: -10,
}
export default function SpectrumScope({
dataSource,
nativeAnalyzer,
getDataFrameCount,
showFpsMeter = false,
}: SpectrumScopeProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const [fps, setFps] = useState({ render: 0, data: 0 })
useEffect(() => {
const container = containerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const analyzer = new SpectrumAnalyzer(canvas, {
...VISUAL_OPTIONS,
dataSource,
nativeAnalyzer,
})
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
analyzer.resize()
}
}
applySize()
analyzer.start()
const observer = new ResizeObserver(applySize)
observer.observe(container)
return () => {
observer.disconnect()
analyzer.dispose()
}
}, [dataSource, nativeAnalyzer])
// Independent FPS meter: counts our own rAF ticks (the webview's actual render
// rate) and the host data-frame delta over each measurement window.
useEffect(() => {
if (!showFpsMeter) return
let raf = 0
let renderCount = 0
let lastData = getDataFrameCount?.() ?? 0
let lastT = performance.now()
const loop = (t: number): void => {
renderCount += 1
const elapsed = t - lastT
if (elapsed >= 500) {
const dataNow = getDataFrameCount?.() ?? 0
const seconds = elapsed / 1000
setFps({
render: Math.round(renderCount / seconds),
data: Math.round((dataNow - lastData) / seconds),
})
renderCount = 0
lastData = dataNow
lastT = t
}
raf = requestAnimationFrame(loop)
}
raf = requestAnimationFrame(loop)
return () => cancelAnimationFrame(raf)
}, [showFpsMeter, getDataFrameCount])
return (
<div ref={containerRef} className="spectrum-scope">
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
{showFpsMeter && (
<div className="spectrum-scope__fps">
render {fps.render} fps · data {fps.data} fps · dpr {window.devicePixelRatio || 1}
</div>
)}
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
<!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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
/**
* Bridge between the JUCE 8 plugin host (C++) and this webview UI.
*
* The C++ side (plugin/Source/PluginEditor.cpp) computes spectrum magnitudes off
* the realtime thread and emits a "spectrumFrame" event ~60x/sec via
* juce::WebBrowserComponent::emitEventIfBrowserIsVisible(). JUCE injects
* `window.__JUCE__` into any page loaded by the webview (including the Vite dev
* server) when native integration is enabled, so we subscribe to that here.
*
* When `window.__JUCE__` is absent (e.g. opening the dev server in a normal
* browser), we fall back to a synthetic generator so the UI is still developable
* outside a DAW.
*/
export interface SpectrumFrame {
/** Host sample rate in Hz. */
sampleRate: number
/** Smoothed magnitudes in dB, length = fftSize/2 (1024 for a 2048 FFT). */
magnitudes: Float32Array
}
/** Raw payload as it arrives from C++ across the JUCE var bridge. */
interface SpectrumFramePayload {
sampleRate?: number
/** base64 of little-endian Float32 magnitude bytes. */
magnitudes?: string
}
type JuceBackend = {
addEventListener: (eventId: string, fn: (payload: unknown) => void) => number
removeEventListener?: (id: number) => void
}
declare global {
interface Window {
__JUCE__?: {
backend?: JuceBackend
initialisationData?: unknown
}
}
}
const SPECTRUM_EVENT_ID = 'spectrumFrame'
const HOST_WAIT_TIMEOUT_MS = 3000
const HOST_POLL_INTERVAL_MS = 50
/** Decode a base64 string of little-endian Float32 bytes into a Float32Array. */
function base64ToFloat32Array(b64: string): Float32Array {
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)
}
// byteLength is a multiple of 4 (Float32) when produced by the C++ side.
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 } = payload as SpectrumFramePayload
if (typeof magnitudes !== 'string' || magnitudes.length === 0) {
return null
}
return {
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
magnitudes: base64ToFloat32Array(magnitudes),
}
}
export interface SpectrumBridgeHandlers {
/** Called for every decoded frame (from the host or the mock generator). */
onFrame: (frame: SpectrumFrame) => void
/** Called once we know whether a real JUCE host is present. */
onConnected?: (usingMock: boolean) => void
}
/**
* Connect to the JUCE host. Resolves to a disposer that detaches the listener
* (or stops the mock generator).
*/
export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => void {
let disposed = false
let listenerId: number | null = null
let mockRaf: number | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let waited = 0
const attachToHost = (backend: JuceBackend): void => {
listenerId = backend.addEventListener(SPECTRUM_EVENT_ID, (payload) => {
const frame = decodeFrame(payload)
if (frame) {
handlers.onFrame(frame)
}
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host')
}
const startMock = (): void => {
handlers.onConnected?.(true)
console.warn('[prism-plugin] no JUCE host detected — using synthetic spectrum (browser dev mode)')
const binCount = 1024
const sampleRate = 48000
const data = 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
// A couple of moving peaks over a -100 dB noise floor.
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
const noise = Math.random() * 6
data[i] = -100 + peak1 + peak2 + noise
}
handlers.onFrame({ sampleRate, magnitudes: data })
mockRaf = requestAnimationFrame(tick)
}
mockRaf = requestAnimationFrame(tick)
}
const tryConnect = (): void => {
if (disposed) return
const backend = window.__JUCE__?.backend
if (backend && typeof backend.addEventListener === 'function') {
if (pollTimer) clearInterval(pollTimer)
pollTimer = null
attachToHost(backend)
return
}
waited += HOST_POLL_INTERVAL_MS
if (waited >= HOST_WAIT_TIMEOUT_MS) {
if (pollTimer) clearInterval(pollTimer)
pollTimer = null
startMock()
}
}
// Try immediately, then poll briefly (the __JUCE__ script may inject slightly late).
tryConnect()
if (listenerId === null && mockRaf === null) {
pollTimer = setInterval(tryConnect, HOST_POLL_INTERVAL_MS)
}
return () => {
disposed = true
if (pollTimer) clearInterval(pollTimer)
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
if (listenerId !== null) {
window.__JUCE__?.backend?.removeEventListener?.(listenerId)
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import SpectrumScope from './SpectrumScope'
import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
import { PluginWebViewDataSource } from './PluginWebViewDataSource'
import { connectSpectrumBridge } from './juceBridge'
import './styles.css'
// One shared analyzer shim + data source for the lifetime of the page.
const nativeAnalyzer = new BridgeSpectrumAnalyzer(2048)
const dataSource = new PluginWebViewDataSource()
// Diagnostic: count host frames so the FPS meter (off by default) can show the
// data push rate. Toggle it on by adding `showFpsMeter` to <SpectrumScope/>.
let dataFrameCount = 0
// Pipe host frames into the shim/data source. The SpectrumAnalyzer (mounted by
// <SpectrumScope/>) reads from both on its own render loop.
connectSpectrumBridge({
onFrame: (frame) => {
dataFrameCount += 1
nativeAnalyzer.setMagnitudes(frame.magnitudes)
dataSource.setSampleRate(frame.sampleRate)
dataSource.setPlaying(true)
},
})
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Missing #root element')
}
createRoot(rootElement).render(
<StrictMode>
<SpectrumScope
dataSource={dataSource}
nativeAnalyzer={nativeAnalyzer}
getDataFrameCount={() => dataFrameCount}
/>
</StrictMode>,
)
+49
View File
@@ -0,0 +1,49 @@
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: #0a0a0f;
font-family: 'Inter', system-ui, sans-serif;
}
.spectrum-scope {
position: relative;
width: 100%;
height: 100%;
}
.spectrum-scope__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.spectrum-scope__fps {
position: absolute;
top: 6px;
left: 8px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.45);
color: #7dd3fc;
font: 11px/1.4 'JetBrains Mono', ui-monospace, monospace;
pointer-events: none;
user-select: none;
}
+41
View File
@@ -0,0 +1,41 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
/**
* Standalone Vite build for the plugin webview UI (src/plugin-ui).
*
* - dev: `npx vite --config vite.plugin-ui.config.ts` serves on :5174, which
* the JUCE plugin's WebBrowserComponent points at for hot reload.
* - build: emits a static bundle the C++ side can later serve via JUCE's
* resource provider (production path; not used by the dev-server POC).
*
* Reuses the existing visualizer source under src/renderer via relative imports.
*/
export default defineConfig({
root: resolve(__dirname, 'src/plugin-ui'),
base: './',
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5174,
strictPort: true,
},
build: {
outDir: resolve(__dirname, 'plugin/webview-dist'),
emptyOutDir: true,
// Stable (unhashed) asset names so the C++ resource provider can map them
// deterministically and CMake's embedded BinaryData symbols stay stable.
rollupOptions: {
output: {
entryFileNames: 'assets/[name].js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name][extname]',
},
},
},
})