mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-28 16:50:57 +02:00
@@ -0,0 +1,2 @@
|
||||
build/
|
||||
webview-dist/
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user