diff --git a/package.json b/package.json index 100c158..f94304a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "test:build-metadata": "node scripts/run-build-metadata-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", "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'", diff --git a/plugin/.gitignore b/plugin/.gitignore new file mode 100644 index 0000000..563db30 --- /dev/null +++ b/plugin/.gitignore @@ -0,0 +1,2 @@ +build/ +webview-dist/ diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt new file mode 100644 index 0000000..d92110e --- /dev/null +++ b/plugin/CMakeLists.txt @@ -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) diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 0000000..3811793 --- /dev/null +++ b/plugin/README.md @@ -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 + `` 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). diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp new file mode 100644 index 0000000..567cb0a --- /dev/null +++ b/plugin/Source/PluginEditor.cpp @@ -0,0 +1,135 @@ +#include "PluginEditor.h" +#include + +#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 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 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)); +} diff --git a/plugin/Source/PluginEditor.h b/plugin/Source/PluginEditor.h new file mode 100644 index 0000000..c8bd038 --- /dev/null +++ b/plugin/Source/PluginEditor.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include "PluginProcessor.h" +#include "spectrum.h" // reused, unmodified, from native/src +#include + +/** + * 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 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) +}; diff --git a/plugin/Source/PluginProcessor.cpp b/plugin/Source/PluginProcessor.cpp new file mode 100644 index 0000000..252885c --- /dev/null +++ b/plugin/Source/PluginProcessor.cpp @@ -0,0 +1,95 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" +#include + +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& 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(); +} diff --git a/plugin/Source/PluginProcessor.h b/plugin/Source/PluginProcessor.h new file mode 100644 index 0000000..d6ddc09 --- /dev/null +++ b/plugin/Source/PluginProcessor.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include + +/** + * 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&, 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 fifoBuffer; // backing storage for `fifo` + std::vector monoScratch; // realtime-thread mono mixdown buffer + std::atomic currentSampleRate { 48000.0 }; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismSpectrumProcessor) +}; diff --git a/plugin/Source/WebViewFrameRate.h b/plugin/Source/WebViewFrameRate.h new file mode 100644 index 0000000..9891893 --- /dev/null +++ b/plugin/Source/WebViewFrameRate.h @@ -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); diff --git a/plugin/Source/WebViewFrameRate.mm b/plugin/Source/WebViewFrameRate.mm new file mode 100644 index 0000000..e31c824 --- /dev/null +++ b/plugin/Source/WebViewFrameRate.mm @@ -0,0 +1,62 @@ +#import +#import +#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; +} diff --git a/src/plugin-ui/BridgeSpectrumAnalyzer.ts b/src/plugin-ui/BridgeSpectrumAnalyzer.ts new file mode 100644 index 0000000..003fb6d --- /dev/null +++ b/src/plugin-ui/BridgeSpectrumAnalyzer.ts @@ -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) + } +} diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts new file mode 100644 index 0000000..65d8f1d --- /dev/null +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -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): void { + this.sessionState = { ...this.sessionState, ...partial } + for (const listener of this.listeners) { + listener(this.sessionState) + } + } +} diff --git a/src/plugin-ui/SpectrumScope.tsx b/src/plugin-ui/SpectrumScope.tsx new file mode 100644 index 0000000..1d4db6c --- /dev/null +++ b/src/plugin-ui/SpectrumScope.tsx @@ -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(null) + const canvasRef = useRef(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 ( +
+ + {showFpsMeter && ( +
+ render {fps.render} fps · data {fps.data} fps · dpr {window.devicePixelRatio || 1} +
+ )} +
+ ) +} diff --git a/src/plugin-ui/index.html b/src/plugin-ui/index.html new file mode 100644 index 0000000..698de1c --- /dev/null +++ b/src/plugin-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Prism Spectrum + + +
+ + + diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts new file mode 100644 index 0000000..d3a70de --- /dev/null +++ b/src/plugin-ui/juceBridge.ts @@ -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 | 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) + } + } +} diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx new file mode 100644 index 0000000..d828b1a --- /dev/null +++ b/src/plugin-ui/main.tsx @@ -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 . +let dataFrameCount = 0 + +// Pipe host frames into the shim/data source. The SpectrumAnalyzer (mounted by +// ) 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( + + dataFrameCount} + /> + , +) diff --git a/src/plugin-ui/styles.css b/src/plugin-ui/styles.css new file mode 100644 index 0000000..9c0b248 --- /dev/null +++ b/src/plugin-ui/styles.css @@ -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; +} diff --git a/vite.plugin-ui.config.ts b/vite.plugin-ui.config.ts new file mode 100644 index 0000000..0dc31bb --- /dev/null +++ b/vite.plugin-ui.config.ts @@ -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]', + }, + }, + }, +})