Merge pull request #10 from Boof2015/VSTs

Add cross platform VSTs
This commit is contained in:
Boof2015
2026-06-03 10:21:21 -04:00
committed by GitHub
parent 180d0dbef5
commit 63ef5ffa0f
63 changed files with 5617 additions and 66 deletions
+2
View File
@@ -0,0 +1,2 @@
build/
webview-dist/
+218
View File
@@ -0,0 +1,218 @@
cmake_minimum_required(VERSION 3.22)
# macOS deployment target. Without an override, the build inherits the host SDK
# (e.g. 26 on a current Mac) which restricts the plugin to that macOS or newer.
# 11.0 (Big Sur) is a reasonable floor; well within JUCE 8's supported range.
# Override on the command line with -DCMAKE_OSX_DEPLOYMENT_TARGET=X.X — the FORCE
# is only applied when the value is empty/unset, so explicit overrides win.
if(NOT CMAKE_OSX_DEPLOYMENT_TARGET)
set(CMAKE_OSX_DEPLOYMENT_TARGET "11.0" CACHE STRING "Minimum macOS version supported" FORCE)
endif()
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.
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)
# 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)
# Build controls. Defaults preserve the full local build; CI narrows these to
# the formats that are actually packaged. AU is macOS-only, so Linux/Windows
# default to the portable VST3 + Standalone set.
if(APPLE)
set(PRISM_DEFAULT_PLUGIN_FORMATS "AU;VST3;Standalone")
else()
set(PRISM_DEFAULT_PLUGIN_FORMATS "VST3;Standalone")
endif()
set(PRISM_PLUGIN_FORMATS "${PRISM_DEFAULT_PLUGIN_FORMATS}" CACHE STRING "JUCE plugin formats to build")
option(PRISM_COPY_PLUGIN_AFTER_BUILD "Copy built plugins into user plugin folders after build" ON)
set(PRISM_LINUX_UI_MODE "webview" CACHE STRING "Linux-only UI diagnostic mode: webview, web_smoke, native_smoke, or floating_webview")
set_property(CACHE PRISM_LINUX_UI_MODE PROPERTY STRINGS webview web_smoke native_smoke floating_webview)
set(PRISM_VALID_LINUX_UI_MODES webview web_smoke native_smoke floating_webview)
option(PRISM_LINUX_UI_DIAGNOSTICS "Write Linux VST UI diagnostic logs" OFF)
if(UNIX AND NOT APPLE)
list(FIND PRISM_VALID_LINUX_UI_MODES "${PRISM_LINUX_UI_MODE}" PRISM_LINUX_UI_MODE_INDEX)
if(PRISM_LINUX_UI_MODE_INDEX EQUAL -1)
message(FATAL_ERROR "Invalid PRISM_LINUX_UI_MODE='${PRISM_LINUX_UI_MODE}'. Expected one of: ${PRISM_VALID_LINUX_UI_MODES}")
endif()
else()
if(NOT PRISM_LINUX_UI_MODE STREQUAL "webview")
message(FATAL_ERROR "PRISM_LINUX_UI_MODE is Linux-only; macOS/Windows builds must use 'webview'.")
endif()
endif()
set(PRISM_NEEDS_WEBUI OFF)
if(NOT PRISM_DEV_SERVER)
if(NOT UNIX OR APPLE OR PRISM_LINUX_UI_MODE STREQUAL "webview" OR PRISM_LINUX_UI_MODE STREQUAL "floating_webview")
set(PRISM_NEEDS_WEBUI ON)
endif()
endif()
if(PRISM_NEEDS_WEBUI)
set(PRISM_EMBED_WEBUI_DEFINITION PRISM_EMBED_WEBUI=1)
else()
set(PRISM_EMBED_WEBUI_DEFINITION PRISM_EMBED_WEBUI=0)
endif()
string(TOUPPER "${PRISM_LINUX_UI_MODE}" PRISM_LINUX_UI_MODE_UPPER)
set(PRISM_LINUX_UI_MODE_DEFINITIONS
PRISM_LINUX_UI_MODE_NAME="${PRISM_LINUX_UI_MODE}"
PRISM_LINUX_UI_MODE_${PRISM_LINUX_UI_MODE_UPPER}=1)
# Embed the built webview bundle once; every scope plugin shares it (the C++ tells
# the UI which scope to mount via initialisation data).
if(PRISM_NEEDS_WEBUI)
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(PrismWebUI SOURCES ${PRISM_WEBUI_FILES})
endif()
if(UNIX AND NOT APPLE)
find_package(PkgConfig REQUIRED)
pkg_check_modules(PRISM_LINUX_WEBVIEW_DEPS REQUIRED
webkit2gtk-4.1
gtk+-x11-3.0)
endif()
# Define one per-scope plugin product from the shared codebase.
# SCOPE_DEFINE selects the ScopeEngine at compile time (empty = spectrum default).
function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE)
juce_add_plugin(${TARGET}
PRODUCT_NAME ${PRODUCT}
COMPANY_NAME "Prism"
BUNDLE_ID com.astra.prism.${TARGET}
PLUGIN_MANUFACTURER_CODE Prsm
PLUGIN_CODE ${PLUGIN_CODE}
FORMATS ${PRISM_PLUGIN_FORMATS}
IS_SYNTH FALSE
NEEDS_MIDI_INPUT FALSE
NEEDS_MIDI_OUTPUT FALSE
IS_MIDI_EFFECT FALSE
NEEDS_WEBVIEW2 TRUE # Windows: links Microsoft Edge WebView2. No-op elsewhere.
COPY_PLUGIN_AFTER_BUILD ${PRISM_COPY_PLUGIN_AFTER_BUILD})
target_sources(${TARGET} PRIVATE
Source/PluginProcessor.cpp
Source/PluginEditor.cpp
${PRISM_NATIVE_DIR}/spectrum.cpp
${PRISM_NATIVE_DIR}/oscilloscope.cpp
${PRISM_NATIVE_DIR}/vumeter.cpp
${PRISM_NATIVE_DIR}/lufsmeter.cpp
${PRISM_NATIVE_DIR}/vectorscope.cpp
${PRISM_NATIVE_DIR}/multiband.cpp
${PRISM_NATIVE_DIR}/spectrogram.cpp
${PRISM_NATIVE_DIR}/waveform.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR})
target_compile_definitions(${TARGET} PRIVATE
JUCE_WEB_BROWSER=1 # enables WebBrowserComponent (WKWebView on macOS)
JUCE_USE_CURL=0
JUCE_VST3_CAN_REPLACE_VST2=0
${PRISM_EMBED_WEBUI_DEFINITION}
PRISM_LINUX_UI_DIAGNOSTICS=$<BOOL:${PRISM_LINUX_UI_DIAGNOSTICS}>
${PRISM_LINUX_UI_MODE_DEFINITIONS})
if(WIN32)
# NEEDS_WEBVIEW2 links the WebView2 SDK but doesn't turn on the JUCE compile
# paths that gate withResourceProvider. JUCE_USE_WIN_WEBVIEW2=1 enables the
# WebView2-aware code (incl. the resource provider); _WITH_STATIC_LINKING=1
# matches the static-linked SDK that NEEDS_WEBVIEW2 wires up.
target_compile_definitions(${TARGET} PRIVATE
JUCE_USE_WIN_WEBVIEW2=1
JUCE_USE_WIN_WEBVIEW2_WITH_STATIC_LINKING=1)
endif()
if(NOT SCOPE_DEFINE STREQUAL "")
target_compile_definitions(${TARGET} PRIVATE ${SCOPE_DEFINE})
endif()
# macOS: private-API helper to lift WKWebView's 60fps cap (no public alternative).
if(APPLE)
target_sources(${TARGET} PRIVATE Source/WebViewFrameRate.mm)
set_source_files_properties(Source/WebViewFrameRate.mm PROPERTIES COMPILE_FLAGS "-fno-objc-arc")
target_link_libraries(${TARGET} PRIVATE "-framework WebKit")
endif()
if(PRISM_DEV_SERVER)
target_compile_definitions(${TARGET} PRIVATE PRISM_USE_DEV_SERVER=1)
else()
target_compile_definitions(${TARGET} PRIVATE PRISM_USE_DEV_SERVER=0)
endif()
if(PRISM_NEEDS_WEBUI)
target_link_libraries(${TARGET} PRIVATE PrismWebUI)
endif()
target_link_libraries(${TARGET} 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)
if(UNIX AND NOT APPLE)
# JUCE's Linux WebBrowserComponent dynamically opens WebKitGTK/GTK when
# the editor is created. We need their headers to compile, but linking
# them here makes DAW plugin scanners resolve WebKitGTK/JSC before the UI
# is even opened, which is fragile when hosts carry their own GLib stack.
target_include_directories(${TARGET} PRIVATE ${PRISM_LINUX_WEBVIEW_DEPS_INCLUDE_DIRS})
target_compile_options(${TARGET} PRIVATE ${PRISM_LINUX_WEBVIEW_DEPS_CFLAGS_OTHER})
# In plugin hosts, JUCE's Linux WebBrowserComponent must exec a helper
# process for WebKitGTK. Without this, JUCE falls back to running WebKit
# directly after fork(), which is unsafe in multi-threaded hosts and has
# crashed during pluginval/DAW editor creation.
juce_link_with_embedded_linux_subprocess(${TARGET})
endif()
endfunction()
add_prism_scope(PrismSpectrum "Prism Spectrum" Pspc "")
add_prism_scope(PrismOscilloscope "Prism Oscilloscope" Posc "PRISM_SCOPE_OSCILLOSCOPE=1")
add_prism_scope(PrismVUMeter "Prism VU Meter" Pvum "PRISM_SCOPE_VUMETER=1")
add_prism_scope(PrismLUFSMeter "Prism Loudness Meter" Pluf "PRISM_SCOPE_LUFSMETER=1")
add_prism_scope(PrismVectorscope "Prism Vectorscope" Pvct "PRISM_SCOPE_VECTORSCOPE=1")
add_prism_scope(PrismSpectrogram "Prism Spectrogram" Pspg "PRISM_SCOPE_SPECTROGRAM=1")
add_prism_scope(PrismWaveform "Prism Waveform" Pwav "PRISM_SCOPE_WAVEFORM=1")
set(PRISM_SCOPE_TARGETS
PrismSpectrum
PrismOscilloscope
PrismVUMeter
PrismLUFSMeter
PrismVectorscope
PrismSpectrogram
PrismWaveform)
add_custom_target(PrismInstallerPlugins)
foreach(PRISM_SCOPE_TARGET IN LISTS PRISM_SCOPE_TARGETS)
if("VST3" IN_LIST PRISM_PLUGIN_FORMATS)
add_dependencies(PrismInstallerPlugins ${PRISM_SCOPE_TARGET}_VST3)
endif()
if(APPLE AND "AU" IN_LIST PRISM_PLUGIN_FORMATS)
add_dependencies(PrismInstallerPlugins ${PRISM_SCOPE_TARGET}_AU)
endif()
endforeach()
+127
View File
@@ -0,0 +1,127 @@
# Prism — DAW plugins
JUCE 8 plugins (VST3 / AU / Standalone) that render Prism's scopes inside a DAW —
one plugin per scope: **spectrum, oscilloscope, vectorscope, spectrogram, VU meter,
loudness meter, waveform**. They **reuse Prism's existing C++ DSP** (`native/src/*.cpp`)
and the **existing React canvas UI** (`src/plugin-ui`, importing the unchanged
visualizers from `src/renderer/visualizers/`). VST3 is built for macOS, Windows,
and Linux; AU is macOS-only.
## 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 all seven plugins into your user plugin folders:
- AU: `~/Library/Audio/Plug-Ins/Components/Prism *.component`
- VST3: `~/Library/Audio/Plug-Ins/VST3/Prism *.vst3`
Load any `Prism *` AU / VST3 on a track in Ableton / FL / Logic / Reaper (or run the
matching **Standalone** from `plugin/build/Prism*_artefacts/Release/Standalone/`),
play audio, and the scope 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.
## Build & run (Windows)
Prereqs: Visual Studio 2022 with the "Desktop development with C++" workload,
CMake ≥ 3.22, Node.js. Modern Win10 / Win11 ships with the Microsoft Edge WebView2
Runtime preinstalled; if it's missing, install the "Evergreen Standalone Installer"
from Microsoft.
```sh
npm install
npm run plugin-ui:build
cmake -B plugin\build -S plugin -G "Visual Studio 17 2022" -A x64
cmake --build plugin\build --config Release
```
`COPY_PLUGIN_AFTER_BUILD` targets the system VST3 folder
(`C:\Program Files\Common Files\VST3\Prism *.vst3`), which **needs admin
privileges**. Either run the `cmake --build` step from an elevated shell, or copy
the built bundles from `plugin\build\Prism*_artefacts\Release\VST3\` into your
user-local VST3 folder (`%LOCALAPPDATA%\Programs\Common\VST3\`) by hand — most
DAWs scan both.
Dev-server mode works the same as macOS: `-DPRISM_DEV_SERVER=ON` + `npm run plugin-ui:dev`.
## Build & run (Linux)
Prereqs: CMake >= 3.22, Ninja, GCC/Clang, Node.js, and JUCE's Linux GUI/WebView
dependencies. On Ubuntu 24.04+:
```sh
sudo apt-get install build-essential cmake ninja-build pkg-config \
libasound2-dev libjack-jackd2-dev ladspa-sdk libcurl4-openssl-dev \
libfreetype-dev libfontconfig1-dev libgtk-3-dev libx11-dev libxcomposite-dev \
libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev libxrender-dev \
libwebkit2gtk-4.1-dev libglu1-mesa-dev mesa-common-dev
```
```sh
npm install
npm run plugin-ui:build
cmake -B plugin/build -S plugin -G Ninja -DCMAKE_BUILD_TYPE=Release -DPRISM_PLUGIN_FORMATS=VST3
cmake --build plugin/build --target PrismInstallerPlugins --parallel
```
Built bundles land under `plugin/build/Prism*_artefacts/Release/VST3/`. Copy the
seven `Prism *.vst3` directories to one of the standard Linux VST3 scan paths:
- User-local: `$HOME/.vst3`
- System-wide: `/usr/lib/vst3`
- System-wide local: `/usr/local/lib/vst3`
The Linux `.deb` and `.rpm` release packages install Prism's VST3 bundles to
`/usr/lib/vst3` and remove only those seven bundles on package removal. The Linux
`tar.gz` release includes `resources/plugins/install-vst3.sh`, which installs to
`$HOME/.vst3` by default or `/usr/lib/vst3` with `--system`. The AppImage is
portable app-only and does not install DAW plugins.
## 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:** `NEEDS_WEBVIEW2 TRUE` in CMake links Microsoft's Edge WebView2 runtime.
The macOS 120 Hz uncap (`Source/WebViewFrameRate.mm`, gated `if(APPLE)`) doesn't
apply — WebView2 has its own rate behavior; please report actual fps if it ever
feels low.
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include "ScopeEngine.h"
#include "lufsmeter.h" // reused, unmodified, from native/src
/**
* Loudness (LUFS) meter engine. Pushes stereo audio into the reused
* `Visualizer::LUFSMeterAnalyzer` (K-weighting + gated integration + the same fast
* VU/peak/correlation block) and emits its scalar snapshot each frame. Like the VU
* engine the frame is plain numbers (no base64); getSnapshot() advances peak decay
* on the steady clock, so it's safe to call every frame. configure() is a no-op —
* LUFS settings (mode/readout) are render-side.
*/
class LUFSMeterEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "lufsmeter"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 360, 340, 240, 220 }; }
void setSampleRate(double sampleRate) override
{
lufs.setSampleRate((float) sampleRate);
}
void configure(const juce::var&) override {}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
lufs.pushSamples(left, right, (size_t) numSamples);
}
juce::var buildFrame(double sampleRate) override
{
const auto snap = lufs.getSnapshot();
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("momentaryLUFS", snap.momentaryLUFS);
obj->setProperty("shortTermLUFS", snap.shortTermLUFS);
obj->setProperty("integratedLUFS", snap.integratedLUFS);
obj->setProperty("vuLDb", snap.vuLDb);
obj->setProperty("vuRDb", snap.vuRDb);
obj->setProperty("barLDb", snap.barLDb);
obj->setProperty("barRDb", snap.barRDb);
obj->setProperty("peakLDb", snap.peakLDb);
obj->setProperty("peakRDb", snap.peakRDb);
obj->setProperty("correlation", snap.correlation);
return juce::var(obj);
}
private:
const juce::Identifier frameId { "lufsmeterFrame" };
Visualizer::LUFSMeterAnalyzer lufs;
};
+99
View File
@@ -0,0 +1,99 @@
#pragma once
#include "ScopeEngine.h"
#include "oscilloscope.h" // reused, unmodified, from native/src
#include <vector>
#include <algorithm>
#include <cmath>
class OscilloscopeEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "oscilloscope"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 760, 300, 320, 160 }; }
void setSampleRate(double sampleRate) override
{
osc.setSampleRate((float) sampleRate);
osc.setDisplaySamples(normalizedDisplaySamples(sampleRate));
}
void configure(const juce::var& settings) override
{
pitchLock = (bool) settings.getProperty("pitchLock", true);
osc.setPitchLock(pitchLock);
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
if ((int) mono.size() < numSamples)
mono.resize((size_t) numSamples);
for (int i = 0; i < numSamples; ++i)
mono[(size_t) i] = 0.5f * (left[i] + right[i]);
osc.pushSamples(mono.data(), (size_t) numSamples);
samplesSeen += numSamples;
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("pitch", 0.0);
// Pitch lock needs samples buffered before trigger detection is meaningful.
if (pitchLock && samplesSeen < kWarmupSamples)
{
obj->setProperty("samples", juce::String());
return juce::var(obj);
}
const auto result = osc.process();
const int samplesToShow = result.samplesToShow;
if (samplesToShow <= 1)
{
obj->setProperty("samples", juce::String());
return juce::var(obj);
}
float triggerIndex = result.triggerIndex;
if (! pitchLock)
{
// Free-run: show the most recent window ending at the write head.
const size_t writePos = osc.getWritePos();
triggerIndex = (float) ((writePos + Visualizer::OSCILLOSCOPE_BUFFER_SIZE - (size_t) samplesToShow)
% Visualizer::OSCILLOSCOPE_BUFFER_SIZE);
}
if ((int) window.size() != samplesToShow)
window.resize((size_t) samplesToShow);
osc.getSamplesInterpolated(window.data(), triggerIndex, (size_t) samplesToShow);
obj->setProperty("pitch", result.detectedPitch);
obj->setProperty("samples", juce::Base64::toBase64(window.data(), window.size() * sizeof(float)));
return juce::var(obj);
}
private:
// Mirrors getNormalizedOscilloscopeDisplaySamples in the renderer.
static int normalizedDisplaySamples(double sampleRate)
{
const double base = 2048.0, rateMin = 44100.0, rateMax = 48000.0;
double samples = base;
if (sampleRate > 0.0)
{
if (sampleRate < rateMin) samples = std::round(base * (sampleRate / rateMin));
else if (sampleRate > rateMax) samples = std::round(base * (sampleRate / rateMax));
}
return (int) std::clamp(samples, 64.0, 32767.0);
}
static constexpr long long kWarmupSamples = 4096;
const juce::Identifier frameId { "oscilloscopeFrame" };
Visualizer::Oscilloscope osc;
std::vector<float> mono, window;
bool pitchLock = true;
long long samplesSeen = 0;
};
+712
View File
@@ -0,0 +1,712 @@
#include "PluginEditor.h"
#include "SpectrumEngine.h"
#include "OscilloscopeEngine.h"
#include "VUMeterEngine.h"
#include "LUFSMeterEngine.h"
#include "VectorscopeEngine.h"
#include "SpectrogramEngine.h"
#include "WaveformEngine.h"
#include <cmath>
#include <cstdlib>
#include <cstring>
#ifndef PRISM_EMBED_WEBUI
#define PRISM_EMBED_WEBUI 0
#endif
#ifndef PRISM_LINUX_UI_MODE_NAME
#define PRISM_LINUX_UI_MODE_NAME "webview"
#endif
#ifndef PRISM_LINUX_UI_DIAGNOSTICS
#define PRISM_LINUX_UI_DIAGNOSTICS 0
#endif
#if PRISM_EMBED_WEBUI
#include "BinaryData.h"
#endif
#if JUCE_MAC
#include "WebViewFrameRate.h"
#endif
namespace
{
const juce::Identifier kRestoreSettingsEvent { "prismRestoreSettings" };
constexpr int kDrainCapacity = 1 << 16;
#if JUCE_WINDOWS
constexpr int kFallbackWindowsFrameRateHz = 60;
constexpr int kMaxWindowsFrameRateHz = 240;
int resolveWindowsFrameRateHz(const juce::Component& component)
{
const auto& displays = juce::Desktop::getInstance().getDisplays();
if (const auto* display = displays.getDisplayForRect(component.getScreenBounds()))
{
const auto frequency = display->verticalFrequencyHz;
if (frequency.has_value() && std::isfinite(*frequency) && *frequency > 0.0)
return juce::jlimit(30, kMaxWindowsFrameRateHz, (int) std::lround(*frequency));
}
return kFallbackWindowsFrameRateHz;
}
#endif
#if JUCE_LINUX
constexpr int kLinuxFrameRateHz = 20;
#if PRISM_LINUX_UI_DIAGNOSTICS
juce::String envValue(const char* name)
{
if (const auto* value = std::getenv(name); value != nullptr && value[0] != '\0')
return value;
return "<unset>";
}
juce::File linuxDiagnosticLogFile()
{
juce::File baseDir;
if (const auto* xdgState = std::getenv("XDG_STATE_HOME"); xdgState != nullptr && xdgState[0] != '\0')
baseDir = juce::File(juce::String(xdgState));
else if (const auto* home = std::getenv("HOME"); home != nullptr && home[0] != '\0')
baseDir = juce::File(juce::String(home)).getChildFile(".local").getChildFile("state");
else
baseDir = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory);
return baseDir.getChildFile("prism").getChildFile("plugin-ui-diagnostics.log");
}
void logLinuxDiagnostic(const juce::String& message)
{
const auto file = linuxDiagnosticLogFile();
file.getParentDirectory().createDirectory();
file.appendText(juce::Time::getCurrentTime().toISO8601(true) + " " + message + "\n",
false,
false,
"\n");
}
juce::String wrapperTypeName()
{
switch (juce::PluginHostType::getPluginLoadedAs())
{
case juce::AudioProcessor::wrapperType_VST3: return "VST3";
case juce::AudioProcessor::wrapperType_VST: return "VST2";
case juce::AudioProcessor::wrapperType_Standalone: return "Standalone";
case juce::AudioProcessor::wrapperType_AudioUnit: return "AudioUnit";
case juce::AudioProcessor::wrapperType_AudioUnitv3:return "AudioUnitv3";
case juce::AudioProcessor::wrapperType_AAX: return "AAX";
case juce::AudioProcessor::wrapperType_LV2: return "LV2";
case juce::AudioProcessor::wrapperType_Unity: return "Unity";
default: return "Undefined";
}
}
juce::String linuxHostDisplayContext()
{
const juce::PluginHostType host;
juce::String context;
context << "mode=" << PRISM_LINUX_UI_MODE_NAME
<< " host=\"" << host.getHostDescription() << "\""
<< " wrapper=" << wrapperTypeName()
<< " display=" << envValue("DISPLAY")
<< " waylandDisplay=" << envValue("WAYLAND_DISPLAY")
<< " sessionType=" << envValue("XDG_SESSION_TYPE")
<< " gdkBackend=" << envValue("GDK_BACKEND")
<< " desktop=" << envValue("XDG_CURRENT_DESKTOP");
return context;
}
juce::String describeBounds(const juce::Rectangle<int>& bounds)
{
juce::String text;
text << bounds.getX() << "," << bounds.getY()
<< " " << bounds.getWidth() << "x" << bounds.getHeight();
return text;
}
#endif
#endif
std::unique_ptr<ScopeEngine> makeEngine()
{
#if defined(PRISM_SCOPE_WAVEFORM) && PRISM_SCOPE_WAVEFORM
return std::make_unique<WaveformEngine>();
#elif defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM
return std::make_unique<SpectrogramEngine>();
#elif defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE
return std::make_unique<VectorscopeEngine>();
#elif defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER
return std::make_unique<LUFSMeterEngine>();
#elif defined(PRISM_SCOPE_VUMETER) && PRISM_SCOPE_VUMETER
return std::make_unique<VUMeterEngine>();
#elif defined(PRISM_SCOPE_OSCILLOSCOPE) && PRISM_SCOPE_OSCILLOSCOPE
return std::make_unique<OscilloscopeEngine>();
#else
return std::make_unique<SpectrumEngine>();
#endif
}
#if PRISM_USE_DEV_SERVER
const juce::String kDevServerUrl { "http://localhost:5174" };
#elif PRISM_EMBED_WEBUI
const char* getWebResourceData(const juce::String& name, int& dataSize)
{
dataSize = 0;
for (int i = 0; i < BinaryData::namedResourceListSize; ++i)
{
if (name == juce::String(BinaryData::originalFilenames[i]))
return BinaryData::getNamedResource(BinaryData::namedResourceList[i], dataSize);
}
return nullptr;
}
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)
{
auto name = (url == "/") ? juce::String("index.html")
: url.fromLastOccurrenceOf("/", false, false);
name = name.upToFirstOccurrenceOf("?", false, false);
int dataSize = 0;
if (const char* data = getWebResourceData(name, 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;
}
juce::String getResourceProviderOrigin()
{
return juce::WebBrowserComponent::getResourceProviderRoot().trimCharactersAtEnd("/");
}
#if JUCE_LINUX
juce::File writeLinuxWebViewIndexFile()
{
int dataSize = 0;
const char* data = getWebResourceData("index.html", dataSize);
if (data == nullptr || dataSize <= 0)
return {};
const auto webViewDir =
juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
.getChildFile("prism")
.getChildFile("plugin-webview");
if (! webViewDir.createDirectory())
return {};
auto indexFile = webViewDir.getChildFile("index.html");
if (! indexFile.replaceWithData(data, (size_t) dataSize))
return {};
return indexFile;
}
#endif
#endif
#if JUCE_LINUX
juce::String makeWebSmokeUrl()
{
const juce::String html =
"<!doctype html><html><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
"<style>html,body{width:100%;height:100%;margin:0;background:#000;color:rgba(255,255,255,.82);"
"font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}body{display:grid;place-items:center;}"
"#status{padding:20px;text-align:center;white-space:pre-wrap;}</style></head>"
"<body><div id=\"status\">Prism WebView smoke test\\nWaiting for JUCE bridge...</div>"
"<script>"
"function ready(){var s=document.getElementById('status');"
"try{if(window.__JUCE__&&window.__JUCE__.backend){window.__JUCE__.backend.emitEvent('prismReady',{});"
"if(s)s.textContent='Prism WebView smoke test\\nJUCE bridge emitted prismReady';return;}}"
"catch(e){if(s)s.textContent='Prism WebView smoke test\\n'+String(e&&e.message||e);return;}"
"setTimeout(ready,50);}ready();"
"</script></body></html>";
return "data:text/html;charset=utf-8," + juce::URL::addEscapeChars(html, false);
}
class FloatingWebViewWindow final : public juce::DocumentWindow
{
public:
FloatingWebViewWindow()
: juce::DocumentWindow("Prism WebView diagnostic",
juce::Colours::black,
juce::DocumentWindow::closeButton)
{
setUsingNativeTitleBar(true);
setResizable(true, true);
}
void closeButtonPressed() override
{
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("floating_webview closeButtonPressed");
#endif
setVisible(false);
}
};
#endif
juce::WebBrowserComponent::Options makeWebOptions(PrismSpectrumEditor& editor, const char* scopeId)
{
auto options = juce::WebBrowserComponent::Options{}
.withNativeIntegrationEnabled()
.withInitialisationData("prismScope", juce::String(scopeId))
.withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); })
.withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); })
.withEventListener("prismSpectrogramConfig", [&editor](juce::var v) { editor.onScopeNativeConfig(std::move(v)); })
.withEventListener("prismSettingsPanel", [&editor](juce::var v) { editor.onSettingsPanel(std::move(v)); });
#if JUCE_WINDOWS
options = options.withBackend(juce::WebBrowserComponent::Options::Backend::webview2);
// WebView2's default user-data folder is created next to the host executable.
// For plugins installed under Program Files\Common Files\VST3\..., that path
// is read-only to the (non-admin) DAW process; WebView2 may fail to
// initialise, leaving a blank webview or canceled navigation page.
// Point it at a writable per-user folder instead (JUCE's docs flag this
// explicitly for plugin projects).
const auto webView2DataDir =
juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
.getChildFile("prism")
.getChildFile("WebView2");
webView2DataDir.createDirectory();
options = options.withWinWebView2Options(
juce::WebBrowserComponent::Options::WinWebView2{}.withUserDataFolder(webView2DataDir));
#endif
#if PRISM_EMBED_WEBUI
options = options.withResourceProvider(
[](const auto& url) { return provideResource(url); },
getResourceProviderOrigin());
#endif
return options;
}
}
PrismSpectrumEditor::PrismSpectrumEditor(PrismSpectrumProcessor& p)
: juce::AudioProcessorEditor(&p),
processorRef(p),
engine(makeEngine())
{
drainLeft.assign((size_t) kDrainCapacity, 0.0f);
drainRight.assign((size_t) kDrainCapacity, 0.0f);
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("editor constructed scope=" + juce::String(engine->scopeId())
+ " " + linuxHostDisplayContext()
+ " logFile=" + linuxDiagnosticLogFile().getFullPathName());
#endif
#endif
loadUi();
// Per-scope sizing: open at the scope's preferred default, with a per-scope min.
const auto pref = engine->preferredSize();
setResizable(true, true);
setResizeLimits(pref.minWidth, pref.minHeight, 4096, 4096);
setSize(pref.defaultWidth, pref.defaultHeight);
}
PrismSpectrumEditor::~PrismSpectrumEditor()
{
#if JUCE_WINDOWS || JUCE_LINUX
stopTimer();
#endif
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("editor destructed scope=" + juce::String(engine->scopeId())
+ " mode=" + juce::String(PRISM_LINUX_UI_MODE_NAME));
#endif
floatingWebViewWindow.reset();
#endif
webViewReady = false;
}
void PrismSpectrumEditor::resized()
{
if (webView != nullptr && webView->getParentComponent() == this)
webView->setBounds(getLocalBounds());
webViewFallback.setBounds(getLocalBounds());
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("editor resized local=" + describeBounds(getLocalBounds())
+ " screen=" + describeBounds(getScreenBounds())
+ " mode=" + juce::String(PRISM_LINUX_UI_MODE_NAME));
#endif
#endif
}
void PrismSpectrumEditor::loadUi()
{
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("loadUi " + linuxHostDisplayContext());
#endif
#if defined(PRISM_LINUX_UI_MODE_NATIVE_SMOKE) && PRISM_LINUX_UI_MODE_NATIVE_SMOKE
loadNativeSmoke();
return;
#elif defined(PRISM_LINUX_UI_MODE_WEB_SMOKE) && PRISM_LINUX_UI_MODE_WEB_SMOKE
loadWebSmoke();
return;
#elif defined(PRISM_LINUX_UI_MODE_FLOATING_WEBVIEW) && PRISM_LINUX_UI_MODE_FLOATING_WEBVIEW
loadFloatingWebView();
return;
#endif
#endif
loadEmbeddedWebView();
}
bool PrismSpectrumEditor::createWebView()
{
auto options = makeWebOptions(*this, engine->scopeId());
#if JUCE_WINDOWS
if (! juce::WebBrowserComponent::areOptionsSupported(options))
{
showWebViewFallback();
return false;
}
#endif
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("creating WebBrowserComponent mode=" + juce::String(PRISM_LINUX_UI_MODE_NAME));
#endif
#endif
webView = std::make_unique<juce::WebBrowserComponent>(options);
return true;
}
void PrismSpectrumEditor::loadEmbeddedWebView()
{
if (! createWebView())
return;
addAndMakeVisible(*webView);
#if PRISM_USE_DEV_SERVER
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating embedded WebView url=" + kDevServerUrl);
#endif
#endif
webView->goToURL(kDevServerUrl);
#elif PRISM_EMBED_WEBUI
#if JUCE_LINUX
const auto indexFile = writeLinuxWebViewIndexFile();
if (indexFile.existsAsFile())
{
const auto url = juce::URL(indexFile).toString(false);
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating embedded WebView url=" + url);
#endif
webView->goToURL(url);
return;
}
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating embedded WebView url=" + juce::WebBrowserComponent::getResourceProviderRoot());
#endif
#endif
webView->goToURL(juce::WebBrowserComponent::getResourceProviderRoot());
#endif
}
#if JUCE_LINUX
void PrismSpectrumEditor::showLinuxDiagnosticPlaceholder(const juce::String& text)
{
webViewFallback.setText(text, juce::dontSendNotification);
webViewFallback.setJustificationType(juce::Justification::centred);
webViewFallback.setColour(juce::Label::backgroundColourId, juce::Colours::black);
webViewFallback.setColour(juce::Label::textColourId, juce::Colour(0xffe5e7eb));
webViewFallback.setMinimumHorizontalScale(0.7f);
addAndMakeVisible(webViewFallback);
}
void PrismSpectrumEditor::loadNativeSmoke()
{
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("native_smoke loaded");
#endif
webViewReady = true;
showLinuxDiagnosticPlaceholder("Prism native smoke test\n"
"Root JUCE editor rendered without WebView\n"
"Mode: native_smoke");
}
void PrismSpectrumEditor::loadWebSmoke()
{
if (! createWebView())
return;
addAndMakeVisible(*webView);
const auto url = makeWebSmokeUrl();
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating web_smoke WebView url=data:text/html;charset=utf-8,<inline smoke page>");
#endif
webView->goToURL(url);
}
void PrismSpectrumEditor::loadFloatingWebView()
{
showLinuxDiagnosticPlaceholder("Prism floating WebView diagnostic\n"
"Embedded native placeholder is visible\n"
"The real Prism WebView should open in a separate window");
if (! createWebView())
return;
floatingWebViewWindow = std::make_unique<FloatingWebViewWindow>();
floatingWebViewWindow->setContentNonOwned(webView.get(), false);
const auto pref = engine->preferredSize();
floatingWebViewWindow->setSize(pref.defaultWidth, pref.defaultHeight);
floatingWebViewWindow->centreWithSize(pref.defaultWidth, pref.defaultHeight);
floatingWebViewWindow->setVisible(true);
floatingWebViewWindow->toFront(true);
#if PRISM_USE_DEV_SERVER
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating floating WebView url=" + kDevServerUrl);
#endif
webView->goToURL(kDevServerUrl);
#elif PRISM_EMBED_WEBUI
const auto indexFile = writeLinuxWebViewIndexFile();
if (indexFile.existsAsFile())
{
const auto url = juce::URL(indexFile).toString(false);
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating floating WebView url=" + url);
#endif
webView->goToURL(url);
return;
}
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating floating WebView url=" + juce::WebBrowserComponent::getResourceProviderRoot());
#endif
webView->goToURL(juce::WebBrowserComponent::getResourceProviderRoot());
#endif
}
#endif
void PrismSpectrumEditor::showWebViewFallback()
{
webViewFallback.setText(
"Prism needs the Microsoft Edge WebView2 Runtime to show this plugin on Windows.\n"
"Install the Evergreen WebView2 Runtime from Microsoft, then reopen the plugin.",
juce::dontSendNotification);
webViewFallback.setJustificationType(juce::Justification::centred);
webViewFallback.setColour(juce::Label::backgroundColourId, juce::Colour(0xff111216));
webViewFallback.setColour(juce::Label::textColourId, juce::Colour(0xfff4f6f8));
webViewFallback.setMinimumHorizontalScale(0.75f);
addAndMakeVisible(webViewFallback);
}
#if JUCE_WINDOWS || JUCE_LINUX
void PrismSpectrumEditor::timerCallback()
{
renderFrame();
}
#endif
void PrismSpectrumEditor::startFrameDriver()
{
if (webView == nullptr || ! webViewReady || frameDriverStarted)
return;
frameDriverStarted = true;
#if JUCE_WINDOWS
startTimerHz(resolveWindowsFrameRateHz(*this));
#elif JUCE_LINUX
startTimerHz(kLinuxFrameRateHz);
#else
// Drive frames at the display's refresh rate (adapts to 60/120/144 Hz).
vblank = juce::VBlankAttachment(this, [this] { renderFrame(); });
#endif
}
void PrismSpectrumEditor::onSettingsPanel(juce::var payload)
{
const int height = juce::jmax(0, (int) payload.getProperty("height", 0));
const int delta = height - settingsPanelHeight;
if (delta == 0)
return;
settingsPanelHeight = height;
// Grow/shrink the window by exactly the panel height so the scope area is
// unchanged. Deliberately NOT touching resize limits — doing so makes JUCE's
// constrainer snap the window and double the applied size.
setSize(getWidth(), getHeight() + delta);
}
void PrismSpectrumEditor::onPrismConfig(juce::var payload)
{
const auto settings = payload.getProperty("settings", juce::var());
// Persist only genuine per-instance overrides (persist=true). App-default /
// restore-driven updates carry persist=false so a non-overridden instance
// keeps re-reading the app's current settings on reopen.
if ((bool) payload.getProperty("persist", false))
processorRef.setSettingsJson(juce::JSON::toString(settings));
engine->configure(settings);
}
void PrismSpectrumEditor::onPrismReady()
{
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("prismReady received mode=" + juce::String(PRISM_LINUX_UI_MODE_NAME)
+ " scope=" + juce::String(engine->scopeId()));
#endif
#endif
webViewReady = true;
pushRestoreSettings();
sendAppDefaults();
startFrameDriver();
}
void PrismSpectrumEditor::onScopeNativeConfig(juce::var payload)
{
engine->configureNative(payload);
}
void PrismSpectrumEditor::pushRestoreSettings()
{
if (webView == nullptr)
return;
auto* obj = new juce::DynamicObject();
obj->setProperty("json", processorRef.getSettingsJson());
webView->emitEventIfBrowserIsVisible(kRestoreSettingsEvent, juce::var(obj));
}
void PrismSpectrumEditor::sendAppDefaults()
{
if (webView == nullptr)
return;
// Resolve Prism's app-data dir to match Electron's userData per platform.
// macOS: userApplicationDataDirectory == ~/Library, so the Electron path is
// ~/Library/Application Support/prism (the extra subfolder only exists on mac).
// Windows/Linux: JUCE's userApplicationDataDirectory already points at the right
// per-platform config root (%APPDATA% / ~/.config), so no extra append needed.
#if JUCE_MAC
const auto appData = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
.getChildFile("Application Support")
.getChildFile("prism");
#else
const auto appData = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
.getChildFile("prism");
#endif
const auto docs = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory);
const auto themesDir = docs.getChildFile("Prism Themes");
const auto profilesDir = docs.getChildFile("Prism Profiles");
juce::String themeId, themeFile, profileJson;
if (const auto themeState = juce::JSON::parse(appData.getChildFile("theme-state.json"));
auto* obj = themeState.getDynamicObject())
themeId = obj->getProperty("activeThemeId").toString();
if (themeId.isNotEmpty())
{
const auto file = themesDir.getChildFile(themeId + ".iro");
if (file.existsAsFile())
themeFile = file.loadFileAsString();
}
juce::String activeProfileId;
if (const auto profileState = juce::JSON::parse(appData.getChildFile("profile-state.json"));
auto* obj = profileState.getDynamicObject())
activeProfileId = obj->getProperty("activeProfileId").toString();
if (activeProfileId.isNotEmpty() && profilesDir.isDirectory())
{
for (const auto& file : profilesDir.findChildFiles(juce::File::findFiles, false, "*.prsm"))
{
const auto content = file.loadFileAsString();
if (const auto parsed = juce::JSON::parse(content); auto* o = parsed.getDynamicObject())
{
if (o->getProperty("id").toString() == activeProfileId)
{
profileJson = content;
break;
}
}
}
}
auto* payload = new juce::DynamicObject();
payload->setProperty("themeId", themeId);
payload->setProperty("themeFile", themeFile);
payload->setProperty("profileJson", profileJson);
webView->emitEventIfBrowserIsVisible(juce::Identifier("prismAppDefaults"), juce::var(payload));
}
void PrismSpectrumEditor::renderFrame()
{
if (webView == nullptr || ! webViewReady)
return;
#if JUCE_MAC
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)
{
engine->setSampleRate(sampleRate);
lastSampleRate = sampleRate;
}
const int drained = processorRef.drainStereo(drainLeft.data(), drainRight.data(), (int) drainLeft.size());
#if JUCE_LINUX
if (drained <= 0)
return;
#endif
engine->process(drainLeft.data(), drainRight.data(), drained);
webView->emitEventIfBrowserIsVisible(engine->frameEventId(), engine->buildFrame(sampleRate));
}
+95
View File
@@ -0,0 +1,95 @@
#pragma once
#include <juce_gui_extra/juce_gui_extra.h>
#include "PluginProcessor.h"
#include "ScopeEngine.h"
#include <memory>
#include <vector>
/**
* Hosts the React webview UI and bridges the reused Prism spectrum DSP to it.
*
* Drains stereo audio buffered by the processor, feeds the selected scope engine,
* and emits frame payloads to the webview after the UI reports readiness. macOS
* uses display vblank; Windows/Linux use steady message-thread timers to avoid
* fragile host/browser embedding behavior.
*/
class PrismSpectrumEditor : public juce::AudioProcessorEditor
#if JUCE_WINDOWS || JUCE_LINUX
, private juce::Timer
#endif
{
public:
explicit PrismSpectrumEditor(PrismSpectrumProcessor&);
~PrismSpectrumEditor() override;
void resized() override;
#if JUCE_WINDOWS || JUCE_LINUX
void timerCallback() override;
#endif
// Called by the webview event listeners (message thread).
void onPrismConfig(juce::var payload);
void onPrismReady();
// Scope-specific native config (e.g. the spectrogram's canvas-derived rowCount).
void onScopeNativeConfig(juce::var payload);
// The UI's settings panel opened/closed along the bottom. Grow/shrink the editor
// height by exactly the panel height so the scope area is unchanged (the window
// accommodates the panel, like the app). 0 = closed.
void onSettingsPanel(juce::var payload);
// Push the processor's saved settings to the UI (used on ready + on host
// state restore, to cover either ordering).
void pushRestoreSettings();
// Read the user's Prism app theme + active profile from disk and send them
// to the UI as defaults (per-instance overrides still win).
void sendAppDefaults();
private:
void loadUi();
bool createWebView();
void loadEmbeddedWebView();
void showWebViewFallback();
void startFrameDriver();
void renderFrame();
#if JUCE_LINUX
void loadNativeSmoke();
void loadWebSmoke();
void loadFloatingWebView();
void showLinuxDiagnosticPlaceholder(const juce::String& text);
#endif
PrismSpectrumProcessor& processorRef;
std::unique_ptr<ScopeEngine> engine;
std::vector<float> drainLeft, drainRight;
double lastSampleRate = 0.0;
// Height (px) the editor is currently grown by for the open settings panel.
int settingsPanelHeight = 0;
bool webViewReady = false;
bool frameDriverStarted = false;
// One-time attempt to lift WKWebView's private 60fps cap (macOS).
bool frameRateUncapped = false;
int uncapAttempts = 0;
std::unique_ptr<juce::WebBrowserComponent> webView;
#if JUCE_LINUX
std::unique_ptr<juce::DocumentWindow> floatingWebViewWindow;
#endif
juce::Label webViewFallback;
// Declared last so it is destroyed first; no vblank callback can fire into a
// partially-destroyed editor. Windows/Linux use juce::Timer instead.
#if ! JUCE_WINDOWS && ! JUCE_LINUX
juce::VBlankAttachment vblank;
#endif
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismSpectrumEditor)
};
+122
View File
@@ -0,0 +1,122 @@
#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))
{
leftBuffer.assign((size_t) fifo.getTotalSize(), 0.0f);
rightBuffer.assign((size_t) fifo.getTotalSize(), 0.0f);
}
void PrismSpectrumProcessor::prepareToPlay(double sampleRate, int)
{
currentSampleRate.store(sampleRate);
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::pushStereoToFifo(const float* left, const float* right, int num) noexcept
{
int start1, size1, start2, size2;
fifo.prepareToWrite(num, start1, size1, start2, size2);
if (size1 > 0)
{
std::memcpy(leftBuffer.data() + start1, left, (size_t) size1 * sizeof(float));
std::memcpy(rightBuffer.data() + start1, right, (size_t) size1 * sizeof(float));
}
if (size2 > 0)
{
std::memcpy(leftBuffer.data() + start2, left + size1, (size_t) size2 * sizeof(float));
std::memcpy(rightBuffer.data() + start2, right + size1, (size_t) size2 * sizeof(float));
}
fifo.finishedWrite(size1 + size2);
}
int PrismSpectrumProcessor::drainStereo(float* destLeft, float* destRight, 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(destLeft, leftBuffer.data() + start1, (size_t) size1 * sizeof(float));
std::memcpy(destRight, rightBuffer.data() + start1, (size_t) size1 * sizeof(float));
}
if (size2 > 0)
{
std::memcpy(destLeft + size1, leftBuffer.data() + start2, (size_t) size2 * sizeof(float));
std::memcpy(destRight + size1, rightBuffer.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;
const float* left = buffer.getReadPointer(0);
const float* right = numChannels >= 2 ? buffer.getReadPointer(1) : left;
pushStereoToFifo(left, right, numSamples);
// Pure analyzer: the audio buffer is left untouched (pass-through).
}
void PrismSpectrumProcessor::setSettingsJson(const juce::String& json)
{
const juce::ScopedLock sl(settingsLock);
settingsJson = json;
}
juce::String PrismSpectrumProcessor::getSettingsJson() const
{
const juce::ScopedLock sl(settingsLock);
return settingsJson;
}
void PrismSpectrumProcessor::getStateInformation(juce::MemoryBlock& destData)
{
const juce::String json = getSettingsJson();
destData.setSize(0);
destData.append(json.toRawUTF8(), json.getNumBytesAsUTF8());
}
void PrismSpectrumProcessor::setStateInformation(const void* data, int sizeInBytes)
{
if (data == nullptr || sizeInBytes <= 0)
return;
setSettingsJson(juce::String::fromUTF8(static_cast<const char*>(data), sizeInBytes));
// If the editor is already open (host restored state after opening it), push
// the settings to the UI now — the prismReady reply alone would have missed it.
if (auto* editor = dynamic_cast<PrismSpectrumEditor*>(getActiveEditor()))
editor->pushRestoreSettings();
}
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();
}
+69
View File
@@ -0,0 +1,69 @@
#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 writes
* the input's L/R into a lock-free FIFO. The editor's frame callback drains the
* FIFO off the realtime thread, runs the reused Prism DSP (native/src/spectrum.cpp)
* as stereo (so mid + side are available), and pushes magnitudes to the webview.
*
* UI settings (JSON) are owned here so they survive editor open/close and DAW
* session save/restore.
*/
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 JucePlugin_Name; }
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;
double getSampleRateHz() const noexcept { return currentSampleRate.load(); }
/** Copy up to `maxSamples` of buffered L/R audio into the destinations; returns count. */
int drainStereo(float* destLeft, float* destRight, int maxSamples) noexcept;
/** Persisted UI settings as a JSON string (set from the editor, read on save). */
void setSettingsJson(const juce::String& json);
juce::String getSettingsJson() const;
private:
void pushStereoToFifo(const float* left, const float* right, int num) noexcept;
juce::AbstractFifo fifo { 1 << 16 };
std::vector<float> leftBuffer, rightBuffer; // backing storage for `fifo`
std::atomic<double> currentSampleRate { 48000.0 };
juce::CriticalSection settingsLock;
juce::String settingsJson;
std::atomic<int> editorWidth { 0 };
std::atomic<int> editorHeight { 0 };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismSpectrumProcessor)
};
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <juce_core/juce_core.h>
/**
* Per-scope DSP + frame producer. The editor is otherwise scope-agnostic: it
* buffers stereo audio and, each frame, feeds it to the engine and emits the
* engine's frame payload to the webview. One implementation per scope; the build
* (PRISM_SCOPE_*) selects which one a given plugin product uses.
*/
class ScopeEngine
{
public:
virtual ~ScopeEngine() = default;
/** Editor window sizing (logical px). Each scope picks a shape that fits it. */
struct PreferredSize
{
int defaultWidth = 720;
int defaultHeight = 320;
int minWidth = 320;
int minHeight = 160;
};
virtual PreferredSize preferredSize() const { return {}; }
/** Stable id sent to the webview (via initialisation data) to pick the UI scope. */
virtual const char* scopeId() const = 0;
/** Event name this engine emits frames on (the webview subscribes to it). */
virtual juce::Identifier frameEventId() const = 0;
virtual void setSampleRate(double sampleRate) = 0;
/** Apply scope settings (the JS settings object) to the DSP. */
virtual void configure(const juce::var& settings) = 0;
/**
* Apply a scope-specific native config pushed from the UI on the
* "prismSpectrogramConfig" event. Default no-op; only scopes whose DSP needs
* canvas-derived parameters (e.g. the spectrogram's rowCount) override this.
*/
virtual void configureNative(const juce::var&) {}
/** Feed audio (called off the realtime thread). numSamples may be 0. */
virtual void process(const float* left, const float* right, int numSamples) = 0;
/** Build the per-frame payload to emit to the webview. */
virtual juce::var buildFrame(double sampleRate) = 0;
};
+122
View File
@@ -0,0 +1,122 @@
#pragma once
#include "ScopeEngine.h"
#include "spectrogram.h" // reused, unmodified, from native/src
#include <vector>
#include <algorithm>
/**
* Spectrogram engine. The reused Visualizer::SpectrogramAnalyzer runs in C++ and
* produces finished display+heat columns. Unlike the other scopes, its DSP needs
* the canvas-derived rowCount (and fft/freq/db/scale/orientation), which only the
* webview knows — the UI pushes the full native config via "prismSpectrogramConfig",
* routed here through configureNative(). process() mixes to mono and runs the DSP;
* buildFrame() emits the columns produced since the last frame (base64) tagged with
* rowCount, so the bridge can match them to the config the UI currently expects.
* configure() (scope settings) is a no-op — every DSP parameter arrives in the
* native config. The host sample rate (from setSampleRate) overrides whatever the
* UI believed, so frequency mapping is always correct.
*/
class SpectrogramEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "spectrogram"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 700, 340, 360, 200 }; }
void setSampleRate(double sampleRate) override
{
if (sampleRate > 0.0 && (float) sampleRate != config.sampleRate)
{
config.sampleRate = (float) sampleRate;
if (hasConfig)
spectro.configure(config);
}
}
void configure(const juce::var&) override {}
void configureNative(const juce::var& opts) override
{
if (! opts.isObject())
return;
config.fftSize = (size_t) std::max(0, (int) opts.getProperty("fftSize", 4096));
config.rowCount = (size_t) std::max(0, (int) opts.getProperty("rowCount", 0));
config.minFrequency = (float) opts.getProperty("minFrequency", 20.0);
config.maxFrequency = (float) opts.getProperty("maxFrequency", 20000.0);
config.minDecibels = (float) opts.getProperty("minDecibels", -90.0);
config.maxDecibels = (float) opts.getProperty("maxDecibels", -12.0);
config.scrollSpeed = (float) opts.getProperty("scrollSpeed", 2.0);
config.contrast = (float) opts.getProperty("contrast", 1.0);
config.tiltDbPerOctave = (float) opts.getProperty("tiltDbPerOctave", 4.0);
config.clarityMode = opts.getProperty("clarityMode", "sharper").toString().toStdString();
config.scaleMode = opts.getProperty("scaleMode", "log").toString().toStdString();
config.orientation = opts.getProperty("orientation", "horizontal").toString().toStdString();
// Host rate (from setSampleRate) is authoritative; the UI may still hold a
// stale default before it has seen a frame. Only fall back to the UI value
// if we have not yet learned the host rate.
if (config.sampleRate <= 0.0f)
config.sampleRate = (float) opts.getProperty("sampleRate", 48000.0);
hasConfig = config.rowCount > 0 && config.fftSize > 0;
if (hasConfig)
spectro.configure(config);
// The config changed: drop any half-built column batch so the next frame
// starts clean at the new rowCount.
pendingDisplay.clear();
pendingHeat.clear();
pendingColumns = 0;
}
void process(const float* left, const float* right, int numSamples) override
{
if (! hasConfig || numSamples <= 0)
return;
if ((int) mono.size() < numSamples)
mono.resize((size_t) numSamples);
for (int i = 0; i < numSamples; ++i)
mono[(size_t) i] = 0.5f * (left[i] + right[i]);
auto result = spectro.process(mono.data(), (size_t) numSamples);
if (result.columnCount > 0 && result.rowCount == config.rowCount)
{
pendingDisplay.insert(pendingDisplay.end(), result.display.begin(), result.display.end());
pendingHeat.insert(pendingHeat.end(), result.heat.begin(), result.heat.end());
pendingColumns += result.columnCount;
}
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("rowCount", (int) config.rowCount);
obj->setProperty("columnCount", (int) pendingColumns);
if (pendingColumns > 0)
{
obj->setProperty("display", juce::Base64::toBase64(pendingDisplay.data(), pendingDisplay.size() * sizeof(float)));
obj->setProperty("heat", juce::Base64::toBase64(pendingHeat.data(), pendingHeat.size() * sizeof(float)));
}
else
{
obj->setProperty("display", juce::String());
obj->setProperty("heat", juce::String());
}
pendingDisplay.clear();
pendingHeat.clear();
pendingColumns = 0;
return juce::var(obj);
}
private:
const juce::Identifier frameId { "spectrogramFrame" };
Visualizer::SpectrogramAnalyzer spectro;
Visualizer::SpectrogramConfig config;
bool hasConfig = false;
std::vector<float> mono;
std::vector<float> pendingDisplay, pendingHeat;
size_t pendingColumns = 0;
};
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "ScopeEngine.h"
#include "spectrum.h" // reused, unmodified, from native/src
#include <vector>
class SpectrumEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "spectrum"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 820, 320, 360, 180 }; }
void setSampleRate(double sampleRate) override
{
spectrum.setSampleRate((float) sampleRate);
}
void configure(const juce::var& settings) override
{
const int fftSize = (int) settings.getProperty("fftSize", 2048);
if (fftSize > 0 && (size_t) fftSize != spectrum.getFFTSize())
spectrum.setFFTSize((size_t) fftSize);
spectrum.setSmoothing((float) (double) settings.getProperty("smoothing", 0.9));
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples > 0)
spectrum.pushStereoSamples(left, right, (size_t) numSamples);
else
spectrum.pushStereoSamples(nullptr, nullptr, 0); // recompute / decay
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("magnitudes", toBase64(spectrum.getMagnitudes()));
obj->setProperty("side", toBase64(spectrum.getSideMagnitudes()));
return juce::var(obj);
}
private:
static juce::String toBase64(const std::vector<float>& data)
{
return data.empty() ? juce::String()
: juce::Base64::toBase64(data.data(), data.size() * sizeof(float));
}
const juce::Identifier frameId { "spectrumFrame" };
Visualizer::Spectrum spectrum { 2048 };
};
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "ScopeEngine.h"
#include "vumeter.h" // reused, unmodified, from native/src
/**
* VU meter engine. Pushes stereo audio into the reused `Visualizer::VUMeterAnalyzer`
* (RMS integration + ballistics + peak hold + correlation, all sample-accurate) and
* emits the resulting scalar snapshot each frame. No base64 needed — the frame is a
* handful of numbers. getSnapshot() advances peak decay on the steady clock, so the
* meter still settles when audio momentarily stops.
*/
class VUMeterEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "vumeter"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 480, 300, 280, 180 }; }
void setSampleRate(double sampleRate) override
{
vu.setSampleRate((float) sampleRate);
}
void configure(const juce::var&) override
{
// VU settings (mode/orientation/needleChannels/referenceDb) are render-side only.
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
vu.pushSamples(left, right, (size_t) numSamples);
}
juce::var buildFrame(double sampleRate) override
{
const auto snap = vu.getSnapshot();
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("vuLDb", snap.vuLDb);
obj->setProperty("vuRDb", snap.vuRDb);
obj->setProperty("barLDb", snap.barLDb);
obj->setProperty("barRDb", snap.barRDb);
obj->setProperty("peakLDb", snap.peakLDb);
obj->setProperty("peakRDb", snap.peakRDb);
obj->setProperty("correlation", snap.correlation);
return juce::var(obj);
}
private:
const juce::Identifier frameId { "vumeterFrame" };
Visualizer::VUMeterAnalyzer vu;
};
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include "ScopeEngine.h"
#include "vectorscope.h" // reused, unmodified, from native/src
#include <vector>
/**
* Vectorscope engine. Pushes stereo audio into the reused `Visualizer::Vectorscope`
* (lowpass-filtered L/R + a 3-band split, both in circular buffers) and emits the
* most recent display points each frame. Two layouts share the buffers: the standard
* X/Y point cloud and the multiband (low/mid/high) cloud. Both buffers are kept warm
* so toggling is instant; the active layout (from the `multiband` setting) is flagged
* in the frame so the webview reads the right payload.
*/
class VectorscopeEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "vectorscope"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 440, 440, 240, 200 }; }
void setSampleRate(double sampleRate) override
{
vec.setSampleRate((float) sampleRate);
}
void configure(const juce::var& settings) override
{
multiband = (bool) settings.getProperty("multiband", false);
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
vec.pushSamples(left, right, (size_t) numSamples);
vec.pushMultibandSamples(left, right, (size_t) numSamples);
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("multiband", multiband);
if (multiband)
{
constexpr size_t stride = Visualizer::MULTIBAND_POINT_STRIDE;
if (mbData.size() < (size_t) kDisplayPoints * stride)
mbData.resize((size_t) kDisplayPoints * stride);
const size_t count = vec.getMultibandPoints(mbData.data(), (size_t) kDisplayPoints);
obj->setProperty("count", (int) count);
obj->setProperty("data", juce::Base64::toBase64(mbData.data(), count * stride * sizeof(float)));
}
else
{
if (pointX.size() < (size_t) kDisplayPoints)
{
pointX.resize((size_t) kDisplayPoints);
pointY.resize((size_t) kDisplayPoints);
}
const size_t count = vec.getPoints(pointX.data(), pointY.data(), (size_t) kDisplayPoints);
obj->setProperty("count", (int) count);
obj->setProperty("x", juce::Base64::toBase64(pointX.data(), count * sizeof(float)));
obj->setProperty("y", juce::Base64::toBase64(pointY.data(), count * sizeof(float)));
}
return juce::var(obj);
}
private:
static constexpr int kDisplayPoints = 4096; // matches Vectorscope.ts default
const juce::Identifier frameId { "vectorscopeFrame" };
Visualizer::Vectorscope vec;
std::vector<float> pointX, pointY, mbData;
bool multiband = false;
};
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include "ScopeEngine.h"
#include "waveform.h" // reused, unmodified, from native/src
#include <vector>
#include <cmath>
#include <algorithm>
/**
* Waveform engine. Runs the reused Visualizer::WaveformMultibandAnalyzer, which
* summarizes audio into per-column min/max + 3-band RMS. Unlike the spectrogram, the
* column width (samplesPerColumn) is a pure function of sampleRate and scrollSpeed —
* sampleRate / (128 * scrollSpeed), the same formula the renderer uses — so the engine
* derives it itself; no canvas round-trip. mode ('stereo' vs 'mono') selects
* processStereo (stride 10) vs processMono (stride 5), flagged in the frame. multiband
* is render-only (the analyzer always emits the band RMS columns use for coloring).
*/
class WaveformEngine : public ScopeEngine
{
public:
WaveformEngine() { reconfigure(); }
const char* scopeId() const override { return "waveform"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 720, 260, 360, 140 }; }
void setSampleRate(double sr) override
{
if (sr > 0.0 && (float) sr != sampleRate)
{
sampleRate = (float) sr;
reconfigure();
}
}
void configure(const juce::var& settings) override
{
stereo = settings.getProperty("mode", "mono").toString() == "stereo";
const auto speed = (float) settings.getProperty("scrollSpeed", (double) scrollSpeed);
if (speed > 0.0f)
scrollSpeed = speed;
reconfigure();
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
if (stereo)
{
const auto& cols = wave.processStereo(left, right, (size_t) numSamples);
pending.insert(pending.end(), cols.begin(), cols.end());
}
else
{
if ((int) mono.size() < numSamples)
mono.resize((size_t) numSamples);
for (int i = 0; i < numSamples; ++i)
mono[(size_t) i] = 0.5f * (left[i] + right[i]);
const auto& cols = wave.processMono(mono.data(), (size_t) numSamples);
pending.insert(pending.end(), cols.begin(), cols.end());
}
}
juce::var buildFrame(double sr) override
{
const size_t stride = stereo ? Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE
: Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE;
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sr);
obj->setProperty("stereo", stereo);
obj->setProperty("columnCount", (int) (pending.size() / stride));
if (! pending.empty())
obj->setProperty("summaries", juce::Base64::toBase64(pending.data(), pending.size() * sizeof(float)));
else
obj->setProperty("summaries", juce::String());
pending.clear();
return juce::var(obj);
}
private:
void reconfigure()
{
const float pps = 128.0f * std::max(0.01f, scrollSpeed);
samplesPerColumn = (size_t) std::max(1L, std::lround(sampleRate / pps));
wave.configure(sampleRate, samplesPerColumn);
pending.clear();
}
const juce::Identifier frameId { "waveformFrame" };
Visualizer::WaveformMultibandAnalyzer wave;
std::vector<float> mono, pending;
float sampleRate = 48000.0f;
float scrollSpeed = 1.0f;
size_t samplesPerColumn = 1;
bool stereo = false;
};
+13
View File
@@ -0,0 +1,13 @@
#pragma once
/**
* macOS only. Finds the WKWebView living under the given NSView (the plugin
* editor's peer) and disables WebKit's private `PreferPageRenderingUpdatesNear60FPSEnabled`
* feature, which otherwise throttles requestAnimationFrame to 60fps regardless of
* the display refresh rate. Returns true once the feature was found and toggled.
*
* Uses private WebKit API. There is no public alternative (Apple FB16411517 is
* unresolved). Acceptable for a non-App-Store FOSS plugin; guarded by
* respondsToSelector so it degrades to a no-op if the private API changes.
*/
bool prismUncapWebViewFrameRate(void* nsViewHandle);
+62
View File
@@ -0,0 +1,62 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#include "WebViewFrameRate.h"
// Private WebKit API. +_features is a CLASS method; the enable setter is an
// instance method. Guarded by respondsToSelector so this degrades to a no-op if
// the private API changes.
@interface WKPreferences (PrismPrivate)
+ (NSArray *)_features;
- (void)_setEnabled:(BOOL)enabled forFeature:(id)feature;
@end
static WKWebView* prismFindWebView(NSView* view)
{
if (view == nil)
return nil;
if ([view isKindOfClass:[WKWebView class]])
return (WKWebView*) view;
for (NSView* sub in [view subviews])
if (WKWebView* found = prismFindWebView(sub))
return found;
return nil;
}
bool prismUncapWebViewFrameRate(void* nsViewHandle)
{
WKWebView* webView = (nsViewHandle != nullptr) ? prismFindWebView((NSView*) nsViewHandle) : nil;
// Fallback: search every app window's content view.
if (webView == nil)
for (NSWindow* win in [NSApp windows])
if ((webView = prismFindWebView([win contentView])) != nil)
break;
if (webView == nil)
return false; // not in the hierarchy yet — caller retries
WKPreferences* prefs = [[webView configuration] preferences];
if (prefs == nil
|| ! [WKPreferences respondsToSelector:@selector(_features)]
|| ! [prefs respondsToSelector:@selector(_setEnabled:forFeature:)])
return false;
// Disable WebKit's private "prefer ~60fps page rendering" throttle so the
// canvas repaints at the display's native rate (e.g. 120Hz). Applied live:
// a reload is NOT used because JUCE's resource provider doesn't re-serve on
// reload (which would blank the page). The preference syncs to the WebContent
// process and takes effect on the running page.
for (id feature in [WKPreferences _features])
{
NSString* key = nil;
@try { key = [feature valueForKey:@"key"]; }
@catch (NSException*) { key = nil; }
if ([key isEqualToString:@"PreferPageRenderingUpdatesNear60FPSEnabled"])
{
[prefs _setEnabled:NO forFeature:feature];
return true;
}
}
return false;
}