diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 716100e..d82ee65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2269308..25def0d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ out/ dist/ native/build/ +# JUCE plugin build + installer staging +plugin/build/ +plugin/webview-dist/ +plugin/dist-installer/ + # Electron *.log diff --git a/README.md b/README.md index 723253d..d8a12fc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 100c158..fbcfe80 100644 --- a/package.json +++ b/package.json @@ -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" + ] } } } diff --git a/plugin/.gitignore b/plugin/.gitignore new file mode 100644 index 0000000..563db30 --- /dev/null +++ b/plugin/.gitignore @@ -0,0 +1,2 @@ +build/ +webview-dist/ diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt new file mode 100644 index 0000000..27a061b --- /dev/null +++ b/plugin/CMakeLists.txt @@ -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=$ + ${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() diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 0000000..d6efe80 --- /dev/null +++ b/plugin/README.md @@ -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 + `` 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. diff --git a/plugin/Source/LUFSMeterEngine.h b/plugin/Source/LUFSMeterEngine.h new file mode 100644 index 0000000..a7dab59 --- /dev/null +++ b/plugin/Source/LUFSMeterEngine.h @@ -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; +}; diff --git a/plugin/Source/OscilloscopeEngine.h b/plugin/Source/OscilloscopeEngine.h new file mode 100644 index 0000000..bf9e1dd --- /dev/null +++ b/plugin/Source/OscilloscopeEngine.h @@ -0,0 +1,99 @@ +#pragma once + +#include "ScopeEngine.h" +#include "oscilloscope.h" // reused, unmodified, from native/src +#include +#include +#include + +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 mono, window; + bool pitchLock = true; + long long samplesSeen = 0; +}; diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp new file mode 100644 index 0000000..ef80da8 --- /dev/null +++ b/plugin/Source/PluginEditor.cpp @@ -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 +#include +#include + +#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 ""; + } + + 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& bounds) + { + juce::String text; + text << bounds.getX() << "," << bounds.getY() + << " " << bounds.getWidth() << "x" << bounds.getHeight(); + return text; + } +#endif +#endif + + std::unique_ptr makeEngine() + { +#if defined(PRISM_SCOPE_WAVEFORM) && PRISM_SCOPE_WAVEFORM + return std::make_unique(); +#elif defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM + return std::make_unique(); +#elif defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE + return std::make_unique(); +#elif defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER + return std::make_unique(); +#elif defined(PRISM_SCOPE_VUMETER) && PRISM_SCOPE_VUMETER + return std::make_unique(); +#elif defined(PRISM_SCOPE_OSCILLOSCOPE) && PRISM_SCOPE_OSCILLOSCOPE + return std::make_unique(); +#else + return std::make_unique(); +#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 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 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 = + "" + "" + "" + "
Prism WebView smoke test\\nWaiting for JUCE bridge...
" + ""; + + 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(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,"); + #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->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)); +} diff --git a/plugin/Source/PluginEditor.h b/plugin/Source/PluginEditor.h new file mode 100644 index 0000000..fa4809c --- /dev/null +++ b/plugin/Source/PluginEditor.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include "PluginProcessor.h" +#include "ScopeEngine.h" +#include +#include + +/** + * 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 engine; + std::vector 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 webView; +#if JUCE_LINUX + std::unique_ptr 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) +}; diff --git a/plugin/Source/PluginProcessor.cpp b/plugin/Source/PluginProcessor.cpp new file mode 100644 index 0000000..1a8c299 --- /dev/null +++ b/plugin/Source/PluginProcessor.cpp @@ -0,0 +1,122 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" +#include + +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& 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(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(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(); +} diff --git a/plugin/Source/PluginProcessor.h b/plugin/Source/PluginProcessor.h new file mode 100644 index 0000000..bc234ab --- /dev/null +++ b/plugin/Source/PluginProcessor.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include + +/** + * 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&, 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 leftBuffer, rightBuffer; // backing storage for `fifo` + std::atomic currentSampleRate { 48000.0 }; + + juce::CriticalSection settingsLock; + juce::String settingsJson; + std::atomic editorWidth { 0 }; + std::atomic editorHeight { 0 }; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismSpectrumProcessor) +}; diff --git a/plugin/Source/ScopeEngine.h b/plugin/Source/ScopeEngine.h new file mode 100644 index 0000000..4401a17 --- /dev/null +++ b/plugin/Source/ScopeEngine.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +/** + * 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; +}; diff --git a/plugin/Source/SpectrogramEngine.h b/plugin/Source/SpectrogramEngine.h new file mode 100644 index 0000000..512dc8e --- /dev/null +++ b/plugin/Source/SpectrogramEngine.h @@ -0,0 +1,122 @@ +#pragma once + +#include "ScopeEngine.h" +#include "spectrogram.h" // reused, unmodified, from native/src +#include +#include + +/** + * 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 mono; + std::vector pendingDisplay, pendingHeat; + size_t pendingColumns = 0; +}; diff --git a/plugin/Source/SpectrumEngine.h b/plugin/Source/SpectrumEngine.h new file mode 100644 index 0000000..038051d --- /dev/null +++ b/plugin/Source/SpectrumEngine.h @@ -0,0 +1,54 @@ +#pragma once + +#include "ScopeEngine.h" +#include "spectrum.h" // reused, unmodified, from native/src +#include + +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& data) + { + return data.empty() ? juce::String() + : juce::Base64::toBase64(data.data(), data.size() * sizeof(float)); + } + + const juce::Identifier frameId { "spectrumFrame" }; + Visualizer::Spectrum spectrum { 2048 }; +}; diff --git a/plugin/Source/VUMeterEngine.h b/plugin/Source/VUMeterEngine.h new file mode 100644 index 0000000..05da1a2 --- /dev/null +++ b/plugin/Source/VUMeterEngine.h @@ -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; +}; diff --git a/plugin/Source/VectorscopeEngine.h b/plugin/Source/VectorscopeEngine.h new file mode 100644 index 0000000..3e3696b --- /dev/null +++ b/plugin/Source/VectorscopeEngine.h @@ -0,0 +1,76 @@ +#pragma once + +#include "ScopeEngine.h" +#include "vectorscope.h" // reused, unmodified, from native/src +#include + +/** + * 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 pointX, pointY, mbData; + bool multiband = false; +}; diff --git a/plugin/Source/WaveformEngine.h b/plugin/Source/WaveformEngine.h new file mode 100644 index 0000000..451cc1f --- /dev/null +++ b/plugin/Source/WaveformEngine.h @@ -0,0 +1,98 @@ +#pragma once + +#include "ScopeEngine.h" +#include "waveform.h" // reused, unmodified, from native/src +#include +#include +#include + +/** + * 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 mono, pending; + float sampleRate = 48000.0f; + float scrollSpeed = 1.0f; + size_t samplesPerColumn = 1; + bool stereo = false; +}; diff --git a/plugin/Source/WebViewFrameRate.h b/plugin/Source/WebViewFrameRate.h new file mode 100644 index 0000000..9891893 --- /dev/null +++ b/plugin/Source/WebViewFrameRate.h @@ -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); diff --git a/plugin/Source/WebViewFrameRate.mm b/plugin/Source/WebViewFrameRate.mm new file mode 100644 index 0000000..e31c824 --- /dev/null +++ b/plugin/Source/WebViewFrameRate.mm @@ -0,0 +1,62 @@ +#import +#import +#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; +} diff --git a/resources/installer/linux/install-vst3-from-package.sh b/resources/installer/linux/install-vst3-from-package.sh new file mode 100755 index 0000000..6ca4171 --- /dev/null +++ b/resources/installer/linux/install-vst3-from-package.sh @@ -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" diff --git a/resources/installer/linux/install-vst3.sh b/resources/installer/linux/install-vst3.sh new file mode 100755 index 0000000..4509ccd --- /dev/null +++ b/resources/installer/linux/install-vst3.sh @@ -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 </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 diff --git a/resources/installer/windows-vst3.nsh b/resources/installer/windows-vst3.nsh new file mode 100644 index 0000000..f9e290f --- /dev/null +++ b/resources/installer/windows-vst3.nsh @@ -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 diff --git a/scripts/build/postinstall-native.cjs b/scripts/build/postinstall-native.cjs new file mode 100644 index 0000000..51bdb79 --- /dev/null +++ b/scripts/build/postinstall-native.cjs @@ -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) diff --git a/src/plugin-ui/BridgeLUFSMeterAnalyzer.ts b/src/plugin-ui/BridgeLUFSMeterAnalyzer.ts new file mode 100644 index 0000000..069ee5a --- /dev/null +++ b/src/plugin-ui/BridgeLUFSMeterAnalyzer.ts @@ -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 + } +} diff --git a/src/plugin-ui/BridgeOscilloscopeAnalyzer.ts b/src/plugin-ui/BridgeOscilloscopeAnalyzer.ts new file mode 100644 index 0000000..10ee42c --- /dev/null +++ b/src/plugin-ui/BridgeOscilloscopeAnalyzer.ts @@ -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 + } +} diff --git a/src/plugin-ui/BridgeSpectrogramAnalyzer.ts b/src/plugin-ui/BridgeSpectrogramAnalyzer.ts new file mode 100644 index 0000000..3b9ff70 --- /dev/null +++ b/src/plugin-ui/BridgeSpectrogramAnalyzer.ts @@ -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 + } +} diff --git a/src/plugin-ui/BridgeSpectrumAnalyzer.ts b/src/plugin-ui/BridgeSpectrumAnalyzer.ts new file mode 100644 index 0000000..672a919 --- /dev/null +++ b/src/plugin-ui/BridgeSpectrumAnalyzer.ts @@ -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) + } +} diff --git a/src/plugin-ui/BridgeVUMeterAnalyzer.ts b/src/plugin-ui/BridgeVUMeterAnalyzer.ts new file mode 100644 index 0000000..427795c --- /dev/null +++ b/src/plugin-ui/BridgeVUMeterAnalyzer.ts @@ -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 + } +} diff --git a/src/plugin-ui/BridgeVectorscopeAnalyzer.ts b/src/plugin-ui/BridgeVectorscopeAnalyzer.ts new file mode 100644 index 0000000..be78ef7 --- /dev/null +++ b/src/plugin-ui/BridgeVectorscopeAnalyzer.ts @@ -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 + } +} diff --git a/src/plugin-ui/BridgeWaveformAnalyzer.ts b/src/plugin-ui/BridgeWaveformAnalyzer.ts new file mode 100644 index 0000000..02d60fa --- /dev/null +++ b/src/plugin-ui/BridgeWaveformAnalyzer.ts @@ -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 + } +} diff --git a/src/plugin-ui/GearIcon.tsx b/src/plugin-ui/GearIcon.tsx new file mode 100644 index 0000000..73b7454 --- /dev/null +++ b/src/plugin-ui/GearIcon.tsx @@ -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 ( + + ) +} diff --git a/src/plugin-ui/LUFSMeterScope.tsx b/src/plugin-ui/LUFSMeterScope.tsx new file mode 100644 index 0000000..acb3bf6 --- /dev/null +++ b/src/plugin-ui/LUFSMeterScope.tsx @@ -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(null) + const canvasRef = useRef(null) + const vizRef = useRef(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 ( +
+ +
+ ) +} diff --git a/src/plugin-ui/OscilloscopeScope.tsx b/src/plugin-ui/OscilloscopeScope.tsx new file mode 100644 index 0000000..1d67932 --- /dev/null +++ b/src/plugin-ui/OscilloscopeScope.tsx @@ -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(null) + const canvasRef = useRef(null) + const vizRef = useRef(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 ( +
+ +
+ ) +} diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts new file mode 100644 index 0000000..11f3eae --- /dev/null +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -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): void { + this.sessionState = { ...this.sessionState, ...partial } + for (const listener of this.listeners) { + listener(this.sessionState) + } + } +} diff --git a/src/plugin-ui/ScopeApp.tsx b/src/plugin-ui/ScopeApp.tsx new file mode 100644 index 0000000..8540427 --- /dev/null +++ b/src/plugin-ui/ScopeApp.tsx @@ -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 { + 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({ kind, renderScope }: ScopeAppProps): JSX.Element { + const { settings, resolvedTheme, handleUpdate } = useScopeHostSync(kind) + const [settingsOpen, setSettingsOpen] = useState(false) + const [lockedViewportHeight, setLockedViewportHeight] = useState(null) + const viewportRef = useRef(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 ( +
+
+ {renderScope(settings, resolvedTheme)} + + +
+ + {settingsOpen && ( +
+ handleUpdate(partial as unknown as Partial)} + /> +
+ )} +
+ ) +} diff --git a/src/plugin-ui/SpectrogramScope.tsx b/src/plugin-ui/SpectrogramScope.tsx new file mode 100644 index 0000000..29cad2b --- /dev/null +++ b/src/plugin-ui/SpectrogramScope.tsx @@ -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(null) + const canvasRef = useRef(null) + const vizRef = useRef(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 ( +
+ +
+ ) +} diff --git a/src/plugin-ui/SpectrumScope.tsx b/src/plugin-ui/SpectrumScope.tsx new file mode 100644 index 0000000..4d1c511 --- /dev/null +++ b/src/plugin-ui/SpectrumScope.tsx @@ -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(null) + const canvasRef = useRef(null) + const analyzerRef = useRef(null) + const resizeStateRef = useRef(null) + const peakOverlayRef = useRef(null) + const [peak, setPeak] = useState(null) + const [overlaySize, setOverlaySize] = useState(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 ( +
+ + {showPeak && peak && ( +
+ {formatSpectrumPeakDb(peak.db)} + / + {formatSpectrumPeakFrequency(peak.frequencyHz)} + / + {peak.key} +
+ )} +
+ ) +} diff --git a/src/plugin-ui/VUMeterScope.tsx b/src/plugin-ui/VUMeterScope.tsx new file mode 100644 index 0000000..e291b46 --- /dev/null +++ b/src/plugin-ui/VUMeterScope.tsx @@ -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(null) + const canvasRef = useRef(null) + const vizRef = useRef(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 ( +
+ +
+ ) +} diff --git a/src/plugin-ui/VectorscopeScope.tsx b/src/plugin-ui/VectorscopeScope.tsx new file mode 100644 index 0000000..26bc865 --- /dev/null +++ b/src/plugin-ui/VectorscopeScope.tsx @@ -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(null) + const canvasRef = useRef(null) + const vizRef = useRef(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 ( +
+ +
+ ) +} diff --git a/src/plugin-ui/WaveformScope.tsx b/src/plugin-ui/WaveformScope.tsx new file mode 100644 index 0000000..8d054fd --- /dev/null +++ b/src/plugin-ui/WaveformScope.tsx @@ -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(null) + const canvasRef = useRef(null) + const vizRef = useRef(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 ( +
+ +
+ ) +} diff --git a/src/plugin-ui/index.html b/src/plugin-ui/index.html new file mode 100644 index 0000000..4df37a1 --- /dev/null +++ b/src/plugin-ui/index.html @@ -0,0 +1,65 @@ + + + + + + Prism Spectrum + + + + +
+
Loading Prism plugin UI...
+ + + diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts new file mode 100644 index 0000000..a9cead8 --- /dev/null +++ b/src/plugin-ui/juceBridge.ts @@ -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 | null = null + +function ensureBackend(): Promise { + 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 + 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 + 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) + } +} diff --git a/src/plugin-ui/lufsmeterOptions.ts b/src/plugin-ui/lufsmeterOptions.ts new file mode 100644 index 0000000..1eceb90 --- /dev/null +++ b/src/plugin-ui/lufsmeterOptions.ts @@ -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, + } +} diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx new file mode 100644 index 0000000..4577d45 --- /dev/null +++ b/src/plugin-ui/main.tsx @@ -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 ( + ( + + )} + /> + ) + } + + 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 ( + ( + + )} + /> + ) + } + + 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 ( + ( + + )} + /> + ) + } + + if (getScopeKind() === 'lufsmeter') { + const analyzer = new BridgeLUFSMeterAnalyzer() + connectLUFSMeterBridge({ + onFrame: (frame) => { + analyzer.setSnapshot(frame) + dataSource.setSampleRate(frame.sampleRate) + dataSource.setPlaying(true) + }, + }) + return ( + ( + + )} + /> + ) + } + + if (getScopeKind() === 'vumeter') { + const analyzer = new BridgeVUMeterAnalyzer() + connectVUMeterBridge({ + onFrame: (frame) => { + analyzer.setSnapshot(frame) + dataSource.setSampleRate(frame.sampleRate) + dataSource.setPlaying(true) + }, + }) + return ( + ( + + )} + /> + ) + } + + if (getScopeKind() === 'oscilloscope') { + const analyzer = new BridgeOscilloscopeAnalyzer() + connectOscilloscopeBridge({ + onFrame: (frame) => { + analyzer.setSamples(frame.samples, frame.detectedPitch) + dataSource.setSampleRate(frame.sampleRate) + dataSource.setPlaying(true) + }, + }) + return ( + ( + + )} + /> + ) + } + + const analyzer = new BridgeSpectrumAnalyzer(2048) + connectSpectrumBridge({ + onFrame: (frame) => { + analyzer.setMagnitudes(frame.magnitudes, frame.side) + dataSource.setSampleRate(frame.sampleRate) + dataSource.setPlaying(true) + }, + }) + return ( + ( + + )} + /> + ) +} + +const rootElement = document.getElementById('root') +if (!rootElement) { + throw new Error('Missing #root element') +} + +try { + createRoot(rootElement).render({buildApp()}) + 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 +} diff --git a/src/plugin-ui/oscilloscopeOptions.ts b/src/plugin-ui/oscilloscopeOptions.ts new file mode 100644 index 0000000..0b70b2e --- /dev/null +++ b/src/plugin-ui/oscilloscopeOptions.ts @@ -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, + } +} diff --git a/src/plugin-ui/peakOverlay.ts b/src/plugin-ui/peakOverlay.ts new file mode 100644 index 0000000..0854171 --- /dev/null +++ b/src/plugin-ui/peakOverlay.ts @@ -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`, + } +} diff --git a/src/plugin-ui/scrollingCanvas.ts b/src/plugin-ui/scrollingCanvas.ts new file mode 100644 index 0000000..e68f0d9 --- /dev/null +++ b/src/plugin-ui/scrollingCanvas.ts @@ -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 } +} diff --git a/src/plugin-ui/spectrogramOptions.ts b/src/plugin-ui/spectrogramOptions.ts new file mode 100644 index 0000000..6f29c42 --- /dev/null +++ b/src/plugin-ui/spectrogramOptions.ts @@ -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, + } +} diff --git a/src/plugin-ui/spectrumOptions.ts b/src/plugin-ui/spectrumOptions.ts new file mode 100644 index 0000000..597238a --- /dev/null +++ b/src/plugin-ui/spectrumOptions.ts @@ -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, + } +} diff --git a/src/plugin-ui/styles.css b/src/plugin-ui/styles.css new file mode 100644 index 0000000..8632e62 --- /dev/null +++ b/src/plugin-ui/styles.css @@ -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; +} diff --git a/src/plugin-ui/useScopeHostSync.ts b/src/plugin-ui/useScopeHostSync.ts new file mode 100644 index 0000000..db67e7c --- /dev/null +++ b/src/plugin-ui/useScopeHostSync.ts @@ -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(kind: K, raw: unknown): ScopeSettings[K] { + const defaults = DEFAULT_SCOPE_SETTINGS[kind] as Record + if (typeof raw !== 'object' || raw === null) return { ...defaults } as ScopeSettings[K] + const parsed = raw as Record + const next: Record = { ...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(kind: K, profileJson: string): ScopeSettings[K] { + try { + const parsed = JSON.parse(profileJson) as { scopeSettings?: Record } + 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 { + settings: ScopeSettings[K] + resolvedTheme: PrismResolvedTheme + handleUpdate: (partial: Partial) => 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(kind: K): ScopeHostSync { + const [settings, setSettings] = useState(() => mergeScopeSettings(kind, undefined)) + const [resolvedTheme, setResolvedTheme] = useState(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): void => { + hasOverride.current = true + applySettings({ ...settingsRef.current, ...partial }, true) + }, [applySettings]) + + return { settings, resolvedTheme, handleUpdate } +} diff --git a/src/plugin-ui/vectorscopeOptions.ts b/src/plugin-ui/vectorscopeOptions.ts new file mode 100644 index 0000000..5c6c4b6 --- /dev/null +++ b/src/plugin-ui/vectorscopeOptions.ts @@ -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, + } +} diff --git a/src/plugin-ui/vumeterOptions.ts b/src/plugin-ui/vumeterOptions.ts new file mode 100644 index 0000000..807f2bf --- /dev/null +++ b/src/plugin-ui/vumeterOptions.ts @@ -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, + } +} diff --git a/src/plugin-ui/waveformOptions.ts b/src/plugin-ui/waveformOptions.ts new file mode 100644 index 0000000..f260204 --- /dev/null +++ b/src/plugin-ui/waveformOptions.ts @@ -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, + } +} diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index 07195c1..2e0564f 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -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) }, diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts index ad8a0fd..d42933b 100644 --- a/src/renderer/visualizers/Oscilloscope.ts +++ b/src/renderer/visualizers/Oscilloscope.ts @@ -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> +type ResolvedOscilloscopeOptions = Required> 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 diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index 76caed8..f943cd5 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -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 diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 23e8466..0106c32 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -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 } diff --git a/vite.plugin-ui.config.ts b/vite.plugin-ui.config.ts new file mode 100644 index 0000000..d5a7dff --- /dev/null +++ b/vite.plugin-ui.config.ts @@ -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( + /` + } + ) + html = html.replace( + //, + () => { + inlinedStyle = true + return `` + } + ) + + 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]', + }, + }, + }, +})