mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-20 20:39:59 +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: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'",
|
||||
|
||||
@@ -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