mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-19 12:04:18 +02:00
initial VST proof of concept
This commit is contained in:
@@ -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'",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
build/
|
||||||
|
webview-dist/
|
||||||
@@ -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)
|
||||||
@@ -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).
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user