mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-28 16:50:57 +02:00
Compare commits
37 Commits
v0.3.0-beta
...
VSTs
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cb7f6c126 | |||
| 9342dc51f2 | |||
| 33b39626aa | |||
| 67f1c46862 | |||
| 2d634fda17 | |||
| 6ff11aa6bd | |||
| 9e9569fcac | |||
| 54fcefb9de | |||
| f5f406b43c | |||
| 583b436600 | |||
| 6ebafcc942 | |||
| 73ade9074d | |||
| f38fe4fa29 | |||
| 217c753eb8 | |||
| e7cc6f12fd | |||
| f25b0b671d | |||
| 9e7e20606e | |||
| f3131e365b | |||
| 6dc0e43914 | |||
| 593d0e5ee8 | |||
| f9fa9b8f4f | |||
| aa4672d258 | |||
| 32d5c91092 | |||
| 6f65b69ad8 | |||
| 3f74a74d65 | |||
| 586906c8e0 | |||
| 5fe4a45df4 | |||
| 281f84ec46 | |||
| c9d741c979 | |||
| 8006764608 | |||
| 4de676a8ad | |||
| 7da5f62c5e | |||
| 434b65605a | |||
| ddae07c985 | |||
| b877541882 | |||
| 60949a9389 | |||
| 3335b4a917 |
@@ -12,6 +12,12 @@ jobs:
|
||||
build:
|
||||
name: Build on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CMAKE_BUILD_PARALLEL_LEVEL: '4'
|
||||
PRISM_JUCE_VERSION: 8.0.4
|
||||
PRISM_WEBVIEW2_VERSION: 1.0.1901.177
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: '1'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -19,8 +25,11 @@ jobs:
|
||||
- os: macos-latest
|
||||
dist_cmd: dist:mac
|
||||
- os: ubuntu-latest
|
||||
dist_cmd: dist:linux
|
||||
- os: windows-latest
|
||||
dist_cmd: dist:linux:packages
|
||||
# Pinned to windows-2022 because windows-latest now ships VS 2026
|
||||
# (internal version 18), which the @electron/node-gyp version in our
|
||||
# lockfile rejects as "unsupported". 2022 has VS 2022 only, no ambiguity.
|
||||
- os: windows-2022
|
||||
dist_cmd: dist:win
|
||||
|
||||
steps:
|
||||
@@ -40,14 +49,59 @@ jobs:
|
||||
~/.cache/electron-builder
|
||||
key: ${{ runner.os }}-electron-cache
|
||||
|
||||
- name: Setup sccache
|
||||
if: runner.os != 'Linux'
|
||||
uses: mozilla-actions/sccache-action@v0.0.10
|
||||
|
||||
- name: Cache JUCE
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .ci-cache/juce/JUCE-${{ env.PRISM_JUCE_VERSION }}
|
||||
key: ${{ runner.os }}-juce-${{ env.PRISM_JUCE_VERSION }}-v1
|
||||
|
||||
- name: Prepare JUCE checkout
|
||||
shell: bash
|
||||
run: |
|
||||
juce_dir=".ci-cache/juce/JUCE-${PRISM_JUCE_VERSION}"
|
||||
if [ ! -f "$juce_dir/CMakeLists.txt" ]; then
|
||||
rm -rf "$juce_dir"
|
||||
mkdir -p "$(dirname "$juce_dir")"
|
||||
git clone --depth 1 --branch "$PRISM_JUCE_VERSION" https://github.com/juce-framework/JUCE.git "$juce_dir"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
PRISM_SKIP_NATIVE_POSTINSTALL: '1'
|
||||
run: npm ci
|
||||
|
||||
- name: Install Linux native build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libpulse-dev
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
cmake \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
libpulse-dev \
|
||||
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 \
|
||||
rpm
|
||||
|
||||
- name: Build native DSP module
|
||||
run: npm run rebuild:native
|
||||
@@ -61,20 +115,167 @@ jobs:
|
||||
fi
|
||||
echo "Native module built successfully: $(ls -lh native/build/Release/visualizer_dsp.node)"
|
||||
|
||||
# Linux AppImage is app-only. Build it before staging DAW plugins so the
|
||||
# portable artifact does not carry installable VST3 resources.
|
||||
- name: Prepare empty Linux plugin resources for AppImage
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf plugin/dist-installer
|
||||
mkdir -p plugin/dist-installer
|
||||
|
||||
- name: Build Linux AppImage
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run dist:linux:appimage
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
|
||||
# --- JUCE plugins. These are staged into installer resources after the
|
||||
# app-only AppImage has already been built on Linux. ---
|
||||
|
||||
- name: Build plugin webview bundle
|
||||
run: npm run plugin-ui:build
|
||||
|
||||
- name: Configure JUCE plugins (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
cmake -B plugin/build -S plugin -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DJUCE_PATH="$GITHUB_WORKSPACE/.ci-cache/juce/JUCE-${PRISM_JUCE_VERSION}" \
|
||||
-DPRISM_PLUGIN_FORMATS="AU;VST3" \
|
||||
-DPRISM_COPY_PLUGIN_AFTER_BUILD=OFF \
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
|
||||
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
|
||||
|
||||
- name: Configure JUCE plugins (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
cmake -B plugin/build -S plugin -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DJUCE_PATH="$GITHUB_WORKSPACE/.ci-cache/juce/JUCE-${PRISM_JUCE_VERSION}" \
|
||||
-DPRISM_PLUGIN_FORMATS=VST3 \
|
||||
-DPRISM_COPY_PLUGIN_AFTER_BUILD=OFF
|
||||
|
||||
# JUCE's NEEDS_WEBVIEW2 expects the Microsoft.Web.WebView2 NuGet package
|
||||
# to already be installed locally; it doesn't fetch it itself. Install it
|
||||
# and point CMake at the resulting folder via JUCE_WEBVIEW2_PACKAGE_LOCATION.
|
||||
- name: Cache WebView2 SDK (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/webview2
|
||||
key: ${{ runner.os }}-webview2-${{ env.PRISM_WEBVIEW2_VERSION }}-v1
|
||||
|
||||
- name: Install WebView2 SDK (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$sdkRoot = "$env:RUNNER_TEMP\webview2"
|
||||
$packageRoot = Join-Path $sdkRoot "Microsoft.Web.WebView2"
|
||||
$webViewHeader = Join-Path $packageRoot "build\native\include\WebView2.h"
|
||||
if (-not (Test-Path $webViewHeader)) {
|
||||
Remove-Item -Recurse -Force $sdkRoot -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $sdkRoot | Out-Null
|
||||
nuget install Microsoft.Web.WebView2 -Version $env:PRISM_WEBVIEW2_VERSION -OutputDirectory $sdkRoot -ExcludeVersion
|
||||
}
|
||||
"JUCE_WEBVIEW2_PACKAGE_LOCATION=$sdkRoot" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Configure JUCE plugins (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: cmd
|
||||
run: |
|
||||
for /f "usebackq tokens=*" %%i in (`vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set VSINSTALL=%%i
|
||||
call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat"
|
||||
cmake -B plugin/build -S plugin -G Ninja -DCMAKE_BUILD_TYPE=Release -DJUCE_PATH="%GITHUB_WORKSPACE%\.ci-cache\juce\JUCE-%PRISM_JUCE_VERSION%" -DPRISM_PLUGIN_FORMATS=VST3 -DPRISM_COPY_PLUGIN_AFTER_BUILD=OFF -DJUCE_WEBVIEW2_PACKAGE_LOCATION="%JUCE_WEBVIEW2_PACKAGE_LOCATION%" -DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache
|
||||
|
||||
- name: Build JUCE plugins (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: cmake --build plugin/build --target PrismInstallerPlugins --parallel 4
|
||||
|
||||
- name: Build JUCE plugins (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: cmake --build plugin/build --target PrismInstallerPlugins --parallel 4
|
||||
|
||||
- name: Build JUCE plugins (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: cmd
|
||||
run: |
|
||||
for /f "usebackq tokens=*" %%i in (`vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set VSINSTALL=%%i
|
||||
call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat"
|
||||
cmake --build plugin/build --target PrismInstallerPlugins --config Release --parallel 4
|
||||
|
||||
- name: Show sccache stats
|
||||
if: runner.os != 'Linux'
|
||||
run: sccache --show-stats
|
||||
|
||||
- name: Stage plugins for installer
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf plugin/dist-installer
|
||||
mkdir -p plugin/dist-installer
|
||||
mkdir -p plugin/dist-installer/VST3
|
||||
[ "$RUNNER_OS" = "macOS" ] && mkdir -p plugin/dist-installer/AU
|
||||
for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform; do
|
||||
vst3_dir="plugin/build/${art}_artefacts/Release/VST3"
|
||||
[ -d "$vst3_dir" ] && find "$vst3_dir" -maxdepth 1 -name "*.vst3" -exec cp -R {} plugin/dist-installer/VST3/ \;
|
||||
if [ "$RUNNER_OS" = "macOS" ]; then
|
||||
au_dir="plugin/build/${art}_artefacts/Release/AU"
|
||||
[ -d "$au_dir" ] && find "$au_dir" -maxdepth 1 -name "*.component" -exec cp -R {} plugin/dist-installer/AU/ \;
|
||||
fi
|
||||
done
|
||||
echo "--- staged VST3 ---"
|
||||
ls -la plugin/dist-installer/VST3
|
||||
vst3_count="$(find plugin/dist-installer/VST3 -maxdepth 1 -name "*.vst3" | wc -l | tr -d ' ')"
|
||||
if [ "$vst3_count" -ne 7 ]; then
|
||||
echo "ERROR: expected 7 VST3 plugins, found $vst3_count"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RUNNER_OS" = "Linux" ]; then
|
||||
for vst3 in plugin/dist-installer/VST3/*.vst3; do
|
||||
if [ ! -d "$vst3/Contents/x86_64-linux" ]; then
|
||||
echo "ERROR: Linux VST3 bundle is missing Contents/x86_64-linux: $vst3"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cp resources/installer/linux/install-vst3.sh plugin/dist-installer/install-vst3.sh
|
||||
chmod +x plugin/dist-installer/install-vst3.sh
|
||||
fi
|
||||
if [ "$RUNNER_OS" = "macOS" ]; then
|
||||
echo "--- staged AU ---"
|
||||
ls -la plugin/dist-installer/AU
|
||||
au_count="$(find plugin/dist-installer/AU -maxdepth 1 -name "*.component" | wc -l | tr -d ' ')"
|
||||
if [ "$au_count" -ne 7 ]; then
|
||||
echo "ERROR: expected 7 AU plugins, found $au_count"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Build distributable
|
||||
if: runner.os != 'Linux'
|
||||
run: npm run ${{ matrix.dist_cmd }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
|
||||
- name: Build Linux packages with VST3 plugins
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf dist/linux-unpacked
|
||||
npm run ${{ matrix.dist_cmd }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: prism-${{ runner.os }}${{ inputs.version_tag && format('-{0}', inputs.version_tag) || '' }}
|
||||
path: |
|
||||
dist/*.exe
|
||||
dist/*.dmg
|
||||
dist/*.pkg
|
||||
dist/*.zip
|
||||
dist/*.AppImage
|
||||
dist/*.deb
|
||||
dist/*.rpm
|
||||
dist/*.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
@@ -6,6 +6,11 @@ out/
|
||||
dist/
|
||||
native/build/
|
||||
|
||||
# JUCE plugin build + installer staging
|
||||
plugin/build/
|
||||
plugin/webview-dist/
|
||||
plugin/dist-installer/
|
||||
|
||||
# Electron
|
||||
*.log
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ Prebuilt binaries for Windows, macOS, and Linux are available on the [Releases](
|
||||
|----------|-----------|
|
||||
| macOS | Xcode Command Line Tools |
|
||||
| Windows | Visual Studio Build Tools |
|
||||
| Linux | `build-essential`, `python3`, `libasound2-dev`, `libpulse-dev` |
|
||||
| Linux | `build-essential`, `python3`, `libasound2-dev`, `libpulse-dev`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` |
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Boof2015/prism.git
|
||||
@@ -75,9 +75,15 @@ npm run build # Build application assets
|
||||
npm run dist # Package for current platform
|
||||
npm run dist:mac # macOS (DMG + ZIP)
|
||||
npm run dist:win # Windows (NSIS + Portable)
|
||||
npm run dist:linux # Linux (AppImage + DEB)
|
||||
npm run dist:linux # Linux (AppImage + DEB + RPM + tar.gz)
|
||||
```
|
||||
|
||||
Linux `.deb` and `.rpm` releases install the seven Prism VST3 plugins to
|
||||
`/usr/lib/vst3`. The Linux `tar.gz` release includes a `resources/plugins/install-vst3.sh`
|
||||
helper that installs them to `$HOME/.vst3` by default. The AppImage is app-only;
|
||||
it does not install DAW plugins. DAWs commonly scan `$HOME/.vst3`, `/usr/lib/vst3`,
|
||||
and `/usr/local/lib/vst3`.
|
||||
|
||||
## Astra Integration
|
||||
|
||||
If you use [Astra](https://github.com/Boof2015/astra), Prism can connect to its local API to show what's playing, cover art, track info, and playback controls, alongside your scopes.
|
||||
|
||||
+54
-4
@@ -23,13 +23,17 @@
|
||||
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
|
||||
"test:build-metadata": "node scripts/run-build-metadata-tests.mjs",
|
||||
"test:updates": "node scripts/run-update-tests.mjs",
|
||||
"plugin-ui:dev": "vite --config vite.plugin-ui.config.ts",
|
||||
"plugin-ui:build": "vite build --config vite.plugin-ui.config.ts",
|
||||
"build:native": "cd native && node-gyp rebuild",
|
||||
"rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"",
|
||||
"postinstall": "npm run rebuild:native || echo 'Native build failed, will use JS fallback'",
|
||||
"postinstall": "node scripts/build/postinstall-native.cjs",
|
||||
"dist": "npm run build && electron-builder --publish never",
|
||||
"dist:mac": "npm run build && electron-builder --mac --publish never",
|
||||
"dist:win": "npm run build && electron-builder --win --publish never",
|
||||
"dist:linux": "npm run build && electron-builder --linux --publish never"
|
||||
"dist:linux": "npm run build && electron-builder --linux AppImage deb rpm tar.gz --publish never",
|
||||
"dist:linux:appimage": "npm run build && electron-builder --linux AppImage --publish never",
|
||||
"dist:linux:packages": "electron-builder --linux deb rpm tar.gz --publish never"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -90,16 +94,24 @@
|
||||
{
|
||||
"from": "resources/icon.png",
|
||||
"to": "icon.png"
|
||||
},
|
||||
{
|
||||
"from": "plugin/dist-installer/",
|
||||
"to": "plugins/"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"target": ["dmg", "zip"],
|
||||
"target": ["pkg", "zip"],
|
||||
"icon": "resources/icon.icns",
|
||||
"category": "public.app-category.music",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "resources/entitlements.mac.plist",
|
||||
"entitlementsInherit": "resources/entitlements.mac.inherit.plist"
|
||||
},
|
||||
"pkg": {
|
||||
"installLocation": "/Applications",
|
||||
"scripts": "../resources/installer/macos-scripts"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{ "target": "nsis", "arch": ["x64"] },
|
||||
@@ -107,10 +119,48 @@
|
||||
],
|
||||
"icon": "resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"include": "resources/installer/windows-vst3.nsh"
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage", "deb"],
|
||||
"target": ["AppImage", "deb", "rpm", "tar.gz"],
|
||||
"icon": "resources/icon.png",
|
||||
"category": "Audio"
|
||||
},
|
||||
"deb": {
|
||||
"afterInstall": "resources/installer/linux/install-vst3-from-package.sh",
|
||||
"afterRemove": "resources/installer/linux/remove-vst3-from-system.sh",
|
||||
"depends": [
|
||||
"libgtk-3-0",
|
||||
"libnotify4",
|
||||
"libnss3",
|
||||
"libxss1",
|
||||
"libxtst6",
|
||||
"xdg-utils",
|
||||
"libatspi2.0-0",
|
||||
"libuuid1",
|
||||
"libsecret-1-0",
|
||||
"libwebkit2gtk-4.1-0"
|
||||
]
|
||||
},
|
||||
"rpm": {
|
||||
"afterInstall": "resources/installer/linux/install-vst3-from-package.sh",
|
||||
"afterRemove": "resources/installer/linux/remove-vst3-from-system.sh",
|
||||
"depends": [
|
||||
"gtk3",
|
||||
"libnotify",
|
||||
"nss",
|
||||
"libXScrnSaver",
|
||||
"libXtst",
|
||||
"xdg-utils",
|
||||
"at-spi2-core",
|
||||
"libuuid",
|
||||
"libsecret",
|
||||
"webkit2gtk4.1"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/sh
|
||||
# Package post-install hook for Linux .deb/.rpm builds. The app package installs
|
||||
# Prism under /opt, then this copies the bundled native Linux VST3 bundles into
|
||||
# the global VST3 scan path used by Linux DAWs.
|
||||
|
||||
set -eu
|
||||
|
||||
DEST_DIR="${PRISM_VST3_DEST_DIR:-/usr/lib/vst3}"
|
||||
|
||||
find_source_dir() {
|
||||
if [ -n "${PRISM_VST3_SOURCE_DIR:-}" ] && [ -d "$PRISM_VST3_SOURCE_DIR" ]; then
|
||||
printf '%s\n' "$PRISM_VST3_SOURCE_DIR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for dir in \
|
||||
/opt/Prism/resources/plugins/VST3 \
|
||||
/opt/prism/resources/plugins/VST3
|
||||
do
|
||||
if [ -d "$dir" ]; then
|
||||
printf '%s\n' "$dir"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
install_plugin() {
|
||||
plugin_name="$1"
|
||||
source_plugin="$SOURCE_DIR/$plugin_name"
|
||||
dest_plugin="$DEST_DIR/$plugin_name"
|
||||
|
||||
if [ ! -d "$source_plugin" ]; then
|
||||
echo "Prism VST3 install: missing bundled plugin: $source_plugin" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -rf "$dest_plugin"
|
||||
cp -a "$source_plugin" "$DEST_DIR/"
|
||||
}
|
||||
|
||||
SOURCE_DIR="$(find_source_dir || true)"
|
||||
if [ -z "$SOURCE_DIR" ]; then
|
||||
echo "Prism VST3 install: bundled VST3 directory not found; skipping plugin install." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
install_plugin "Prism Spectrum.vst3"
|
||||
install_plugin "Prism Oscilloscope.vst3"
|
||||
install_plugin "Prism VU Meter.vst3"
|
||||
install_plugin "Prism Loudness Meter.vst3"
|
||||
install_plugin "Prism Vectorscope.vst3"
|
||||
install_plugin "Prism Spectrogram.vst3"
|
||||
install_plugin "Prism Waveform.vst3"
|
||||
|
||||
chmod -R a+rX "$DEST_DIR"/Prism*.vst3 2>/dev/null || true
|
||||
echo "Prism VST3 plugins installed to $DEST_DIR"
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/bin/sh
|
||||
# Helper bundled in the Linux tarball under resources/plugins/. By default it
|
||||
# installs Prism's native Linux VST3 bundles to the current user's VST3 folder.
|
||||
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: install-vst3.sh [--system] [--dest PATH] [--source PATH]
|
||||
|
||||
Installs the bundled Prism VST3 plugins.
|
||||
|
||||
Options:
|
||||
--system Install to /usr/lib/vst3 instead of $HOME/.vst3.
|
||||
--dest PATH Install to a custom VST3 directory.
|
||||
--source PATH Read Prism *.vst3 bundles from a custom source directory.
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
SOURCE_DIR="${PRISM_VST3_SOURCE_DIR:-}"
|
||||
DEST_DIR="${PRISM_VST3_DEST_DIR:-$HOME/.vst3}"
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--system)
|
||||
DEST_DIR="/usr/lib/vst3"
|
||||
;;
|
||||
--dest)
|
||||
shift
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "install-vst3.sh: --dest requires a path" >&2
|
||||
exit 2
|
||||
fi
|
||||
DEST_DIR="$1"
|
||||
;;
|
||||
--source)
|
||||
shift
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "install-vst3.sh: --source requires a path" >&2
|
||||
exit 2
|
||||
fi
|
||||
SOURCE_DIR="$1"
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "install-vst3.sh: unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ -z "$SOURCE_DIR" ]; then
|
||||
if [ -d "$SCRIPT_DIR/VST3" ]; then
|
||||
SOURCE_DIR="$SCRIPT_DIR/VST3"
|
||||
elif [ -d "$SCRIPT_DIR/plugins/VST3" ]; then
|
||||
SOURCE_DIR="$SCRIPT_DIR/plugins/VST3"
|
||||
else
|
||||
SOURCE_DIR="$SCRIPT_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "install-vst3.sh: source directory not found: $SOURCE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install_plugin() {
|
||||
plugin_name="$1"
|
||||
source_plugin="$SOURCE_DIR/$plugin_name"
|
||||
dest_plugin="$DEST_DIR/$plugin_name"
|
||||
|
||||
if [ ! -d "$source_plugin" ]; then
|
||||
echo "install-vst3.sh: missing bundled plugin: $source_plugin" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
rm -rf "$dest_plugin"
|
||||
cp -a "$source_plugin" "$DEST_DIR/"
|
||||
}
|
||||
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
install_plugin "Prism Spectrum.vst3"
|
||||
install_plugin "Prism Oscilloscope.vst3"
|
||||
install_plugin "Prism VU Meter.vst3"
|
||||
install_plugin "Prism Loudness Meter.vst3"
|
||||
install_plugin "Prism Vectorscope.vst3"
|
||||
install_plugin "Prism Spectrogram.vst3"
|
||||
install_plugin "Prism Waveform.vst3"
|
||||
|
||||
chmod -R u+rwX,go+rX "$DEST_DIR"/Prism*.vst3 2>/dev/null || true
|
||||
|
||||
cat <<EOF
|
||||
Prism VST3 plugins installed to:
|
||||
$DEST_DIR
|
||||
|
||||
Rescan VST3 plugins in your DAW if they do not appear immediately.
|
||||
EOF
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# Package removal hook for Linux .deb/.rpm builds. Remove only Prism's known
|
||||
# VST3 bundles from the global VST3 scan path.
|
||||
|
||||
set -eu
|
||||
|
||||
DEST_DIR="${PRISM_VST3_DEST_DIR:-/usr/lib/vst3}"
|
||||
|
||||
remove_plugin() {
|
||||
rm -rf "$DEST_DIR/$1"
|
||||
}
|
||||
|
||||
remove_plugin "Prism Spectrum.vst3"
|
||||
remove_plugin "Prism Oscilloscope.vst3"
|
||||
remove_plugin "Prism VU Meter.vst3"
|
||||
remove_plugin "Prism Loudness Meter.vst3"
|
||||
remove_plugin "Prism Vectorscope.vst3"
|
||||
remove_plugin "Prism Spectrogram.vst3"
|
||||
remove_plugin "Prism Waveform.vst3"
|
||||
|
||||
echo "Prism VST3 plugins removed from $DEST_DIR"
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Postinstall: after the macOS Installer drops Prism.app into /Applications, copy
|
||||
# the bundled VST3 / AU plugins out of the app's Resources into the system
|
||||
# Audio Plug-Ins folders so DAWs find them.
|
||||
#
|
||||
# Standard pkg postinstall args:
|
||||
# $1 = full path to the installer package
|
||||
# $2 = full path of the installed destination (e.g. /Applications)
|
||||
# $3 = mountpoint of the target volume (typically /)
|
||||
# $4 = root directory of the system being installed onto
|
||||
#
|
||||
# Runs as root, so writes to /Library/Audio/Plug-Ins/* succeed.
|
||||
|
||||
set -eu
|
||||
|
||||
APP_PATH="${2:-/Applications}/Prism.app"
|
||||
[ -d "$APP_PATH" ] || APP_PATH="/Applications/Prism.app"
|
||||
|
||||
PLUGINS_SRC="$APP_PATH/Contents/Resources/plugins"
|
||||
VST3_DEST="/Library/Audio/Plug-Ins/VST3"
|
||||
AU_DEST="/Library/Audio/Plug-Ins/Components"
|
||||
|
||||
mkdir -p "$VST3_DEST" "$AU_DEST"
|
||||
|
||||
if [ -d "$PLUGINS_SRC/VST3" ]; then
|
||||
cp -R "$PLUGINS_SRC/VST3/"*.vst3 "$VST3_DEST/" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "$PLUGINS_SRC/AU" ]; then
|
||||
cp -R "$PLUGINS_SRC/AU/"*.component "$AU_DEST/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,91 @@
|
||||
; Custom NSIS include for electron-builder.
|
||||
;
|
||||
; Wizard installer (oneClick=false, perMachine=true) with an optional VST3
|
||||
; install step. After the directory page, we add a custom page with a checkbox:
|
||||
;
|
||||
; [x] Install Prism VST3 plugins (recommended)
|
||||
;
|
||||
; If checked, customInstall xcopies the bundled .vst3 bundles from
|
||||
; $INSTDIR\resources\plugins\VST3\ into $COMMONFILES64\VST3\. The whole
|
||||
; installer already runs elevated (perMachine=true → UAC at launch), so writes
|
||||
; to Common Files succeed without a second elevation. On uninstall, the bundles
|
||||
; are removed unconditionally (harmless if the user opted out at install time).
|
||||
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
!include "nsDialogs.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
|
||||
Var PRISM_VST_CHECKBOX
|
||||
Var PRISM_VST_STATE
|
||||
|
||||
!macro customInit
|
||||
; Silent installs skip the options page, so default to installing plugins.
|
||||
StrCpy $PRISM_VST_STATE ${BST_CHECKED}
|
||||
!macroend
|
||||
|
||||
; NOTE on parse order: this file is `!include`d before electron-builder's main
|
||||
; installer template, so MUI references live inside inserted macro bodies.
|
||||
!macro customPageAfterChangeDir
|
||||
Page custom prismVstOptionsPage prismVstOptionsPageLeave
|
||||
|
||||
Function prismVstOptionsPage
|
||||
!insertmacro MUI_HEADER_TEXT "VST3 Plugins" "Choose whether to install the Prism scope plugins."
|
||||
|
||||
nsDialogs::Create 1018
|
||||
Pop $0
|
||||
${If} $0 == error
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
${NSD_CreateCheckBox} 0 0u 100% 12u "Install Prism VST3 plugins (recommended)"
|
||||
Pop $PRISM_VST_CHECKBOX
|
||||
${NSD_Check} $PRISM_VST_CHECKBOX
|
||||
|
||||
${NSD_CreateLabel} 0 22u 100% 80u "Installs the seven Prism scope plugins (Spectrum, Oscilloscope, Vectorscope, Spectrogram, VU Meter, Loudness Meter, Waveform) to:$\r$\n$\r$\n $COMMONFILES64\VST3$\r$\n$\r$\nDAWs (FL Studio, Ableton, Logic, Reaper, etc.) will find them on the next plugin rescan.$\r$\n$\r$\nUncheck to install only the Prism desktop app. You can re-run this installer at any time to add the plugins later."
|
||||
Pop $0
|
||||
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function prismVstOptionsPageLeave
|
||||
${NSD_GetState} $PRISM_VST_CHECKBOX $PRISM_VST_STATE
|
||||
FunctionEnd
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
${If} $PRISM_VST_STATE == ${BST_CHECKED}
|
||||
DetailPrint "Installing Prism VST3 plugins to $COMMONFILES64\VST3"
|
||||
|
||||
${IfNot} ${FileExists} "$INSTDIR\resources\plugins\VST3\*.vst3"
|
||||
DetailPrint "ERROR: bundled Prism VST3 plugins were not found at $INSTDIR\resources\plugins\VST3"
|
||||
MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugins were selected, but the bundled plugin files were not found.$\r$\n$\r$\nMissing path:$\r$\n$INSTDIR\resources\plugins\VST3" /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
CreateDirectory "$COMMONFILES64\VST3"
|
||||
; xcopy /E recurse, /I treat dest as dir, /Y overwrite without prompt.
|
||||
; The trailing "\*" + "/E" copies each *.vst3 bundle subfolder verbatim.
|
||||
nsExec::ExecToLog 'cmd.exe /c xcopy /E /I /Y "$INSTDIR\resources\plugins\VST3\*" "$COMMONFILES64\VST3\"'
|
||||
Pop $0
|
||||
${If} $0 != 0
|
||||
DetailPrint "ERROR: Prism VST3 plugin copy failed with xcopy exit code $0"
|
||||
MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugin installation failed while copying files to:$\r$\n$COMMONFILES64\VST3$\r$\n$\r$\nxcopy exit code: $0" /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
${Else}
|
||||
DetailPrint "Prism VST3 plugins: skipped (opted out)."
|
||||
${EndIf}
|
||||
!macroend
|
||||
!endif
|
||||
|
||||
!macro customUnInstall
|
||||
; Always attempt removal — harmless RMDir if the bundle isn't there (user opted
|
||||
; out at install or removed manually).
|
||||
RMDir /r "$COMMONFILES64\VST3\Prism Spectrum.vst3"
|
||||
RMDir /r "$COMMONFILES64\VST3\Prism Oscilloscope.vst3"
|
||||
RMDir /r "$COMMONFILES64\VST3\Prism VU Meter.vst3"
|
||||
RMDir /r "$COMMONFILES64\VST3\Prism Loudness Meter.vst3"
|
||||
RMDir /r "$COMMONFILES64\VST3\Prism Vectorscope.vst3"
|
||||
RMDir /r "$COMMONFILES64\VST3\Prism Spectrogram.vst3"
|
||||
RMDir /r "$COMMONFILES64\VST3\Prism Waveform.vst3"
|
||||
!macroend
|
||||
@@ -0,0 +1,18 @@
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const skipNativePostinstall = /^(1|true|yes)$/i.test(process.env.PRISM_SKIP_NATIVE_POSTINSTALL || '')
|
||||
|
||||
if (skipNativePostinstall) {
|
||||
console.log('Skipping native postinstall because PRISM_SKIP_NATIVE_POSTINSTALL is set.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
const result = spawnSync(npmCommand, ['run', 'rebuild:native'], { stdio: 'inherit' })
|
||||
|
||||
if (result.status === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.warn('Native build failed, will use JS fallback')
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { LUFSMeterNativeAnalyzer, LUFSMeterNativeSnapshot } from '../renderer/audio/native'
|
||||
|
||||
/**
|
||||
* Drop-in `LUFSMeterNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* The loudness DSP (K-weighting, gated integration, fast VU/peak/correlation) runs
|
||||
* in the C++ plugin, which pushes a finished scalar snapshot each frame. This shim
|
||||
* caches that snapshot and serves it through the interface `LUFSMeter` consumes.
|
||||
* `pushSamples` is a no-op (audio never flows through the webview); `reset` clears
|
||||
* only the cache (the C++ integrator keeps its own state).
|
||||
*/
|
||||
export class BridgeLUFSMeterAnalyzer implements LUFSMeterNativeAnalyzer {
|
||||
private snapshot: LUFSMeterNativeSnapshot | null = null
|
||||
|
||||
/** Called by the bridge whenever the host emits a new LUFS frame. */
|
||||
setSnapshot(snapshot: LUFSMeterNativeSnapshot): void {
|
||||
this.snapshot = snapshot
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
setSampleRate(_sampleRate: number): void {}
|
||||
pushSamples(_left: Float32Array, _right: Float32Array): void {}
|
||||
|
||||
getSnapshot(): LUFSMeterNativeSnapshot | null {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.snapshot = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { OscilloscopeNativeAnalyzer, OscilloscopeResult } from '../renderer/audio/native'
|
||||
|
||||
/**
|
||||
* Drop-in `OscilloscopeNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* The oscilloscope DSP (circular buffer + trigger detection) runs in the C++
|
||||
* plugin, which pushes the finished, already-triggered display window each frame.
|
||||
* This shim serves that window through the interface `Oscilloscope` consumes, so
|
||||
* the visualizer renders it unchanged. `pushSamples` is a no-op (audio never
|
||||
* flows through the webview); `processContinuous` reports the window at index 0.
|
||||
*/
|
||||
export class BridgeOscilloscopeAnalyzer implements OscilloscopeNativeAnalyzer {
|
||||
private samples = new Float32Array(0)
|
||||
private pitch = 0
|
||||
|
||||
/** Called by the bridge whenever the host emits a new oscilloscope frame. */
|
||||
setSamples(samples: Float32Array, pitch: number): void {
|
||||
if (samples.length !== this.samples.length) {
|
||||
this.samples = new Float32Array(samples.length)
|
||||
}
|
||||
this.samples.set(samples)
|
||||
this.pitch = pitch
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
setSampleRate(_sampleRate: number): void {}
|
||||
setPitchLock(_enabled: boolean): void {}
|
||||
setDisplaySamples(_samples: number): void {}
|
||||
pushSamples(_samples: Float32Array): void {}
|
||||
|
||||
processContinuous(): OscilloscopeResult {
|
||||
const count = this.samples.length
|
||||
// C++ already applied the trigger, so the window starts at index 0.
|
||||
return { triggerIndex: 0, samplesToShow: count, detectedPitch: this.pitch, writePos: count }
|
||||
}
|
||||
|
||||
fillSamples(_startPos: number, output: Float32Array): number {
|
||||
const count = Math.min(output.length, this.samples.length)
|
||||
if (count > 0) {
|
||||
output.set(this.samples.subarray(0, count), 0)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.samples = new Float32Array(0)
|
||||
this.pitch = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { SpectrogramNativeAnalyzer, SpectrogramNativeOptions, SpectrogramNativeResult } from '../renderer/audio/native'
|
||||
import { emitToHost } from './juceBridge'
|
||||
|
||||
const EMPTY = new Float32Array(0)
|
||||
|
||||
// The C++ engine emits columns every vblank (audio-driven), independently of how
|
||||
// fast the webview can render them. Cap the backlog so that when the consumer falls
|
||||
// behind we drop the OLDEST columns instead of accumulating unbounded latency (which
|
||||
// otherwise death-spirals into stutter at high scroll speeds). One entry == one
|
||||
// emitted frame, so this bounds latency to ~N display frames regardless of rate.
|
||||
const MAX_QUEUED_FRAMES = 8
|
||||
|
||||
interface QueuedColumns {
|
||||
display: Float32Array
|
||||
heat: Float32Array
|
||||
columnCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in `SpectrogramNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* The spectrogram DSP runs in the C++ plugin, but its output depends on the
|
||||
* canvas-derived `rowCount` that only the UI knows. So this shim works in two
|
||||
* directions: `configure()` forwards the full native config to C++ (event
|
||||
* "prismSpectrogramConfig"), and the host streams finished display+heat columns
|
||||
* back which the bridge enqueues via `pushFrame()`. `process()` ignores its audio
|
||||
* argument (no samples flow through the webview) and returns all columns queued
|
||||
* since the last call, concatenated into one result.
|
||||
*
|
||||
* Columns are only kept while their rowCount matches the rowCount the UI last
|
||||
* asked for — on a resize the UI reconfigures, we drop the stale queue, and the
|
||||
* C++ side catches up within a frame or two (a brief gap, never a mismatch).
|
||||
*/
|
||||
export class BridgeSpectrogramAnalyzer implements SpectrogramNativeAnalyzer {
|
||||
private expectedRowCount = 0
|
||||
private queue: QueuedColumns[] = []
|
||||
private queuedColumns = 0
|
||||
|
||||
configure(options: SpectrogramNativeOptions): void {
|
||||
if (options.rowCount !== this.expectedRowCount) {
|
||||
this.expectedRowCount = options.rowCount
|
||||
this.clearQueue()
|
||||
}
|
||||
emitToHost('prismSpectrogramConfig', options)
|
||||
}
|
||||
|
||||
/** Called by the bridge when the host emits a spectrogram frame. */
|
||||
pushFrame(display: Float32Array, heat: Float32Array, columnCount: number, rowCount: number): void {
|
||||
if (rowCount !== this.expectedRowCount || columnCount <= 0) return
|
||||
if (display.length < columnCount * rowCount || heat.length < columnCount * rowCount) return
|
||||
this.queue.push({ display, heat, columnCount })
|
||||
this.queuedColumns += columnCount
|
||||
// Drop oldest backlog beyond the cap so we stay near real-time under overload.
|
||||
while (this.queue.length > MAX_QUEUED_FRAMES) {
|
||||
const dropped = this.queue.shift()
|
||||
if (dropped) this.queuedColumns -= dropped.columnCount
|
||||
}
|
||||
}
|
||||
|
||||
/** The rowCount the UI last asked for (0 until first configure). */
|
||||
getExpectedRowCount(): number {
|
||||
return this.expectedRowCount
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
process(_audioData: Float32Array): SpectrogramNativeResult {
|
||||
const rowCount = this.expectedRowCount
|
||||
if (this.queuedColumns === 0 || rowCount <= 0) {
|
||||
return { display: EMPTY, heat: EMPTY, columnCount: 0, rowCount }
|
||||
}
|
||||
|
||||
const total = this.queuedColumns * rowCount
|
||||
const display = new Float32Array(total)
|
||||
const heat = new Float32Array(total)
|
||||
let offset = 0
|
||||
for (const entry of this.queue) {
|
||||
display.set(entry.display, offset)
|
||||
heat.set(entry.heat, offset)
|
||||
offset += entry.display.length
|
||||
}
|
||||
|
||||
const columnCount = this.queuedColumns
|
||||
this.clearQueue()
|
||||
return { display, heat, columnCount, rowCount }
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.clearQueue()
|
||||
}
|
||||
|
||||
private clearQueue(): void {
|
||||
this.queue = []
|
||||
this.queuedColumns = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { SpectrumNativeAnalyzer } from '../renderer/audio/native'
|
||||
|
||||
const FFT_SILENCE_DB = -100
|
||||
|
||||
/**
|
||||
* A drop-in `SpectrumNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* In the Electron app, `SpectrumAnalyzer` pushes raw samples into the N-API DSP
|
||||
* addon and reads magnitudes back. There is no N-API addon inside a webview, so
|
||||
* here the DSP runs in the C++ plugin instead: it computes magnitudes off the
|
||||
* realtime thread and pushes them over the JUCE bridge. This shim simply caches
|
||||
* the latest pushed magnitudes and serves them through the same interface
|
||||
* `SpectrumAnalyzer` already consumes — so the visualizer needs no changes.
|
||||
*
|
||||
* `pushSamples` / `pushStereoSamples` are intentional no-ops: audio never flows
|
||||
* through the webview.
|
||||
*/
|
||||
export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
|
||||
private fftSize = 2048
|
||||
private sampleRate = 48000
|
||||
private magnitudes: Float32Array
|
||||
private sideMagnitudes: Float32Array
|
||||
|
||||
constructor(fftSize = 2048) {
|
||||
this.fftSize = fftSize
|
||||
this.magnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
|
||||
this.sideMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
|
||||
}
|
||||
|
||||
/** Called by the bridge whenever the host emits a new frame. */
|
||||
setMagnitudes(magnitudes: Float32Array, side?: Float32Array): void {
|
||||
if (magnitudes.length !== this.magnitudes.length) {
|
||||
this.magnitudes = new Float32Array(magnitudes.length)
|
||||
}
|
||||
this.magnitudes.set(magnitudes)
|
||||
|
||||
if (side && side.length > 0) {
|
||||
if (side.length !== this.sideMagnitudes.length) {
|
||||
this.sideMagnitudes = new Float32Array(side.length)
|
||||
}
|
||||
this.sideMagnitudes.set(side)
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
setFFTSize(size: number): void {
|
||||
if (size > 0 && size !== this.fftSize) {
|
||||
this.fftSize = size
|
||||
this.magnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
|
||||
this.sideMagnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
|
||||
}
|
||||
}
|
||||
|
||||
getFFTSize(): number {
|
||||
return this.fftSize
|
||||
}
|
||||
|
||||
setSampleRate(sampleRate: number): void {
|
||||
this.sampleRate = sampleRate
|
||||
}
|
||||
|
||||
// Smoothing is applied in the C++ DSP; nothing to do on this side.
|
||||
setSmoothing(_smoothing: number): void {}
|
||||
|
||||
pushSamples(_audioData: Float32Array): void {}
|
||||
|
||||
pushStereoSamples(_leftChannel: Float32Array, _rightChannel: Float32Array): void {}
|
||||
|
||||
fillMagnitudes(output: Float32Array): number {
|
||||
const count = Math.min(output.length, this.magnitudes.length)
|
||||
if (count > 0) {
|
||||
output.set(this.magnitudes.subarray(0, count), 0)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// The C++ side sends already-smoothed magnitudes; serve them for the raw
|
||||
// request too (the heatmap path re-smooths from this in the visualizer).
|
||||
fillRawMagnitudes(output: Float32Array): number {
|
||||
return this.fillMagnitudes(output)
|
||||
}
|
||||
|
||||
fillSideMagnitudes(output: Float32Array): number {
|
||||
const count = Math.min(output.length, this.sideMagnitudes.length)
|
||||
if (count > 0) {
|
||||
output.set(this.sideMagnitudes.subarray(0, count), 0)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
getMagnitudes(): Float32Array {
|
||||
return this.magnitudes
|
||||
}
|
||||
|
||||
getRawMagnitudes(): Float32Array {
|
||||
return this.magnitudes
|
||||
}
|
||||
|
||||
getSideMagnitudes(): Float32Array {
|
||||
return this.sideMagnitudes
|
||||
}
|
||||
|
||||
process(_audioData: Float32Array): Float32Array {
|
||||
return this.magnitudes
|
||||
}
|
||||
|
||||
binToFrequency(bin: number): number {
|
||||
return (bin * this.sampleRate) / this.fftSize
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.magnitudes.fill(FFT_SILENCE_DB)
|
||||
this.sideMagnitudes.fill(FFT_SILENCE_DB)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { VUMeterNativeAnalyzer, VUMeterNativeSnapshot } from '../renderer/audio/native'
|
||||
|
||||
/**
|
||||
* Drop-in `VUMeterNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* The VU DSP (RMS integration, ballistics, peak hold, correlation) runs in the
|
||||
* C++ plugin, which pushes a finished scalar snapshot each frame. This shim caches
|
||||
* that snapshot and serves it through the interface `VUMeter` consumes, so the
|
||||
* visualizer renders it unchanged. `pushSamples` is a no-op (audio never flows
|
||||
* through the webview).
|
||||
*/
|
||||
export class BridgeVUMeterAnalyzer implements VUMeterNativeAnalyzer {
|
||||
private snapshot: VUMeterNativeSnapshot | null = null
|
||||
|
||||
/** Called by the bridge whenever the host emits a new VU frame. */
|
||||
setSnapshot(snapshot: VUMeterNativeSnapshot): void {
|
||||
this.snapshot = snapshot
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
setSampleRate(_sampleRate: number): void {}
|
||||
pushSamples(_left: Float32Array, _right: Float32Array): void {}
|
||||
|
||||
getSnapshot(): VUMeterNativeSnapshot | null {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.snapshot = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { VectorscopeNativeAnalyzer, VectorscopeMultibandPointsResult } from '../renderer/audio/native'
|
||||
|
||||
/**
|
||||
* Drop-in `VectorscopeNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* The vectorscope DSP (channel lowpass + 3-band split, circular buffers) runs in
|
||||
* the C++ plugin, which pushes the most recent display points each frame — either
|
||||
* a standard X/Y cloud or a multiband (6 floats/point: lowL,lowR,midL,midR,highL,
|
||||
* highR) cloud, depending on the active mode. This shim caches whichever arrived
|
||||
* and serves it through the two readout methods `Vectorscope` consumes. The push
|
||||
* methods are no-ops (audio never flows through the webview).
|
||||
*/
|
||||
export class BridgeVectorscopeAnalyzer implements VectorscopeNativeAnalyzer {
|
||||
private x: Float32Array = new Float32Array(0)
|
||||
private y: Float32Array = new Float32Array(0)
|
||||
private count = 0
|
||||
private mbData: Float32Array = new Float32Array(0)
|
||||
private mbCount = 0
|
||||
|
||||
/** Standard X/Y point cloud from the host. */
|
||||
setStandard(x: Float32Array, y: Float32Array, count: number): void {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.count = count
|
||||
}
|
||||
|
||||
/** Multiband point cloud from the host (flat, 6 floats per point). */
|
||||
setMultiband(data: Float32Array, count: number): void {
|
||||
this.mbData = data
|
||||
this.mbCount = count
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
isMultibandAvailable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
setSampleRate(_sampleRate: number): void {}
|
||||
pushSamples(_left: Float32Array, _right: Float32Array): void {}
|
||||
pushMultibandSamples(_left: Float32Array, _right: Float32Array): void {}
|
||||
|
||||
fillPoints(xOut: Float32Array, yOut: Float32Array): number {
|
||||
const count = Math.min(xOut.length, yOut.length, this.count, this.x.length, this.y.length)
|
||||
if (count > 0) {
|
||||
xOut.set(this.x.subarray(0, count), 0)
|
||||
yOut.set(this.y.subarray(0, count), 0)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
getMultibandPoints(maxPoints: number): VectorscopeMultibandPointsResult {
|
||||
const count = Math.min(maxPoints, this.mbCount, Math.floor(this.mbData.length / 6))
|
||||
return { data: this.mbData, count }
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.x = new Float32Array(0)
|
||||
this.y = new Float32Array(0)
|
||||
this.count = 0
|
||||
this.mbData = new Float32Array(0)
|
||||
this.mbCount = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { WaveformNativeAnalyzer } from '../renderer/audio/native'
|
||||
|
||||
interface QueuedColumns {
|
||||
summaries: Float32Array
|
||||
stereo: boolean
|
||||
}
|
||||
|
||||
// The C++ engine emits columns every vblank (audio-driven), independently of how
|
||||
// fast the webview renders them. Cap the backlog so an overloaded consumer drops the
|
||||
// OLDEST columns rather than accumulating unbounded latency (which death-spirals into
|
||||
// stutter at high scroll speeds). One entry == one emitted frame.
|
||||
const MAX_QUEUED_FRAMES = 8
|
||||
|
||||
/**
|
||||
* Drop-in `WaveformNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* The waveform DSP (per-column min/max + 3-band RMS) runs in the C++ plugin, which
|
||||
* pushes finished column summaries each frame — stride 10 in stereo mode, stride 5
|
||||
* in mono. This shim queues them and serves whichever the visualizer asks for:
|
||||
* `processStereo`/`processMono` return the queued summaries matching that mode and
|
||||
* clear the queue (so a mode switch never returns mismatched-stride data, and the
|
||||
* queue can't grow unbounded). `configure` is a no-op — the engine derives
|
||||
* samplesPerColumn itself from the host sample rate + scroll speed.
|
||||
*/
|
||||
export class BridgeWaveformAnalyzer implements WaveformNativeAnalyzer {
|
||||
private queue: QueuedColumns[] = []
|
||||
|
||||
/** Called by the bridge when the host emits a waveform frame. */
|
||||
pushFrame(summaries: Float32Array, stereo: boolean): void {
|
||||
if (summaries.length === 0) return
|
||||
this.queue.push({ summaries, stereo })
|
||||
// Drop oldest backlog beyond the cap so we stay near real-time under overload.
|
||||
while (this.queue.length > MAX_QUEUED_FRAMES) {
|
||||
this.queue.shift()
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
configure(_sampleRate: number, _samplesPerColumn: number): void {}
|
||||
|
||||
processMono(_samples: Float32Array): Float32Array | null {
|
||||
return this.drain(false)
|
||||
}
|
||||
|
||||
processStereo(_left: Float32Array, _right: Float32Array): Float32Array | null {
|
||||
return this.drain(true)
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.queue = []
|
||||
}
|
||||
|
||||
private drain(stereo: boolean): Float32Array {
|
||||
const matching = this.queue.filter((entry) => entry.stereo === stereo)
|
||||
this.queue = []
|
||||
if (matching.length === 0) return new Float32Array(0)
|
||||
if (matching.length === 1) return matching[0].summaries
|
||||
|
||||
let total = 0
|
||||
for (const entry of matching) total += entry.summaries.length
|
||||
const out = new Float32Array(total)
|
||||
let offset = 0
|
||||
for (const entry of matching) {
|
||||
out.set(entry.summaries, offset)
|
||||
offset += entry.summaries.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { JSX } from 'react'
|
||||
|
||||
// Prism's settings icon (matches the app's scope chrome).
|
||||
export default function GearIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 118 118" width="15" height="15" aria-hidden="true">
|
||||
<path d="M104.811 35.1118L102.384 30.9002C100.549 27.7151 99.6313 26.1225 98.0697 25.4874C96.5082 24.8524 94.7421 25.3535 91.2105 26.3557L85.2112 28.0456C82.9564 28.5655 80.5905 28.2706 78.5319 27.2127L76.8755 26.2571C75.1099 25.1263 73.7519 23.4591 73.0002 21.4993L71.3585 16.5955C70.2788 13.3504 69.7389 11.7279 68.4537 10.7998C67.169 9.87175 65.4619 9.87175 62.0478 9.87175H56.5667C53.1531 9.87175 51.446 9.87175 50.1608 10.7998C48.8758 11.7279 48.336 13.3504 47.2564 16.5955L45.6145 21.4993C44.8628 23.4591 43.5048 25.1263 41.7394 26.2571L40.083 27.2127C38.0242 28.2706 35.6585 28.5655 33.4037 28.0456L27.4042 26.3557C23.8724 25.3535 22.1065 24.8524 20.5451 25.4874C18.9836 26.1225 18.066 27.7151 16.2306 30.9002L13.8038 35.1118C12.0834 38.0975 11.2232 39.5903 11.3902 41.1795C11.5571 42.7687 12.7087 44.0493 15.0118 46.6106L20.0811 52.2779C21.3201 53.8464 22.1997 56.58 22.1997 59.0379C22.1997 61.4967 21.3204 64.2294 20.0812 65.7983L15.0118 71.4657C12.7087 74.0273 11.5572 75.3076 11.3902 76.8972C11.2232 78.4862 12.0834 79.9789 13.8038 82.9643L16.2306 87.1759C18.0659 90.361 18.9836 91.954 20.5451 92.5887C22.1065 93.2239 23.8724 92.7229 27.4043 91.7204L33.4035 90.0306C35.6587 89.5104 38.0248 89.8059 40.0839 90.8639L41.74 91.8197C43.5051 92.9506 44.8628 94.6173 45.6143 96.5771L47.2564 101.481C48.336 104.726 48.8758 106.349 50.1608 107.277C51.446 108.205 53.1531 108.205 56.5667 108.205H62.0478C65.4619 108.205 67.169 108.205 68.4537 107.277C69.7389 106.349 70.2788 104.726 71.3585 101.481L73.0007 96.5771C73.7519 94.6173 75.1094 92.9506 76.875 91.8197L78.5309 90.8639C80.59 89.8059 82.9559 89.5104 85.2112 90.0306L91.2105 91.7204C94.7421 92.7229 96.5082 93.2239 98.0697 92.5887C99.6313 91.954 100.549 90.361 102.384 87.1759L104.811 82.9643C106.531 79.9789 107.391 78.4862 107.225 76.8972C107.057 75.3076 105.906 74.0273 103.603 71.4657L98.5334 65.7983C97.2944 64.2294 96.4148 61.4967 96.4148 59.0379C96.4148 56.58 97.2949 53.8464 98.5334 52.2779L103.603 46.6106C105.906 44.0493 107.057 42.7687 107.225 41.1795C107.391 39.5903 106.531 38.0975 104.811 35.1118Z" fill="none" stroke="currentColor" strokeWidth="8.5" strokeLinecap="round" />
|
||||
<path d="M76.3042 59C76.3042 68.5039 68.5998 76.2083 59.0959 76.2083C49.592 76.2083 41.8877 68.5039 41.8877 59C41.8877 49.4961 49.592 41.7917 59.0959 41.7917C68.5998 41.7917 76.3042 49.4961 76.3042 59Z" fill="none" stroke="currentColor" strokeWidth="8.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { LUFSMeter } from '../renderer/visualizers/LUFSMeter'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedLUFSMeterTheme } from '../types/theme'
|
||||
import type { BridgeLUFSMeterAnalyzer } from './BridgeLUFSMeterAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { lufsmeterSettingsToOptions } from './lufsmeterOptions'
|
||||
|
||||
interface LUFSMeterScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
nativeAnalyzer: BridgeLUFSMeterAnalyzer
|
||||
settings: ScopeSettings['lufsmeter']
|
||||
theme: ResolvedLUFSMeterTheme
|
||||
}
|
||||
|
||||
export default function LUFSMeterScope({
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
settings,
|
||||
theme,
|
||||
}: LUFSMeterScopeProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<LUFSMeter | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
|
||||
const viz = new LUFSMeter(canvas, {
|
||||
...lufsmeterSettingsToOptions(settings, theme),
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
})
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
|
||||
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
|
||||
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
||||
canvas.width = pixelWidth
|
||||
canvas.height = pixelHeight
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
const observer = new ResizeObserver(applySize)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
// settings/theme applied via setOptions below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSource, nativeAnalyzer])
|
||||
|
||||
useEffect(() => {
|
||||
vizRef.current?.setOptions(lufsmeterSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { Oscilloscope } from '../renderer/visualizers/Oscilloscope'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedOscilloscopeTheme } from '../types/theme'
|
||||
import type { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions'
|
||||
|
||||
interface OscilloscopeScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
nativeAnalyzer: BridgeOscilloscopeAnalyzer
|
||||
settings: ScopeSettings['oscilloscope']
|
||||
theme: ResolvedOscilloscopeTheme
|
||||
}
|
||||
|
||||
export default function OscilloscopeScope({
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
settings,
|
||||
theme,
|
||||
}: OscilloscopeScopeProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Oscilloscope | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
|
||||
const viz = new Oscilloscope(canvas, {
|
||||
...oscilloscopeSettingsToOptions(settings, theme),
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
})
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
|
||||
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
|
||||
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
||||
canvas.width = pixelWidth
|
||||
canvas.height = pixelHeight
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
const observer = new ResizeObserver(applySize)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
// settings/theme applied via setOptions below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSource, nativeAnalyzer])
|
||||
|
||||
useEffect(() => {
|
||||
vizRef.current?.setOptions(oscilloscopeSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ScopePopoutSessionState } from '../types/popout'
|
||||
import type { SpectrumAnalyzerDataSource } from '../renderer/visualizers/SpectrumAnalyzer'
|
||||
|
||||
type SpectrumStereoChunk = { left: Float32Array; right: Float32Array }
|
||||
|
||||
/**
|
||||
* `SpectrumAnalyzerDataSource` for the plugin webview.
|
||||
*
|
||||
* In Electron this source drains raw sample queues from the AudioRouter. In the
|
||||
* plugin the DSP runs in C++, so there are no raw samples to drain here — the
|
||||
* pending-sample getters return empty. This source's only job is to report the
|
||||
* session state (sample rate + whether the host is feeding us frames) so the
|
||||
* visualizer maps frequencies correctly and runs its render loop.
|
||||
*
|
||||
* Mirrors the seam used by ScopePopoutDataSource so the visualizer is unchanged.
|
||||
*/
|
||||
export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource {
|
||||
private sessionState: ScopePopoutSessionState = {
|
||||
sessionId: 1,
|
||||
sampleRate: 48000,
|
||||
channelCount: 2,
|
||||
capturing: false,
|
||||
backendKind: null,
|
||||
}
|
||||
|
||||
private readonly listeners = new Set<(state: ScopePopoutSessionState) => void>()
|
||||
|
||||
// The DSP runs in C++ and pushes finished magnitudes (no raw samples flow
|
||||
// through here). But SpectrumAnalyzer only refreshes its heatmap buffer when
|
||||
// it sees "new samples arrived" (a non-empty pending queue). We therefore
|
||||
// hand it a reusable sentinel chunk each frame to signal a fresh frame. Its
|
||||
// length saturates `nativeBufferedSamples` so heatmap smoothing applies — the
|
||||
// values are unused (the shim's pushSamples is a no-op; magnitudes come from
|
||||
// fillMagnitudes). Sized to the max FFT so any fftSize saturates in one frame.
|
||||
private readonly sentinel = new Float32Array(16384)
|
||||
private readonly sentinelStereo: SpectrumStereoChunk = {
|
||||
left: this.sentinel,
|
||||
right: this.sentinel,
|
||||
}
|
||||
|
||||
getPendingSpectrumSamples(): Float32Array[] {
|
||||
return this.sessionState.capturing ? [this.sentinel] : []
|
||||
}
|
||||
|
||||
getPendingSpectrumStereoSamples(): SpectrumStereoChunk[] {
|
||||
return this.sessionState.capturing ? [this.sentinelStereo] : []
|
||||
}
|
||||
|
||||
// Oscilloscope: same sentinel trick — the DSP runs in C++ and pushes finished
|
||||
// display windows; this just advances the visualizer's warmup/"new data" gate.
|
||||
getPendingOscilloscopeSamples(): Float32Array[] {
|
||||
return this.sessionState.capturing ? [this.sentinel] : []
|
||||
}
|
||||
|
||||
// Spectrogram: needs a sentinel so the visualizer calls the analyzer's process()
|
||||
// each frame (it only does so per pending chunk) to drain the C++ column queue.
|
||||
getPendingSpectrogramSamples(): Float32Array[] {
|
||||
return this.sessionState.capturing ? [this.sentinel] : []
|
||||
}
|
||||
|
||||
// Waveform: same sentinel trick — the visualizer calls processMono/processStereo
|
||||
// per pending chunk, which drains the C++-pushed column summaries from the bridge.
|
||||
getPendingWaveformSamples(): Float32Array[] {
|
||||
return this.sessionState.capturing ? [this.sentinel] : []
|
||||
}
|
||||
|
||||
getPendingWaveformStereoSamples(): SpectrumStereoChunk[] {
|
||||
return this.sessionState.capturing ? [this.sentinelStereo] : []
|
||||
}
|
||||
|
||||
// VU + loudness meters read the C++-pushed snapshot from the bridge analyzer
|
||||
// every frame, so there are no raw samples to drain here (no sentinel needed).
|
||||
getPendingVUMeterSamples(): Array<{ left: Float32Array; right: Float32Array }> {
|
||||
return []
|
||||
}
|
||||
|
||||
getPendingLUFSMeterSamples(): Array<{ left: Float32Array; right: Float32Array }> {
|
||||
return []
|
||||
}
|
||||
|
||||
// Vectorscope reads the C++-pushed point cloud via fillPoints/getMultibandPoints
|
||||
// each frame — no raw samples drain here, and no sentinel is needed.
|
||||
getPendingVectorscopeSamples(): Array<{ left: Float32Array; right: Float32Array }> {
|
||||
return []
|
||||
}
|
||||
|
||||
getSampleRate(): number {
|
||||
return this.sessionState.sampleRate
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return this.sessionState.capturing
|
||||
}
|
||||
|
||||
subscribeToSessionChanges(listener: (state: ScopePopoutSessionState) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
listener(this.sessionState)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the bridge when a host frame arrives. */
|
||||
setSampleRate(sampleRate: number): void {
|
||||
if (sampleRate > 0 && sampleRate !== this.sessionState.sampleRate) {
|
||||
this.updateSession({ sampleRate, sessionId: this.sessionState.sessionId + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
setPlaying(playing: boolean): void {
|
||||
if (playing !== this.sessionState.capturing) {
|
||||
this.updateSession({ capturing: playing })
|
||||
}
|
||||
}
|
||||
|
||||
private updateSession(partial: Partial<ScopePopoutSessionState>): void {
|
||||
this.sessionState = { ...this.sessionState, ...partial }
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.sessionState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type JSX, type ReactNode } from 'react'
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { PrismResolvedTheme } from '../types/theme'
|
||||
import ScopeSettingsSection from '../renderer/components/ScopeSettingsSection'
|
||||
import GearIcon from './GearIcon'
|
||||
import { useScopeHostSync } from './useScopeHostSync'
|
||||
import { emitToHost } from './juceBridge'
|
||||
|
||||
// Height (CSS px) of the bottom settings panel. The C++ editor grows its window by
|
||||
// exactly this when settings open (and shrinks back on close) so the scope area is
|
||||
// unchanged — like the desktop app. Must match `.spectrum-app__panel` in styles.css.
|
||||
const PANEL_HEIGHT = 280
|
||||
|
||||
interface ScopeAppProps<K extends ScopeKind> {
|
||||
kind: K
|
||||
/** Render the scope's canvas given the current settings + resolved theme. */
|
||||
renderScope: (settings: ScopeSettings[K], theme: PrismResolvedTheme) => ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic plugin shell for any scope: the scope fills the viewport, and the gear
|
||||
* toggles a settings panel that opens along the bottom (like the desktop app). The
|
||||
* window grows by the panel height to accommodate it, so the scope area never resizes.
|
||||
*/
|
||||
export default function ScopeApp<K extends ScopeKind>({ kind, renderScope }: ScopeAppProps<K>): JSX.Element {
|
||||
const { settings, resolvedTheme, handleUpdate } = useScopeHostSync(kind)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [lockedViewportHeight, setLockedViewportHeight] = useState<number | null>(null)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
emitToHost('prismSettingsPanel', { height: settingsOpen ? PANEL_HEIGHT : 0 })
|
||||
}, [settingsOpen])
|
||||
|
||||
const toggleSettings = (): void => {
|
||||
if (settingsOpen) {
|
||||
setSettingsOpen(false)
|
||||
setLockedViewportHeight(null)
|
||||
return
|
||||
}
|
||||
|
||||
const rect = viewportRef.current?.getBoundingClientRect()
|
||||
setLockedViewportHeight(rect && rect.height > 0 ? Math.ceil(rect.height) : null)
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
|
||||
const appStyle = lockedViewportHeight === null
|
||||
? undefined
|
||||
: ({ '--spectrum-viewport-height': `${lockedViewportHeight}px` } as CSSProperties)
|
||||
|
||||
return (
|
||||
<div className={`spectrum-app ${settingsOpen ? 'has-settings' : ''}`.trim()} style={appStyle}>
|
||||
<div ref={viewportRef} className="spectrum-app__viewport">
|
||||
{renderScope(settings, resolvedTheme)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`spectrum-app__gear ${settingsOpen ? 'is-active' : ''}`.trim()}
|
||||
onClick={toggleSettings}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<GearIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settingsOpen && (
|
||||
<div className="spectrum-app__panel">
|
||||
<ScopeSettingsSection
|
||||
kind={kind}
|
||||
settings={settings}
|
||||
onUpdate={(_k, partial) => handleUpdate(partial as unknown as Partial<ScopeSettings[K]>)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { Spectrogram } from '../renderer/visualizers/Spectrogram'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedSpectrogramTheme } from '../types/theme'
|
||||
import type { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { spectrogramSettingsToOptions } from './spectrogramOptions'
|
||||
import { resolveScrollingCanvasSize } from './scrollingCanvas'
|
||||
|
||||
interface SpectrogramScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
nativeAnalyzer: BridgeSpectrogramAnalyzer
|
||||
settings: ScopeSettings['spectrogram']
|
||||
theme: ResolvedSpectrogramTheme
|
||||
}
|
||||
|
||||
export default function SpectrogramScope({
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
settings,
|
||||
theme,
|
||||
}: SpectrogramScopeProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Spectrogram | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
|
||||
const viz = new Spectrogram(canvas, {
|
||||
...spectrogramSettingsToOptions(settings, theme),
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
})
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr)
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
const observer = new ResizeObserver(applySize)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
// settings/theme applied via setOptions below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSource, nativeAnalyzer])
|
||||
|
||||
useEffect(() => {
|
||||
vizRef.current?.setOptions(spectrogramSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react'
|
||||
import { SpectrumAnalyzer } from '../renderer/visualizers/SpectrumAnalyzer'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedSpectrumTheme } from '../types/theme'
|
||||
import type { SpectrumPeakInfo } from '../types/spectrum'
|
||||
import type { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { spectrumSettingsToOptions } from './spectrumOptions'
|
||||
import {
|
||||
formatSpectrumPeakDb,
|
||||
formatSpectrumPeakFrequency,
|
||||
measureCanvasResizeState,
|
||||
resolveFollowingPeakOverlayStyle,
|
||||
type CanvasResizeState,
|
||||
type SizeMeasurement,
|
||||
} from './peakOverlay'
|
||||
|
||||
interface SpectrumScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
nativeAnalyzer: BridgeSpectrumAnalyzer
|
||||
settings: ScopeSettings['spectrum']
|
||||
theme: ResolvedSpectrumTheme
|
||||
}
|
||||
|
||||
export default function SpectrumScope({
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
settings,
|
||||
theme,
|
||||
}: SpectrumScopeProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const analyzerRef = useRef<SpectrumAnalyzer | null>(null)
|
||||
const resizeStateRef = useRef<CanvasResizeState | null>(null)
|
||||
const peakOverlayRef = useRef<HTMLDivElement | null>(null)
|
||||
const [peak, setPeak] = useState<SpectrumPeakInfo | null>(null)
|
||||
const [overlaySize, setOverlaySize] = useState<SizeMeasurement | null>(null)
|
||||
|
||||
const peakMode = settings.peakInfoMode
|
||||
|
||||
// Create the analyzer once per data source / shim.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
|
||||
const analyzer = new SpectrumAnalyzer(canvas, {
|
||||
...spectrumSettingsToOptions(settings, theme),
|
||||
capturePeakInfo: settings.peakInfoMode !== 'off',
|
||||
onPeakInfo: setPeak,
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
})
|
||||
analyzerRef.current = analyzer
|
||||
|
||||
const applySize = (): void => {
|
||||
const state = measureCanvasResizeState(container)
|
||||
resizeStateRef.current = state
|
||||
if (canvas.width !== state.pixelWidth || canvas.height !== state.pixelHeight) {
|
||||
canvas.width = state.pixelWidth
|
||||
canvas.height = state.pixelHeight
|
||||
analyzer.resize()
|
||||
}
|
||||
}
|
||||
|
||||
applySize()
|
||||
analyzer.start()
|
||||
const observer = new ResizeObserver(applySize)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
analyzer.dispose()
|
||||
analyzerRef.current = null
|
||||
}
|
||||
// settings/theme are applied via setOptions below, not on recreation.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSource, nativeAnalyzer])
|
||||
|
||||
// Apply settings/theme changes live.
|
||||
useEffect(() => {
|
||||
if (settings.peakInfoMode === 'off') setPeak(null)
|
||||
analyzerRef.current?.setOptions({
|
||||
...spectrumSettingsToOptions(settings, theme),
|
||||
capturePeakInfo: settings.peakInfoMode !== 'off',
|
||||
onPeakInfo: setPeak,
|
||||
})
|
||||
}, [settings, theme])
|
||||
|
||||
// Measure the overlay so "following" placement can avoid the screen edges.
|
||||
useLayoutEffect(() => {
|
||||
const overlay = peakOverlayRef.current
|
||||
if (peakMode !== 'following' || !peak || !overlay) {
|
||||
setOverlaySize(null)
|
||||
return
|
||||
}
|
||||
const measure = (): void => {
|
||||
const next = { width: overlay.offsetWidth, height: overlay.offsetHeight }
|
||||
setOverlaySize((prev) => (prev?.width === next.width && prev?.height === next.height ? prev : next))
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(overlay)
|
||||
return () => observer.disconnect()
|
||||
}, [peakMode, peak])
|
||||
|
||||
const showPeak = peakMode !== 'off' && peak !== null
|
||||
const overlayStyle: CSSProperties | undefined =
|
||||
peakMode === 'following' && peak
|
||||
? resolveFollowingPeakOverlayStyle(peak, resizeStateRef.current, overlaySize)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
{showPeak && peak && (
|
||||
<div
|
||||
ref={peakMode === 'following' ? peakOverlayRef : null}
|
||||
className={['scope-module__peak-info', peakMode === 'following' ? 'is-following' : 'is-corner'].join(' ')}
|
||||
style={overlayStyle}
|
||||
>
|
||||
<span className="scope-module__peak-info-value">{formatSpectrumPeakDb(peak.db)}</span>
|
||||
<span className="scope-module__peak-info-separator">/</span>
|
||||
<span className="scope-module__peak-info-value">{formatSpectrumPeakFrequency(peak.frequencyHz)}</span>
|
||||
<span className="scope-module__peak-info-separator">/</span>
|
||||
<span className="scope-module__peak-info-value">{peak.key}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { VUMeter } from '../renderer/visualizers/VUMeter'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedVUMeterTheme } from '../types/theme'
|
||||
import type { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { vumeterSettingsToOptions } from './vumeterOptions'
|
||||
|
||||
interface VUMeterScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
nativeAnalyzer: BridgeVUMeterAnalyzer
|
||||
settings: ScopeSettings['vumeter']
|
||||
theme: ResolvedVUMeterTheme
|
||||
}
|
||||
|
||||
export default function VUMeterScope({
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
settings,
|
||||
theme,
|
||||
}: VUMeterScopeProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<VUMeter | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
|
||||
const viz = new VUMeter(canvas, {
|
||||
...vumeterSettingsToOptions(settings, theme),
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
})
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
|
||||
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
|
||||
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
||||
canvas.width = pixelWidth
|
||||
canvas.height = pixelHeight
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
const observer = new ResizeObserver(applySize)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
// settings/theme applied via setOptions below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSource, nativeAnalyzer])
|
||||
|
||||
useEffect(() => {
|
||||
vizRef.current?.setOptions(vumeterSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { Vectorscope } from '../renderer/visualizers/Vectorscope'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedVectorscopeTheme } from '../types/theme'
|
||||
import type { BridgeVectorscopeAnalyzer } from './BridgeVectorscopeAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { vectorscopeSettingsToOptions } from './vectorscopeOptions'
|
||||
|
||||
interface VectorscopeScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
nativeAnalyzer: BridgeVectorscopeAnalyzer
|
||||
settings: ScopeSettings['vectorscope']
|
||||
theme: ResolvedVectorscopeTheme
|
||||
}
|
||||
|
||||
export default function VectorscopeScope({
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
settings,
|
||||
theme,
|
||||
}: VectorscopeScopeProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Vectorscope | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
|
||||
const viz = new Vectorscope(canvas, {
|
||||
...vectorscopeSettingsToOptions(settings, theme),
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
})
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
|
||||
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
|
||||
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
||||
canvas.width = pixelWidth
|
||||
canvas.height = pixelHeight
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
const observer = new ResizeObserver(applySize)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
// settings/theme applied via setOptions below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSource, nativeAnalyzer])
|
||||
|
||||
useEffect(() => {
|
||||
vizRef.current?.setOptions(vectorscopeSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { Waveform } from '../renderer/visualizers/Waveform'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedWaveformTheme } from '../types/theme'
|
||||
import type { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { waveformSettingsToOptions } from './waveformOptions'
|
||||
import { resolveScrollingCanvasSize } from './scrollingCanvas'
|
||||
|
||||
interface WaveformScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
nativeAnalyzer: BridgeWaveformAnalyzer
|
||||
settings: ScopeSettings['waveform']
|
||||
theme: ResolvedWaveformTheme
|
||||
}
|
||||
|
||||
export default function WaveformScope({
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
settings,
|
||||
theme,
|
||||
}: WaveformScopeProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Waveform | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!container || !canvas) return
|
||||
|
||||
const viz = new Waveform(canvas, {
|
||||
...waveformSettingsToOptions(settings, theme),
|
||||
dataSource,
|
||||
nativeAnalyzer,
|
||||
})
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr)
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
const observer = new ResizeObserver(applySize)
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
// settings/theme applied via setOptions below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSource, nativeAnalyzer])
|
||||
|
||||
useEffect(() => {
|
||||
vizRef.current?.setOptions(waveformSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Prism Spectrum</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#prism-plugin-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: #000;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
html.prism-plugin-mounted #prism-plugin-status {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.__PRISM_PLUGIN_ERROR__ = function (message) {
|
||||
var status = document.getElementById('prism-plugin-status')
|
||||
if (status) status.textContent = 'Prism plugin UI failed to load\n\n' + message
|
||||
}
|
||||
|
||||
window.addEventListener('error', function (event) {
|
||||
window.__PRISM_PLUGIN_ERROR__(
|
||||
event.message || (event.error && event.error.message) || 'Unknown error'
|
||||
)
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', function (event) {
|
||||
var reason = event.reason
|
||||
window.__PRISM_PLUGIN_ERROR__(
|
||||
(reason && reason.message) || String(reason || 'Unhandled promise rejection')
|
||||
)
|
||||
})
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="prism-plugin-status">Loading Prism plugin UI...</div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* Bridge between the JUCE 8 plugin host (C++) and this webview UI.
|
||||
*
|
||||
* - C++ -> JS: emits "spectrumFrame" (~display rate) and "prismRestoreSettings".
|
||||
* - JS -> C++: emits "prismConfig" (settings + DSP params) and "prismReady".
|
||||
*
|
||||
* JUCE injects `window.__JUCE__` into pages loaded by the webview (including the
|
||||
* Vite dev server) when native integration is enabled. When it's absent (e.g. a
|
||||
* plain browser), a synthetic generator drives the UI so it's developable.
|
||||
*/
|
||||
|
||||
export interface SpectrumFrame {
|
||||
/** Host sample rate in Hz. */
|
||||
sampleRate: number
|
||||
/** Mid (mono) magnitudes in dB, length = fftSize/2. */
|
||||
magnitudes: Float32Array
|
||||
/** Side magnitudes in dB (same length); empty if unavailable. */
|
||||
side: Float32Array
|
||||
}
|
||||
|
||||
interface SpectrumFramePayload {
|
||||
sampleRate?: number
|
||||
magnitudes?: string
|
||||
side?: string
|
||||
}
|
||||
|
||||
type JuceBackend = {
|
||||
addEventListener: (eventId: string, fn: (payload: unknown) => void) => number
|
||||
removeEventListener?: (id: number) => void
|
||||
emitEvent?: (eventId: string, payload: unknown) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__JUCE__?: { backend?: JuceBackend; initialisationData?: unknown }
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_WAIT_TIMEOUT_MS = 4000
|
||||
const HOST_POLL_INTERVAL_MS = 50
|
||||
|
||||
/** Resolves to the JUCE backend once available, or null if no host (timeout). */
|
||||
let backendPromise: Promise<JuceBackend | null> | null = null
|
||||
|
||||
function ensureBackend(): Promise<JuceBackend | null> {
|
||||
if (backendPromise) return backendPromise
|
||||
backendPromise = new Promise((resolve) => {
|
||||
const existing = window.__JUCE__?.backend
|
||||
if (existing && typeof existing.addEventListener === 'function') {
|
||||
resolve(existing)
|
||||
return
|
||||
}
|
||||
let waited = 0
|
||||
const timer = setInterval(() => {
|
||||
const backend = window.__JUCE__?.backend
|
||||
if (backend && typeof backend.addEventListener === 'function') {
|
||||
clearInterval(timer)
|
||||
resolve(backend)
|
||||
return
|
||||
}
|
||||
waited += HOST_POLL_INTERVAL_MS
|
||||
if (waited >= HOST_WAIT_TIMEOUT_MS) {
|
||||
clearInterval(timer)
|
||||
resolve(null)
|
||||
}
|
||||
}, HOST_POLL_INTERVAL_MS)
|
||||
})
|
||||
return backendPromise
|
||||
}
|
||||
|
||||
/** Fire-and-forget event to C++ (no-op when running without a host). */
|
||||
export function emitToHost(eventId: string, payload: unknown): void {
|
||||
void ensureBackend().then((backend) => backend?.emitEvent?.(eventId, payload))
|
||||
}
|
||||
|
||||
/** Subscribe to a C++ event. Returns an unsubscribe function. */
|
||||
export function onHostEvent(eventId: string, handler: (payload: unknown) => void): () => void {
|
||||
let listenerId: number | null = null
|
||||
let cancelled = false
|
||||
void ensureBackend().then((backend) => {
|
||||
if (!backend || cancelled) return
|
||||
listenerId = backend.addEventListener(eventId, handler)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (listenerId !== null) {
|
||||
window.__JUCE__?.backend?.removeEventListener?.(listenerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function base64ToFloat32Array(b64: string): Float32Array {
|
||||
if (!b64) return new Float32Array(0)
|
||||
const binary = atob(b64)
|
||||
const byteLength = binary.length
|
||||
const bytes = new Uint8Array(byteLength)
|
||||
for (let i = 0; i < byteLength; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return new Float32Array(bytes.buffer, 0, byteLength >> 2)
|
||||
}
|
||||
|
||||
function decodeFrame(payload: unknown): SpectrumFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const { sampleRate, magnitudes, side } = payload as SpectrumFramePayload
|
||||
if (typeof magnitudes !== 'string' || magnitudes.length === 0) return null
|
||||
return {
|
||||
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
|
||||
magnitudes: base64ToFloat32Array(magnitudes),
|
||||
side: typeof side === 'string' ? base64ToFloat32Array(side) : new Float32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
export interface SpectrumBridgeHandlers {
|
||||
onFrame: (frame: SpectrumFrame) => void
|
||||
onConnected?: (usingMock: boolean) => void
|
||||
}
|
||||
|
||||
export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => void {
|
||||
let disposed = false
|
||||
let listenerId: number | null = null
|
||||
let mockRaf: number | null = null
|
||||
|
||||
const startMock = (): void => {
|
||||
handlers.onConnected?.(true)
|
||||
console.warn('[prism-plugin] no JUCE host — using synthetic spectrum (browser dev mode)')
|
||||
const binCount = 1024
|
||||
const sampleRate = 48000
|
||||
const mid = new Float32Array(binCount)
|
||||
const side = new Float32Array(binCount).fill(-100)
|
||||
let phase = 0
|
||||
const tick = (): void => {
|
||||
if (disposed) return
|
||||
phase += 0.05
|
||||
for (let i = 0; i < binCount; i += 1) {
|
||||
const t = i / binCount
|
||||
const peak1 = Math.exp(-Math.pow((t - (0.15 + 0.05 * Math.sin(phase))) * 12, 2)) * 70
|
||||
const peak2 = Math.exp(-Math.pow((t - 0.5) * 18, 2)) * 50
|
||||
mid[i] = -100 + peak1 + peak2 + Math.random() * 6
|
||||
side[i] = -100 + peak2 * 0.4 + Math.random() * 4
|
||||
}
|
||||
handlers.onFrame({ sampleRate, magnitudes: mid, side })
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
void ensureBackend().then((backend) => {
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('spectrumFrame', (payload) => {
|
||||
const frame = decodeFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
console.log('[prism-plugin] connected to JUCE host')
|
||||
} else {
|
||||
startMock()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
|
||||
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VU meter frames (event "vumeterFrame": scalar snapshot, no base64).
|
||||
|
||||
export interface VUMeterFrame {
|
||||
sampleRate: number
|
||||
vuLDb: number
|
||||
vuRDb: number
|
||||
barLDb: number
|
||||
barRDb: number
|
||||
peakLDb: number
|
||||
peakRDb: number
|
||||
correlation: number
|
||||
}
|
||||
|
||||
function decodeVUMeterFrame(payload: unknown): VUMeterFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const p = payload as Record<string, unknown>
|
||||
const num = (key: string, fallback: number): number =>
|
||||
typeof p[key] === 'number' && Number.isFinite(p[key]) ? (p[key] as number) : fallback
|
||||
return {
|
||||
sampleRate: num('sampleRate', 48000) > 0 ? num('sampleRate', 48000) : 48000,
|
||||
vuLDb: num('vuLDb', -60),
|
||||
vuRDb: num('vuRDb', -60),
|
||||
barLDb: num('barLDb', -60),
|
||||
barRDb: num('barRDb', -60),
|
||||
peakLDb: num('peakLDb', -60),
|
||||
peakRDb: num('peakRDb', -60),
|
||||
correlation: num('correlation', 0),
|
||||
}
|
||||
}
|
||||
|
||||
export interface VUMeterBridgeHandlers {
|
||||
onFrame: (frame: VUMeterFrame) => void
|
||||
onConnected?: (usingMock: boolean) => void
|
||||
}
|
||||
|
||||
export function connectVUMeterBridge(handlers: VUMeterBridgeHandlers): () => void {
|
||||
let disposed = false
|
||||
let listenerId: number | null = null
|
||||
let mockRaf: number | null = null
|
||||
|
||||
const startMock = (): void => {
|
||||
handlers.onConnected?.(true)
|
||||
console.warn('[prism-plugin] no JUCE host — using synthetic VU meter (browser dev mode)')
|
||||
let phase = 0
|
||||
const tick = (): void => {
|
||||
if (disposed) return
|
||||
phase += 0.04
|
||||
const level = (offset: number): number => -40 + (Math.sin(phase + offset) * 0.5 + 0.5) * 42
|
||||
const vuL = level(0)
|
||||
const vuR = level(0.7)
|
||||
handlers.onFrame({
|
||||
sampleRate: 48000,
|
||||
vuLDb: vuL,
|
||||
vuRDb: vuR,
|
||||
barLDb: vuL,
|
||||
barRDb: vuR,
|
||||
peakLDb: vuL + 3,
|
||||
peakRDb: vuR + 3,
|
||||
correlation: Math.sin(phase * 0.3),
|
||||
})
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
void ensureBackend().then((backend) => {
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('vumeterFrame', (payload) => {
|
||||
const frame = decodeVUMeterFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
console.log('[prism-plugin] connected to JUCE host (vumeter)')
|
||||
} else {
|
||||
startMock()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
|
||||
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loudness meter frames (event "lufsmeterFrame": scalar snapshot, no base64).
|
||||
|
||||
export interface LUFSMeterFrame {
|
||||
sampleRate: number
|
||||
momentaryLUFS: number
|
||||
shortTermLUFS: number
|
||||
integratedLUFS: number
|
||||
vuLDb: number
|
||||
vuRDb: number
|
||||
barLDb: number
|
||||
barRDb: number
|
||||
peakLDb: number
|
||||
peakRDb: number
|
||||
correlation: number
|
||||
}
|
||||
|
||||
function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const p = payload as Record<string, unknown>
|
||||
const num = (key: string, fallback: number): number =>
|
||||
typeof p[key] === 'number' && Number.isFinite(p[key]) ? (p[key] as number) : fallback
|
||||
return {
|
||||
sampleRate: num('sampleRate', 48000) > 0 ? num('sampleRate', 48000) : 48000,
|
||||
momentaryLUFS: num('momentaryLUFS', -70),
|
||||
shortTermLUFS: num('shortTermLUFS', -70),
|
||||
integratedLUFS: num('integratedLUFS', -70),
|
||||
vuLDb: num('vuLDb', -60),
|
||||
vuRDb: num('vuRDb', -60),
|
||||
barLDb: num('barLDb', -60),
|
||||
barRDb: num('barRDb', -60),
|
||||
peakLDb: num('peakLDb', -60),
|
||||
peakRDb: num('peakRDb', -60),
|
||||
correlation: num('correlation', 0),
|
||||
}
|
||||
}
|
||||
|
||||
export interface LUFSMeterBridgeHandlers {
|
||||
onFrame: (frame: LUFSMeterFrame) => void
|
||||
onConnected?: (usingMock: boolean) => void
|
||||
}
|
||||
|
||||
export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () => void {
|
||||
let disposed = false
|
||||
let listenerId: number | null = null
|
||||
let mockRaf: number | null = null
|
||||
|
||||
const startMock = (): void => {
|
||||
handlers.onConnected?.(true)
|
||||
console.warn('[prism-plugin] no JUCE host — using synthetic LUFS meter (browser dev mode)')
|
||||
let phase = 0
|
||||
const tick = (): void => {
|
||||
if (disposed) return
|
||||
phase += 0.04
|
||||
const lufs = (offset: number): number => -24 + Math.sin(phase + offset) * 6
|
||||
const vuL = -36 + (Math.sin(phase) * 0.5 + 0.5) * 30
|
||||
const vuR = -36 + (Math.sin(phase + 0.7) * 0.5 + 0.5) * 30
|
||||
handlers.onFrame({
|
||||
sampleRate: 48000,
|
||||
momentaryLUFS: lufs(0),
|
||||
shortTermLUFS: lufs(0.5),
|
||||
integratedLUFS: -23,
|
||||
vuLDb: vuL,
|
||||
vuRDb: vuR,
|
||||
barLDb: vuL,
|
||||
barRDb: vuR,
|
||||
peakLDb: vuL + 3,
|
||||
peakRDb: vuR + 3,
|
||||
correlation: Math.sin(phase * 0.3),
|
||||
})
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
void ensureBackend().then((backend) => {
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('lufsmeterFrame', (payload) => {
|
||||
const frame = decodeLUFSMeterFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
console.log('[prism-plugin] connected to JUCE host (lufsmeter)')
|
||||
} else {
|
||||
startMock()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
|
||||
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vectorscope frames (event "vectorscopeFrame"): a point cloud, either standard
|
||||
// (x, y base64) or multiband (data base64, 6 floats/point), flagged by `multiband`.
|
||||
|
||||
export interface VectorscopeFrame {
|
||||
sampleRate: number
|
||||
multiband: boolean
|
||||
count: number
|
||||
x?: Float32Array
|
||||
y?: Float32Array
|
||||
data?: Float32Array
|
||||
}
|
||||
|
||||
interface VectorscopeFramePayload {
|
||||
sampleRate?: number
|
||||
multiband?: boolean
|
||||
count?: number
|
||||
x?: string
|
||||
y?: string
|
||||
data?: string
|
||||
}
|
||||
|
||||
function decodeVectorscopeFrame(payload: unknown): VectorscopeFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const { sampleRate, multiband, count, x, y, data } = payload as VectorscopeFramePayload
|
||||
const frame: VectorscopeFrame = {
|
||||
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
|
||||
multiband: Boolean(multiband),
|
||||
count: typeof count === 'number' ? count : 0,
|
||||
}
|
||||
if (frame.multiband) {
|
||||
if (typeof data !== 'string') return null
|
||||
frame.data = base64ToFloat32Array(data)
|
||||
} else {
|
||||
if (typeof x !== 'string' || typeof y !== 'string') return null
|
||||
frame.x = base64ToFloat32Array(x)
|
||||
frame.y = base64ToFloat32Array(y)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
export interface VectorscopeBridgeHandlers {
|
||||
onFrame: (frame: VectorscopeFrame) => void
|
||||
onConnected?: (usingMock: boolean) => void
|
||||
}
|
||||
|
||||
export function connectVectorscopeBridge(handlers: VectorscopeBridgeHandlers): () => void {
|
||||
let disposed = false
|
||||
let listenerId: number | null = null
|
||||
let mockRaf: number | null = null
|
||||
|
||||
const startMock = (): void => {
|
||||
handlers.onConnected?.(true)
|
||||
console.warn('[prism-plugin] no JUCE host — using synthetic vectorscope (browser dev mode)')
|
||||
const count = 2048
|
||||
const x = new Float32Array(count)
|
||||
const y = new Float32Array(count)
|
||||
let phase = 0
|
||||
const tick = (): void => {
|
||||
if (disposed) return
|
||||
phase += 0.03
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const t = (i / count) * Math.PI * 2
|
||||
x[i] = Math.sin(t * 3 + phase) * 0.7
|
||||
y[i] = Math.sin(t * 2 + phase * 1.3) * 0.7
|
||||
}
|
||||
handlers.onFrame({ sampleRate: 48000, multiband: false, count, x, y })
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
void ensureBackend().then((backend) => {
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('vectorscopeFrame', (payload) => {
|
||||
const frame = decodeVectorscopeFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
console.log('[prism-plugin] connected to JUCE host (vectorscope)')
|
||||
} else {
|
||||
startMock()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
|
||||
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spectrogram frames (event "spectrogramFrame"): the new display+heat columns
|
||||
// produced since the last frame, base64-encoded, tagged with rowCount/columnCount.
|
||||
|
||||
export interface SpectrogramFrame {
|
||||
sampleRate: number
|
||||
display: Float32Array
|
||||
heat: Float32Array
|
||||
columnCount: number
|
||||
rowCount: number
|
||||
}
|
||||
|
||||
interface SpectrogramFramePayload {
|
||||
sampleRate?: number
|
||||
display?: string
|
||||
heat?: string
|
||||
columnCount?: number
|
||||
rowCount?: number
|
||||
}
|
||||
|
||||
function decodeSpectrogramFrame(payload: unknown): SpectrogramFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const { sampleRate, display, heat, columnCount, rowCount } = payload as SpectrogramFramePayload
|
||||
return {
|
||||
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
|
||||
display: typeof display === 'string' ? base64ToFloat32Array(display) : new Float32Array(0),
|
||||
heat: typeof heat === 'string' ? base64ToFloat32Array(heat) : new Float32Array(0),
|
||||
columnCount: typeof columnCount === 'number' ? columnCount : 0,
|
||||
rowCount: typeof rowCount === 'number' ? rowCount : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SpectrogramBridgeHandlers {
|
||||
onFrame: (frame: SpectrogramFrame) => void
|
||||
onConnected?: (usingMock: boolean) => void
|
||||
/** Lets the dev mock size its columns to the canvas-derived rowCount. */
|
||||
getRowCount?: () => number
|
||||
}
|
||||
|
||||
export function connectSpectrogramBridge(handlers: SpectrogramBridgeHandlers): () => void {
|
||||
let disposed = false
|
||||
let listenerId: number | null = null
|
||||
let mockRaf: number | null = null
|
||||
|
||||
const startMock = (): void => {
|
||||
handlers.onConnected?.(true)
|
||||
console.warn('[prism-plugin] no JUCE host — using synthetic spectrogram (browser dev mode)')
|
||||
let phase = 0
|
||||
const tick = (): void => {
|
||||
if (disposed) return
|
||||
phase += 0.08
|
||||
const rowCount = handlers.getRowCount?.() ?? 0
|
||||
if (rowCount > 0) {
|
||||
const columnCount = 2
|
||||
const display = new Float32Array(rowCount * columnCount)
|
||||
const heat = new Float32Array(rowCount * columnCount)
|
||||
for (let c = 0; c < columnCount; c += 1) {
|
||||
for (let r = 0; r < rowCount; r += 1) {
|
||||
const t = r / rowCount
|
||||
const band = Math.exp(-Math.pow((t - (0.3 + 0.2 * Math.sin(phase))) * 6, 2))
|
||||
const v = Math.min(1, band + Math.random() * 0.15)
|
||||
display[c * rowCount + r] = v
|
||||
heat[c * rowCount + r] = v
|
||||
}
|
||||
}
|
||||
handlers.onFrame({ sampleRate: 48000, display, heat, columnCount, rowCount })
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
void ensureBackend().then((backend) => {
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('spectrogramFrame', (payload) => {
|
||||
const frame = decodeSpectrogramFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
console.log('[prism-plugin] connected to JUCE host (spectrogram)')
|
||||
} else {
|
||||
startMock()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
|
||||
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Waveform frames (event "waveformFrame"): per-column summaries (base64), stride 10
|
||||
// in stereo mode / stride 5 in mono, flagged by `stereo`.
|
||||
|
||||
export interface WaveformFrame {
|
||||
sampleRate: number
|
||||
stereo: boolean
|
||||
columnCount: number
|
||||
summaries: Float32Array
|
||||
}
|
||||
|
||||
interface WaveformFramePayload {
|
||||
sampleRate?: number
|
||||
stereo?: boolean
|
||||
columnCount?: number
|
||||
summaries?: string
|
||||
}
|
||||
|
||||
function decodeWaveformFrame(payload: unknown): WaveformFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const { sampleRate, stereo, columnCount, summaries } = payload as WaveformFramePayload
|
||||
return {
|
||||
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
|
||||
stereo: Boolean(stereo),
|
||||
columnCount: typeof columnCount === 'number' ? columnCount : 0,
|
||||
summaries: typeof summaries === 'string' ? base64ToFloat32Array(summaries) : new Float32Array(0),
|
||||
}
|
||||
}
|
||||
|
||||
export interface WaveformBridgeHandlers {
|
||||
onFrame: (frame: WaveformFrame) => void
|
||||
onConnected?: (usingMock: boolean) => void
|
||||
}
|
||||
|
||||
export function connectWaveformBridge(handlers: WaveformBridgeHandlers): () => void {
|
||||
let disposed = false
|
||||
let listenerId: number | null = null
|
||||
let mockRaf: number | null = null
|
||||
|
||||
const startMock = (): void => {
|
||||
handlers.onConnected?.(true)
|
||||
console.warn('[prism-plugin] no JUCE host — using synthetic waveform (browser dev mode)')
|
||||
let phase = 0
|
||||
const tick = (): void => {
|
||||
if (disposed) return
|
||||
phase += 0.12
|
||||
const columns = 2
|
||||
// Emit both mono (stride 5) and stereo (stride 10) so the dev mock works in
|
||||
// either mode (the analyzer serves whichever the visualizer asks for).
|
||||
const mono = new Float32Array(columns * 5)
|
||||
const stereo = new Float32Array(columns * 10)
|
||||
for (let c = 0; c < columns; c += 1) {
|
||||
const amp = 0.3 + 0.6 * Math.abs(Math.sin(phase + c * 0.4))
|
||||
const m = c * 5
|
||||
mono[m] = -amp; mono[m + 1] = amp; mono[m + 2] = amp * 0.5; mono[m + 3] = amp * 0.7; mono[m + 4] = amp * 0.3
|
||||
const s = c * 10
|
||||
stereo[s] = -amp; stereo[s + 1] = amp; stereo[s + 2] = amp * 0.5; stereo[s + 3] = amp * 0.7; stereo[s + 4] = amp * 0.3
|
||||
const ampR = amp * 0.85
|
||||
stereo[s + 5] = -ampR; stereo[s + 6] = ampR; stereo[s + 7] = ampR * 0.5; stereo[s + 8] = ampR * 0.7; stereo[s + 9] = ampR * 0.3
|
||||
}
|
||||
handlers.onFrame({ sampleRate: 48000, stereo: false, columnCount: columns, summaries: mono })
|
||||
handlers.onFrame({ sampleRate: 48000, stereo: true, columnCount: columns, summaries: stereo })
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
void ensureBackend().then((backend) => {
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('waveformFrame', (payload) => {
|
||||
const frame = decodeWaveformFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
console.log('[prism-plugin] connected to JUCE host (waveform)')
|
||||
} else {
|
||||
startMock()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
|
||||
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oscilloscope frames (event "oscilloscopeFrame": { sampleRate, samples, pitch }).
|
||||
|
||||
export interface OscilloscopeFrame {
|
||||
sampleRate: number
|
||||
/** Already-triggered display window of time-domain samples. */
|
||||
samples: Float32Array
|
||||
detectedPitch: number
|
||||
}
|
||||
|
||||
interface OscilloscopeFramePayload {
|
||||
sampleRate?: number
|
||||
samples?: string
|
||||
pitch?: number
|
||||
}
|
||||
|
||||
function decodeOscilloscopeFrame(payload: unknown): OscilloscopeFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const { sampleRate, samples, pitch } = payload as OscilloscopeFramePayload
|
||||
if (typeof samples !== 'string' || samples.length === 0) return null
|
||||
return {
|
||||
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
|
||||
samples: base64ToFloat32Array(samples),
|
||||
detectedPitch: typeof pitch === 'number' ? pitch : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export interface OscilloscopeBridgeHandlers {
|
||||
onFrame: (frame: OscilloscopeFrame) => void
|
||||
onConnected?: (usingMock: boolean) => void
|
||||
}
|
||||
|
||||
export function connectOscilloscopeBridge(handlers: OscilloscopeBridgeHandlers): () => void {
|
||||
let disposed = false
|
||||
let listenerId: number | null = null
|
||||
let mockRaf: number | null = null
|
||||
|
||||
const startMock = (): void => {
|
||||
handlers.onConnected?.(true)
|
||||
console.warn('[prism-plugin] no JUCE host — using synthetic oscilloscope (browser dev mode)')
|
||||
const count = 2048
|
||||
const samples = new Float32Array(count)
|
||||
let phase = 0
|
||||
const tick = (): void => {
|
||||
if (disposed) return
|
||||
phase += 0.08
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const t = (i / count) * Math.PI * 2 * 3
|
||||
samples[i] = Math.sin(t + phase) * 0.7 + Math.sin(t * 2 + phase) * 0.15
|
||||
}
|
||||
handlers.onFrame({ sampleRate: 48000, samples, detectedPitch: 220 })
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
void ensureBackend().then((backend) => {
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('oscilloscopeFrame', (payload) => {
|
||||
const frame = decodeOscilloscopeFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
console.log('[prism-plugin] connected to JUCE host (oscilloscope)')
|
||||
} else {
|
||||
startMock()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (mockRaf !== null) cancelAnimationFrame(mockRaf)
|
||||
if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedLUFSMeterTheme } from '../types/theme'
|
||||
import type { LUFSMeterOptions } from '../renderer/visualizers/LUFSMeter'
|
||||
|
||||
/**
|
||||
* Map Prism's loudness meter settings + resolved theme to LUFSMeter options.
|
||||
* Mirrors the `lufsmeter` case of `scopeSettingsToOptions` in ScopeModule.tsx.
|
||||
*/
|
||||
export function lufsmeterSettingsToOptions(
|
||||
settings: ScopeSettings['lufsmeter'],
|
||||
theme: ResolvedLUFSMeterTheme,
|
||||
): LUFSMeterOptions {
|
||||
return {
|
||||
backgroundColor: theme.background,
|
||||
lineColor: theme.level,
|
||||
trackColor: theme.track,
|
||||
targetColor: theme.target,
|
||||
scaleColor: theme.scale,
|
||||
labelColor: theme.labels,
|
||||
mode: settings.mode,
|
||||
readout: settings.readout,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { StrictMode, type JSX } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '../renderer/styles/globals.css'
|
||||
import './styles.css'
|
||||
import ScopeApp from './ScopeApp'
|
||||
import SpectrumScope from './SpectrumScope'
|
||||
import OscilloscopeScope from './OscilloscopeScope'
|
||||
import VUMeterScope from './VUMeterScope'
|
||||
import LUFSMeterScope from './LUFSMeterScope'
|
||||
import VectorscopeScope from './VectorscopeScope'
|
||||
import SpectrogramScope from './SpectrogramScope'
|
||||
import WaveformScope from './WaveformScope'
|
||||
import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
|
||||
import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
|
||||
import { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer'
|
||||
import { BridgeLUFSMeterAnalyzer } from './BridgeLUFSMeterAnalyzer'
|
||||
import { BridgeVectorscopeAnalyzer } from './BridgeVectorscopeAnalyzer'
|
||||
import { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
|
||||
import { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
|
||||
import { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge, connectSpectrogramBridge, connectWaveformBridge } from './juceBridge'
|
||||
|
||||
// The C++ plugin tells us which scope it is via JUCE initialisation data.
|
||||
// JUCE stores each value as an array (e.g. prismScope = ["oscilloscope"]).
|
||||
function getScopeKind(): string {
|
||||
const raw = (window as unknown as {
|
||||
__JUCE__?: { initialisationData?: { prismScope?: unknown } }
|
||||
}).__JUCE__?.initialisationData?.prismScope
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
return typeof value === 'string' ? value : 'spectrum'
|
||||
}
|
||||
|
||||
const dataSource = new PluginWebViewDataSource()
|
||||
|
||||
function buildApp(): JSX.Element {
|
||||
if (getScopeKind() === 'waveform') {
|
||||
const analyzer = new BridgeWaveformAnalyzer()
|
||||
connectWaveformBridge({
|
||||
onFrame: (frame) => {
|
||||
analyzer.pushFrame(frame.summaries, frame.stereo)
|
||||
dataSource.setSampleRate(frame.sampleRate)
|
||||
dataSource.setPlaying(true)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<ScopeApp
|
||||
kind="waveform"
|
||||
renderScope={(settings, theme) => (
|
||||
<WaveformScope
|
||||
settings={settings}
|
||||
theme={theme.waveform}
|
||||
dataSource={dataSource}
|
||||
nativeAnalyzer={analyzer}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (getScopeKind() === 'spectrogram') {
|
||||
const analyzer = new BridgeSpectrogramAnalyzer()
|
||||
connectSpectrogramBridge({
|
||||
onFrame: (frame) => {
|
||||
analyzer.pushFrame(frame.display, frame.heat, frame.columnCount, frame.rowCount)
|
||||
dataSource.setSampleRate(frame.sampleRate)
|
||||
dataSource.setPlaying(true)
|
||||
},
|
||||
getRowCount: () => analyzer.getExpectedRowCount(),
|
||||
})
|
||||
return (
|
||||
<ScopeApp
|
||||
kind="spectrogram"
|
||||
renderScope={(settings, theme) => (
|
||||
<SpectrogramScope
|
||||
settings={settings}
|
||||
theme={theme.spectrogram}
|
||||
dataSource={dataSource}
|
||||
nativeAnalyzer={analyzer}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (getScopeKind() === 'vectorscope') {
|
||||
const analyzer = new BridgeVectorscopeAnalyzer()
|
||||
connectVectorscopeBridge({
|
||||
onFrame: (frame) => {
|
||||
if (frame.multiband && frame.data) {
|
||||
analyzer.setMultiband(frame.data, frame.count)
|
||||
} else if (frame.x && frame.y) {
|
||||
analyzer.setStandard(frame.x, frame.y, frame.count)
|
||||
}
|
||||
dataSource.setSampleRate(frame.sampleRate)
|
||||
dataSource.setPlaying(true)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<ScopeApp
|
||||
kind="vectorscope"
|
||||
renderScope={(settings, theme) => (
|
||||
<VectorscopeScope
|
||||
settings={settings}
|
||||
theme={theme.vectorscope}
|
||||
dataSource={dataSource}
|
||||
nativeAnalyzer={analyzer}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (getScopeKind() === 'lufsmeter') {
|
||||
const analyzer = new BridgeLUFSMeterAnalyzer()
|
||||
connectLUFSMeterBridge({
|
||||
onFrame: (frame) => {
|
||||
analyzer.setSnapshot(frame)
|
||||
dataSource.setSampleRate(frame.sampleRate)
|
||||
dataSource.setPlaying(true)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<ScopeApp
|
||||
kind="lufsmeter"
|
||||
renderScope={(settings, theme) => (
|
||||
<LUFSMeterScope
|
||||
settings={settings}
|
||||
theme={theme.lufsmeter}
|
||||
dataSource={dataSource}
|
||||
nativeAnalyzer={analyzer}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (getScopeKind() === 'vumeter') {
|
||||
const analyzer = new BridgeVUMeterAnalyzer()
|
||||
connectVUMeterBridge({
|
||||
onFrame: (frame) => {
|
||||
analyzer.setSnapshot(frame)
|
||||
dataSource.setSampleRate(frame.sampleRate)
|
||||
dataSource.setPlaying(true)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<ScopeApp
|
||||
kind="vumeter"
|
||||
renderScope={(settings, theme) => (
|
||||
<VUMeterScope
|
||||
settings={settings}
|
||||
theme={theme.vumeter}
|
||||
dataSource={dataSource}
|
||||
nativeAnalyzer={analyzer}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (getScopeKind() === 'oscilloscope') {
|
||||
const analyzer = new BridgeOscilloscopeAnalyzer()
|
||||
connectOscilloscopeBridge({
|
||||
onFrame: (frame) => {
|
||||
analyzer.setSamples(frame.samples, frame.detectedPitch)
|
||||
dataSource.setSampleRate(frame.sampleRate)
|
||||
dataSource.setPlaying(true)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<ScopeApp
|
||||
kind="oscilloscope"
|
||||
renderScope={(settings, theme) => (
|
||||
<OscilloscopeScope
|
||||
settings={settings}
|
||||
theme={theme.oscilloscope}
|
||||
dataSource={dataSource}
|
||||
nativeAnalyzer={analyzer}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const analyzer = new BridgeSpectrumAnalyzer(2048)
|
||||
connectSpectrumBridge({
|
||||
onFrame: (frame) => {
|
||||
analyzer.setMagnitudes(frame.magnitudes, frame.side)
|
||||
dataSource.setSampleRate(frame.sampleRate)
|
||||
dataSource.setPlaying(true)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<ScopeApp
|
||||
kind="spectrum"
|
||||
renderScope={(settings, theme) => (
|
||||
<SpectrumScope
|
||||
settings={settings}
|
||||
theme={theme.spectrum}
|
||||
dataSource={dataSource}
|
||||
nativeAnalyzer={analyzer}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
throw new Error('Missing #root element')
|
||||
}
|
||||
|
||||
try {
|
||||
createRoot(rootElement).render(<StrictMode>{buildApp()}</StrictMode>)
|
||||
document.documentElement.classList.add('prism-plugin-mounted')
|
||||
} catch (error) {
|
||||
const reporter = (window as unknown as {
|
||||
__PRISM_PLUGIN_ERROR__?: (message: string) => void
|
||||
}).__PRISM_PLUGIN_ERROR__
|
||||
reporter?.(error instanceof Error ? error.message : String(error))
|
||||
throw error
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedOscilloscopeTheme } from '../types/theme'
|
||||
import type { OscilloscopeOptions } from '../renderer/visualizers/Oscilloscope'
|
||||
|
||||
/**
|
||||
* Map Prism's oscilloscope settings + resolved theme to Oscilloscope options.
|
||||
* Mirrors the `oscilloscope` case of `scopeSettingsToOptions` in ScopeModule.tsx.
|
||||
*/
|
||||
export function oscilloscopeSettingsToOptions(
|
||||
settings: ScopeSettings['oscilloscope'],
|
||||
theme: ResolvedOscilloscopeTheme,
|
||||
): OscilloscopeOptions {
|
||||
return {
|
||||
lineColor: theme.line,
|
||||
backgroundColor: theme.background,
|
||||
gridMajorColor: theme.guides,
|
||||
gridMinorColor: theme.guidesSecondary,
|
||||
underfillColor: theme.fill,
|
||||
pitchLock: settings.pitchLock,
|
||||
underfillEnabled: settings.underfillEnabled,
|
||||
showGrid: settings.showGrid,
|
||||
lineWidth: settings.lineWidth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { SpectrumPeakInfo } from '../types/spectrum'
|
||||
|
||||
/**
|
||||
* Peak-overlay positioning + formatting, mirroring ScopeModule.tsx so the plugin's
|
||||
* "following" peak readout behaves exactly like the Prism app. Kept as a local
|
||||
* copy (pure functions) so the plugin doesn't import the heavy ScopeModule.
|
||||
*/
|
||||
|
||||
export interface CanvasResizeState {
|
||||
cssWidth: number
|
||||
cssHeight: number
|
||||
pixelWidth: number
|
||||
pixelHeight: number
|
||||
dpr: number
|
||||
}
|
||||
|
||||
export interface SizeMeasurement {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10
|
||||
const SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX = 248
|
||||
const SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX = 42
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function formatSpectrumPeakDb(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '--'
|
||||
}
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dB`
|
||||
}
|
||||
|
||||
export function formatSpectrumPeakFrequency(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '--'
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(2)}kHz`
|
||||
}
|
||||
return `${value.toFixed(2)}Hz`
|
||||
}
|
||||
|
||||
export function measureCanvasResizeState(container: HTMLElement): CanvasResizeState {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const cssWidth = Math.max(1, Math.floor(rect.width))
|
||||
const cssHeight = Math.max(1, Math.floor(rect.height))
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
return {
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)),
|
||||
pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)),
|
||||
dpr,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFollowingPeakOverlayStyle(
|
||||
peakInfo: SpectrumPeakInfo,
|
||||
resizeState: CanvasResizeState | null,
|
||||
overlaySize: SizeMeasurement | null,
|
||||
): CSSProperties {
|
||||
if (!resizeState) {
|
||||
return {
|
||||
left: `${SPECTRUM_PEAK_OVERLAY_MARGIN_PX}px`,
|
||||
top: `${SPECTRUM_PEAK_OVERLAY_MARGIN_PX}px`,
|
||||
}
|
||||
}
|
||||
|
||||
const width = resizeState.cssWidth
|
||||
const height = resizeState.cssHeight
|
||||
const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX
|
||||
const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX
|
||||
const peakX = peakInfo.normalizedX * width
|
||||
const peakY = peakInfo.normalizedY * height
|
||||
const maxLeft = Math.max(
|
||||
SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
|
||||
width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
|
||||
)
|
||||
const maxTop = Math.max(
|
||||
SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
|
||||
height - overlayHeight - SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
|
||||
)
|
||||
|
||||
const canPlaceAbove = peakY - overlayHeight >= SPECTRUM_PEAK_OVERLAY_MARGIN_PX
|
||||
const canPlaceBelow = peakY + overlayHeight <= height - SPECTRUM_PEAK_OVERLAY_MARGIN_PX
|
||||
|
||||
const left = peakX
|
||||
const top = canPlaceAbove || !canPlaceBelow
|
||||
? peakY - overlayHeight
|
||||
: peakY
|
||||
|
||||
return {
|
||||
left: `${clampNumber(left, SPECTRUM_PEAK_OVERLAY_MARGIN_PX, maxLeft)}px`,
|
||||
top: `${clampNumber(top, SPECTRUM_PEAK_OVERLAY_MARGIN_PX, maxTop)}px`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// The scrolling scopes (spectrogram + waveform) advance their waterfall by blitting
|
||||
// the entire canvas onto itself every frame. That self-blit costs ~canvas area per
|
||||
// frame and WKWebView's 2D canvas handles it far less efficiently than Chromium, so a
|
||||
// large plugin window blows the frame budget even at low scroll speeds. Cap the
|
||||
// backing-store resolution so the per-frame cost stays bounded regardless of window
|
||||
// size; the canvas is CSS-stretched to fill, so it just looks slightly softer when the
|
||||
// window is very large. Typical sizes stay fully crisp (they fall under the budget).
|
||||
//
|
||||
// Bonus for the spectrogram: rowCount is derived from the canvas height, so capping
|
||||
// here also shrinks the per-column DSP work and the bridge payload.
|
||||
const MAX_DEVICE_PIXELS = 2_000_000
|
||||
|
||||
export function resolveScrollingCanvasSize(
|
||||
cssWidth: number,
|
||||
cssHeight: number,
|
||||
devicePixelRatio: number,
|
||||
): { width: number; height: number } {
|
||||
const dpr = devicePixelRatio > 0 ? devicePixelRatio : 1
|
||||
let width = Math.max(1, Math.floor(cssWidth * dpr))
|
||||
let height = Math.max(1, Math.floor(cssHeight * dpr))
|
||||
|
||||
const pixels = width * height
|
||||
if (pixels > MAX_DEVICE_PIXELS) {
|
||||
const scale = Math.sqrt(MAX_DEVICE_PIXELS / pixels)
|
||||
width = Math.max(1, Math.floor(width * scale))
|
||||
height = Math.max(1, Math.floor(height * scale))
|
||||
}
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedSpectrogramTheme } from '../types/theme'
|
||||
import type { SpectrogramOptions } from '../renderer/visualizers/Spectrogram'
|
||||
|
||||
/**
|
||||
* Map Prism's spectrogram settings + resolved theme to Spectrogram options.
|
||||
* Mirrors the `spectrogram` case of `scopeSettingsToOptions` in ScopeModule.tsx.
|
||||
*/
|
||||
export function spectrogramSettingsToOptions(
|
||||
settings: ScopeSettings['spectrogram'],
|
||||
theme: ResolvedSpectrogramTheme,
|
||||
): SpectrogramOptions {
|
||||
return {
|
||||
lineColor: theme.mono,
|
||||
heatColors: theme.heatColors,
|
||||
backgroundColor: theme.background,
|
||||
fftSize: settings.fftSize,
|
||||
tiltDbPerOctave: settings.tiltDbPerOctave,
|
||||
scrollSpeed: settings.scrollSpeed,
|
||||
contrast: settings.contrast,
|
||||
clarityMode: settings.clarityMode,
|
||||
scaleMode: settings.scaleMode,
|
||||
orientation: settings.orientation,
|
||||
colorScheme: settings.colorScheme,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedSpectrumTheme } from '../types/theme'
|
||||
import type { SpectrumAnalyzerOptions } from '../renderer/visualizers/SpectrumAnalyzer'
|
||||
|
||||
/**
|
||||
* Map Prism's spectrum settings + resolved theme to SpectrumAnalyzer options.
|
||||
* Mirrors the `spectrum` case of `scopeSettingsToOptions` in ScopeModule.tsx —
|
||||
* kept local so the spectrum plugin doesn't pull in every other visualizer.
|
||||
*/
|
||||
export function spectrumSettingsToOptions(
|
||||
settings: ScopeSettings['spectrum'],
|
||||
theme: ResolvedSpectrumTheme,
|
||||
): SpectrumAnalyzerOptions {
|
||||
return {
|
||||
lineColor: theme.line,
|
||||
secondaryLineColor: theme.sideLine,
|
||||
gradientColors: theme.fillGradient,
|
||||
heatColors: theme.heatColors,
|
||||
heatBaseColor: theme.heatBase,
|
||||
backgroundColor: theme.background,
|
||||
gridColor: theme.guides,
|
||||
fftSize: settings.fftSize,
|
||||
tiltDbPerOctave: settings.tiltDbPerOctave,
|
||||
heatmapFill: settings.heatmap,
|
||||
heatmapTiltDbPerOctave: settings.heatmapTiltDbPerOctave,
|
||||
heatmapSmoothing: settings.heatmapSmoothing,
|
||||
showGrid: settings.showGrid,
|
||||
fillGradient: settings.fillGradient,
|
||||
smoothing: settings.smoothing,
|
||||
showSideLine: settings.showSideLine,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.spectrum-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spectrum-app.has-settings {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The scope fills everything above the (optional) bottom settings panel. */
|
||||
.spectrum-app__viewport {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.spectrum-app.has-settings .spectrum-app__viewport {
|
||||
flex: 0 0 var(--spectrum-viewport-height, 100%);
|
||||
height: var(--spectrum-viewport-height, 100%);
|
||||
}
|
||||
|
||||
.spectrum-scope {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.spectrum-scope__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Settings gear — appears on hover (like the app's scope chrome). */
|
||||
.spectrum-app__gear {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.62));
|
||||
background: var(--control-bg, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--control-border, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.spectrum-app:hover .spectrum-app__gear,
|
||||
.spectrum-app__gear.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spectrum-app__gear:hover {
|
||||
color: var(--text-primary, #fff);
|
||||
background: var(--control-bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.spectrum-app__gear.is-active {
|
||||
color: var(--accent, #38bdf8);
|
||||
border-color: var(--control-border-active, rgba(56, 189, 248, 0.4));
|
||||
}
|
||||
|
||||
/* Settings panel along the bottom. The editor asks the host to grow by this height,
|
||||
but Windows DAWs may delay or constrain that resize, so the viewport locks to its
|
||||
previous height and this panel overflows instead of shrinking the scope. */
|
||||
.spectrum-app__panel {
|
||||
flex: 0 0 280px;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background: var(--settings-bg-top, rgba(8, 10, 14, 0.96));
|
||||
border-top: 1px solid var(--panel-outline, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.spectrum-scope__peak {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font: 11px/1.3 'JetBrains Mono', ui-monospace, monospace;
|
||||
color: var(--scope-overlay-text, rgba(255, 255, 255, 0.82));
|
||||
background: var(--scope-overlay-surface, rgba(8, 12, 18, 0.82));
|
||||
border: 1px solid var(--scope-overlay-border, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.spectrum-scope__peak.is-corner {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.spectrum-scope__peak.is-following {
|
||||
transform: translate(-50%, -120%);
|
||||
}
|
||||
|
||||
.spectrum-scope__peak-sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
import type { PrismResolvedTheme } from '../types/theme'
|
||||
import { createBundledThemes, createDefaultTheme, parseThemeFileContent, resolveTheme } from '../shared/themeState'
|
||||
import { emitToHost, onHostEvent } from './juceBridge'
|
||||
|
||||
const DEFAULT_THEME = resolveTheme(createDefaultTheme())
|
||||
|
||||
function mergeScopeSettings<K extends ScopeKind>(kind: K, raw: unknown): ScopeSettings[K] {
|
||||
const defaults = DEFAULT_SCOPE_SETTINGS[kind] as Record<string, unknown>
|
||||
if (typeof raw !== 'object' || raw === null) return { ...defaults } as ScopeSettings[K]
|
||||
const parsed = raw as Record<string, unknown>
|
||||
const next: Record<string, unknown> = { ...defaults }
|
||||
for (const key of Object.keys(defaults)) {
|
||||
if (key in parsed && typeof parsed[key] === typeof defaults[key]) {
|
||||
next[key] = parsed[key]
|
||||
}
|
||||
}
|
||||
return next as ScopeSettings[K]
|
||||
}
|
||||
|
||||
function resolveAppTheme(themeId: string, themeFile: string): PrismResolvedTheme {
|
||||
try {
|
||||
if (themeFile) return resolveTheme(parseThemeFileContent(themeFile, themeId || undefined))
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
if (themeId) {
|
||||
const bundled = createBundledThemes().find((theme) => theme.name === themeId)
|
||||
if (bundled) return resolveTheme(bundled)
|
||||
}
|
||||
return DEFAULT_THEME
|
||||
}
|
||||
|
||||
function resolveAppScopeSettings<K extends ScopeKind>(kind: K, profileJson: string): ScopeSettings[K] {
|
||||
try {
|
||||
const parsed = JSON.parse(profileJson) as { scopeSettings?: Record<string, unknown> }
|
||||
const scoped = parsed?.scopeSettings?.[kind]
|
||||
if (scoped) return mergeScopeSettings(kind, scoped)
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return { ...(DEFAULT_SCOPE_SETTINGS[kind] as object) } as ScopeSettings[K]
|
||||
}
|
||||
|
||||
export interface ScopeHostSync<K extends ScopeKind> {
|
||||
settings: ScopeSettings[K]
|
||||
resolvedTheme: PrismResolvedTheme
|
||||
handleUpdate: (partial: Partial<ScopeSettings[K]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared host sync for any scope plugin: applies app-default theme/settings,
|
||||
* persists per-instance overrides, and reconciles precedence (per-instance DAW
|
||||
* override > app settings > built-in defaults). Theme always follows the app.
|
||||
*/
|
||||
export function useScopeHostSync<K extends ScopeKind>(kind: K): ScopeHostSync<K> {
|
||||
const [settings, setSettings] = useState<ScopeSettings[K]>(() => mergeScopeSettings(kind, undefined))
|
||||
const [resolvedTheme, setResolvedTheme] = useState<PrismResolvedTheme>(DEFAULT_THEME)
|
||||
const settingsRef = useRef(settings)
|
||||
const hasOverride = useRef(false)
|
||||
|
||||
const applySettings = useCallback((next: ScopeSettings[K], persist: boolean): void => {
|
||||
settingsRef.current = next
|
||||
setSettings(next)
|
||||
emitToHost('prismConfig', { settings: next, persist })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubRestore = onHostEvent('prismRestoreSettings', (payload) => {
|
||||
const json = (payload as { json?: unknown })?.json
|
||||
if (typeof json === 'string' && json.length > 0) {
|
||||
try {
|
||||
hasOverride.current = true
|
||||
applySettings(mergeScopeSettings(kind, JSON.parse(json)), false)
|
||||
} catch {
|
||||
// ignore malformed saved settings
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const unsubDefaults = onHostEvent('prismAppDefaults', (payload) => {
|
||||
const p = (payload ?? {}) as { themeId?: string; themeFile?: string; profileJson?: string }
|
||||
setResolvedTheme(resolveAppTheme(p.themeId ?? '', p.themeFile ?? ''))
|
||||
if (!hasOverride.current) {
|
||||
applySettings(resolveAppScopeSettings(kind, p.profileJson ?? ''), false)
|
||||
}
|
||||
})
|
||||
|
||||
emitToHost('prismReady', {})
|
||||
return () => {
|
||||
unsubRestore()
|
||||
unsubDefaults()
|
||||
}
|
||||
}, [kind, applySettings])
|
||||
|
||||
const handleUpdate = useCallback((partial: Partial<ScopeSettings[K]>): void => {
|
||||
hasOverride.current = true
|
||||
applySettings({ ...settingsRef.current, ...partial }, true)
|
||||
}, [applySettings])
|
||||
|
||||
return { settings, resolvedTheme, handleUpdate }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedVectorscopeTheme } from '../types/theme'
|
||||
import type { VectorscopeOptions } from '../renderer/visualizers/Vectorscope'
|
||||
|
||||
/**
|
||||
* Map Prism's vectorscope settings + resolved theme to Vectorscope options.
|
||||
* Mirrors the `vectorscope` case of `scopeSettingsToOptions` in ScopeModule.tsx.
|
||||
*/
|
||||
export function vectorscopeSettingsToOptions(
|
||||
settings: ScopeSettings['vectorscope'],
|
||||
theme: ResolvedVectorscopeTheme,
|
||||
): VectorscopeOptions {
|
||||
return {
|
||||
lineColor: theme.trace,
|
||||
backgroundColor: theme.background,
|
||||
gridMajorColor: theme.guides,
|
||||
gridMinorColor: theme.guidesSecondary,
|
||||
labelColor: theme.labels,
|
||||
bandColors: {
|
||||
low: theme.bandLow,
|
||||
mid: theme.bandMid,
|
||||
high: theme.bandHigh,
|
||||
},
|
||||
mode: settings.mode,
|
||||
multiband: settings.multiband,
|
||||
showGrid: settings.showGrid,
|
||||
persistence: settings.persistence,
|
||||
lineWidth: settings.lineWidth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedVUMeterTheme } from '../types/theme'
|
||||
import type { VUMeterOptions } from '../renderer/visualizers/VUMeter'
|
||||
|
||||
/**
|
||||
* Map Prism's VU meter settings + resolved theme to VUMeter options.
|
||||
* Mirrors the `vumeter` case of `scopeSettingsToOptions` in ScopeModule.tsx.
|
||||
*/
|
||||
export function vumeterSettingsToOptions(
|
||||
settings: ScopeSettings['vumeter'],
|
||||
theme: ResolvedVUMeterTheme,
|
||||
): VUMeterOptions {
|
||||
return {
|
||||
backgroundColor: theme.background,
|
||||
lineColor: theme.level,
|
||||
trackColor: theme.track,
|
||||
peakColor: theme.peak,
|
||||
clipColor: theme.clip,
|
||||
scaleColor: theme.scale,
|
||||
labelColor: theme.labels,
|
||||
needleLeftColor: theme.needleLeft,
|
||||
needleRightColor: theme.needleRight,
|
||||
needleCombinedColor: theme.needleCombined,
|
||||
mode: settings.mode,
|
||||
orientation: settings.orientation,
|
||||
needleChannels: settings.needleChannels,
|
||||
referenceDb: settings.referenceDb,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedWaveformTheme } from '../types/theme'
|
||||
import type { WaveformOptions } from '../renderer/visualizers/Waveform'
|
||||
|
||||
/**
|
||||
* Map Prism's waveform settings + resolved theme to Waveform options.
|
||||
* Mirrors the `waveform` case of `scopeSettingsToOptions` in ScopeModule.tsx.
|
||||
*/
|
||||
export function waveformSettingsToOptions(
|
||||
settings: ScopeSettings['waveform'],
|
||||
theme: ResolvedWaveformTheme,
|
||||
): WaveformOptions {
|
||||
return {
|
||||
backgroundColor: theme.background,
|
||||
lineColor: theme.line,
|
||||
gridMajorColor: theme.guides,
|
||||
gridMinorColor: theme.guidesSecondary,
|
||||
bandColors: {
|
||||
low: theme.bandLow,
|
||||
mid: theme.bandMid,
|
||||
high: theme.bandHigh,
|
||||
},
|
||||
mode: settings.mode,
|
||||
scrollSpeed: settings.scrollSpeed,
|
||||
multiband: settings.multiband,
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,25 @@ export interface SpectrumNativeAnalyzer {
|
||||
isAvailable?: () => boolean
|
||||
}
|
||||
|
||||
// Injectable interface for the oscilloscope DSP (mirrors SpectrumNativeAnalyzer)
|
||||
// so the visualizer can be driven by a non-N-API source (e.g. a plugin webview).
|
||||
export interface OscilloscopeNativeAnalyzer {
|
||||
setSampleRate(sampleRate: number): void
|
||||
setPitchLock(enabled: boolean): void
|
||||
setDisplaySamples(samples: number): void
|
||||
pushSamples(samples: Float32Array): void
|
||||
processContinuous(): OscilloscopeResult | null
|
||||
fillSamples(startPos: number, output: Float32Array): number
|
||||
reset(): void
|
||||
isAvailable?: () => boolean
|
||||
}
|
||||
|
||||
// Export the native module functions with type safety
|
||||
export const oscilloscope = {
|
||||
isAvailable: (): boolean => {
|
||||
return Boolean(nativeModule?.oscilloscope)
|
||||
},
|
||||
|
||||
setSampleRate: (sampleRate: number): void => {
|
||||
nativeModule?.oscilloscope.setSampleRate(sampleRate)
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { audioRouter } from '../audio/AudioRouter'
|
||||
import {
|
||||
oscilloscope as nativeOscilloscope,
|
||||
oscilloscope as defaultNativeOscilloscope,
|
||||
OSCILLOSCOPE_BUFFER_SIZE,
|
||||
isNativeAvailable
|
||||
type OscilloscopeNativeAnalyzer,
|
||||
} from '../audio/native'
|
||||
import { getNormalizedOscilloscopeDisplaySamples } from '../audio/native/oscilloscopeDisplaySamples'
|
||||
import { colorToRgbChannels, multiplyColorAlpha } from '../utils/color'
|
||||
@@ -26,9 +26,10 @@ export interface OscilloscopeOptions {
|
||||
underfillEnabled?: boolean
|
||||
dataSource?: OscilloscopeDataSource
|
||||
frameScheduler?: FrameScheduler
|
||||
nativeAnalyzer?: OscilloscopeNativeAnalyzer | null
|
||||
}
|
||||
|
||||
type ResolvedOscilloscopeOptions = Required<Omit<OscilloscopeOptions, 'dataSource' | 'frameScheduler'>>
|
||||
type ResolvedOscilloscopeOptions = Required<Omit<OscilloscopeOptions, 'dataSource' | 'frameScheduler' | 'nativeAnalyzer'>>
|
||||
|
||||
const defaultOptions: ResolvedOscilloscopeOptions = {
|
||||
lineColor: '#00ffff',
|
||||
@@ -77,6 +78,7 @@ export class Oscilloscope {
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private options: ResolvedOscilloscopeOptions
|
||||
private dataSource: OscilloscopeDataSource
|
||||
private nativeAnalyzer: OscilloscopeNativeAnalyzer
|
||||
private frameLoop: VisualizerFrameLoop
|
||||
private nativeInitialized = false
|
||||
private samplesReceived = 0
|
||||
@@ -95,9 +97,10 @@ export class Oscilloscope {
|
||||
if (!ctx) throw new Error('Could not get 2D context')
|
||||
this.ctx = ctx
|
||||
|
||||
const { dataSource, frameScheduler, ...optionOverrides } = options
|
||||
const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options
|
||||
this.options = { ...defaultOptions, ...optionOverrides }
|
||||
this.dataSource = dataSource ?? defaultOscilloscopeDataSource
|
||||
this.nativeAnalyzer = nativeAnalyzer === undefined ? defaultNativeOscilloscope : (nativeAnalyzer ?? defaultNativeOscilloscope)
|
||||
this.frameLoop = new VisualizerFrameLoop({
|
||||
frameScheduler,
|
||||
shouldRun: () => this.dataSource.isPlaying(),
|
||||
@@ -121,27 +124,31 @@ export class Oscilloscope {
|
||||
})
|
||||
}
|
||||
|
||||
private nativeReady(): boolean {
|
||||
return Boolean(this.nativeAnalyzer) && this.nativeAnalyzer.isAvailable?.() !== false
|
||||
}
|
||||
|
||||
private initNative(): void {
|
||||
if (isNativeAvailable() && !this.nativeInitialized) {
|
||||
if (this.nativeReady() && !this.nativeInitialized) {
|
||||
const sampleRate = this.dataSource.getSampleRate()
|
||||
this.lastSampleRate = 0
|
||||
nativeOscilloscope.setSampleRate(sampleRate)
|
||||
nativeOscilloscope.setPitchLock(this.options.pitchLock)
|
||||
nativeOscilloscope.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(sampleRate))
|
||||
this.nativeAnalyzer.setSampleRate(sampleRate)
|
||||
this.nativeAnalyzer.setPitchLock(this.options.pitchLock)
|
||||
this.nativeAnalyzer.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(sampleRate))
|
||||
this.nativeInitialized = true
|
||||
console.log(`Oscilloscope: Using native DSP with AudioWorklet (${sampleRate}Hz)`)
|
||||
} else if (!isNativeAvailable()) {
|
||||
} else if (!this.nativeReady()) {
|
||||
console.error('Oscilloscope: Native DSP not available!')
|
||||
}
|
||||
}
|
||||
|
||||
private updateSampleRateIfNeeded(): void {
|
||||
if (!isNativeAvailable()) return
|
||||
if (!this.nativeReady()) return
|
||||
const currentRate = this.dataSource.getSampleRate()
|
||||
if (currentRate !== this.lastSampleRate && currentRate > 0) {
|
||||
this.lastSampleRate = currentRate
|
||||
nativeOscilloscope.setSampleRate(currentRate)
|
||||
nativeOscilloscope.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(currentRate))
|
||||
this.nativeAnalyzer.setSampleRate(currentRate)
|
||||
this.nativeAnalyzer.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(currentRate))
|
||||
console.log(`Oscilloscope: Sample rate updated to ${currentRate}Hz`)
|
||||
}
|
||||
}
|
||||
@@ -155,8 +162,8 @@ export class Oscilloscope {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
if (isNativeAvailable() && options.pitchLock !== undefined) {
|
||||
nativeOscilloscope.setPitchLock(options.pitchLock)
|
||||
if (this.nativeReady() && options.pitchLock !== undefined) {
|
||||
this.nativeAnalyzer.setPitchLock(options.pitchLock)
|
||||
}
|
||||
|
||||
this.staticLayerKey = ''
|
||||
@@ -226,7 +233,7 @@ export class Oscilloscope {
|
||||
|
||||
this.renderStaticLayer()
|
||||
|
||||
if (!isNativeAvailable()) {
|
||||
if (!this.nativeReady()) {
|
||||
console.error('Oscilloscope: Native DSP required')
|
||||
return
|
||||
}
|
||||
@@ -240,7 +247,7 @@ export class Oscilloscope {
|
||||
const pendingSamples = this.dataSource.getPendingOscilloscopeSamples()
|
||||
if (pendingSamples.length > 0) {
|
||||
const merged = this.concatMonoChunks(pendingSamples)
|
||||
nativeOscilloscope.pushSamples(merged)
|
||||
this.nativeAnalyzer.pushSamples(merged)
|
||||
this.samplesReceived += merged.length
|
||||
}
|
||||
|
||||
@@ -248,7 +255,7 @@ export class Oscilloscope {
|
||||
return
|
||||
}
|
||||
|
||||
const result = nativeOscilloscope.processContinuous()
|
||||
const result = this.nativeAnalyzer.processContinuous()
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
@@ -263,7 +270,7 @@ export class Oscilloscope {
|
||||
}
|
||||
|
||||
const renderData = this.ensureRenderBuffer(samplesToShow)
|
||||
const sampleCount = nativeOscilloscope.fillSamples(triggerIndex, renderData)
|
||||
const sampleCount = this.nativeAnalyzer.fillSamples(triggerIndex, renderData)
|
||||
if (sampleCount < 2) {
|
||||
return
|
||||
}
|
||||
@@ -383,8 +390,8 @@ export class Oscilloscope {
|
||||
reset(): void {
|
||||
this.samplesReceived = 0
|
||||
|
||||
if (isNativeAvailable()) {
|
||||
nativeOscilloscope.reset()
|
||||
if (this.nativeReady()) {
|
||||
this.nativeAnalyzer.reset()
|
||||
}
|
||||
|
||||
this.invalidate()
|
||||
@@ -399,8 +406,8 @@ export class Oscilloscope {
|
||||
this.unsubscribeSessionChange = null
|
||||
}
|
||||
|
||||
if (isNativeAvailable()) {
|
||||
nativeOscilloscope.reset()
|
||||
if (this.nativeReady()) {
|
||||
this.nativeAnalyzer.reset()
|
||||
}
|
||||
|
||||
this.samplesReceived = 0
|
||||
|
||||
@@ -352,28 +352,38 @@ export class Spectrogram {
|
||||
this.columnImageData = new ImageData(imageWidth, imageHeight)
|
||||
}
|
||||
|
||||
private shiftAndPaintColumn(values: Float32Array, heatValues: Float32Array = values): void {
|
||||
// Scroll the waterfall once for the whole batch of new columns, then paint them —
|
||||
// far cheaper than a full-canvas self-blit per column (which dominates cost at high
|
||||
// scroll speeds / large windows). `display`/`heat` are columnCount * rowCount long.
|
||||
private shiftAndPaintColumns(display: Float32Array, heat: Float32Array, columnCount: number, rowCount: number): void {
|
||||
const width = this.waterfallCanvas.width
|
||||
const height = this.waterfallCanvas.height
|
||||
if (width <= 0 || height <= 0 || !this.columnImageData) return
|
||||
if (width <= 0 || height <= 0 || columnCount <= 0 || rowCount <= 0 || !this.columnImageData) return
|
||||
|
||||
this.paintColumnImage(values, heatValues)
|
||||
const vertical = this.options.orientation === 'vertical'
|
||||
const span = vertical ? height : width
|
||||
const shift = Math.min(columnCount, span)
|
||||
|
||||
const previousCompositeOperation = this.waterfallCtx.globalCompositeOperation
|
||||
this.waterfallCtx.globalCompositeOperation = 'copy'
|
||||
if (this.options.orientation === 'vertical') {
|
||||
// Shift existing content up by 1 pixel.
|
||||
this.waterfallCtx.drawImage(this.waterfallCanvas, 0, -1)
|
||||
if (vertical) {
|
||||
this.waterfallCtx.drawImage(this.waterfallCanvas, 0, -shift)
|
||||
} else {
|
||||
// Shift existing content left by 1 pixel.
|
||||
this.waterfallCtx.drawImage(this.waterfallCanvas, -1, 0)
|
||||
this.waterfallCtx.drawImage(this.waterfallCanvas, -shift, 0)
|
||||
}
|
||||
this.waterfallCtx.globalCompositeOperation = previousCompositeOperation
|
||||
|
||||
if (this.options.orientation === 'vertical') {
|
||||
this.waterfallCtx.putImageData(this.columnImageData, 0, height - 1)
|
||||
} else {
|
||||
this.waterfallCtx.putImageData(this.columnImageData, width - 1, 0)
|
||||
for (let column = 0; column < columnCount; column += 1) {
|
||||
const dst = span - columnCount + column
|
||||
if (dst < 0) continue
|
||||
const start = column * rowCount
|
||||
const end = start + rowCount
|
||||
this.paintColumnImage(display.subarray(start, end), heat.subarray(start, end))
|
||||
if (vertical) {
|
||||
this.waterfallCtx.putImageData(this.columnImageData, 0, dst)
|
||||
} else {
|
||||
this.waterfallCtx.putImageData(this.columnImageData, dst, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,14 +490,7 @@ export class Spectrogram {
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
for (let column = 0; column < result.columnCount; column += 1) {
|
||||
const start = column * result.rowCount
|
||||
const end = start + result.rowCount
|
||||
this.shiftAndPaintColumn(
|
||||
result.display.subarray(start, end),
|
||||
result.heat.subarray(start, end),
|
||||
)
|
||||
}
|
||||
this.shiftAndPaintColumns(result.display, result.heat, result.columnCount, result.rowCount)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -370,9 +370,9 @@ export class Waveform {
|
||||
return [lineColor.r, lineColor.g, lineColor.b]
|
||||
}
|
||||
|
||||
private shiftWaterfall(): void {
|
||||
private shiftWaterfall(columns = 1): void {
|
||||
this.waterfallCtx.globalCompositeOperation = 'copy'
|
||||
this.waterfallCtx.drawImage(this.waterfallCanvas, -1, 0)
|
||||
this.waterfallCtx.drawImage(this.waterfallCanvas, -columns, 0)
|
||||
this.waterfallCtx.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ export class Waveform {
|
||||
laneTop: number,
|
||||
laneHeight: number,
|
||||
color: [number, number, number],
|
||||
x: number = width - 1,
|
||||
): void {
|
||||
const scaledMin = Math.max(-1, Math.min(1, min))
|
||||
const scaledMax = Math.max(-1, Math.min(1, max))
|
||||
@@ -397,12 +398,12 @@ export class Waveform {
|
||||
const [r, g, b] = color
|
||||
|
||||
this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${fillAlpha})`
|
||||
this.waterfallCtx.fillRect(width - 1, yTop, 1, lineHeight)
|
||||
this.waterfallCtx.fillRect(x, yTop, 1, lineHeight)
|
||||
|
||||
this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${edgeAlpha})`
|
||||
this.waterfallCtx.fillRect(width - 1, yTop, 1, 1)
|
||||
this.waterfallCtx.fillRect(x, yTop, 1, 1)
|
||||
if (lineHeight > 1) {
|
||||
this.waterfallCtx.fillRect(width - 1, yBottom - 1, 1, 1)
|
||||
this.waterfallCtx.fillRect(x, yBottom - 1, 1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +496,7 @@ export class Waveform {
|
||||
}
|
||||
|
||||
private processMonoChunk(chunk: Float32Array, width: number, height: number): void {
|
||||
if (this.useNativeMultiband()) {
|
||||
if (this.useNativeAnalyzer()) {
|
||||
if (this.processNativeMonoChunk(chunk, width, height)) {
|
||||
return
|
||||
}
|
||||
@@ -540,7 +541,7 @@ export class Waveform {
|
||||
const leftSamples = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length)
|
||||
const rightSamples = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length)
|
||||
|
||||
if (this.useNativeMultiband()) {
|
||||
if (this.useNativeAnalyzer()) {
|
||||
if (this.processNativeStereoChunk(leftSamples, rightSamples, width, height)) {
|
||||
return
|
||||
}
|
||||
@@ -590,8 +591,13 @@ export class Waveform {
|
||||
}
|
||||
}
|
||||
|
||||
private useNativeMultiband(): boolean {
|
||||
return this.options.multiband && Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false
|
||||
private useNativeAnalyzer(): boolean {
|
||||
// The native analyzer computes per-column min/max (and band RMS) identically to
|
||||
// the JS sample loop, so prefer it whenever available — for plain mode it just
|
||||
// colors columns with lineColor. The JS path below remains the fallback when no
|
||||
// native analyzer is present (and is what feeds raw samples in the Electron app
|
||||
// when the addon is unavailable).
|
||||
return Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false
|
||||
}
|
||||
|
||||
private processNativeMonoChunk(chunk: Float32Array, width: number, height: number): boolean {
|
||||
@@ -602,11 +608,17 @@ export class Waveform {
|
||||
|
||||
const stride = 5
|
||||
const columnCount = Math.floor(summaries.length / stride)
|
||||
if (columnCount <= 0) return true
|
||||
|
||||
// Scroll once for the whole batch, then paint the new columns — far cheaper than
|
||||
// a full-canvas self-blit per column (which dominates cost at high scroll speeds).
|
||||
this.shiftWaterfall(Math.min(columnCount, width))
|
||||
for (let column = 0; column < columnCount; column += 1) {
|
||||
const x = width - columnCount + column
|
||||
if (x < 0) continue
|
||||
const offset = column * stride
|
||||
const color = this.resolveNativeColumnColor(summaries[offset + 2], summaries[offset + 3], summaries[offset + 4])
|
||||
this.shiftWaterfall()
|
||||
this.paintColumn(summaries[offset], summaries[offset + 1], width, 0, height, color)
|
||||
this.paintColumn(summaries[offset], summaries[offset + 1], width, 0, height, color, x)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -620,13 +632,17 @@ export class Waveform {
|
||||
const stride = 10
|
||||
const laneHeight = height / 2
|
||||
const columnCount = Math.floor(summaries.length / stride)
|
||||
if (columnCount <= 0) return true
|
||||
|
||||
this.shiftWaterfall(Math.min(columnCount, width))
|
||||
for (let column = 0; column < columnCount; column += 1) {
|
||||
const x = width - columnCount + column
|
||||
if (x < 0) continue
|
||||
const offset = column * stride
|
||||
const leftColor = this.resolveNativeColumnColor(summaries[offset + 2], summaries[offset + 3], summaries[offset + 4])
|
||||
const rightColor = this.resolveNativeColumnColor(summaries[offset + 7], summaries[offset + 8], summaries[offset + 9])
|
||||
this.shiftWaterfall()
|
||||
this.paintColumn(summaries[offset], summaries[offset + 1], width, 0, laneHeight, leftColor)
|
||||
this.paintColumn(summaries[offset + 5], summaries[offset + 6], width, laneHeight, laneHeight, rightColor)
|
||||
this.paintColumn(summaries[offset], summaries[offset + 1], width, 0, laneHeight, leftColor, x)
|
||||
this.paintColumn(summaries[offset + 5], summaries[offset + 6], width, laneHeight, laneHeight, rightColor, x)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
function inlinePluginUiAssets() {
|
||||
const outDir = resolve(__dirname, 'plugin/webview-dist')
|
||||
const indexPath = resolve(outDir, 'index.html')
|
||||
const scriptPath = resolve(outDir, 'assets/index.js')
|
||||
const stylePath = resolve(outDir, 'assets/index.css')
|
||||
|
||||
return {
|
||||
name: 'prism-inline-plugin-ui-assets',
|
||||
apply: 'build' as const,
|
||||
enforce: 'post' as const,
|
||||
closeBundle() {
|
||||
const script = readFileSync(scriptPath, 'utf8').replace(/<\/script/gi, '<\\/script')
|
||||
const style = readFileSync(stylePath, 'utf8').replace(/<\/style/gi, '<\\/style')
|
||||
let html = readFileSync(indexPath, 'utf8')
|
||||
let inlinedScript = false
|
||||
let inlinedStyle = false
|
||||
|
||||
html = html.replace(
|
||||
/<script type="module" crossorigin src="\.\/assets\/index\.js"><\/script>/,
|
||||
() => {
|
||||
inlinedScript = true
|
||||
return `<script type="module">\n${script}\n</script>`
|
||||
}
|
||||
)
|
||||
html = html.replace(
|
||||
/<link rel="stylesheet" crossorigin href="\.\/assets\/index\.css"\s*\/?>/,
|
||||
() => {
|
||||
inlinedStyle = true
|
||||
return `<style>\n${style}\n</style>`
|
||||
}
|
||||
)
|
||||
|
||||
if (! inlinedScript || ! inlinedStyle) {
|
||||
throw new Error('Failed to inline plugin UI assets into index.html')
|
||||
}
|
||||
|
||||
writeFileSync(indexPath, html)
|
||||
rmSync(resolve(outDir, 'assets'), { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone Vite build for the plugin webview UI (src/plugin-ui).
|
||||
*
|
||||
* - dev: `npx vite --config vite.plugin-ui.config.ts` serves on :5174, which
|
||||
* the JUCE plugin's WebBrowserComponent points at for hot reload.
|
||||
* - build: emits a self-contained static HTML bundle. The C++ side embeds it;
|
||||
* Linux writes it to a local file before loading to avoid custom-scheme
|
||||
* WebKitGTK issues in DAW hosts.
|
||||
*
|
||||
* Reuses the existing visualizer source under src/renderer via relative imports.
|
||||
*/
|
||||
export default defineConfig({
|
||||
root: resolve(__dirname, 'src/plugin-ui'),
|
||||
base: './',
|
||||
plugins: [react(), inlinePluginUiAssets()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'plugin/webview-dist'),
|
||||
emptyOutDir: true,
|
||||
// Stable (unhashed) asset names so the C++ resource provider can map them
|
||||
// deterministically and CMake's embedded BinaryData symbols stay stable.
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].js',
|
||||
chunkFileNames: 'assets/[name].js',
|
||||
assetFileNames: 'assets/[name][extname]',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user