50 Commits

Author SHA1 Message Date
Boof2015 df0755b8ca Add Ko-fi username for funding support 2026-08-22 19:08:30 -04:00
Boof2015 6fc488d5df update built in themes 2026-08-22 12:24:34 -04:00
Boof2015 6e761909a8 update readme 2026-08-18 14:25:34 -04:00
Boof2015 2e35c2aa29 update readme 2026-08-18 14:25:12 -04:00
Boof2015 3bd11d6b7a fix tui peak consumer after true peak update 2026-08-18 11:35:57 -04:00
Boof2015 8d6916f203 fix icon for clip drag 2026-08-18 02:23:14 -04:00
Boof2015 eda0fbef51 BS.1770 true-peak 2026-08-18 02:11:46 -04:00
Boof2015 741de975bb add stats for nerds in the readme 2026-08-18 00:02:55 -04:00
Boof2015 425e3cde68 update themes in readme 2026-08-17 22:01:35 -04:00
Boof2015 209f0a1dcf fix ci fail + clearer windows installer steps 2026-08-17 21:13:28 -04:00
Boof2015 325ec155c0 fix windows tui packing error 2026-08-17 20:44:19 -04:00
Boof2015 0db46f7bb3 update readme 2026-08-17 16:44:52 -04:00
Boof2015 a6dbc737e9 add ci validation 2026-08-17 15:45:19 -04:00
Boof2015 b31089764b Revise support message in README 2026-08-17 01:24:25 -04:00
Boof2015 8d5f758784 Merge pull request #11 from Boof2015/transparency
did a lot more than transparency lol
2026-08-17 01:23:10 -04:00
Boof2015 a3a8b5a5b8 add tui gif to readme 2026-08-17 01:21:01 -04:00
Boof2015 9b8c2cc25b update readme 2026-08-17 01:15:06 -04:00
Boof2015 5c6677c2a5 LUFS panel fix 2026-08-17 00:56:17 -04:00
Boof2015 c73dac4fc1 update vu needles 2026-08-16 21:47:43 -04:00
Boof2015 04f254512c significantly improved and more accurate vectorscope 2026-08-16 20:52:50 -04:00
Boof2015 3e95cbfc28 centering 2026-08-16 20:06:19 -04:00
Boof2015 03f0d8dd2b fix confusing pin button css 2026-08-16 20:03:40 -04:00
Boof2015 806c58c9cb ui/ux QoL improvements 2026-08-16 19:53:39 -04:00
Boof2015 3b302d5526 make Spectrum and Spectrogram more frequency accurate and detail preserving 2026-08-16 16:43:31 -04:00
Boof2015 f6d9719c43 rolling recording buffer 2026-08-16 14:38:12 -04:00
Boof2015 90cde23f4d fix windows tui frame timing bug 2026-08-16 00:40:38 -04:00
Boof2015 10fca00c41 remove obsolete horizontal db lines in spectrum 2026-08-15 21:25:29 -04:00
Boof2015 5236a92763 tray icon and open on login 2026-08-15 21:20:48 -04:00
Boof2015 6debfb8b34 launch options 2026-08-15 20:04:33 -04:00
Boof2015 0763d84d84 terminal compatibility modes and fps counter 2026-08-15 19:55:15 -04:00
Boof2015 b691178d65 output control TUI 2026-08-15 19:35:25 -04:00
Boof2015 12ff657929 experimental 120fps tui 2026-08-15 18:49:48 -04:00
Boof2015 1bb096c231 tui support themes 2026-08-15 18:45:32 -04:00
Boof2015 700c8df4af profile and customization system for tui 2026-08-15 18:04:27 -04:00
Boof2015 81e97c7b3e adjust spectrogram 2026-08-14 21:52:15 -04:00
Boof2015 fbec2a5aa8 spectrogram and waveform 2026-08-14 20:58:44 -04:00
Boof2015 5910005dd4 add vu and lufs 2026-08-14 19:19:53 -04:00
Boof2015 c45451476d settings, toggles, and more 2026-08-14 19:02:26 -04:00
Boof2015 e712421bad early tui layout 2026-08-14 17:15:47 -04:00
Boof2015 7047df3e38 prism-tui PoC 2026-08-14 13:37:16 -04:00
Boof2015 1a0185306a version bump 2026-07-21 01:40:09 -04:00
Boof2015 1fcabb2b32 measurement crosshair overlay 2026-07-21 01:39:48 -04:00
Boof2015 bddebefd54 change spectrum db to dbFS 2026-07-10 21:53:47 -04:00
Boof2015 7a1a799266 scope rotation 2026-07-10 21:08:29 -04:00
Boof2015 4a9b325532 fix blur on macos 2026-07-10 03:24:43 -04:00
Boof2015 0ee95fe480 initial transparency 2026-06-11 17:30:07 -04:00
Boof2015 c56b86ba63 fix windows window issues 2026-06-07 18:34:23 -04:00
Boof2015 bf447dd8df Fix case sensitivity in GitHub downloads badge URL 2026-06-06 00:13:04 -04:00
Boof2015 05e0d385e4 update readme 2026-06-05 23:57:45 -04:00
Boof2015 63ef5ffa0f Merge pull request #10 from Boof2015/VSTs
Add cross platform VSTs
2026-06-03 10:21:21 -04:00
230 changed files with 28005 additions and 1730 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: boof2015
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+306
View File
@@ -0,0 +1,306 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: 22.x
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'
jobs:
quality-gate:
name: Quality Gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Cache Electron
uses: actions/cache@v5
with:
path: |
~/.cache/electron
~/.cache/electron-builder
key: ${{ runner.os }}-electron-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-electron-
- name: Install Linux native build tools
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
cmake \
ninja-build \
pkg-config \
python3 \
libpulse-dev
- name: Install dependencies
env:
PRISM_SKIP_NATIVE_POSTINSTALL: '1'
run: npm ci
- name: Build native DSP module
run: npm run rebuild:native
- name: Verify native module exists
shell: bash
run: |
if [ ! -f native/build/Release/visualizer_dsp.node ]; then
echo "ERROR: native DSP module was not built!"
exit 1
fi
echo "Native module built successfully: $(ls -lh native/build/Release/visualizer_dsp.node)"
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm test
- name: Build and test Prism TUI
run: npm run test:tui
- name: Build desktop application
run: npm run build
- name: Build plugin webview bundle
run: npm run plugin-ui:build
build-smoke:
name: Build Smoke (${{ matrix.os }})
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
needs: quality-gate
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-2022]
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Cache Electron
uses: actions/cache@v5
with:
path: |
~/.cache/electron
~/.cache/electron-builder
key: ${{ runner.os }}-electron-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-electron-
- name: Setup sccache
if: runner.os != 'Linux'
uses: mozilla-actions/sccache-action@v0.0.10
- name: Cache JUCE
uses: actions/cache@v5
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 \
build-essential \
cmake \
ninja-build \
pkg-config \
python3 \
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
- name: Build native DSP module
run: npm run rebuild:native
- name: Verify native module exists
shell: bash
run: |
if [ ! -f native/build/Release/visualizer_dsp.node ]; then
echo "ERROR: native DSP module was not built!"
exit 1
fi
echo "Native module built successfully: $(ls -lh native/build/Release/visualizer_dsp.node)"
- name: Test native spectrum and capture exports
run: npm run test:spectrum-native
- name: Test native spectrogram accuracy
run: npm run test:spectrogram-native
- name: Test native vectorscope calibration
run: npm run test:vectorscope-native
- name: Test native BS.1770 true-peak calibration
run: npm run test:lufsmeter-native
- name: Build and test Prism TUI
run: npm run test:tui
- name: Build desktop application
run: npm run build
- name: Build plugin webview bundle
run: npm run plugin-ui:build
- name: Cache WebView2 SDK (Windows)
if: runner.os == 'Windows'
uses: actions/cache@v5
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 (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
- 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: Stage plugins for Windows installer
if: runner.os == 'Windows'
shell: bash
run: |
rm -rf plugin/dist-installer
mkdir -p plugin/dist-installer/VST3
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/ \;
done
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
- name: Stage Prism TUI for Windows installer
if: runner.os == 'Windows'
run: npm run stage:tui
- name: Build Windows NSIS smoke artifact
if: runner.os == 'Windows'
run: npx electron-builder --win nsis --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- name: Smoke-test Windows installer PATH integration
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/test-windows-installer.ps1
- name: Show sccache stats
if: runner.os != 'Linux'
run: sccache --show-stats
+238 -4
View File
@@ -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,200 @@ jobs:
fi
echo "Native module built successfully: $(ls -lh native/build/Release/visualizer_dsp.node)"
- name: Typecheck
run: npm run typecheck
- name: Test native spectrum and capture exports
run: npm run test:spectrum-native
- name: Test native spectrogram accuracy
run: npm run test:spectrogram-native
- name: Test native vectorscope calibration
run: npm run test:vectorscope-native
- name: Test native BS.1770 true-peak calibration
run: npm run test:lufsmeter-native
- name: Build and test Prism TUI
run: npm run test:tui
# The AppImage-specific electron-builder config excludes both the native
# terminal frontend and DAW plugins regardless of local staging state.
- 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: Stage Prism TUI for installer
run: npm run stage:tui
- name: Smoke-test staged Prism TUI
shell: bash
run: |
tui_binary="tui/dist-installer/prism-tui"
if [ "$RUNNER_OS" = "Windows" ]; then
tui_binary="tui/dist-installer/prism-tui.exe"
fi
if [ ! -f "$tui_binary" ]; then
echo "ERROR: staged prism-tui executable was not found: $tui_binary"
exit 1
fi
"$tui_binary" --help
"$tui_binary" --version
- name: Build distributable
if: runner.os != 'Linux'
run: npm run ${{ matrix.dist_cmd }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- name: Smoke-test Windows installer PATH integration
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/test-windows-installer.ps1
- 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
+8
View File
@@ -5,6 +5,14 @@ node_modules/
out/
dist/
native/build/
tui/build/
tui/dist-installer/prism-tui
tui/dist-installer/prism-tui.exe
# JUCE plugin build + installer staging
plugin/build/
plugin/webview-dist/
plugin/dist-installer/
# Electron
*.log
+257 -44
View File
@@ -1,65 +1,270 @@
# Prism
<div align="center">
A free, open-source audio visualizer and meter rack for your desktop.
<img src="assets/prism-badge.png" alt="Prism" width="440">
**Real-time audio analysis and metering, wherever you work.**
![code size](https://img.shields.io/github/languages/code-size/Boof2015/prism)
![GitHub Release](https://img.shields.io/github/v/release/Boof2015/prism?include_prereleases)
![GitHub License](https://img.shields.io/github/license/Boof2015/prism)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Boof2015/prism/main.yml)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/boof2015/prism/total)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/Boof2015/prism/total)
Prism taps into your system audio and runs it through a rack of real-time scopes and meters. It grew out of the visualization engine in [Astra](https://github.com/Boof2015/astra), rebuilt as a standalone tool. Whether you're mixing a track, tuning a room, or just like watching your music, Prism gives you a window into what you're hearing.
</div>
Prism is a free, open-source audio analyzer and meter rack for Windows, macOS, and Linux.
Monitor system audio or an input with a configurable set of real-time scopes and meters, arrange them however you like, and save the setup as a profile. The same analyzers are also available as VST3/AU plugins, and Prism includes a native terminal interface for lightweight monitoring.
![Prism scopes](assets/prism-demo-readme-optimized.gif)
## Scopes
Seven visualizers driven by a native C++ analysis engine:
Prism includes seven real-time scopes and meters:
- **Spectrum Analyzer** — FFT frequency display with heatmap and fill modes, configurable FFT size, spectral tilt, and log or linear scaling
- **Oscilloscope** — Time-domain waveform with a pitch-lock mode that syncs the display to the fundamental frequency
- **Vectorscope** — Stereo phase visualization in five display modes (Lissajous, polar, linear) with optional multiband RGB split
- **Spectrogram** — Scrolling frequency-over-time display with mel, log, and linear scale modes
- **VU Meter** — Classic loudness metering in needle or bar style, horizontal or vertical
- **Loudness Meter** — Compact LUFS metering following ITU-R BS.1770 with fast stereo peak activity
- **Waveform** — Scrolling time-domain view with mono, stereo, and multiband modes
* **Spectrum Analyzer** — FFT spectrum with calibrated dBFS levels, configurable FFT size, spectral tilt, Log/Mel/Linear scales, heatmap and fill modes, and peak/pitch readouts
* **Oscilloscope** — Time-domain waveform with fundamental-frequency pitch locking and sub-sample triggering for a stable display
* **Vectorscope** — Full-band stereo phase analysis with XY, Polar, and M/S Linear views, calibrated references, adjustable zoom, and optional multiband RGB
* **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, stereo-energy analysis, and frequency reassignment in Sharp and Sharper modes
* **VU Meter** — 300 ms metering with adjustable 0 VU reference, stereo correlation, and needle or bar displays
* **Loudness Meter** — ITU-R BS.1770 momentary, short-term, and integrated LUFS metering with BS.1770 true-peak activity
* **Waveform** — Scrolling mono or stereo waveform with transient-preserving min/max sampling and optional multiband energy visualization
![Prism scopes](assets/prism-demo-12fps.gif)
Every scope can be configured independently. Resize and rearrange them into a rack, rotate supported scopes, pop them into separate windows, or pin them on top of other applications.
Every scope is independently configurable. Drag and resize them into whatever layout makes sense, pop any scope out into its own window, and pin windows on top so they stay visible while you work.
![Prism customize](assets/prism-showcase-layout-readme.gif)
## Audio Capture
Spectrum, Spectrogram, Oscilloscope, and Waveform also include interactive measurement overlays for inspecting frequency, level, pitch, amplitude, or time directly from the display.
Prism pulls audio at the OS level, straight from CoreAudio on macOS, WASAPI on Windows, or PulseAudio on Linux. No virtual cables or routing hacks. Flip on device input mode when you want to analyze a mic or a line-in instead.
<details> <summary><strong>Stats for nerds</strong></summary>
Capture-to-display latency measures under 8ms. When tested at 120fps, measured latency was 0ms. What you see is what you hear.
Most of Prism's analysis runs in native C++, with the same DSP implementations reused across the desktop app, DAW plugins, and terminal interface where applicable.
### Spectrum
* Hann-windowed FFT with selectable sizes from 1024 to 16384 samples
* Coherent-gain correction for calibrated dBFS magnitude rather than arbitrary FFT amplitude
* Native calibration tests measure bin-centered tones within ±0.05 dB and off-bin interpolated peaks within 0.3 dB across all exposed FFT sizes
* Maintains Mid, Side, Left, Right, and channel-max spectra internally
* Logarithmic, Slaney Mel, and Linear frequency scales
* Extended 10 Hz–24 kHz and Audible 20 Hz–20 kHz ranges, automatically clamped to Nyquist
* Independent spectral and heatmap tilt around a 1 kHz reference
* Peak analysis can report dBFS, frequency, musical note, octave, and cents offset
* Interactive measurement overlay exposes frequency, level, and pitch directly from the graph
### Spectrogram
* Hann-windowed FFT with 4× zero-padding
* Coherent amplitude normalization for calibrated spectral levels
* Native tests keep tone levels within 0.3 dB across multiple FFT sizes, amplitudes, and off-bin frequencies
* Logarithmic, Slaney Mel, and Linear frequency mapping
* Frequency placement is tested across 44.1, 48, and 96 kHz sample rates
* Classic mode provides a conventional spectral display
* Focused mode relocates local spectral peaks using phase correction and a local spectral centroid
* Sharp and Sharper perform phase-based frequency reassignment rather than post-process image sharpening
* Reassignment operates on every visible contributing FFT bin instead of only local maxima, preserving quieter partials and ambience
* Reassigned power compensates for Hann-window equivalent noise bandwidth and zero-padding so relocation remains level-calibrated
* Stereo analysis combines L/R energy rather than summing to mono, so anti-phase material remains visible instead of cancelling out
* Native tests verify reassigned tones stay on the correct Log, Mel, and Linear frequency rows
* Interactive inspection reports frequency, pitch, and time history
### Oscilloscope
* FFT-based fundamental detection over a 2048-sample analysis window
* Pitch detector currently searches from 40 to 1000 Hz
* Pitch-lock dynamically retunes a narrow FIR band-pass around the detected fundamental
* Triggering follows a rising zero crossing rather than simply drawing the newest block of samples
* Trigger positions retain sub-sample precision
* Catmull-Rom interpolation is used when reading the waveform at fractional sample positions
* Adaptive pitch smoothing locks quickly at startup and becomes more conservative once stable
* Interactive measurement reports time, linear amplitude, and dBFS
### Vectorscope
* Full-band L/R analysis with no hidden high-frequency low-pass
* Native regression tests explicitly verify preservation of content above 8 kHz at 44.1, 48, and 96 kHz
* XY mode uses the original channel samples directly: Right on X, Left on Y
* XY and M/S Linear modes use calibrated channel-amplitude reference boundaries
* Polar retains an intentionally amplitude-compressed radial projection so low-level stereo structure remains readable
* Folded modes rotate negative-Mid samples rather than discarding them
* Adjustable display zoom from −12 to +24 dB without modifying the underlying signal
* Optional three-band stereo view with 250 Hz and 2.5 kHz crossovers
* Multiband analysis retains separate low, mid, and high L/R signals
### Loudness
* ITU-R BS.1770-style K-weighting using the pre-filter and RLB weighting stages
* Momentary loudness uses a 400 ms window
* Short-term loudness uses a 3 second window
* Integrated loudness uses overlapping 400 ms blocks with 100 ms hops
* −70 LUFS absolute gate
* Relative gate at −10 LU below ungated programme loudness
* Momentary, short-term, and integrated LUFS are calculated independently
* Stereo true-peak reconstruction uses a rate-aware 48-tap-per-phase FIR at 2× to 8× oversampling for common 44.1–192 kHz host rates
* Held L/R true-peak markers and a reset-scoped combined maximum are reported in dBTP
### VU and correlation
* 300 ms RMS integration window
* Adjustable 0 VU calibration from −30 to 0 dBFS
* Default reference is 0 VU = −14 dBFS
* Stereo correlation is calculated over the same analysis window and reported from −1 to +1
* 750 ms sample-peak hold
* Peak decay of 18 dB/s
* Bar envelope uses a 5 ms attack and 180 ms release
* Needle and bar displays can use the same underlying analysis
### Waveform
* Each rendered time column retains the minimum and maximum sample instead of selecting or averaging a single sample, helping preserve short transients while downsampling the history
* Mono and separate stereo-lane modes
* Optional multiband view calculates low, mid, and high RMS energy for every displayed time column
* Uses the same 250 Hz and 2.5 kHz multiband split as the vectorscope
* Interactive measurement reports time history, amplitude, and dBFS
### Capture
* Native CoreAudio system-output capture on macOS using Audio Hardware Taps
* Native WASAPI loopback capture on Windows
* Native PulseAudio monitor-source capture on Linux
* Linux capture handles U8, 16-bit, 24-bit, 32-bit integer, and 32-bit floating-point PulseAudio formats before normalizing them for analysis
* Microphone and interface inputs use a separate low-latency AudioWorklet capture path
* Capture follows the active device sample rate instead of assuming a fixed 44.1 or 48 kHz analysis rate
### Rolling capture
* Keeps the previous 5, 10, 30, or 60 seconds of audio available without starting a recording beforehand
* Buffer is stored as PCM and exported as a standard RIFF/WAV file
* Mono and stereo capture are supported
* Exported clips use 16-bit PCM
* The export path supports source sample rates up to 384 kHz
* Captured audio can be dragged directly out of Prism as a file
### DAW plugins
* All seven Prism analyzers are built from the same native DSP source used by the desktop application
* VST3 on Windows, macOS, and Linux
* AU on macOS
* Mono and stereo host layouts are supported
* Analyzer plugins are pure pass-through: Prism does not modify the host's audio buffer
* The realtime process callback only copies samples into a FIFO
* FFT, loudness, visualization, and other analysis work happens outside the realtime audio thread
* Scope configuration is serialized into the DAW's project/plugin state
* Plugins use JUCE 8 and a shared Prism web UI while keeping analysis native
### Terminal UI
* Native C++ frontend using the same Spectrum, Oscilloscope, Vectorscope, VU, Loudness, Spectrogram, Waveform, and system-capture implementations
* 60 FPS default rendering
* Experimental 120 FPS mode
* Compatibility mode uses 256-color output at up to 60 FPS
* Safe mode uses basic ANSI colors and caps rendering at 30 FPS
* Supports live output-device switching
* Supports input trim before every analyzer
* Includes pitch locking, vectorscope modes, calibrated VU reference levels, spectrogram reassignment modes, mono/stereo waveform views, and multiband analysis
* Reads Prism .iro themes and has its own shareable profile/customization system
* Typically uses around 35 MB of RAM
### Rendering and measurement
* Desktop visualizers can target 10, 30, 60, 120, or 144 FPS, or synchronize to the display refresh rate
* Spectrum, Spectrogram, Oscilloscope, and Waveform support interactive measurement overlays
* Measurement coordinates account for scope rotation and mirroring, so displayed readouts continue to correspond to the underlying signal after transforming a scope
</details>
## Desktop
Prism can capture system output directly through CoreAudio on macOS, WASAPI on Windows, and PulseAudio on Linux. It can also switch to an input device when you want to analyze a microphone, interface, or line input.
No virtual audio cable is required for normal system capture.
The main rack and scope popouts can use solid, blurred, or clear backgrounds, making Prism usable as a normal desktop application or as a set of unobtrusive overlays.
Prism can also live in the system tray, start automatically with your computer, and launch either normally or out of the way when you want it running all the time.
## Rolling Capture
Prism can continuously keep the last **5, 10, 30, or 60 seconds** of audio in memory.
When you hear something you want to keep, drag the buffered audio out of Prism as a WAV file. There is no need to start recording beforehand.
## DAW Plugins
Every Prism scope is also available as a DAW plugin.
Drop a **Spectrum**, **Oscilloscope**, **Vectorscope**, **Spectrogram**, **VU Meter**, **Loudness Meter**, or **Waveform** onto a track and analyze it using the same interface and analysis engine as the desktop app.
* **VST3** on Windows, macOS, and Linux
* **AU** on macOS
* Settings are stored with your project
* Plugins follow your Prism themes and profiles
* Audio passes through untouched
Tested in Ableton Live, FL Studio, and Reaper.
The plugins install alongside Prism, so there is no separate download.
## Terminal UI
Prism also includes `prism-tui`, a native terminal interface built on the same capture and analysis code.
![Prism TUI](assets/prism-tui-demo-readme.gif)
It provides real-time spectrum, metering, loudness, and other monitoring tools without needing to run the desktop interface.
`prism-tui` is lightweight, typically using around 35 MB of RAM.
```bash
prism-tui
prism-tui --list-outputs
prism-tui --output <id>
prism-tui --profile <name>
prism-tui --theme <name>
prism-tui --help
```
Profiles and `.iro` themes can be used from the TUI as well, and output devices can be changed without leaving it.
`prism-tui --help` lists the available controls and launch options.
## Profiles
Save the whole rack as a profile. What's visible, how it's laid out, per-scope settings, popout window positions as a `.prsm` file. Keep separate profiles for different workflows and share them with others.
A profile stores your Prism workspace: which scopes are visible, how they are laid out, their individual settings, and window state.
Keep different profiles for different setups or workflows and switch between them when needed.
Profiles are stored as `.prsm` files and can be shared with other Prism users.
## Themes
The whole interface is themeable through editable `.iro` files. Themes control everything, build your own in an editor and drop it into the `Prism Themes` folder, or download one from [the community](https://discord.gg/hsKK8Kr9Nj).
Prism's interface is themeable through editable `.iro` files.
![Prism themes](assets/themes.png)
Themes control the colors used throughout the application and its scopes. You can edit your own, drop them into the `Prism Themes` folder, or download themes made by the community.
![Prism themes](assets/prism-themes-readme-10fps.gif)
Prism also includes Chroma key friendly themes by default, so you can key out your scopes in OBS and drop them directly into your stream as transparent overlays.
Prism also includes chroma-key-friendly themes for using scopes as overlays in OBS or other video software.
![Prism themes](assets/chroma.png)
![Prism chroma themes](assets/chroma.png)
## Download
Prebuilt binaries for Windows, macOS, and Linux are available on the [Releases](https://github.com/Boof2015/prism/releases) page.
Prebuilt versions of Prism for Windows, macOS, and Linux are available on the [Releases](https://github.com/Boof2015/prism/releases) page.
Installable packages include the desktop application and the components supported by that package, including DAW plugins and `prism-tui` where applicable.
On Windows, the NSIS installer adds `prism-tui` to the machine `PATH`. Open a new terminal after installation so it inherits the updated environment. The portable Windows package does not modify `PATH`.
## Building from Source
**Prerequisites:** Node.js 18+, npm, and a C++ compiler toolchain.
**Prerequisites:** Node.js 18+, npm, CMake 3.22+, and a C++ compiler toolchain.
| Platform | Toolchain |
|----------|-----------|
| macOS | Xcode Command Line Tools |
| Windows | Visual Studio Build Tools |
| Linux | `build-essential`, `python3`, `libasound2-dev`, `libpulse-dev` |
| Platform | Toolchain |
| -------- | ------------------------------------------------------------------------------------------------------- |
| macOS | Xcode Command Line Tools |
| Windows | Visual Studio Build Tools |
| Linux | `build-essential`, `python3`, `libasound2-dev`, `libpulse-dev`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` |
```bash
git clone https://github.com/Boof2015/prism.git
@@ -72,32 +277,40 @@ The `postinstall` script compiles the native C++ module for your platform.
```bash
npm run dev # Development
npm run build # Build application assets
npm run configure:tui # Configure the standalone CMake project
npm run build:tui # Build prism-tui
npm run test:tui # Build and run native TUI tests
npm run test:lufsmeter-native # Run generated BS.1770/EBU true-peak vectors
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:mac # macOS
npm run dist:win # Windows
npm run dist:linux # Linux
```
If you have downloaded and extracted the official [EBU Loudness Test Set](https://tech.ebu.ch/publications/ebu_loudness_test_set) for internal testing, run cases 15–23 without copying the WAVs into the repository. The files are not downloaded or redistributed by Prism, in accordance with the [EBU test-sequence usage terms](https://tech.ebu.ch/files/live/sites/tech/files/shared/testmaterial/use%20of%20EBU%20AUDIO%20test%20sequences.pdf).
```bash
npm run test:lufsmeter-ebu -- /path/to/extracted-ebu-loudness-test-set
```
The TUI build downloads the pinned FTXUI source through CMake. Linux also requires the PulseAudio development package.
The DAW plugins build with CMake from the [`plugin/`](plugin/) directory. See [`plugin/README.md`](plugin/README.md) for per-platform build and installation details.
## 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.
Prism can optionally connect to [Astra](https://github.com/Boof2015/astra) through its local API to show currently playing music, cover art, track information, and playback controls alongside your scopes.
Prism does not require Astra and works as a standalone application.
## Support
If you find Prism useful and want to support a broke college student, consider supporting development:
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/boof2015)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-ff5e5b?logo=ko-fi\&logoColor=white)](https://ko-fi.com/boof2015)
## License
This project is licensed under the [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html). See [LICENSE](LICENSE) for the full text.
Prism is licensed under the [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html). See [LICENSE](LICENSE) for the full text.
## Star History
<a href="https://www.star-history.com/#Boof2015/prism&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Boof2015/prism&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Boof2015/prism&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Boof2015/prism&type=date&legend=top-left" />
</picture>
</a>
Third-party license notices are recorded in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
+28
View File
@@ -0,0 +1,28 @@
# Third-Party Notices
## FTXUI
Prism TUI statically links [FTXUI](https://github.com/ArthurSonzogni/FTXUI),
version 7.0.1.
The MIT License
Copyright (c) 2019 Arthur Sonzogni.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

+10
View File
@@ -0,0 +1,10 @@
const packageJson = require('./package.json')
const excludedResources = new Set(['plugins/', 'tui/'])
module.exports = {
...packageJson.build,
extraResources: packageJson.build.extraResources.filter(
(resource) => !excludedResources.has(resource.to),
),
}
+6 -7
View File
@@ -7,6 +7,7 @@
"cflags_cc": ["-std=c++17", "-O3", "-ffast-math"],
"sources": [
"src/main.cpp",
"src/system_audio_capture_napi.cpp",
"src/oscilloscope.cpp",
"src/spectrum.cpp",
"src/spectrogram.cpp",
@@ -15,6 +16,7 @@
"src/waveform.cpp",
"src/vumeter.cpp",
"src/lufsmeter.cpp",
"src/window_chrome.cpp",
"src/dsp_utils.cpp"
],
"include_dirs": [
@@ -26,8 +28,7 @@
["OS=='mac'", {
"sources": [
"src/macos_capture.mm",
"src/windows_capture_stub.cpp",
"src/linux_capture_stub.cpp"
"src/windows_capture_stub.cpp"
],
"xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
@@ -43,9 +44,7 @@
}],
["OS=='win'", {
"sources": [
"src/macos_capture_stub.cpp",
"src/windows_capture.cpp",
"src/linux_capture_stub.cpp"
"src/windows_capture.cpp"
],
"defines": [
"WIN32_LEAN_AND_MEAN",
@@ -55,7 +54,8 @@
"ole32.lib",
"avrt.lib",
"runtimeobject.lib",
"uuid.lib"
"uuid.lib",
"dwmapi.lib"
],
"msvs_settings": {
"VCCLCompilerTool": {
@@ -66,7 +66,6 @@
}],
["OS=='linux'", {
"sources": [
"src/macos_capture_stub.cpp",
"src/windows_capture_stub.cpp",
"src/linux_capture.cpp"
],
+77 -149
View File
@@ -1,4 +1,4 @@
#include "linux_capture.h"
#include "system_audio_capture.h"
#if defined(__linux__)
@@ -406,150 +406,111 @@ private:
bool started_ = false;
};
class LinuxNativeCaptureEngine {
class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
public:
Napi::Value GetSupport(const Napi::CallbackInfo& info) const {
Napi::Object support = Napi::Object::New(info.Env());
~LinuxNativeCaptureEngine() override {
stop();
}
Prism::Capture::Support getSupport() const override {
PulseContextConnection connection;
std::string errorMessage;
std::vector<OutputDeviceInfo> devices;
const bool available =
connection.connect("Prism Linux Capture Probe", &errorMessage) &&
connection.enumerateOutputDevices(&devices, &errorMessage);
support.Set("available", Napi::Boolean::New(info.Env(), available));
if (available) {
support.Set("reason", info.Env().Null());
} else {
const std::string reason = errorMessage.empty()
return {
available,
available ? std::string() : (errorMessage.empty()
? "Native Linux capture is unavailable."
: errorMessage;
support.Set("reason", Napi::String::New(info.Env(), reason));
}
return support;
: errorMessage),
};
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) const {
Napi::Env env = info.Env();
Napi::Array devicesArray = Napi::Array::New(env);
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
PulseContextConnection connection;
std::string errorMessage;
std::vector<OutputDeviceInfo> devices;
if (!connection.connect("Prism Linux Capture Devices", &errorMessage) ||
!connection.enumerateOutputDevices(&devices, &errorMessage)) {
return devicesArray;
return {};
}
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set(
"sampleRate",
Napi::Number::New(env, static_cast<double>(device.sampleSpec.rate)));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.sampleSpec.channels)));
devicesArray.Set(static_cast<uint32_t>(index), entry);
std::vector<Prism::Capture::OutputDevice> result;
result.reserve(devices.size());
for (const auto& device : devices) {
result.push_back({
device.id,
device.label,
static_cast<double>(device.sampleSpec.rate),
static_cast<uint32_t>(device.sampleSpec.channels),
device.isDefault,
});
}
return devicesArray;
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string requestedDeviceId;
if (info.Length() > 0 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
std::string errorMessage;
if (!startInternal(requestedDeviceId, &errorMessage)) {
const std::string reason = errorMessage.empty()
? "Native Linux monitor capture failed to start."
: errorMessage;
Napi::Error::New(env, reason)
.ThrowAsJavaScriptException();
return env.Null();
}
std::lock_guard<std::mutex> lock(stateMutex_);
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set("channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceId_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
stopInternal();
return info.Env().Undefined();
bool start(const std::string& requestedDeviceId,
Prism::Capture::StartResult* result,
std::string* errorMessage) override {
if (!startInternal(requestedDeviceId, errorMessage)) {
if (errorMessage != nullptr && errorMessage->empty()) {
*errorMessage = "Native Linux monitor capture failed to start.";
}
return false;
}
if (result != nullptr) {
std::lock_guard<std::mutex> lock(stateMutex_);
result->sampleRate = sampleRate_;
result->channelCount = channelCount_;
result->deviceId = activeDeviceId_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const size_t maxChunks = info.Length() > 0 && info[0].IsNumber()
? std::max<size_t>(1, info[0].As<Napi::Number>().Uint32Value())
: kDefaultDrainChunkLimit;
std::deque<CapturedChunk> drainedChunks;
size_t overwriteCount = 0;
size_t queueDepth = 0;
void stop() override {
stopInternal();
}
Prism::Capture::DrainResult drain(size_t maxChunks) override {
const size_t drainLimit = maxChunks == 0
? kDefaultDrainChunkLimit
: std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
Prism::Capture::DrainResult result;
{
std::lock_guard<std::mutex> lock(chunkMutex_);
const size_t chunkCount = std::min(maxChunks, chunkQueue_.size());
for (size_t index = 0; index < chunkCount; ++index) {
drainedChunks.push_back(std::move(chunkQueue_.front()));
const size_t count = std::min(drainLimit, chunkQueue_.size());
for (size_t index = 0; index < count; ++index) {
drained.push_back(std::move(chunkQueue_.front()));
chunkQueue_.pop_front();
}
overwriteCount = overwriteCount_;
result.overwriteCount = overwriteCount_;
overwriteCount_ = 0;
queueDepth = chunkQueue_.size();
result.queueDepth = chunkQueue_.size();
}
Napi::Array chunks = Napi::Array::New(env, drainedChunks.size());
for (size_t index = 0; index < drainedChunks.size(); ++index) {
const auto& chunk = drainedChunks[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
result.chunks.reserve(drained.size());
while (!drained.empty()) {
auto chunk = std::move(drained.front());
drained.pop_front();
result.chunks.push_back({
std::move(chunk.left),
std::move(chunk.right),
chunk.channelCount,
chunk.capturedAtMilliseconds,
chunk.sequence,
});
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount", Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(queueDepth)));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) const {
return Napi::Number::New(info.Env(), monotonicMilliseconds());
double nowMilliseconds() const override {
return monotonicMilliseconds();
}
const char* backendName() const override {
return "PulseAudio";
}
private:
@@ -885,47 +846,14 @@ private:
size_t overwriteCount_ = 0;
};
LinuxNativeCaptureEngine& GetLinuxNativeCaptureEngine() {
static LinuxNativeCaptureEngine engine;
return engine;
}
Napi::Value LinuxGetSupport(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().GetSupport(info);
}
Napi::Value LinuxListOutputDevices(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().ListOutputDevices(info);
}
Napi::Value LinuxStart(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().Start(info);
}
Napi::Value LinuxStop(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().Stop(info);
}
Napi::Value LinuxDrain(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().Drain(info);
}
Napi::Value LinuxNowMilliseconds(const Napi::CallbackInfo& info) {
return GetLinuxNativeCaptureEngine().NowMilliseconds(info);
}
} // namespace
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, LinuxGetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, LinuxListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, LinuxStart));
captureExports.Set("stop", Napi::Function::New(env, LinuxStop));
captureExports.Set("drain", Napi::Function::New(env, LinuxDrain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, LinuxNowMilliseconds));
exports.Set("linuxCapture", captureExports);
namespace Prism::Capture {
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
return std::make_unique<LinuxNativeCaptureEngine>();
}
} // namespace Prism::Capture
#endif
-5
View File
@@ -1,5 +0,0 @@
#pragma once
#include <napi.h>
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports);
-54
View File
@@ -1,54 +0,0 @@
#include "linux_capture.h"
namespace {
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(
info.Env(), "Native Linux output-device capture is unavailable on this platform."));
return support;
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "Native Linux output-device capture is unavailable on this platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
} // namespace
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, Start));
captureExports.Set("stop", Napi::Function::New(env, Stop));
captureExports.Set("drain", Napi::Function::New(env, Drain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set("linuxCapture", captureExports);
}
+142 -28
View File
@@ -31,10 +31,37 @@ constexpr double RLB_FILTER_F0_HZ = 38.13547087613982;
constexpr double RLB_FILTER_Q = 0.5003270373223665;
constexpr double VU_INTEGRATION_WINDOW_MS = 300.0;
constexpr double VU_PEAK_HOLD_MS = 750.0;
constexpr double VU_PEAK_DECAY_DB_PER_SECOND = 18.0;
constexpr double TRUE_PEAK_HOLD_MS = 750.0;
constexpr double TRUE_PEAK_DECAY_DB_PER_SECOND = 18.0;
constexpr double BAR_ATTACK_MS = 5.0;
constexpr double BAR_RELEASE_MS = 180.0;
constexpr double TRUE_PEAK_MIN_OUTPUT_RATE_HZ = 320000.0;
constexpr size_t TRUE_PEAK_TAPS_PER_PHASE = 48;
constexpr size_t TRUE_PEAK_MAX_OVERSAMPLE_FACTOR = 64;
constexpr double TRUE_PEAK_KAISER_BETA = 8.6;
double besselI0(double value) {
const double absoluteValue = std::abs(value);
if (absoluteValue < 3.75) {
const double ratio = value / 3.75;
const double squared = ratio * ratio;
return 1.0 + squared * (3.5156229 + squared * (3.0899424 + squared * (1.2067492
+ squared * (0.2659732 + squared * (0.0360768 + squared * 0.0045813)))));
}
const double ratio = 3.75 / absoluteValue;
return (std::exp(absoluteValue) / std::sqrt(absoluteValue))
* (0.39894228 + ratio * (0.01328592 + ratio * (0.00225319 + ratio * (-0.00157565
+ ratio * (0.00916281 + ratio * (-0.02057706 + ratio * (0.02635537
+ ratio * (-0.01647633 + ratio * 0.00392377))))))));
}
double normalizedSinc(double value) {
if (std::abs(value) < 1e-12) {
return 1.0;
}
return std::sin(PI * value) / (PI * value);
}
LUFSMeterSnapshot makeInitialSnapshot() {
return {
@@ -47,6 +74,7 @@ LUFSMeterSnapshot makeInitialSnapshot() {
static_cast<float>(VU_METER_MIN_DB),
static_cast<float>(VU_METER_MIN_DB),
static_cast<float>(VU_METER_MIN_DB),
static_cast<float>(VU_METER_MIN_DB),
0.0f,
};
}
@@ -82,6 +110,7 @@ void LUFSMeterAnalyzer::configureForSampleRate(float sampleRate) {
integratedHistogramPowerSums_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0.0);
configureFastMeter();
configureTruePeakDetector();
reset();
}
@@ -102,6 +131,44 @@ void LUFSMeterAnalyzer::configureFastMeter() {
barReleaseCoeff_ = std::exp(-1.0 / (static_cast<double>(sampleRate_) * (BAR_RELEASE_MS / 1000.0)));
}
void LUFSMeterAnalyzer::configureTruePeakDetector() {
truePeakOversampleFactor_ = 1;
while (static_cast<double>(sampleRate_) * truePeakOversampleFactor_ < TRUE_PEAK_MIN_OUTPUT_RATE_HZ
&& truePeakOversampleFactor_ < TRUE_PEAK_MAX_OVERSAMPLE_FACTOR) {
truePeakOversampleFactor_ *= 2;
}
truePeakCoefficients_.assign(truePeakOversampleFactor_ * TRUE_PEAK_TAPS_PER_PHASE, 0.0);
truePeakHistoryL_.assign(TRUE_PEAK_TAPS_PER_PHASE * 2, 0.0);
truePeakHistoryR_.assign(TRUE_PEAK_TAPS_PER_PHASE * 2, 0.0);
const double windowRadius = static_cast<double>(TRUE_PEAK_TAPS_PER_PHASE) / 2.0;
const double inverseI0Beta = 1.0 / besselI0(TRUE_PEAK_KAISER_BETA);
// Each phase is a causal fractional-delay reconstruction. The 24-sample
// delay keeps the 48 source taps centered without requiring future input.
for (size_t phase = 0; phase < truePeakOversampleFactor_; phase += 1) {
const double fraction = static_cast<double>(phase) / truePeakOversampleFactor_;
double coefficientSum = 0.0;
for (size_t tap = 0; tap < TRUE_PEAK_TAPS_PER_PHASE; tap += 1) {
const double distance = static_cast<double>(tap) - windowRadius + fraction;
const double normalizedDistance = distance / windowRadius;
const double windowArgument = std::sqrt(std::max(0.0, 1.0 - normalizedDistance * normalizedDistance));
const double window = std::abs(normalizedDistance) <= 1.0
? besselI0(TRUE_PEAK_KAISER_BETA * windowArgument) * inverseI0Beta
: 0.0;
const double coefficient = normalizedSinc(distance) * window;
truePeakCoefficients_[phase * TRUE_PEAK_TAPS_PER_PHASE + tap] = coefficient;
coefficientSum += coefficient;
}
if (std::abs(coefficientSum) > 1e-12) {
for (size_t tap = 0; tap < TRUE_PEAK_TAPS_PER_PHASE; tap += 1) {
truePeakCoefficients_[phase * TRUE_PEAK_TAPS_PER_PHASE + tap] /= coefficientSum;
}
}
}
}
void LUFSMeterAnalyzer::reset() {
std::fill(ringBufferL_.begin(), ringBufferL_.end(), 0.0);
std::fill(ringBufferR_.begin(), ringBufferR_.end(), 0.0);
@@ -131,6 +198,10 @@ void LUFSMeterAnalyzer::reset() {
lastPeakUpdateMs_ = 0.0;
hasLastPeakUpdate_ = false;
std::fill(truePeakHistoryL_.begin(), truePeakHistoryL_.end(), 0.0);
std::fill(truePeakHistoryR_.begin(), truePeakHistoryR_.end(), 0.0);
truePeakHistoryWriteIndex_ = 0;
snapshot_ = makeInitialSnapshot();
}
@@ -140,27 +211,35 @@ void LUFSMeterAnalyzer::pushSamples(const float* leftChannel, const float* right
}
const double nowMs = currentTimeMs();
advancePeaks(nowMs);
advanceTruePeaks(nowMs);
double maxPeakL = 0.0;
double maxPeakR = 0.0;
double maxTruePeakL = 0.0;
double maxTruePeakR = 0.0;
for (size_t index = 0; index < length; index += 1) {
const float left = leftChannel[index];
const float right = rightChannel[index];
processLoudnessSample(left, right);
processFastMeterSample(left, right, maxPeakL, maxPeakR);
processFastMeterSample(left, right);
processTruePeakSample(left, right, maxTruePeakL, maxTruePeakR);
}
maybeUpdatePeak(amplitudeToDb(maxPeakL), nowMs, true);
maybeUpdatePeak(amplitudeToDb(maxPeakR), nowMs, false);
const double truePeakLDb = truePeakAmplitudeToDb(maxTruePeakL);
const double truePeakRDb = truePeakAmplitudeToDb(maxTruePeakR);
maybeUpdateTruePeak(truePeakLDb, nowMs, true);
maybeUpdateTruePeak(truePeakRDb, nowMs, false);
snapshot_.maxTruePeakDb = static_cast<float>(std::max({
static_cast<double>(snapshot_.maxTruePeakDb),
truePeakLDb,
truePeakRDb,
}));
recomputeFastSnapshot();
updateMomentaryShortTermLoudness();
snapshot_.integratedLUFS = static_cast<float>(computeGatedIntegratedLoudness());
}
LUFSMeterSnapshot LUFSMeterAnalyzer::getSnapshot() {
advancePeaks(currentTimeMs());
advanceTruePeaks(currentTimeMs());
recomputeFastSnapshot();
return snapshot_;
}
@@ -303,7 +382,7 @@ double LUFSMeterAnalyzer::applyBiquad(const BiquadCoeffs& coeffs, BiquadState& s
return output;
}
void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR) {
void LUFSMeterAnalyzer::processFastMeterSample(float left, float right) {
if (fastSqL_.empty()) {
return;
}
@@ -334,16 +413,44 @@ void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double&
const double coeffR = absR > barEnvelopeR_ ? barAttackCoeff_ : barReleaseCoeff_;
barEnvelopeL_ = coeffL * barEnvelopeL_ + (1.0 - coeffL) * absL;
barEnvelopeR_ = coeffR * barEnvelopeR_ + (1.0 - coeffR) * absR;
if (absL > maxPeakL) {
maxPeakL = absL;
}
if (absR > maxPeakR) {
maxPeakR = absR;
}
}
void LUFSMeterAnalyzer::advancePeaks(double nowMs) {
void LUFSMeterAnalyzer::processTruePeakSample(
float left,
float right,
double& maxTruePeakL,
double& maxTruePeakR
) {
if (truePeakHistoryL_.empty() || truePeakCoefficients_.empty()) {
return;
}
const double leftSample = static_cast<double>(left);
const double rightSample = static_cast<double>(right);
truePeakHistoryL_[truePeakHistoryWriteIndex_] = leftSample;
truePeakHistoryL_[truePeakHistoryWriteIndex_ + TRUE_PEAK_TAPS_PER_PHASE] = leftSample;
truePeakHistoryR_[truePeakHistoryWriteIndex_] = rightSample;
truePeakHistoryR_[truePeakHistoryWriteIndex_ + TRUE_PEAK_TAPS_PER_PHASE] = rightSample;
for (size_t phase = 0; phase < truePeakOversampleFactor_; phase += 1) {
const size_t coefficientOffset = phase * TRUE_PEAK_TAPS_PER_PHASE;
double reconstructedL = 0.0;
double reconstructedR = 0.0;
const size_t newestHistoryIndex = truePeakHistoryWriteIndex_ + TRUE_PEAK_TAPS_PER_PHASE;
for (size_t tap = 0; tap < TRUE_PEAK_TAPS_PER_PHASE; tap += 1) {
const size_t historyIndex = newestHistoryIndex - tap;
const double coefficient = truePeakCoefficients_[coefficientOffset + tap];
reconstructedL += coefficient * truePeakHistoryL_[historyIndex];
reconstructedR += coefficient * truePeakHistoryR_[historyIndex];
}
maxTruePeakL = std::max(maxTruePeakL, std::abs(reconstructedL));
maxTruePeakR = std::max(maxTruePeakR, std::abs(reconstructedR));
}
truePeakHistoryWriteIndex_ = (truePeakHistoryWriteIndex_ + 1) % TRUE_PEAK_TAPS_PER_PHASE;
}
void LUFSMeterAnalyzer::advanceTruePeaks(double nowMs) {
if (!std::isfinite(nowMs)) {
return;
}
@@ -358,23 +465,23 @@ void LUFSMeterAnalyzer::advancePeaks(double nowMs) {
return;
}
snapshot_.peakLDb = static_cast<float>(applyPeakDecay(snapshot_.peakLDb, peakHoldUntilL_, nowMs));
snapshot_.peakRDb = static_cast<float>(applyPeakDecay(snapshot_.peakRDb, peakHoldUntilR_, nowMs));
snapshot_.truePeakLDb = static_cast<float>(applyPeakDecay(snapshot_.truePeakLDb, peakHoldUntilL_, nowMs));
snapshot_.truePeakRDb = static_cast<float>(applyPeakDecay(snapshot_.truePeakRDb, peakHoldUntilR_, nowMs));
lastPeakUpdateMs_ = nowMs;
}
void LUFSMeterAnalyzer::maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel) {
void LUFSMeterAnalyzer::maybeUpdateTruePeak(double peakDb, double nowMs, bool leftChannel) {
if (leftChannel) {
if (peakDb > snapshot_.peakLDb) {
snapshot_.peakLDb = static_cast<float>(peakDb);
peakHoldUntilL_ = nowMs + VU_PEAK_HOLD_MS;
if (peakDb > snapshot_.truePeakLDb) {
snapshot_.truePeakLDb = static_cast<float>(peakDb);
peakHoldUntilL_ = nowMs + TRUE_PEAK_HOLD_MS;
}
return;
}
if (peakDb > snapshot_.peakRDb) {
snapshot_.peakRDb = static_cast<float>(peakDb);
peakHoldUntilR_ = nowMs + VU_PEAK_HOLD_MS;
if (peakDb > snapshot_.truePeakRDb) {
snapshot_.truePeakRDb = static_cast<float>(peakDb);
peakHoldUntilR_ = nowMs + TRUE_PEAK_HOLD_MS;
}
}
@@ -384,7 +491,7 @@ double LUFSMeterAnalyzer::applyPeakDecay(double currentDb, double holdUntilMs, d
return currentDb;
}
const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * VU_PEAK_DECAY_DB_PER_SECOND;
const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * TRUE_PEAK_DECAY_DB_PER_SECOND;
return std::max(VU_METER_MIN_DB, currentDb - decayAmount);
}
@@ -424,6 +531,13 @@ double LUFSMeterAnalyzer::amplitudeToDb(double amplitude) {
return clampDb(20.0 * std::log10(std::max(amplitude, 1e-10)), VU_METER_MIN_DB, VU_METER_MAX_DB);
}
double LUFSMeterAnalyzer::truePeakAmplitudeToDb(double amplitude) {
if (!std::isfinite(amplitude) || amplitude <= 0.0) {
return VU_METER_MIN_DB;
}
return std::max(VU_METER_MIN_DB, 20.0 * std::log10(std::max(amplitude, 1e-10)));
}
double LUFSMeterAnalyzer::clampDb(double db, double minDb, double maxDb) {
return std::max(minDb, std::min(maxDb, db));
}
+15 -5
View File
@@ -14,8 +14,9 @@ struct LUFSMeterSnapshot {
float vuRDb;
float barLDb;
float barRDb;
float peakLDb;
float peakRDb;
float truePeakLDb;
float truePeakRDb;
float maxTruePeakDb;
float correlation;
};
@@ -47,6 +48,7 @@ private:
void configureForSampleRate(float sampleRate);
void configureKWeighting();
void configureFastMeter();
void configureTruePeakDetector();
void processLoudnessSample(float left, float right);
void updateMomentaryShortTermLoudness();
double computeGatedIntegratedLoudness() const;
@@ -54,13 +56,15 @@ private:
double histogramLufsAtIndex(size_t index) const;
double applyBiquad(const BiquadCoeffs& coeffs, BiquadState& state, double input);
void processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR);
void advancePeaks(double nowMs);
void maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel);
void processFastMeterSample(float left, float right);
void processTruePeakSample(float left, float right, double& maxTruePeakL, double& maxTruePeakR);
void advanceTruePeaks(double nowMs);
void maybeUpdateTruePeak(double peakDb, double nowMs, bool leftChannel);
double applyPeakDecay(double currentDb, double holdUntilMs, double nowMs) const;
void recomputeFastSnapshot();
static double currentTimeMs();
static double amplitudeToDb(double amplitude);
static double truePeakAmplitudeToDb(double amplitude);
static double clampDb(double db, double minDb, double maxDb);
static BiquadCoeffs preFilterCoeffs(double sampleRate);
static BiquadCoeffs rlbFilterCoeffs(double sampleRate);
@@ -99,6 +103,12 @@ private:
double lastPeakUpdateMs_ = 0.0;
bool hasLastPeakUpdate_ = false;
size_t truePeakOversampleFactor_ = 1;
std::vector<double> truePeakCoefficients_;
std::vector<double> truePeakHistoryL_;
std::vector<double> truePeakHistoryR_;
size_t truePeakHistoryWriteIndex_ = 0;
LUFSMeterSnapshot snapshot_{};
};
-5
View File
@@ -1,5 +0,0 @@
#pragma once
#include <napi.h>
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports);
+68 -150
View File
@@ -1,4 +1,4 @@
#include "macos_capture.h"
#include "system_audio_capture.h"
#if defined(__APPLE__)
@@ -319,140 +319,104 @@ std::string formatStatusMessage(const char* operation, OSStatus status) {
return std::string(operation) + " failed (" + std::to_string(static_cast<int>(status)) + ")";
}
class MacOSNativeCaptureEngine {
class MacOSNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
public:
Napi::Object GetSupport(Napi::Env env) {
Napi::Object support = Napi::Object::New(env);
if (@available(macOS 14.2, *)) {
support.Set("available", Napi::Boolean::New(env, true));
support.Set("reason", env.Null());
return support;
}
support.Set("available", Napi::Boolean::New(env, false));
support.Set(
"reason",
Napi::String::New(env, "Native output-device capture requires macOS 14.2 or newer."));
return support;
~MacOSNativeCaptureEngine() override {
stop();
}
Napi::Array ListOutputDevices(Napi::Env env) {
Napi::Array result = Napi::Array::New(env);
Prism::Capture::Support getSupport() const override {
if (@available(macOS 14.2, *)) {
return {true, {}};
}
return {false, "Native output-device capture requires macOS 14.2 or newer."};
}
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
std::vector<Prism::Capture::OutputDevice> result;
if (!isSupported()) {
return result;
}
const auto devices = enumerateOutputDevices();
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.uid));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.channelCount)));
result.Set(static_cast<uint32_t>(index), entry);
result.reserve(devices.size());
for (const auto& device : devices) {
result.push_back({
device.uid,
device.label,
device.sampleRate,
static_cast<uint32_t>(device.channelCount),
device.isDefault,
});
}
return result;
}
Napi::Object Start(Napi::Env env, const std::string& requestedDeviceUid) {
Napi::Object result = Napi::Object::New(env);
const Napi::Object support = GetSupport(env);
if (!support.Get("available").As<Napi::Boolean>().Value()) {
Napi::Error::New(
env, support.Get("reason").As<Napi::String>().Utf8Value())
.ThrowAsJavaScriptException();
return result;
bool start(const std::string& requestedDeviceId,
Prism::Capture::StartResult* result,
std::string* errorMessage) override {
const auto support = getSupport();
if (!support.available) {
if (errorMessage != nullptr) {
*errorMessage = support.reason;
}
return false;
}
std::string errorMessage;
if (!startInternal(requestedDeviceUid, &errorMessage)) {
Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
return result;
if (!startInternal(requestedDeviceId, errorMessage)) {
return false;
}
std::lock_guard<std::mutex> lock(stateMutex_);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set("channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceUid_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
if (result != nullptr) {
std::lock_guard<std::mutex> lock(stateMutex_);
result->sampleRate = sampleRate_;
result->channelCount = static_cast<uint32_t>(channelCount_);
result->deviceId = activeDeviceUid_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
}
void Stop() {
void stop() override {
std::lock_guard<std::mutex> lock(stateMutex_);
stopLocked();
}
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
Prism::Capture::DrainResult drain(size_t maxChunks) override {
const size_t drainLimit =
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
uint64_t overwriteCount = 0;
Prism::Capture::DrainResult result;
{
std::lock_guard<std::mutex> queueLock(chunkMutex_);
overwriteCount = overwriteCount_;
result.overwriteCount = overwriteCount_;
const size_t count = std::min(drainLimit, chunkQueue_.size());
for (size_t index = 0; index < count; ++index) {
drained.push_back(std::move(chunkQueue_.front()));
chunkQueue_.pop_front();
}
result.queueDepth = chunkQueue_.size();
}
Napi::Array chunks = Napi::Array::New(env, drained.size());
for (size_t index = 0; index < drained.size(); ++index) {
CapturedChunk& chunk = drained[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left =
Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right =
Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(
left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(
right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
result.chunks.reserve(drained.size());
while (!drained.empty()) {
auto chunk = std::move(drained.front());
drained.pop_front();
result.chunks.push_back({
std::move(chunk.left),
std::move(chunk.right),
static_cast<uint32_t>(chunk.channelCount),
chunk.capturedAtMilliseconds,
chunk.sequence,
});
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount",
Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set(
"queueDepth",
Napi::Number::New(env, static_cast<double>(chunkQueue_.size())));
return result;
}
double NowMilliseconds() const {
double nowMilliseconds() const override {
return monotonicMilliseconds();
}
const char* backendName() const override {
return "CoreAudio";
}
private:
static OSStatus StaticIOProc(AudioObjectID inDevice,
const AudioTimeStamp* inNow,
@@ -780,60 +744,14 @@ private:
UInt32 channelCount_ = 2;
};
MacOSNativeCaptureEngine& engine() {
static MacOSNativeCaptureEngine instance;
return instance;
}
Napi::Value MacOSGetSupport(const Napi::CallbackInfo& info) {
return engine().GetSupport(info.Env());
}
Napi::Value MacOSListOutputDevices(const Napi::CallbackInfo& info) {
return engine().ListOutputDevices(info.Env());
}
Napi::Value MacOSStart(const Napi::CallbackInfo& info) {
std::string requestedDeviceUid;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceUid = info[0].As<Napi::String>().Utf8Value();
}
return engine().Start(info.Env(), requestedDeviceUid);
}
Napi::Value MacOSStop(const Napi::CallbackInfo& info) {
engine().Stop();
return info.Env().Undefined();
}
Napi::Value MacOSDrain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = static_cast<size_t>(requested);
}
}
return engine().Drain(info.Env(), maxChunks);
}
Napi::Value MacOSNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
}
} // namespace
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, MacOSGetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, MacOSListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, MacOSStart));
captureExports.Set("stop", Napi::Function::New(env, MacOSStop));
captureExports.Set("drain", Napi::Function::New(env, MacOSDrain));
captureExports.Set(
"nowMilliseconds", Napi::Function::New(env, MacOSNowMilliseconds));
exports.Set("macosCapture", captureExports);
namespace Prism::Capture {
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
return std::make_unique<MacOSNativeCaptureEngine>();
}
} // namespace Prism::Capture
#endif // defined(__APPLE__)
-54
View File
@@ -1,54 +0,0 @@
#include "macos_capture.h"
namespace {
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(
info.Env(), "Native macOS output-device capture is unavailable on this platform."));
return support;
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "Native macOS output-device capture is unavailable on this platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
} // namespace
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, Start));
captureExports.Set("stop", Napi::Function::New(env, Stop));
captureExports.Set("drain", Napi::Function::New(env, Drain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set("macosCapture", captureExports);
}
+68 -20
View File
@@ -2,8 +2,7 @@
#include <algorithm>
#include <cstring>
#include <string>
#include "linux_capture.h"
#include "macos_capture.h"
#include "system_audio_capture_napi.h"
#include "windows_capture.h"
#include "oscilloscope.h"
#include "spectrum.h"
@@ -12,6 +11,7 @@
#include "waveform.h"
#include "vumeter.h"
#include "lufsmeter.h"
#include "window_chrome.h"
// Global instances
static Visualizer::Oscilloscope oscilloscope;
@@ -211,6 +211,14 @@ Napi::Value SpectrumGetSideMagnitudes(const Napi::CallbackInfo& info) {
return result;
}
Napi::Value SpectrumGetChannelMaxMagnitudes(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const auto& magnitudes = spectrum.getChannelMaxMagnitudes();
Napi::Float32Array result = Napi::Float32Array::New(env, magnitudes.size());
memcpy(result.Data(), magnitudes.data(), magnitudes.size() * sizeof(float));
return result;
}
Napi::Value SpectrumFillRawMagnitudes(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsTypedArray()) {
@@ -259,6 +267,22 @@ Napi::Value SpectrumFillSideMagnitudes(const Napi::CallbackInfo& info) {
return Napi::Number::New(env, static_cast<double>(count));
}
Napi::Value SpectrumFillChannelMaxMagnitudes(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsTypedArray()) {
Napi::TypeError::New(env, "Expected output Float32Array").ThrowAsJavaScriptException();
return env.Null();
}
Napi::Float32Array output = info[0].As<Napi::Float32Array>();
const auto& magnitudes = spectrum.getChannelMaxMagnitudes();
const size_t count = std::min(output.ElementLength(), magnitudes.size());
if (count > 0) {
memcpy(output.Data(), magnitudes.data(), count * sizeof(float));
}
return Napi::Number::New(env, static_cast<double>(count));
}
Napi::Value SpectrumProcess(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsTypedArray()) {
@@ -303,6 +327,27 @@ std::string GetObjectString(const Napi::Object& obj, const char* key, const std:
Napi::Value value = obj.Get(key);
return value.IsString() ? value.As<Napi::String>().Utf8Value() : fallback;
}
Napi::Object SpectrogramResultToJs(
Napi::Env env,
const Visualizer::SpectrogramProcessResult& result
) {
Napi::Float32Array display = Napi::Float32Array::New(env, result.display.size());
Napi::Float32Array heat = Napi::Float32Array::New(env, result.heat.size());
if (!result.display.empty()) {
memcpy(display.Data(), result.display.data(), result.display.size() * sizeof(float));
}
if (!result.heat.empty()) {
memcpy(heat.Data(), result.heat.data(), result.heat.size() * sizeof(float));
}
Napi::Object obj = Napi::Object::New(env);
obj.Set("display", display);
obj.Set("heat", heat);
obj.Set("columnCount", Napi::Number::New(env, static_cast<double>(result.columnCount)));
obj.Set("rowCount", Napi::Number::New(env, static_cast<double>(result.rowCount)));
return obj;
}
} // namespace
Napi::Value SpectrogramConfigure(const Napi::CallbackInfo& info) {
@@ -341,22 +386,21 @@ Napi::Value SpectrogramProcess(const Napi::CallbackInfo& info) {
Napi::Float32Array audioData = info[0].As<Napi::Float32Array>();
auto result = spectrogramAnalyzer.process(audioData.Data(), audioData.ElementLength());
return SpectrogramResultToJs(env, result);
}
Napi::Float32Array display = Napi::Float32Array::New(env, result.display.size());
Napi::Float32Array heat = Napi::Float32Array::New(env, result.heat.size());
if (!result.display.empty()) {
memcpy(display.Data(), result.display.data(), result.display.size() * sizeof(float));
}
if (!result.heat.empty()) {
memcpy(heat.Data(), result.heat.data(), result.heat.size() * sizeof(float));
Napi::Value SpectrogramProcessStereo(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) {
Napi::TypeError::New(env, "Expected left and right Float32Arrays").ThrowAsJavaScriptException();
return env.Null();
}
Napi::Object obj = Napi::Object::New(env);
obj.Set("display", display);
obj.Set("heat", heat);
obj.Set("columnCount", Napi::Number::New(env, static_cast<double>(result.columnCount)));
obj.Set("rowCount", Napi::Number::New(env, static_cast<double>(result.rowCount)));
return obj;
Napi::Float32Array left = info[0].As<Napi::Float32Array>();
Napi::Float32Array right = info[1].As<Napi::Float32Array>();
const size_t length = std::min(left.ElementLength(), right.ElementLength());
auto result = spectrogramAnalyzer.processStereo(left.Data(), right.Data(), length);
return SpectrogramResultToJs(env, result);
}
Napi::Value SpectrogramReset(const Napi::CallbackInfo& info) {
@@ -646,8 +690,9 @@ Napi::Value LUFSMeterGetSnapshot(const Napi::CallbackInfo& info) {
obj.Set("vuRDb", Napi::Number::New(env, snapshot.vuRDb));
obj.Set("barLDb", Napi::Number::New(env, snapshot.barLDb));
obj.Set("barRDb", Napi::Number::New(env, snapshot.barRDb));
obj.Set("peakLDb", Napi::Number::New(env, snapshot.peakLDb));
obj.Set("peakRDb", Napi::Number::New(env, snapshot.peakRDb));
obj.Set("truePeakLDb", Napi::Number::New(env, snapshot.truePeakLDb));
obj.Set("truePeakRDb", Napi::Number::New(env, snapshot.truePeakRDb));
obj.Set("maxTruePeakDb", Napi::Number::New(env, snapshot.maxTruePeakDb));
obj.Set("correlation", Napi::Number::New(env, snapshot.correlation));
return obj;
}
@@ -685,9 +730,11 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
specExports.Set("fillRawMagnitudes", Napi::Function::New(env, SpectrumFillRawMagnitudes));
specExports.Set("fillMagnitudes", Napi::Function::New(env, SpectrumFillMagnitudes));
specExports.Set("fillSideMagnitudes", Napi::Function::New(env, SpectrumFillSideMagnitudes));
specExports.Set("fillChannelMaxMagnitudes", Napi::Function::New(env, SpectrumFillChannelMaxMagnitudes));
specExports.Set("getRawMagnitudes", Napi::Function::New(env, SpectrumGetRawMagnitudes));
specExports.Set("getMagnitudes", Napi::Function::New(env, SpectrumGetMagnitudes));
specExports.Set("getSideMagnitudes", Napi::Function::New(env, SpectrumGetSideMagnitudes));
specExports.Set("getChannelMaxMagnitudes", Napi::Function::New(env, SpectrumGetChannelMaxMagnitudes));
specExports.Set("process", Napi::Function::New(env, SpectrumProcess));
specExports.Set("binToFrequency", Napi::Function::New(env, SpectrumBinToFrequency));
specExports.Set("reset", Napi::Function::New(env, SpectrumReset));
@@ -697,6 +744,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
Napi::Object spectrogramExports = Napi::Object::New(env);
spectrogramExports.Set("configure", Napi::Function::New(env, SpectrogramConfigure));
spectrogramExports.Set("process", Napi::Function::New(env, SpectrogramProcess));
spectrogramExports.Set("processStereo", Napi::Function::New(env, SpectrogramProcessStereo));
spectrogramExports.Set("reset", Napi::Function::New(env, SpectrogramReset));
exports.Set("spectrogram", spectrogramExports);
@@ -738,9 +786,9 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
lufsExports.Set("reset", Napi::Function::New(env, LUFSMeterReset));
exports.Set("lufsmeter", lufsExports);
RegisterMacOSCapture(env, exports);
RegisterWindowsCapture(env, exports);
RegisterLinuxCapture(env, exports);
RegisterSystemAudioCapture(env, exports);
RegisterWindowsMedia(env, exports);
RegisterWindowChrome(env, exports);
return exports;
}
+7
View File
@@ -12,6 +12,7 @@ Oscilloscope::Oscilloscope()
, lastFilterPitch_(200.0f)
, lastTrigger_(0)
, smoothedPitch_(200.0f)
, latestDetectedPitch_(0.0f)
, pitchSamplesProcessed_(0) {
// Initialize circular buffers
@@ -144,6 +145,10 @@ OscilloscopeResult Oscilloscope::process() {
result.detectedPitch = smoothedPitch_;
if (!pitchLock_) {
const size_t samples = static_cast<size_t>(displaySamples_);
result.triggerIndex = static_cast<float>(
(writePos_ + OSCILLOSCOPE_BUFFER_SIZE - samples) %
OSCILLOSCOPE_BUFFER_SIZE);
return result;
}
@@ -172,6 +177,7 @@ OscilloscopeResult Oscilloscope::process() {
float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f);
if (newPitch > 0.0f) {
latestDetectedPitch_ = newPitch;
pitchSamplesProcessed_++;
// Adaptive smoothing: fast convergence initially, then conservative
@@ -307,6 +313,7 @@ void Oscilloscope::reset() {
writePos_ = 0;
lastTrigger_ = 0.0f;
smoothedPitch_ = 200.0f;
latestDetectedPitch_ = 0.0f;
lastFilterPitch_ = 200.0f;
pitchSamplesProcessed_ = 0; // Reset warmup counter for fast convergence on next use
+5
View File
@@ -36,6 +36,10 @@ public:
// Get current write position
size_t getWritePos() const { return writePos_; }
// Latest unsmoothed detector result. The stable detectedPitch returned by
// process() remains the value used for pitch-locked triggering.
float getLatestDetectedPitch() const { return latestDetectedPitch_; }
// Get samples from circular buffer (for rendering)
void getSamples(float* output, size_t startPos, size_t count) const;
@@ -75,6 +79,7 @@ private:
float lastTrigger_;
float smoothedPitch_;
float latestDetectedPitch_;
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
// Internal helpers
+298 -86
View File
@@ -9,10 +9,10 @@ namespace Visualizer {
namespace {
constexpr size_t FFT_PAD_FACTOR = 4;
constexpr float DISPLAY_GAIN_DB = 2.0f;
constexpr float HANN_EQUIVALENT_NOISE_BANDWIDTH_BINS = 1.5f;
constexpr float REASSIGNED_POWER_NORMALIZATION = static_cast<float>(FFT_PAD_FACTOR)
* HANN_EQUIVALENT_NOISE_BANDWIDTH_BINS;
constexpr float SPECTROGRAM_HEAT_GAMMA = 1.45f;
constexpr float SPECTROGRAM_DISPLAY_STROKE_WEIGHT = 0.42f;
constexpr float SPECTROGRAM_HEAT_STROKE_WEIGHT = 0.32f;
constexpr float TILT_REFERENCE_HZ = 1000.0f;
constexpr float HEAT_MIN_DB = -100.0f;
constexpr float HEAT_MAX_DB = -20.0f;
@@ -64,7 +64,8 @@ SpectrogramAnalyzer::SpectrogramAnalyzer()
: fftSize_(0)
, paddedSize_(0)
, frameFill_(0)
, haveLastPhase_(false) {
, haveLastPhase_(false)
, magnitudeScale_(1.0f) {
configureFft(config_.fftSize);
rebuildFrequencyMapping();
}
@@ -96,7 +97,10 @@ void SpectrogramAnalyzer::configure(const SpectrogramConfig& config) {
if (next.orientation != "vertical") {
next.orientation = "horizontal";
}
if (next.clarityMode != "classic" && next.clarityMode != "sharp" && next.clarityMode != "sharper") {
if (next.clarityMode != "classic"
&& next.clarityMode != "focused"
&& next.clarityMode != "sharp"
&& next.clarityMode != "sharper") {
next.clarityMode = "sharper";
}
@@ -128,13 +132,19 @@ void SpectrogramAnalyzer::configureFft(size_t fftSize) {
paddedSize_ = fftSize_ * FFT_PAD_FACTOR;
fft_ = std::make_unique<DSP::FFT>(paddedSize_);
frameBuffer_.assign(fftSize_, 0.0f);
rightFrameBuffer_.assign(fftSize_, 0.0f);
window_.assign(fftSize_, 1.0f);
windowedInput_.assign(paddedSize_, 0.0f);
rightWindowedInput_.assign(paddedSize_, 0.0f);
fftOutput_.assign(paddedSize_, std::complex<float>(0.0f, 0.0f));
rightFftOutput_.assign(paddedSize_, std::complex<float>(0.0f, 0.0f));
magnitudesDb_.assign(paddedSize_ / 2, -200.0f);
magnitudesLinear_.assign(paddedSize_ / 2, 0.0f);
phases_.assign(paddedSize_ / 2, 0.0f);
lastPhases_.assign(paddedSize_ / 2, 0.0f);
rightPhases_.assign(paddedSize_ / 2, 0.0f);
rightLastPhases_.assign(paddedSize_ / 2, 0.0f);
dominantRight_.assign(paddedSize_ / 2, 0);
frameFill_ = 0;
haveLastPhase_ = false;
@@ -145,11 +155,21 @@ void SpectrogramAnalyzer::configureFft(size_t fftSize) {
for (size_t index = 0; index < fftSize_; index += 1) {
window_[index] = 0.5f * (1.0f - std::cos((2.0f * static_cast<float>(M_PI) * index) / (fftSize_ - 1)));
}
float windowSum = 0.0f;
for (const float coefficient : window_) {
windowSum += coefficient;
}
magnitudeScale_ = windowSum > std::numeric_limits<float>::epsilon()
? 2.0f / windowSum
: 1.0f;
}
void SpectrogramAnalyzer::reset() {
std::fill(frameBuffer_.begin(), frameBuffer_.end(), 0.0f);
std::fill(rightFrameBuffer_.begin(), rightFrameBuffer_.end(), 0.0f);
std::fill(lastPhases_.begin(), lastPhases_.end(), 0.0f);
std::fill(rightLastPhases_.begin(), rightLastPhases_.end(), 0.0f);
frameFill_ = 0;
haveLastPhase_ = false;
}
@@ -166,7 +186,7 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() {
const float sampleRate = std::max(1.0f, config_.sampleRate);
const float nyquist = sampleRate * 0.5f;
const float minFrequency = std::max(1.0f, std::min(config_.minFrequency, nyquist));
const float maxFrequency = std::max(minFrequency + 1.0f, std::min(config_.maxFrequency, nyquist));
const float maxFrequency = std::max(minFrequency, std::min(config_.maxFrequency, nyquist));
config_.minFrequency = minFrequency;
config_.maxFrequency = maxFrequency;
@@ -177,8 +197,9 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() {
standardRaw_.assign(rowCount, 0.0f);
standardHeat_.assign(rowCount, 0.0f);
reassignedPower_.assign(rowCount, 0.0f);
blendedRaw_.assign(rowCount, 0.0f);
blendedHeat_.assign(rowCount, 0.0f);
focusedPower_.assign(rowCount, 0.0f);
sourceRaw_.assign(rowCount, 0.0f);
sourceHeat_.assign(rowCount, 0.0f);
shapedDisplay_.assign(rowCount, 0.0f);
shapedHeat_.assign(rowCount, 0.0f);
strokedDisplay_.assign(rowCount, 0.0f);
@@ -218,7 +239,11 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() {
float SpectrogramAnalyzer::frequencyFromScale(float normalizedPosition) const {
const float t = clamp01(normalizedPosition);
const float minFrequency = std::max(1.0f, config_.minFrequency);
const float maxFrequency = std::max(minFrequency + 1.0f, config_.maxFrequency);
const float maxFrequency = std::max(minFrequency, config_.maxFrequency);
if (maxFrequency <= minFrequency) {
return minFrequency;
}
if (config_.scaleMode == "linear") {
return minFrequency + (t * (maxFrequency - minFrequency));
@@ -236,8 +261,15 @@ float SpectrogramAnalyzer::frequencyFromScale(float normalizedPosition) const {
}
float SpectrogramAnalyzer::frequencyToRow(float frequency) const {
if (config_.rowCount <= 1) {
return 0.0f;
}
const float minFrequency = std::max(1.0f, config_.minFrequency);
const float maxFrequency = std::max(minFrequency + 1.0f, config_.maxFrequency);
const float maxFrequency = std::max(minFrequency, config_.maxFrequency);
if (maxFrequency <= minFrequency) {
return config_.orientation == "vertical" ? 0.0f : static_cast<float>(config_.rowCount - 1);
}
const float clampedFrequency = std::clamp(frequency, minFrequency, maxFrequency);
float normalized = 0.0f;
@@ -262,7 +294,7 @@ float SpectrogramAnalyzer::frequencyToRow(float frequency) const {
float SpectrogramAnalyzer::applyDisplayTilt(float db, float frequency) const {
const float safeFrequency = std::max(1.0f, frequency);
const float tiltAmount = config_.tiltDbPerOctave * std::log2(safeFrequency / TILT_REFERENCE_HZ);
return db + tiltAmount + DISPLAY_GAIN_DB;
return db + tiltAmount;
}
float SpectrogramAnalyzer::displayDbToIntensity(float db) const {
@@ -296,10 +328,44 @@ float SpectrogramAnalyzer::sampleDbAtBin(float bin) const {
);
}
float SpectrogramAnalyzer::samplePeakDbInBand(float startBin, float endBin, float& peakBin) const {
if (magnitudesDb_.empty()) {
peakBin = 0.0f;
return -200.0f;
}
const float lastBin = static_cast<float>(magnitudesDb_.size() - 1);
const float lo = std::clamp(std::min(startBin, endBin), 0.0f, lastBin);
const float hi = std::clamp(std::max(startBin, endBin), 0.0f, lastBin);
peakBin = 0.5f * (lo + hi);
float peakDb = sampleDbAtBin(peakBin);
const auto consider = [&](float candidateBin) {
const float candidateDb = sampleDbAtBin(candidateBin);
if (candidateDb > peakDb) {
peakDb = candidateDb;
peakBin = candidateBin;
}
};
consider(lo);
consider(hi);
const size_t firstWholeBin = static_cast<size_t>(std::ceil(lo));
const size_t lastWholeBin = static_cast<size_t>(std::floor(hi));
for (size_t bin = firstWholeBin; bin <= lastWholeBin && bin < magnitudesDb_.size(); bin += 1) {
consider(static_cast<float>(bin));
}
return peakDb;
}
void SpectrogramAnalyzer::computeStandardSpectrum() {
const size_t rowCount = config_.rowCount;
const float binWidth = std::max(1.0f, config_.sampleRate) / static_cast<float>(paddedSize_);
for (size_t row = 0; row < rowCount; row += 1) {
const float displayDb = applyDisplayTilt(sampleDbAtBin(rowCenterBins_[row]), rowCenterFrequencies_[row]);
float peakBin = rowCenterBins_[row];
const float rawDb = samplePeakDbInBand(rowBandStartBins_[row], rowBandEndBins_[row], peakBin);
const float displayDb = applyDisplayTilt(rawDb, peakBin * binWidth);
standardRaw_[row] = displayDbToIntensity(displayDb);
standardHeat_[row] = normalizeHeatDb(displayDb);
}
@@ -314,7 +380,11 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() {
const float sampleRate = std::max(1.0f, config_.sampleRate);
const float binWidth = sampleRate / static_cast<float>(paddedSize_);
const float hopDt = static_cast<float>(resolveHopSize()) / sampleRate;
const float ampThreshold = std::pow(10.0f, config_.minDecibels / 20.0f);
// Reassign every bin that can contribute to either output. Limiting this to
// local maxima throws away low-level partials and ambience — exactly the
// detail a sharpened spectrogram is meant to retain.
const float visibleFloorDb = std::min(config_.minDecibels, HEAT_MIN_DB);
const float ampThreshold = std::pow(10.0f, visibleFloorDb / 20.0f);
const float twoPi = static_cast<float>(2.0 * M_PI);
for (size_t bin = 1; bin + 1 < magnitudesLinear_.size(); bin += 1) {
@@ -322,9 +392,6 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() {
if (mag <= ampThreshold) {
continue;
}
if (mag < magnitudesLinear_[bin - 1] || mag < magnitudesLinear_[bin + 1]) {
continue;
}
const float nominalFrequency = static_cast<float>(bin) * binWidth;
if (nominalFrequency < config_.minFrequency || nominalFrequency > config_.maxFrequency) {
@@ -332,9 +399,67 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() {
}
const float expected = twoPi * nominalFrequency * hopDt;
float correctionHz = wrapPhase(phases_[bin] - lastPhases_[bin] - expected) / (twoPi * hopDt);
const bool useRightPhase = dominantRight_[bin] != 0;
const float currentPhase = useRightPhase ? rightPhases_[bin] : phases_[bin];
const float previousPhase = useRightPhase ? rightLastPhases_[bin] : lastPhases_[bin];
const float correctionHz = wrapPhase(currentPhase - previousPhase - expected) / (twoPi * hopDt);
const float reassignedFrequency = nominalFrequency + correctionHz;
if (reassignedFrequency < config_.minFrequency || reassignedFrequency > config_.maxFrequency) {
continue;
}
const float rowF = frequencyToRow(reassignedFrequency);
const size_t row0 = static_cast<size_t>(std::floor(std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))));
const float frac = rowF - static_cast<float>(row0);
// A coherently-normalized Hann spectrum contains 1.5 bins of equivalent
// noise bandwidth. With 4x zero padding, a bin-centered sinusoid therefore
// contributes 6x its signal power across the positive-frequency bins.
// Divide that back out so relocation conserves calibrated signal power.
const float power = (mag * mag) / REASSIGNED_POWER_NORMALIZATION;
reassignedPower_[row0] += power * (1.0f - frac);
if (row0 + 1 < config_.rowCount) {
reassignedPower_[row0 + 1] += power * frac;
}
}
}
void SpectrogramAnalyzer::computeFocusedSpectrum() {
std::fill(focusedPower_.begin(), focusedPower_.end(), 0.0f);
if (!haveLastPhase_ || magnitudesLinear_.size() < 3 || config_.rowCount == 0) {
return;
}
const float sampleRate = std::max(1.0f, config_.sampleRate);
const float binWidth = sampleRate / static_cast<float>(paddedSize_);
const float hopDt = static_cast<float>(resolveHopSize()) / sampleRate;
const float ampThreshold = std::pow(10.0f, config_.minDecibels / 20.0f);
const float twoPi = static_cast<float>(2.0 * M_PI);
// Focused intentionally restores Prism's former peak-isolation aesthetic:
// only local FFT maxima are relocated, with conservative phase correction
// and a local spectral centroid. It is kept separate from Sharp/Sharper so
// their energy-preserving reassignment cannot silently lose texture.
for (size_t bin = 1; bin + 1 < magnitudesLinear_.size(); bin += 1) {
const float mag = magnitudesLinear_[bin];
if (mag <= ampThreshold
|| mag < magnitudesLinear_[bin - 1]
|| mag < magnitudesLinear_[bin + 1]) {
continue;
}
const float nominalFrequency = static_cast<float>(bin) * binWidth;
if (nominalFrequency < config_.minFrequency || nominalFrequency > config_.maxFrequency) {
continue;
}
const float expected = twoPi * nominalFrequency * hopDt;
const bool useRightPhase = dominantRight_[bin] != 0;
const float currentPhase = useRightPhase ? rightPhases_[bin] : phases_[bin];
const float previousPhase = useRightPhase ? rightLastPhases_[bin] : lastPhases_[bin];
float correctionHz = wrapPhase(currentPhase - previousPhase - expected) / (twoPi * hopDt);
correctionHz = std::clamp(correctionHz, -1.5f * binWidth, 1.5f * binWidth);
float reassignedFrequency = nominalFrequency + correctionHz;
float focusedFrequency = nominalFrequency + correctionHz;
const float leftWeight = magnitudesLinear_[bin - 1];
const float centerWeight = mag;
@@ -346,97 +471,163 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() {
+ (nominalFrequency * centerWeight)
+ (static_cast<float>(bin + 1) * binWidth * rightWeight)
) / weightSum;
reassignedFrequency = 0.5f * reassignedFrequency + 0.5f * centroidFrequency;
focusedFrequency = 0.5f * focusedFrequency + 0.5f * centroidFrequency;
}
reassignedFrequency = std::clamp(reassignedFrequency, config_.minFrequency, config_.maxFrequency);
const float rowF = frequencyToRow(reassignedFrequency);
const size_t row0 = static_cast<size_t>(std::floor(std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))));
if (focusedFrequency < config_.minFrequency || focusedFrequency > config_.maxFrequency) {
continue;
}
const float rowF = frequencyToRow(focusedFrequency);
const size_t row0 = static_cast<size_t>(std::floor(
std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))
));
const float frac = rowF - static_cast<float>(row0);
const float power = mag * mag;
reassignedPower_[row0] += power * (1.0f - frac);
focusedPower_[row0] += power * (1.0f - frac);
if (row0 + 1 < config_.rowCount) {
reassignedPower_[row0 + 1] += power * frac;
focusedPower_[row0 + 1] += power * frac;
}
}
}
SpectrogramAnalyzer::ClarityProfile SpectrogramAnalyzer::clarityProfile(const std::string& mode) {
if (mode == "classic") {
return {1.4f, 0.0f, 3.0f};
return {1.4f, 0.42f, 0.32f, false};
}
if (mode == "sharp") {
return {1.5f, 2.5f, 3.0f};
return {1.25f, 0.22f, 0.16f, true};
}
return {2.0f, 5.0f, 2.0f};
return {1.1f, 0.08f, 0.06f, true};
}
void SpectrogramAnalyzer::blendAndShapeColumn(std::vector<float>& display, std::vector<float>& heat) {
void SpectrogramAnalyzer::shapeColumn(std::vector<float>& display, std::vector<float>& heat) {
if (config_.clarityMode == "focused") {
shapeFocusedColumn(display, heat);
return;
}
const size_t rowCount = config_.rowCount;
const ClarityProfile clarity = clarityProfile(config_.clarityMode);
const float standardWeight = config_.clarityMode == "classic" ? 0.8f : (config_.clarityMode == "sharp" ? 0.6f : 0.45f);
const float reassignedWeight = config_.clarityMode == "classic" ? 0.85f : 1.0f;
const bool useReassignedColumn = clarity.useReassignment && haveLastPhase_;
for (size_t row = 0; row < rowCount; row += 1) {
float reassignedRaw = 0.0f;
float reassignedHeat = 0.0f;
if (reassignedPower_[row] > 0.0f) {
if (useReassignedColumn && reassignedPower_[row] > 0.0f) {
const float reassignedMag = std::sqrt(reassignedPower_[row]);
const float reassignedDb = 20.0f * std::log10(std::max(reassignedMag, 1.0e-10f));
const float displayDb = applyDisplayTilt(reassignedDb, rowCenterFrequencies_[row]);
reassignedRaw = displayDbToIntensity(displayDb);
reassignedHeat = normalizeHeatDb(displayDb);
}
blendedRaw_[row] = std::max(standardRaw_[row] * standardWeight, reassignedRaw * reassignedWeight);
blendedHeat_[row] = std::max(standardHeat_[row] * standardWeight, reassignedHeat * reassignedWeight);
}
if (clarity.sharpness > 0.0f) {
const std::vector<float> peakSource = blendedRaw_;
const float mainlobePaddedBins = 4.0f * static_cast<float>(FFT_PAD_FACTOR);
const float detailPreserve = config_.clarityMode == "sharp" ? 0.18f : 0.14f;
for (size_t row = 0; row < rowCount; row += 1) {
const float bandWidthPerRow = std::max(0.1f, rowBandEndBins_[row] - rowBandStartBins_[row]);
const float mainlobePixels = mainlobePaddedBins / bandWidthPerRow;
const int halfWindow = std::max(2, std::min(50, static_cast<int>(std::lround(mainlobePixels * 0.5f))));
const float scaleFactor = std::max(1.0f, mainlobePixels / clarity.lineWidth);
const float effectiveSharpness = clarity.sharpness * scaleFactor;
float localMax = peakSource[row];
for (int offset = 1; offset <= halfWindow; offset += 1) {
if (row >= static_cast<size_t>(offset)) {
localMax = std::max(localMax, peakSource[row - static_cast<size_t>(offset)]);
}
if (row + static_cast<size_t>(offset) < rowCount) {
localMax = std::max(localMax, peakSource[row + static_cast<size_t>(offset)]);
}
}
if (localMax > 1.0e-6f) {
const float ratio = blendedRaw_[row] / localMax;
const float suppression = std::pow(clamp01(ratio), effectiveSharpness);
const float rawBefore = blendedRaw_[row];
const float heatBefore = blendedHeat_[row];
blendedRaw_[row] = std::max(rawBefore * suppression, rawBefore * detailPreserve);
blendedHeat_[row] = std::max(heatBefore * suppression, heatBefore * detailPreserve);
}
sourceRaw_[row] = displayDbToIntensity(displayDb);
sourceHeat_[row] = normalizeHeatDb(displayDb);
} else if (!useReassignedColumn) {
// The first phase-history frame falls back to Classic. Subsequent Sharp
// and Sharper frames contain only reassigned energy — no hidden Classic
// layer and no local-contrast gate.
sourceRaw_[row] = standardRaw_[row];
sourceHeat_[row] = standardHeat_[row];
} else {
sourceRaw_[row] = 0.0f;
sourceHeat_[row] = 0.0f;
}
}
const float effectiveGamma = clarity.gamma * config_.contrast;
for (size_t row = 0; row < rowCount; row += 1) {
shapedDisplay_[row] = std::pow(clamp01(blendedRaw_[row]), effectiveGamma);
shapedHeat_[row] = std::pow(clamp01(blendedHeat_[row]), SPECTROGRAM_HEAT_GAMMA);
shapedDisplay_[row] = std::pow(clamp01(sourceRaw_[row]), effectiveGamma);
shapedHeat_[row] = std::pow(clamp01(sourceHeat_[row]), SPECTROGRAM_HEAT_GAMMA);
strokedDisplay_[row] = shapedDisplay_[row];
strokedHeat_[row] = shapedHeat_[row];
}
for (size_t row = 0; row < rowCount; row += 1) {
const float displayShoulder = shapedDisplay_[row] * SPECTROGRAM_DISPLAY_STROKE_WEIGHT;
const float heatShoulder = shapedHeat_[row] * SPECTROGRAM_HEAT_STROKE_WEIGHT;
const float displayShoulder = shapedDisplay_[row] * clarity.displayShoulder;
const float heatShoulder = shapedHeat_[row] * clarity.heatShoulder;
if (row > 0) {
strokedDisplay_[row - 1] = std::max(strokedDisplay_[row - 1], displayShoulder);
strokedHeat_[row - 1] = std::max(strokedHeat_[row - 1], heatShoulder);
}
if (row + 1 < rowCount) {
strokedDisplay_[row + 1] = std::max(strokedDisplay_[row + 1], displayShoulder);
strokedHeat_[row + 1] = std::max(strokedHeat_[row + 1], heatShoulder);
}
}
const size_t offset = display.size();
display.resize(offset + rowCount);
heat.resize(offset + rowCount);
for (size_t row = 0; row < rowCount; row += 1) {
display[offset + row] = strokedDisplay_[row];
heat[offset + row] = strokedHeat_[row];
}
}
void SpectrogramAnalyzer::shapeFocusedColumn(std::vector<float>& display, std::vector<float>& heat) {
const size_t rowCount = config_.rowCount;
constexpr float standardWeight = 0.45f;
constexpr float reassignedWeight = 1.0f;
constexpr float sharpness = 5.0f;
constexpr float lineWidth = 2.0f;
constexpr float detailPreserve = 0.14f;
constexpr float gamma = 2.0f;
constexpr float displayShoulderWeight = 0.42f;
constexpr float heatShoulderWeight = 0.32f;
for (size_t row = 0; row < rowCount; row += 1) {
float focusedRaw = 0.0f;
float focusedHeat = 0.0f;
if (focusedPower_[row] > 0.0f) {
const float focusedMag = std::sqrt(focusedPower_[row]);
const float focusedDb = 20.0f * std::log10(std::max(focusedMag, 1.0e-10f));
const float displayDb = applyDisplayTilt(focusedDb, rowCenterFrequencies_[row]);
focusedRaw = displayDbToIntensity(displayDb);
focusedHeat = normalizeHeatDb(displayDb);
}
sourceRaw_[row] = std::max(standardRaw_[row] * standardWeight, focusedRaw * reassignedWeight);
sourceHeat_[row] = std::max(standardHeat_[row] * standardWeight, focusedHeat * reassignedWeight);
}
const std::vector<float> peakSource = sourceRaw_;
const float mainlobePaddedBins = 4.0f * static_cast<float>(FFT_PAD_FACTOR);
for (size_t row = 0; row < rowCount; row += 1) {
const float bandWidthPerRow = std::max(0.1f, rowBandEndBins_[row] - rowBandStartBins_[row]);
const float mainlobePixels = mainlobePaddedBins / bandWidthPerRow;
const int halfWindow = std::max(
2,
std::min(50, static_cast<int>(std::lround(mainlobePixels * 0.5f)))
);
const float scaleFactor = std::max(1.0f, mainlobePixels / lineWidth);
const float effectiveSharpness = sharpness * scaleFactor;
float localMax = peakSource[row];
for (int offset = 1; offset <= halfWindow; offset += 1) {
if (row >= static_cast<size_t>(offset)) {
localMax = std::max(localMax, peakSource[row - static_cast<size_t>(offset)]);
}
if (row + static_cast<size_t>(offset) < rowCount) {
localMax = std::max(localMax, peakSource[row + static_cast<size_t>(offset)]);
}
}
if (localMax > 1.0e-6f) {
const float suppression = std::pow(clamp01(sourceRaw_[row] / localMax), effectiveSharpness);
const float rawBefore = sourceRaw_[row];
const float heatBefore = sourceHeat_[row];
sourceRaw_[row] = std::max(rawBefore * suppression, rawBefore * detailPreserve);
sourceHeat_[row] = std::max(heatBefore * suppression, heatBefore * detailPreserve);
}
}
const float effectiveGamma = gamma * config_.contrast;
for (size_t row = 0; row < rowCount; row += 1) {
shapedDisplay_[row] = std::pow(clamp01(sourceRaw_[row]), effectiveGamma);
shapedHeat_[row] = std::pow(clamp01(sourceHeat_[row]), SPECTROGRAM_HEAT_GAMMA);
strokedDisplay_[row] = shapedDisplay_[row];
strokedHeat_[row] = shapedHeat_[row];
}
for (size_t row = 0; row < rowCount; row += 1) {
const float displayShoulder = shapedDisplay_[row] * displayShoulderWeight;
const float heatShoulder = shapedHeat_[row] * heatShoulderWeight;
if (row > 0) {
strokedDisplay_[row - 1] = std::max(strokedDisplay_[row - 1], displayShoulder);
strokedHeat_[row - 1] = std::max(strokedHeat_[row - 1], heatShoulder);
@@ -458,35 +649,54 @@ void SpectrogramAnalyzer::blendAndShapeColumn(std::vector<float>& display, std::
void SpectrogramAnalyzer::processFrame(std::vector<float>& display, std::vector<float>& heat) {
std::fill(windowedInput_.begin(), windowedInput_.end(), 0.0f);
std::fill(rightWindowedInput_.begin(), rightWindowedInput_.end(), 0.0f);
for (size_t index = 0; index < fftSize_; index += 1) {
windowedInput_[index] = frameBuffer_[index] * window_[index];
rightWindowedInput_[index] = rightFrameBuffer_[index] * window_[index];
}
fft_->forward(windowedInput_.data(), fftOutput_.data());
fft_->forward(rightWindowedInput_.data(), rightFftOutput_.data());
const size_t numBins = paddedSize_ / 2;
const float scale = 2.0f / static_cast<float>(fftSize_);
for (size_t bin = 0; bin < numBins; bin += 1) {
const float re = fftOutput_[bin].real();
const float im = fftOutput_[bin].imag();
const float magnitude = std::sqrt((re * re) + (im * im)) * scale;
magnitudesLinear_[bin] = magnitude;
magnitudesDb_[bin] = 20.0f * std::log10(std::max(magnitude, 1.0e-10f));
phases_[bin] = std::atan2(im, re);
const float leftRe = fftOutput_[bin].real();
const float leftIm = fftOutput_[bin].imag();
const float rightRe = rightFftOutput_[bin].real();
const float rightIm = rightFftOutput_[bin].imag();
const float leftMagnitude = std::sqrt((leftRe * leftRe) + (leftIm * leftIm)) * magnitudeScale_;
const float rightMagnitude = std::sqrt((rightRe * rightRe) + (rightIm * rightIm)) * magnitudeScale_;
const float stereoMagnitude = std::sqrt(
0.5f * ((leftMagnitude * leftMagnitude) + (rightMagnitude * rightMagnitude))
);
magnitudesLinear_[bin] = stereoMagnitude;
magnitudesDb_[bin] = 20.0f * std::log10(std::max(stereoMagnitude, 1.0e-10f));
phases_[bin] = std::atan2(leftIm, leftRe);
rightPhases_[bin] = std::atan2(rightIm, rightRe);
dominantRight_[bin] = rightMagnitude > leftMagnitude ? 1 : 0;
}
computeStandardSpectrum();
computeReassignedSpectrum();
blendAndShapeColumn(display, heat);
if (config_.clarityMode == "focused") {
computeFocusedSpectrum();
} else {
computeReassignedSpectrum();
}
shapeColumn(display, heat);
lastPhases_ = phases_;
rightLastPhases_ = rightPhases_;
haveLastPhase_ = true;
}
SpectrogramProcessResult SpectrogramAnalyzer::process(const float* samples, size_t length) {
return processStereo(samples, samples, length);
}
SpectrogramProcessResult SpectrogramAnalyzer::processStereo(const float* left, const float* right, size_t length) {
SpectrogramProcessResult result;
result.rowCount = config_.rowCount;
if (!samples || length == 0 || fftSize_ == 0 || config_.rowCount == 0) {
if (!left || !right || length == 0 || fftSize_ == 0 || config_.rowCount == 0) {
return result;
}
@@ -494,7 +704,8 @@ SpectrogramProcessResult SpectrogramAnalyzer::process(const float* samples, size
const size_t overlapSamples = fftSize_ - hopSize;
for (size_t index = 0; index < length; index += 1) {
frameBuffer_[frameFill_] = samples[index];
frameBuffer_[frameFill_] = left[index];
rightFrameBuffer_[frameFill_] = right[index];
frameFill_ += 1;
if (frameFill_ >= fftSize_) {
@@ -503,6 +714,7 @@ SpectrogramProcessResult SpectrogramAnalyzer::process(const float* samples, size
if (overlapSamples > 0) {
std::memmove(frameBuffer_.data(), frameBuffer_.data() + hopSize, overlapSamples * sizeof(float));
std::memmove(rightFrameBuffer_.data(), rightFrameBuffer_.data() + hopSize, overlapSamples * sizeof(float));
}
frameFill_ = overlapSamples;
}
+19 -5
View File
@@ -1,6 +1,7 @@
#pragma once
#include "dsp_utils.h"
#include <cstdint>
#include <complex>
#include <memory>
#include <string>
@@ -37,13 +38,15 @@ public:
void configure(const SpectrogramConfig& config);
SpectrogramProcessResult process(const float* samples, size_t length);
SpectrogramProcessResult processStereo(const float* left, const float* right, size_t length);
void reset();
private:
struct ClarityProfile {
float gamma;
float sharpness;
float lineWidth;
float displayShoulder;
float heatShoulder;
bool useReassignment;
};
SpectrogramConfig config_;
@@ -51,16 +54,23 @@ private:
size_t paddedSize_;
size_t frameFill_;
bool haveLastPhase_;
float magnitudeScale_;
std::unique_ptr<DSP::FFT> fft_;
std::vector<float> frameBuffer_;
std::vector<float> rightFrameBuffer_;
std::vector<float> window_;
std::vector<float> windowedInput_;
std::vector<float> rightWindowedInput_;
std::vector<std::complex<float>> fftOutput_;
std::vector<std::complex<float>> rightFftOutput_;
std::vector<float> magnitudesDb_;
std::vector<float> magnitudesLinear_;
std::vector<float> phases_;
std::vector<float> lastPhases_;
std::vector<float> rightPhases_;
std::vector<float> rightLastPhases_;
std::vector<uint8_t> dominantRight_;
std::vector<float> rowCenterBins_;
std::vector<float> rowBandStartBins_;
@@ -69,8 +79,9 @@ private:
std::vector<float> standardRaw_;
std::vector<float> standardHeat_;
std::vector<float> reassignedPower_;
std::vector<float> blendedRaw_;
std::vector<float> blendedHeat_;
std::vector<float> focusedPower_;
std::vector<float> sourceRaw_;
std::vector<float> sourceHeat_;
std::vector<float> shapedDisplay_;
std::vector<float> shapedHeat_;
std::vector<float> strokedDisplay_;
@@ -81,9 +92,12 @@ private:
void processFrame(std::vector<float>& display, std::vector<float>& heat);
void computeStandardSpectrum();
void computeReassignedSpectrum();
void blendAndShapeColumn(std::vector<float>& display, std::vector<float>& heat);
void computeFocusedSpectrum();
void shapeColumn(std::vector<float>& display, std::vector<float>& heat);
void shapeFocusedColumn(std::vector<float>& display, std::vector<float>& heat);
size_t resolveHopSize() const;
float sampleDbAtBin(float bin) const;
float samplePeakDbInBand(float startBin, float endBin, float& peakBin) const;
float frequencyFromScale(float normalizedPosition) const;
float frequencyToRow(float frequency) const;
float applyDisplayTilt(float db, float frequency) const;
+53 -60
View File
@@ -15,12 +15,16 @@ Spectrum::Spectrum(size_t fftSize)
historyBuffer_.resize(fftSize, 0.0f);
sideHistoryBuffer_.resize(fftSize, 0.0f);
windowedInput_.resize(fftSize);
magnitudes_.resize(fftSize / 2);
midSpectrum_.resize(fftSize);
sideSpectrum_.resize(fftSize);
rawMagnitudes_.resize(fftSize / 2, -100.0f);
// Initialize to silence (-100.0f dB)
smoothedMagnitudes_.resize(fftSize / 2, -100.0f);
sideRawMagnitudes_.resize(fftSize / 2, -100.0f);
sideSmoothedMagnitudes_.resize(fftSize / 2, -100.0f);
leftSmoothedMagnitudes_.resize(fftSize / 2, -100.0f);
rightSmoothedMagnitudes_.resize(fftSize / 2, -100.0f);
channelMaxMagnitudes_.resize(fftSize / 2, -100.0f);
}
void Spectrum::setFFTSize(size_t size) {
@@ -30,12 +34,16 @@ void Spectrum::setFFTSize(size_t size) {
historyBuffer_.assign(size, 0.0f);
sideHistoryBuffer_.assign(size, 0.0f);
windowedInput_.resize(size);
magnitudes_.resize(size / 2);
midSpectrum_.resize(size);
sideSpectrum_.resize(size);
rawMagnitudes_.assign(size / 2, -100.0f);
// Initialize to silence (-100.0f dB)
smoothedMagnitudes_.assign(size / 2, -100.0f);
sideRawMagnitudes_.assign(size / 2, -100.0f);
sideSmoothedMagnitudes_.assign(size / 2, -100.0f);
leftSmoothedMagnitudes_.assign(size / 2, -100.0f);
rightSmoothedMagnitudes_.assign(size / 2, -100.0f);
channelMaxMagnitudes_.assign(size / 2, -100.0f);
bufferedSamples_ = 0;
}
}
@@ -94,69 +102,52 @@ void Spectrum::pushZeroHistory(std::vector<float>& history, size_t length) {
std::fill(history.begin() + keep, history.end(), 0.0f);
}
void Spectrum::updateMagnitudesForHistory(
const std::vector<float>& history,
std::vector<float>& rawMagnitudes,
std::vector<float>& smoothedMagnitudes
) {
if (history.empty() || magnitudes_.empty()) {
return;
float Spectrum::magnitudeToDb(float magnitude, float correctionDb) const {
const float db = 20.0f * log10f(std::max(magnitude, 1e-10f)) + correctionDb;
return std::clamp(db, -120.0f, 12.0f);
}
void Spectrum::updateSmoothedMagnitude(float db, float& smoothedMagnitude) {
if (bufferedSamples_ < fftSize_) {
smoothedMagnitude = db;
} else {
smoothedMagnitude = smoothing_ * smoothedMagnitude + (1.0f - smoothing_) * db;
}
// Always analyze a full FFT frame from the rolling buffer.
applyWindow(history.data(), windowedInput_.data(), fftSize_);
// Perform FFT
fft_->forward(windowedInput_.data(), magnitudes_.data());
// Convert to dB and apply smoothing
for (size_t i = 0; i < magnitudes_.size(); i++) {
float mag = magnitudes_[i];
// Convert to dB
// Add epsilon to avoid log(0)
float db = 20.0f * log10f(std::max(mag, 1e-10f));
// Compensate Hann window coherent gain (about -6 dB).
db += 6.0f;
// Clamp to a stable display range.
db = std::clamp(db, -120.0f, 12.0f);
rawMagnitudes[i] = db;
if (bufferedSamples_ < fftSize_) {
smoothedMagnitudes[i] = db;
continue;
}
// Apply temporal smoothing only (no bin-to-bin averaging).
smoothedMagnitudes[i] = smoothing_ * smoothedMagnitudes[i] + (1.0f - smoothing_) * db;
// Safety check
if (!std::isfinite(smoothedMagnitudes[i])) {
smoothedMagnitudes[i] = -100.0f;
}
if (!std::isfinite(smoothedMagnitude)) {
smoothedMagnitude = -100.0f;
}
}
void Spectrum::updateMagnitudes() {
updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_);
updateMagnitudesForHistory(sideHistoryBuffer_, sideRawMagnitudes_, sideSmoothedMagnitudes_);
}
if (historyBuffer_.empty() || rawMagnitudes_.empty()) {
return;
}
void Spectrum::updateSilentSideMagnitudes() {
const float silentDb = -120.0f;
for (size_t i = 0; i < sideSmoothedMagnitudes_.size(); i++) {
sideRawMagnitudes_[i] = silentDb;
if (bufferedSamples_ < fftSize_) {
sideSmoothedMagnitudes_[i] = silentDb;
continue;
}
applyWindow(historyBuffer_.data(), windowedInput_.data(), fftSize_);
fft_->forward(windowedInput_.data(), midSpectrum_.data());
applyWindow(sideHistoryBuffer_.data(), windowedInput_.data(), fftSize_);
fft_->forward(windowedInput_.data(), sideSpectrum_.data());
sideSmoothedMagnitudes_[i] = smoothing_ * sideSmoothedMagnitudes_[i] + (1.0f - smoothing_) * silentDb;
if (!std::isfinite(sideSmoothedMagnitudes_[i])) {
sideSmoothedMagnitudes_[i] = -100.0f;
}
const float scale = 2.0f / static_cast<float>(fftSize_);
const float coherentGain = fftSize_ > 1
? static_cast<float>(fftSize_ - 1) / (2.0f * static_cast<float>(fftSize_))
: 1.0f;
const float correctionDb = -20.0f * log10f(coherentGain);
for (size_t i = 0; i < rawMagnitudes_.size(); i++) {
const std::complex<float> mid = midSpectrum_[i];
const std::complex<float> side = sideSpectrum_[i];
const float midDb = magnitudeToDb(std::abs(mid) * scale, correctionDb);
const float sideDb = magnitudeToDb(std::abs(side) * scale, correctionDb);
const float leftDb = magnitudeToDb(std::abs(mid + side) * scale, correctionDb);
const float rightDb = magnitudeToDb(std::abs(mid - side) * scale, correctionDb);
rawMagnitudes_[i] = midDb;
sideRawMagnitudes_[i] = sideDb;
updateSmoothedMagnitude(midDb, smoothedMagnitudes_[i]);
updateSmoothedMagnitude(sideDb, sideSmoothedMagnitudes_[i]);
updateSmoothedMagnitude(leftDb, leftSmoothedMagnitudes_[i]);
updateSmoothedMagnitude(rightDb, rightSmoothedMagnitudes_[i]);
channelMaxMagnitudes_[i] = std::max(leftSmoothedMagnitudes_[i], rightSmoothedMagnitudes_[i]);
}
}
@@ -165,8 +156,7 @@ void Spectrum::pushSamples(const float* input, size_t length) {
pushHistory(historyBuffer_, input, length);
pushZeroHistory(sideHistoryBuffer_, length);
bufferedSamples_ = length >= fftSize_ ? fftSize_ : std::min(fftSize_, bufferedSamples_ + length);
updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_);
updateSilentSideMagnitudes();
updateMagnitudes();
return;
}
updateMagnitudes();
@@ -215,6 +205,9 @@ void Spectrum::reset() {
std::fill(smoothedMagnitudes_.begin(), smoothedMagnitudes_.end(), -100.0f);
std::fill(sideRawMagnitudes_.begin(), sideRawMagnitudes_.end(), -100.0f);
std::fill(sideSmoothedMagnitudes_.begin(), sideSmoothedMagnitudes_.end(), -100.0f);
std::fill(leftSmoothedMagnitudes_.begin(), leftSmoothedMagnitudes_.end(), -100.0f);
std::fill(rightSmoothedMagnitudes_.begin(), rightSmoothedMagnitudes_.end(), -100.0f);
std::fill(channelMaxMagnitudes_.begin(), channelMaxMagnitudes_.end(), -100.0f);
bufferedSamples_ = 0;
}
+8 -7
View File
@@ -26,6 +26,7 @@ public:
// Read the latest smoothed magnitudes without mutating analyzer state.
const std::vector<float>& getMagnitudes() const { return smoothedMagnitudes_; }
const std::vector<float>& getSideMagnitudes() const { return sideSmoothedMagnitudes_; }
const std::vector<float>& getChannelMaxMagnitudes() const { return channelMaxMagnitudes_; }
// Process audio and get spectrum data
// Returns magnitude data (size = fftSize / 2)
@@ -46,22 +47,22 @@ private:
std::vector<float> historyBuffer_;
std::vector<float> sideHistoryBuffer_;
std::vector<float> windowedInput_;
std::vector<float> magnitudes_;
std::vector<std::complex<float>> midSpectrum_;
std::vector<std::complex<float>> sideSpectrum_;
std::vector<float> rawMagnitudes_;
std::vector<float> smoothedMagnitudes_;
std::vector<float> sideRawMagnitudes_;
std::vector<float> sideSmoothedMagnitudes_;
std::vector<float> leftSmoothedMagnitudes_;
std::vector<float> rightSmoothedMagnitudes_;
std::vector<float> channelMaxMagnitudes_;
size_t bufferedSamples_;
void applyWindow(const float* input, float* output, size_t length);
void pushHistory(std::vector<float>& history, const float* input, size_t length);
void pushZeroHistory(std::vector<float>& history, size_t length);
void updateMagnitudesForHistory(
const std::vector<float>& history,
std::vector<float>& rawMagnitudes,
std::vector<float>& smoothedMagnitudes
);
void updateSilentSideMagnitudes();
float magnitudeToDb(float magnitude, float correctionDb) const;
void updateSmoothedMagnitude(float db, float& smoothedMagnitude);
void updateMagnitudes();
};
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace Prism::Capture {
struct Support {
bool available = false;
std::string reason;
};
struct OutputDevice {
std::string id;
std::string label;
double sampleRate = 48000.0;
uint32_t channelCount = 2;
bool isDefault = false;
};
struct StartResult {
double sampleRate = 48000.0;
uint32_t channelCount = 2;
std::string deviceId;
std::string deviceLabel;
};
struct AudioChunk {
std::vector<float> left;
std::vector<float> right;
uint32_t channelCount = 2;
double capturedAtMilliseconds = 0.0;
uint64_t sequence = 0;
};
struct DrainResult {
std::vector<AudioChunk> chunks;
uint64_t overwriteCount = 0;
size_t queueDepth = 0;
};
class SystemAudioCapture {
public:
virtual ~SystemAudioCapture() = default;
virtual Support getSupport() const = 0;
virtual std::vector<OutputDevice> listOutputDevices() = 0;
virtual bool start(const std::string& requestedDeviceId,
StartResult* result,
std::string* errorMessage) = 0;
virtual void stop() = 0;
virtual DrainResult drain(size_t maxChunks = 64) = 0;
virtual double nowMilliseconds() const = 0;
virtual const char* backendName() const = 0;
};
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture();
} // namespace Prism::Capture
+206
View File
@@ -0,0 +1,206 @@
#include "system_audio_capture_napi.h"
#include "system_audio_capture.h"
#include <algorithm>
#include <cstring>
#include <memory>
#include <string>
namespace {
constexpr size_t kDefaultDrainChunkLimit = 64;
constexpr size_t kMaxDrainChunkLimit = 256;
std::unique_ptr<Prism::Capture::SystemAudioCapture>& activeCapture() {
static std::unique_ptr<Prism::Capture::SystemAudioCapture> capture =
Prism::Capture::createSystemAudioCapture();
return capture;
}
const char* activeExportName() {
#if defined(__APPLE__)
return "macosCapture";
#elif defined(_WIN32)
return "windowsCapture";
#else
return "linuxCapture";
#endif
}
Napi::Object supportToNapi(Napi::Env env, const Prism::Capture::Support& support) {
Napi::Object result = Napi::Object::New(env);
result.Set("available", Napi::Boolean::New(env, support.available));
if (support.available || support.reason.empty()) {
result.Set("reason", env.Null());
} else {
result.Set("reason", Napi::String::New(env, support.reason));
}
return result;
}
Napi::Object drainToNapi(Napi::Env env, Prism::Capture::DrainResult&& drained) {
Napi::Array chunks = Napi::Array::New(env, drained.chunks.size());
for (size_t index = 0; index < drained.chunks.size(); ++index) {
auto& chunk = drained.chunks[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set("channelCount", Napi::Number::New(env, chunk.channelCount));
entry.Set("capturedAtMilliseconds", Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set("sequence", Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set("overwriteCount", Napi::Number::New(env, static_cast<double>(drained.overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(drained.queueDepth)));
return result;
}
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
return supportToNapi(info.Env(), activeCapture()->getSupport());
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const auto devices = activeCapture()->listOutputDevices();
Napi::Array result = Napi::Array::New(env, devices.size());
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set("channelCount", Napi::Number::New(env, device.channelCount));
result.Set(static_cast<uint32_t>(index), entry);
}
return result;
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string requestedDeviceId;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
Prism::Capture::StartResult started;
std::string errorMessage;
if (!activeCapture()->start(requestedDeviceId, &started, &errorMessage)) {
Napi::Error::New(env, errorMessage.empty() ? "System audio capture failed to start." : errorMessage)
.ThrowAsJavaScriptException();
return env.Undefined();
}
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, started.sampleRate));
result.Set("channelCount", Napi::Number::New(env, started.channelCount));
result.Set("deviceId", Napi::String::New(env, started.deviceId));
result.Set("deviceLabel", Napi::String::New(env, started.deviceLabel));
return result;
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
activeCapture()->stop();
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = std::min(static_cast<size_t>(requested), kMaxDrainChunkLimit);
}
}
return drainToNapi(info.Env(), activeCapture()->drain(maxChunks));
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), activeCapture()->nowMilliseconds());
}
void RegisterCaptureObject(Napi::Env env, Napi::Object exports, const char* exportName) {
Napi::Object capture = Napi::Object::New(env);
capture.Set("getSupport", Napi::Function::New(env, GetSupport));
capture.Set("listOutputDevices", Napi::Function::New(env, ListOutputDevices));
capture.Set("start", Napi::Function::New(env, Start));
capture.Set("stop", Napi::Function::New(env, Stop));
capture.Set("drain", Napi::Function::New(env, Drain));
capture.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set(exportName, capture);
}
Napi::Value UnavailableGetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(info.Env(), "This system-audio capture backend is unavailable on the current platform."));
return support;
}
Napi::Value UnavailableListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value UnavailableStart(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "This system-audio capture backend is unavailable on the current platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value UnavailableStop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value UnavailableDrain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value UnavailableNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
void RegisterUnavailableCaptureObject(Napi::Env env, Napi::Object exports, const char* exportName) {
Napi::Object capture = Napi::Object::New(env);
capture.Set("getSupport", Napi::Function::New(env, UnavailableGetSupport));
capture.Set("listOutputDevices", Napi::Function::New(env, UnavailableListOutputDevices));
capture.Set("start", Napi::Function::New(env, UnavailableStart));
capture.Set("stop", Napi::Function::New(env, UnavailableStop));
capture.Set("drain", Napi::Function::New(env, UnavailableDrain));
capture.Set("nowMilliseconds", Napi::Function::New(env, UnavailableNowMilliseconds));
exports.Set(exportName, capture);
}
} // namespace
void RegisterSystemAudioCapture(Napi::Env env, Napi::Object exports) {
RegisterCaptureObject(env, exports, activeExportName());
#if !defined(__APPLE__)
RegisterUnavailableCaptureObject(env, exports, "macosCapture");
#endif
#if !defined(_WIN32)
RegisterUnavailableCaptureObject(env, exports, "windowsCapture");
#endif
#if defined(__APPLE__) || defined(_WIN32)
RegisterUnavailableCaptureObject(env, exports, "linuxCapture");
#endif
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <napi.h>
void RegisterSystemAudioCapture(Napi::Env env, Napi::Object exports);
+3 -26
View File
@@ -22,23 +22,11 @@ Vectorscope::Vectorscope()
highRightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
points_.reserve(1024);
// Cascaded lowpass at 8kHz, Butterworth (Q=0.707)
// Two stages per channel = 4th order = 24 dB/oct rolloff
// Removes HF noise that causes erratic Lissajous motion
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
multibandSplitter_.configure(sampleRate_);
}
void Vectorscope::setSampleRate(float sampleRate) {
sampleRate_ = sampleRate;
// Redesign all filters with new sample rate
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
multibandSplitter_.configure(sampleRate_);
}
@@ -53,15 +41,8 @@ void Vectorscope::pushSamples(
size_t length
) {
for (size_t i = 0; i < length; i++) {
// Apply cascaded lowpass filtering
float filteredL = leftLowpass1_.process(leftChannel[i]);
filteredL = leftLowpass2_.process(filteredL);
float filteredR = rightLowpass1_.process(rightChannel[i]);
filteredR = rightLowpass2_.process(filteredR);
leftBuffer_[writePos_] = filteredL;
rightBuffer_[writePos_] = filteredR;
leftBuffer_[writePos_] = leftChannel[i];
rightBuffer_[writePos_] = rightChannel[i];
writePos_ = (writePos_ + 1) % VECTORSCOPE_BUFFER_SIZE;
if (validSamples_ < VECTORSCOPE_BUFFER_SIZE) {
@@ -128,7 +109,7 @@ const std::vector<VectorscopePoint>& Vectorscope::process(
const float* rightChannel,
size_t length
) {
// Push through the filtering pipeline
// Push through the full-band point pipeline
pushSamples(leftChannel, rightChannel, length);
// Build legacy output from buffer
@@ -157,10 +138,6 @@ void Vectorscope::reset() {
std::fill(midRightBuffer_.begin(), midRightBuffer_.end(), 0.0f);
std::fill(highLeftBuffer_.begin(), highLeftBuffer_.end(), 0.0f);
std::fill(highRightBuffer_.begin(), highRightBuffer_.end(), 0.0f);
leftLowpass1_.reset();
leftLowpass2_.reset();
rightLowpass1_.reset();
rightLowpass2_.reset();
multibandSplitter_.reset();
points_.clear();
}
+1 -7
View File
@@ -1,6 +1,5 @@
#pragma once
#include "dsp_utils.h"
#include "multiband.h"
#include <vector>
#include <cstddef>
@@ -52,7 +51,7 @@ private:
size_t writePos_;
size_t validSamples_;
// Circular buffers for filtered L/R
// Circular buffers for full-band L/R
std::vector<float> leftBuffer_;
std::vector<float> rightBuffer_;
std::vector<float> lowLeftBuffer_;
@@ -62,11 +61,6 @@ private:
std::vector<float> highLeftBuffer_;
std::vector<float> highRightBuffer_;
// Cascaded lowpass filters (4th order Butterworth at 8kHz per channel)
DSP::BiquadFilter leftLowpass1_;
DSP::BiquadFilter leftLowpass2_;
DSP::BiquadFilter rightLowpass1_;
DSP::BiquadFilter rightLowpass2_;
MultibandSplitter multibandSplitter_;
size_t multibandWritePos_;
size_t multibandValidSamples_;
+181
View File
@@ -0,0 +1,181 @@
#include "window_chrome.h"
#ifdef _WIN32
#include <windows.h>
#include <dwmapi.h>
// These DWM attributes/values exist on Windows 11 (build 22000+) but may be
// missing from older SDK headers, so provide fallback definitions.
#ifndef DWMWA_NCRENDERING_POLICY
#define DWMWA_NCRENDERING_POLICY 2
#endif
#ifndef DWMNCRP_DISABLED
#define DWMNCRP_DISABLED 1
#endif
#ifndef DWMWA_WINDOW_CORNER_PREFERENCE
#define DWMWA_WINDOW_CORNER_PREFERENCE 33
#endif
#ifndef DWMWA_BORDER_COLOR
#define DWMWA_BORDER_COLOR 34
#endif
#ifndef DWMWCP_DONOTROUND
#define DWMWCP_DONOTROUND 1
#endif
#ifndef DWMWA_COLOR_NONE
#define DWMWA_COLOR_NONE 0xFFFFFFFE
#endif
namespace {
// Undocumented but long-stable user32 API used for accent-policy acrylic.
// Unlike DWMWA_SYSTEMBACKDROP_TYPE acrylic, this blur stays active while the
// window is unfocused and renders on borderless transparent windows, which is
// exactly what an always-visible visualizer overlay needs.
struct AccentPolicy {
int32_t AccentState;
int32_t AccentFlags;
uint32_t GradientColor; // AABBGGRR
int32_t AnimationId;
};
struct WindowCompositionAttribData {
int32_t Attrib;
void* pvData;
SIZE_T cbData;
};
using SetWindowCompositionAttributeFn = BOOL(WINAPI*)(HWND, WindowCompositionAttribData*);
constexpr int32_t kAccentDisabled = 0;
constexpr int32_t kAccentEnableAcrylicBlurBehind = 4;
constexpr int32_t kWcaAccentPolicy = 19;
// A barely-visible tint keeps some Windows builds from optimizing the blur
// away entirely when the requested tint alpha is zero.
constexpr uint32_t kNearTransparentTint = 0x01000000u;
HWND ReadWindowHandle(const Napi::CallbackInfo& info, Napi::Env env) {
if (info.Length() < 1 || !info[0].IsBuffer()) {
Napi::TypeError::New(env, "Expected native window handle Buffer")
.ThrowAsJavaScriptException();
return nullptr;
}
Napi::Buffer<uint8_t> handle = info[0].As<Napi::Buffer<uint8_t>>();
if (handle.Length() < sizeof(HWND)) {
Napi::TypeError::New(env, "Window handle Buffer is too small")
.ThrowAsJavaScriptException();
return nullptr;
}
HWND hwnd = *reinterpret_cast<HWND*>(handle.Data());
if (hwnd == nullptr || !IsWindow(hwnd)) {
return nullptr;
}
return hwnd;
}
Napi::Value SetAcrylicBlurBehind(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
HWND hwnd = ReadWindowHandle(info, env);
if (env.IsExceptionPending() || hwnd == nullptr) {
return env.IsExceptionPending() ? env.Undefined() : Napi::Boolean::New(env, false);
}
const bool enable = info.Length() > 1 && info[1].IsBoolean()
? info[1].As<Napi::Boolean>().Value()
: true;
HMODULE user32 = GetModuleHandleW(L"user32.dll");
if (user32 == nullptr) {
return Napi::Boolean::New(env, false);
}
auto setWindowCompositionAttribute = reinterpret_cast<SetWindowCompositionAttributeFn>(
GetProcAddress(user32, "SetWindowCompositionAttribute"));
if (setWindowCompositionAttribute == nullptr) {
return Napi::Boolean::New(env, false);
}
AccentPolicy policy = {};
policy.AccentState = enable ? kAccentEnableAcrylicBlurBehind : kAccentDisabled;
policy.AccentFlags = 0;
policy.GradientColor = enable ? kNearTransparentTint : 0u;
policy.AnimationId = 0;
WindowCompositionAttribData data = {};
data.Attrib = kWcaAccentPolicy;
data.pvData = &policy;
data.cbData = sizeof(policy);
return Napi::Boolean::New(env, setWindowCompositionAttribute(hwnd, &data) != FALSE);
}
Napi::Value ApplyFlatFrame(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsBuffer()) {
Napi::TypeError::New(env, "Expected native window handle Buffer")
.ThrowAsJavaScriptException();
return env.Undefined();
}
Napi::Buffer<uint8_t> handle = info[0].As<Napi::Buffer<uint8_t>>();
if (handle.Length() < sizeof(HWND)) {
Napi::TypeError::New(env, "Window handle Buffer is too small")
.ThrowAsJavaScriptException();
return env.Undefined();
}
HWND hwnd = *reinterpret_cast<HWND*>(handle.Data());
if (hwnd == nullptr || !IsWindow(hwnd)) {
return Napi::Boolean::New(env, false);
}
// The window keeps WS_THICKFRAME so native Aero Snap and edge resize work, but
// on Windows 11 that style makes DWM paint a visible border and a drop shadow
// ("depth"). Disabling DWM non-client rendering removes that shadow/border,
// and squaring the corners + clearing the accent border color restores the old
// flat, borderless look. All four calls are no-ops/benign on Windows 10 and
// fail harmlessly on unsupported builds.
DWORD ncRendering = DWMNCRP_DISABLED;
DwmSetWindowAttribute(hwnd, DWMWA_NCRENDERING_POLICY, &ncRendering, sizeof(ncRendering));
DWORD cornerPreference = DWMWCP_DONOTROUND;
DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &cornerPreference, sizeof(cornerPreference));
COLORREF borderColor = DWMWA_COLOR_NONE;
DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, &borderColor, sizeof(borderColor));
MARGINS margins = {0, 0, 0, 0};
DwmExtendFrameIntoClientArea(hwnd, &margins);
return Napi::Boolean::New(env, true);
}
} // namespace
void RegisterWindowChrome(Napi::Env env, Napi::Object exports) {
Napi::Object chrome = Napi::Object::New(env);
chrome.Set("applyFlatFrame", Napi::Function::New(env, ApplyFlatFrame));
chrome.Set("setAcrylicBlurBehind", Napi::Function::New(env, SetAcrylicBlurBehind));
exports.Set("windowChrome", chrome);
}
#else // !_WIN32
namespace {
Napi::Value ApplyFlatFrameNoop(const Napi::CallbackInfo& info) {
return Napi::Boolean::New(info.Env(), false);
}
} // namespace
void RegisterWindowChrome(Napi::Env env, Napi::Object exports) {
Napi::Object chrome = Napi::Object::New(env);
chrome.Set("applyFlatFrame", Napi::Function::New(env, ApplyFlatFrameNoop));
chrome.Set("setAcrylicBlurBehind", Napi::Function::New(env, ApplyFlatFrameNoop));
exports.Set("windowChrome", chrome);
}
#endif // _WIN32
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <napi.h>
// Registers the `windowChrome` export. On Windows it exposes `applyFlatFrame`,
// which strips the visible native frame/shadow from a frameless WS_THICKFRAME
// window while preserving native Aero Snap and edge resize. On other platforms
// it registers a no-op so the JS surface stays uniform.
void RegisterWindowChrome(Napi::Env env, Napi::Object exports);
+77 -129
View File
@@ -1,4 +1,7 @@
#ifndef PRISM_CAPTURE_CORE_ONLY
#include "windows_capture.h"
#endif
#include "system_audio_capture.h"
#if defined(_WIN32)
@@ -10,14 +13,16 @@
#include <mmreg.h>
#include <propidl.h>
#include <avrt.h>
#include <roapi.h>
#include <wrl/client.h>
#include <windows.h>
#ifndef PRISM_CAPTURE_CORE_ONLY
#include <roapi.h>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Media.Control.h>
#include <winrt/Windows.Storage.Streams.h>
#endif
#include <algorithm>
#include <atomic>
@@ -39,9 +44,11 @@
namespace {
using Microsoft::WRL::ComPtr;
#ifndef PRISM_CAPTURE_CORE_ONLY
using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSession;
using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager;
using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus;
#endif
constexpr size_t kMaxQueuedChunks = 256;
constexpr size_t kDefaultDrainChunkLimit = 64;
@@ -131,6 +138,7 @@ std::string hresultMessage(const char* operation, HRESULT hr) {
return stream.str();
}
#ifndef PRISM_CAPTURE_CORE_ONLY
std::string winrtErrorMessage(const char* operation, const winrt::hresult_error& error) {
std::string message = hresultMessage(operation, error.code().value);
const std::wstring detailWide = error.message().c_str();
@@ -165,6 +173,7 @@ std::string playbackStatusToString(GlobalSystemMediaTransportControlsSessionPlay
return "Closed";
}
}
#endif
class ScopedCoInit {
public:
@@ -191,6 +200,7 @@ private:
bool usable_;
};
#ifndef PRISM_CAPTURE_CORE_ONLY
class ScopedRoInit {
public:
ScopedRoInit()
@@ -344,6 +354,7 @@ std::string getOrFetchThumbnail(
}
return result;
}
#endif
std::string getDeviceId(IMMDevice* device) {
if (device == nullptr) {
@@ -584,114 +595,90 @@ std::vector<OutputDeviceInfo> enumerateOutputDevices() {
return devices;
}
class WindowsNativeCaptureEngine {
class WindowsNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
public:
Napi::Object GetSupport(Napi::Env env) {
Napi::Object support = Napi::Object::New(env);
support.Set("available", Napi::Boolean::New(env, true));
support.Set("reason", env.Null());
return support;
~WindowsNativeCaptureEngine() override {
stop();
}
Napi::Array ListOutputDevices(Napi::Env env) {
Prism::Capture::Support getSupport() const override {
return {true, {}};
}
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
const auto devices = enumerateOutputDevices();
Napi::Array result = Napi::Array::New(env, devices.size());
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.id));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.channelCount)));
result.Set(static_cast<uint32_t>(index), entry);
std::vector<Prism::Capture::OutputDevice> result;
result.reserve(devices.size());
for (const auto& device : devices) {
result.push_back({
device.id,
device.label,
device.sampleRate,
static_cast<uint32_t>(device.channelCount),
device.isDefault,
});
}
return result;
}
Napi::Object Start(Napi::Env env, const std::string& requestedDeviceId) {
std::string errorMessage;
if (!startInternal(requestedDeviceId, &errorMessage)) {
Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
return Napi::Object::New(env);
bool start(const std::string& requestedDeviceId,
Prism::Capture::StartResult* result,
std::string* errorMessage) override {
if (!startInternal(requestedDeviceId, errorMessage)) {
return false;
}
std::lock_guard<std::mutex> lock(stateMutex_);
Napi::Object result = Napi::Object::New(env);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set(
"channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceId_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
if (result != nullptr) {
std::lock_guard<std::mutex> lock(stateMutex_);
result->sampleRate = sampleRate_;
result->channelCount = static_cast<uint32_t>(channelCount_);
result->deviceId = activeDeviceId_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
}
void Stop() {
void stop() override {
stopInternal();
}
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
Prism::Capture::DrainResult drain(size_t maxChunks) override {
const size_t drainLimit =
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
uint64_t overwriteCount = 0;
size_t queueDepth = 0;
Prism::Capture::DrainResult result;
{
std::lock_guard<std::mutex> lock(chunkMutex_);
overwriteCount = overwriteCount_;
result.overwriteCount = overwriteCount_;
const size_t count = std::min(drainLimit, chunkQueue_.size());
for (size_t index = 0; index < count; ++index) {
drained.push_back(std::move(chunkQueue_.front()));
chunkQueue_.pop_front();
}
queueDepth = chunkQueue_.size();
result.queueDepth = chunkQueue_.size();
}
Napi::Array chunks = Napi::Array::New(env, drained.size());
for (size_t index = 0; index < drained.size(); ++index) {
auto& chunk = drained[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
result.chunks.reserve(drained.size());
while (!drained.empty()) {
auto chunk = std::move(drained.front());
drained.pop_front();
result.chunks.push_back({
std::move(chunk.left),
std::move(chunk.right),
static_cast<uint32_t>(chunk.channelCount),
chunk.capturedAtMilliseconds,
chunk.sequence,
});
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount", Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(queueDepth)));
return result;
}
double NowMilliseconds() const {
double nowMilliseconds() const override {
return monotonicMilliseconds();
}
const char* backendName() const override {
return "WASAPI";
}
private:
bool startInternal(const std::string& requestedDeviceId, std::string* outErrorMessage) {
stopInternal();
@@ -1063,47 +1050,7 @@ private:
UINT32 channelCount_ = 2;
};
WindowsNativeCaptureEngine& engine() {
static WindowsNativeCaptureEngine instance;
return instance;
}
Napi::Value WindowsGetSupport(const Napi::CallbackInfo& info) {
return engine().GetSupport(info.Env());
}
Napi::Value WindowsListOutputDevices(const Napi::CallbackInfo& info) {
return engine().ListOutputDevices(info.Env());
}
Napi::Value WindowsStart(const Napi::CallbackInfo& info) {
std::string requestedDeviceId;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
}
return engine().Start(info.Env(), requestedDeviceId);
}
Napi::Value WindowsStop(const Napi::CallbackInfo& info) {
engine().Stop();
return info.Env().Undefined();
}
Napi::Value WindowsDrain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = static_cast<size_t>(requested);
}
}
return engine().Drain(info.Env(), maxChunks);
}
Napi::Value WindowsNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
}
#ifndef PRISM_CAPTURE_CORE_ONLY
Napi::Value WindowsMediaGetSupport(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
@@ -1264,20 +1211,12 @@ Napi::Value WindowsMediaSendSpotifyControl(const Napi::CallbackInfo& info) {
return env.Null();
}
}
#endif
} // namespace
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, WindowsGetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, WindowsListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, WindowsStart));
captureExports.Set("stop", Napi::Function::New(env, WindowsStop));
captureExports.Set("drain", Napi::Function::New(env, WindowsDrain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, WindowsNowMilliseconds));
exports.Set("windowsCapture", captureExports);
#ifndef PRISM_CAPTURE_CORE_ONLY
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
Napi::Object mediaExports = Napi::Object::New(env);
mediaExports.Set("getSupport", Napi::Function::New(env, WindowsMediaGetSupport));
mediaExports.Set(
@@ -1288,5 +1227,14 @@ void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
Napi::Function::New(env, WindowsMediaSendSpotifyControl));
exports.Set("windowsMedia", mediaExports);
}
#endif
namespace Prism::Capture {
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
return std::make_unique<WindowsNativeCaptureEngine>();
}
} // namespace Prism::Capture
#endif // defined(_WIN32)
+1 -1
View File
@@ -2,4 +2,4 @@
#include <napi.h>
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports);
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports);
+1 -48
View File
@@ -2,43 +2,6 @@
namespace {
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(
info.Env(), "Native Windows output-device capture is unavailable on this platform."));
return support;
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "Native Windows output-device capture is unavailable on this platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
Napi::Value MediaGetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
@@ -62,17 +25,7 @@ Napi::Value SendSpotifyControl(const Napi::CallbackInfo& info) {
} // namespace
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, Start));
captureExports.Set("stop", Napi::Function::New(env, Stop));
captureExports.Set("drain", Napi::Function::New(env, Drain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set("windowsCapture", captureExports);
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
Napi::Object mediaExports = Napi::Object::New(env);
mediaExports.Set("getSupport", Napi::Function::New(env, MediaGetSupport));
mediaExports.Set(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "prism",
"version": "0.2.0-beta",
"version": "0.3.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prism",
"version": "0.2.0-beta",
"version": "0.3.0-beta",
"hasInstallScript": true,
"license": "GPL-3.0-only",
"dependencies": {
+89 -8
View File
@@ -1,6 +1,6 @@
{
"name": "prism",
"version": "0.2.0-beta",
"version": "0.3.0-beta",
"description": "Open-source audio metering and visualization tool",
"main": "./out/main/index.js",
"scripts": {
@@ -10,7 +10,10 @@
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"build:icons": "node scripts/build/generate-prism-icons.mjs",
"build:clip-drag-icons": "node scripts/build/generate-audio-clip-drag-icons.mjs",
"test": "node scripts/run-tests.mjs",
"test:audio-router": "node scripts/run-audio-router-tests.mjs",
"test:audio-clips": "node scripts/run-audio-clip-tests.mjs",
"test:audio-store": "node scripts/run-audio-store-tests.mjs",
"test:astra": "node scripts/run-astra-integration-tests.mjs",
"test:capture-support": "node scripts/run-capture-support-tests.mjs",
@@ -20,16 +23,30 @@
"test:themes": "node scripts/run-theme-library-tests.mjs",
"test:secret-vault": "node scripts/run-secret-vault-tests.mjs",
"test:window-state": "node scripts/run-window-state-tests.mjs",
"test:desktop-integration": "node scripts/run-desktop-integration-tests.mjs",
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
"test:spectrum-native": "node --test test/spectrum-native.test.mjs",
"test:spectrogram-native": "node --test test/spectrogram-native.test.mjs",
"test:vectorscope-native": "node --test test/vectorscope-native.test.mjs",
"test:lufsmeter-native": "node --test test/lufsmeter-native.test.mjs",
"test:lufsmeter-ebu": "node scripts/run-ebu-true-peak-tests.mjs",
"test:tui": "node scripts/build/build-tui.cjs --test",
"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",
"configure:tui": "node scripts/build/build-tui.cjs --configure-only",
"build:tui": "node scripts/build/build-tui.cjs",
"stage:tui": "node scripts/build/build-tui.cjs --stage",
"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'",
"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"
"postinstall": "node scripts/build/postinstall-native.cjs",
"dist": "node scripts/build/dist-current.cjs",
"dist:mac": "npm run stage:tui && npm run build && electron-builder --mac --publish never",
"dist:win": "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/build/check-windows-dist-output.ps1 && npm run stage:tui && npm run build && electron-builder --win --publish never",
"dist:linux": "npm run dist:linux:appimage && npm run stage:tui && electron-builder --linux deb rpm tar.gz --publish never",
"dist:linux:appimage": "npm run build && electron-builder --config electron-builder.appimage.cjs --linux AppImage --publish never",
"dist:linux:packages": "npm run stage:tui && electron-builder --linux deb rpm tar.gz --publish never"
},
"repository": {
"type": "git",
@@ -90,16 +107,40 @@
{
"from": "resources/icon.png",
"to": "icon.png"
},
{
"from": "resources/drag/",
"to": "drag/"
},
{
"from": "resources/tray/",
"to": "tray/"
},
{
"from": "plugin/dist-installer/",
"to": "plugins/"
},
{
"from": "tui/dist-installer/",
"to": "tui/"
},
{
"from": "THIRD_PARTY_NOTICES.md",
"to": "THIRD_PARTY_NOTICES.md"
}
],
"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 +148,50 @@
],
"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",
"libpulse0",
"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",
"pulseaudio-libs",
"webkit2gtk4.1"
]
}
}
}
+2
View File
@@ -0,0 +1,2 @@
build/
webview-dist/
+218
View File
@@ -0,0 +1,218 @@
cmake_minimum_required(VERSION 3.22)
# macOS deployment target. Without an override, the build inherits the host SDK
# (e.g. 26 on a current Mac) which restricts the plugin to that macOS or newer.
# 11.0 (Big Sur) is a reasonable floor; well within JUCE 8's supported range.
# Override on the command line with -DCMAKE_OSX_DEPLOYMENT_TARGET=X.X — the FORCE
# is only applied when the value is empty/unset, so explicit overrides win.
if(NOT CMAKE_OSX_DEPLOYMENT_TARGET)
set(CMAKE_OSX_DEPLOYMENT_TARGET "11.0" CACHE STRING "Minimum macOS version supported" FORCE)
endif()
project(PrismPlugins VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# JUCE: use a local checkout if provided (-DJUCE_PATH=/path/to/JUCE), otherwise
# fetch a pinned release.
if(DEFINED JUCE_PATH)
add_subdirectory(${JUCE_PATH} juce-build)
else()
include(FetchContent)
FetchContent_Declare(JUCE
GIT_REPOSITORY https://github.com/juce-framework/JUCE.git
GIT_TAG 8.0.4
GIT_SHALLOW TRUE)
FetchContent_MakeAvailable(JUCE)
endif()
# Reused, unmodified Prism DSP (no N-API dependency).
set(PRISM_NATIVE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../native/src)
# UI delivery: bundled (self-contained, default) vs Vite dev server (hot reload).
option(PRISM_DEV_SERVER "Load the webview UI from the Vite dev server instead of the embedded bundle" OFF)
# Build controls. Defaults preserve the full local build; CI narrows these to
# the formats that are actually packaged. AU is macOS-only, so Linux/Windows
# default to the portable VST3 + Standalone set.
if(APPLE)
set(PRISM_DEFAULT_PLUGIN_FORMATS "AU;VST3;Standalone")
else()
set(PRISM_DEFAULT_PLUGIN_FORMATS "VST3;Standalone")
endif()
set(PRISM_PLUGIN_FORMATS "${PRISM_DEFAULT_PLUGIN_FORMATS}" CACHE STRING "JUCE plugin formats to build")
option(PRISM_COPY_PLUGIN_AFTER_BUILD "Copy built plugins into user plugin folders after build" ON)
set(PRISM_LINUX_UI_MODE "webview" CACHE STRING "Linux-only UI diagnostic mode: webview, web_smoke, native_smoke, or floating_webview")
set_property(CACHE PRISM_LINUX_UI_MODE PROPERTY STRINGS webview web_smoke native_smoke floating_webview)
set(PRISM_VALID_LINUX_UI_MODES webview web_smoke native_smoke floating_webview)
option(PRISM_LINUX_UI_DIAGNOSTICS "Write Linux VST UI diagnostic logs" OFF)
if(UNIX AND NOT APPLE)
list(FIND PRISM_VALID_LINUX_UI_MODES "${PRISM_LINUX_UI_MODE}" PRISM_LINUX_UI_MODE_INDEX)
if(PRISM_LINUX_UI_MODE_INDEX EQUAL -1)
message(FATAL_ERROR "Invalid PRISM_LINUX_UI_MODE='${PRISM_LINUX_UI_MODE}'. Expected one of: ${PRISM_VALID_LINUX_UI_MODES}")
endif()
else()
if(NOT PRISM_LINUX_UI_MODE STREQUAL "webview")
message(FATAL_ERROR "PRISM_LINUX_UI_MODE is Linux-only; macOS/Windows builds must use 'webview'.")
endif()
endif()
set(PRISM_NEEDS_WEBUI OFF)
if(NOT PRISM_DEV_SERVER)
if(NOT UNIX OR APPLE OR PRISM_LINUX_UI_MODE STREQUAL "webview" OR PRISM_LINUX_UI_MODE STREQUAL "floating_webview")
set(PRISM_NEEDS_WEBUI ON)
endif()
endif()
if(PRISM_NEEDS_WEBUI)
set(PRISM_EMBED_WEBUI_DEFINITION PRISM_EMBED_WEBUI=1)
else()
set(PRISM_EMBED_WEBUI_DEFINITION PRISM_EMBED_WEBUI=0)
endif()
string(TOUPPER "${PRISM_LINUX_UI_MODE}" PRISM_LINUX_UI_MODE_UPPER)
set(PRISM_LINUX_UI_MODE_DEFINITIONS
PRISM_LINUX_UI_MODE_NAME="${PRISM_LINUX_UI_MODE}"
PRISM_LINUX_UI_MODE_${PRISM_LINUX_UI_MODE_UPPER}=1)
# Embed the built webview bundle once; every scope plugin shares it (the C++ tells
# the UI which scope to mount via initialisation data).
if(PRISM_NEEDS_WEBUI)
set(PRISM_WEBUI_DIST ${CMAKE_CURRENT_SOURCE_DIR}/webview-dist)
file(GLOB_RECURSE PRISM_WEBUI_FILES "${PRISM_WEBUI_DIST}/*")
if(NOT PRISM_WEBUI_FILES)
message(FATAL_ERROR "No webview bundle at ${PRISM_WEBUI_DIST}. Run: npm run plugin-ui:build")
endif()
juce_add_binary_data(PrismWebUI SOURCES ${PRISM_WEBUI_FILES})
endif()
if(UNIX AND NOT APPLE)
find_package(PkgConfig REQUIRED)
pkg_check_modules(PRISM_LINUX_WEBVIEW_DEPS REQUIRED
webkit2gtk-4.1
gtk+-x11-3.0)
endif()
# Define one per-scope plugin product from the shared codebase.
# SCOPE_DEFINE selects the ScopeEngine at compile time (empty = spectrum default).
function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE)
juce_add_plugin(${TARGET}
PRODUCT_NAME ${PRODUCT}
COMPANY_NAME "Prism"
BUNDLE_ID com.astra.prism.${TARGET}
PLUGIN_MANUFACTURER_CODE Prsm
PLUGIN_CODE ${PLUGIN_CODE}
FORMATS ${PRISM_PLUGIN_FORMATS}
IS_SYNTH FALSE
NEEDS_MIDI_INPUT FALSE
NEEDS_MIDI_OUTPUT FALSE
IS_MIDI_EFFECT FALSE
NEEDS_WEBVIEW2 TRUE # Windows: links Microsoft Edge WebView2. No-op elsewhere.
COPY_PLUGIN_AFTER_BUILD ${PRISM_COPY_PLUGIN_AFTER_BUILD})
target_sources(${TARGET} PRIVATE
Source/PluginProcessor.cpp
Source/PluginEditor.cpp
${PRISM_NATIVE_DIR}/spectrum.cpp
${PRISM_NATIVE_DIR}/oscilloscope.cpp
${PRISM_NATIVE_DIR}/vumeter.cpp
${PRISM_NATIVE_DIR}/lufsmeter.cpp
${PRISM_NATIVE_DIR}/vectorscope.cpp
${PRISM_NATIVE_DIR}/multiband.cpp
${PRISM_NATIVE_DIR}/spectrogram.cpp
${PRISM_NATIVE_DIR}/waveform.cpp
${PRISM_NATIVE_DIR}/dsp_utils.cpp)
target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR})
target_compile_definitions(${TARGET} PRIVATE
JUCE_WEB_BROWSER=1 # enables WebBrowserComponent (WKWebView on macOS)
JUCE_USE_CURL=0
JUCE_VST3_CAN_REPLACE_VST2=0
${PRISM_EMBED_WEBUI_DEFINITION}
PRISM_LINUX_UI_DIAGNOSTICS=$<BOOL:${PRISM_LINUX_UI_DIAGNOSTICS}>
${PRISM_LINUX_UI_MODE_DEFINITIONS})
if(WIN32)
# NEEDS_WEBVIEW2 links the WebView2 SDK but doesn't turn on the JUCE compile
# paths that gate withResourceProvider. JUCE_USE_WIN_WEBVIEW2=1 enables the
# WebView2-aware code (incl. the resource provider); _WITH_STATIC_LINKING=1
# matches the static-linked SDK that NEEDS_WEBVIEW2 wires up.
target_compile_definitions(${TARGET} PRIVATE
JUCE_USE_WIN_WEBVIEW2=1
JUCE_USE_WIN_WEBVIEW2_WITH_STATIC_LINKING=1)
endif()
if(NOT SCOPE_DEFINE STREQUAL "")
target_compile_definitions(${TARGET} PRIVATE ${SCOPE_DEFINE})
endif()
# macOS: private-API helper to lift WKWebView's 60fps cap (no public alternative).
if(APPLE)
target_sources(${TARGET} PRIVATE Source/WebViewFrameRate.mm)
set_source_files_properties(Source/WebViewFrameRate.mm PROPERTIES COMPILE_FLAGS "-fno-objc-arc")
target_link_libraries(${TARGET} PRIVATE "-framework WebKit")
endif()
if(PRISM_DEV_SERVER)
target_compile_definitions(${TARGET} PRIVATE PRISM_USE_DEV_SERVER=1)
else()
target_compile_definitions(${TARGET} PRIVATE PRISM_USE_DEV_SERVER=0)
endif()
if(PRISM_NEEDS_WEBUI)
target_link_libraries(${TARGET} PRIVATE PrismWebUI)
endif()
target_link_libraries(${TARGET} PRIVATE
juce::juce_audio_utils
juce::juce_gui_extra
PRIVATE
juce::juce_recommended_config_flags
juce::juce_recommended_lto_flags
juce::juce_recommended_warning_flags)
if(UNIX AND NOT APPLE)
# JUCE's Linux WebBrowserComponent dynamically opens WebKitGTK/GTK when
# the editor is created. We need their headers to compile, but linking
# them here makes DAW plugin scanners resolve WebKitGTK/JSC before the UI
# is even opened, which is fragile when hosts carry their own GLib stack.
target_include_directories(${TARGET} PRIVATE ${PRISM_LINUX_WEBVIEW_DEPS_INCLUDE_DIRS})
target_compile_options(${TARGET} PRIVATE ${PRISM_LINUX_WEBVIEW_DEPS_CFLAGS_OTHER})
# In plugin hosts, JUCE's Linux WebBrowserComponent must exec a helper
# process for WebKitGTK. Without this, JUCE falls back to running WebKit
# directly after fork(), which is unsafe in multi-threaded hosts and has
# crashed during pluginval/DAW editor creation.
juce_link_with_embedded_linux_subprocess(${TARGET})
endif()
endfunction()
add_prism_scope(PrismSpectrum "Prism Spectrum" Pspc "")
add_prism_scope(PrismOscilloscope "Prism Oscilloscope" Posc "PRISM_SCOPE_OSCILLOSCOPE=1")
add_prism_scope(PrismVUMeter "Prism VU Meter" Pvum "PRISM_SCOPE_VUMETER=1")
add_prism_scope(PrismLUFSMeter "Prism Loudness Meter" Pluf "PRISM_SCOPE_LUFSMETER=1")
add_prism_scope(PrismVectorscope "Prism Vectorscope" Pvct "PRISM_SCOPE_VECTORSCOPE=1")
add_prism_scope(PrismSpectrogram "Prism Spectrogram" Pspg "PRISM_SCOPE_SPECTROGRAM=1")
add_prism_scope(PrismWaveform "Prism Waveform" Pwav "PRISM_SCOPE_WAVEFORM=1")
set(PRISM_SCOPE_TARGETS
PrismSpectrum
PrismOscilloscope
PrismVUMeter
PrismLUFSMeter
PrismVectorscope
PrismSpectrogram
PrismWaveform)
add_custom_target(PrismInstallerPlugins)
foreach(PRISM_SCOPE_TARGET IN LISTS PRISM_SCOPE_TARGETS)
if("VST3" IN_LIST PRISM_PLUGIN_FORMATS)
add_dependencies(PrismInstallerPlugins ${PRISM_SCOPE_TARGET}_VST3)
endif()
if(APPLE AND "AU" IN_LIST PRISM_PLUGIN_FORMATS)
add_dependencies(PrismInstallerPlugins ${PRISM_SCOPE_TARGET}_AU)
endif()
endforeach()
+127
View File
@@ -0,0 +1,127 @@
# Prism — DAW plugins
JUCE 8 plugins (VST3 / AU / Standalone) that render Prism's scopes inside a DAW —
one plugin per scope: **spectrum, oscilloscope, vectorscope, spectrogram, VU meter,
loudness meter, waveform**. They **reuse Prism's existing C++ DSP** (`native/src/*.cpp`)
and the **existing React canvas UI** (`src/plugin-ui`, importing the unchanged
visualizers from `src/renderer/visualizers/`). VST3 is built for macOS, Windows,
and Linux; AU is macOS-only.
## How it fits together
```
DAW track audio
→ processBlock (RT thread): mix to mono, write to lock-free FIFO [Source/PluginProcessor.cpp]
→ 60 Hz timer (message thread): drain FIFO → Visualizer::Spectrum [Source/PluginEditor.cpp + native/src/spectrum.cpp]
→ emit "spectrumFrame" (base64 Float32 magnitudes) to the webview
→ juceBridge.ts decodes → BridgeSpectrumAnalyzer (a SpectrumNativeAnalyzer shim)
→ SpectrumAnalyzer.ts renders to canvas (unchanged Electron code) [src/plugin-ui]
```
No DSP or allocation runs on the realtime audio thread; audio passes through unmodified.
## Build & run (macOS)
Prereqs: CMake ≥ 3.22, Xcode command-line tools, Node.
### Default: self-contained build (embedded UI)
The UI is bundled into the plugin binary and served via JUCE's resource provider —
**no dev server needed at runtime.**
```sh
npm run plugin-ui:build # build the webview bundle → plugin/webview-dist
cmake -B plugin/build -S plugin -DCMAKE_BUILD_TYPE=Release # embeds the bundle (reconfigure to pick up UI changes)
cmake --build plugin/build --config Release
```
`COPY_PLUGIN_AFTER_BUILD` installs all seven plugins into your user plugin folders:
- AU: `~/Library/Audio/Plug-Ins/Components/Prism *.component`
- VST3: `~/Library/Audio/Plug-Ins/VST3/Prism *.vst3`
Load any `Prism *` AU / VST3 on a track in Ableton / FL / Logic / Reaper (or run the
matching **Standalone** from `plugin/build/Prism*_artefacts/Release/Standalone/`),
play audio, and the scope animates.
(To use a local JUCE checkout instead of fetching: add `-DJUCE_PATH=/path/to/JUCE`.)
### UI development: dev-server mode (hot reload)
```sh
npm run plugin-ui:dev # serve UI on :5174 with HMR
cmake -B plugin/build -S plugin -DPRISM_DEV_SERVER=ON # editor loads http://localhost:5174
cmake --build plugin/build --config Release
```
Edit React → the plugin window hot-reloads. The UI also runs in a plain browser at
`http://localhost:5174` (no JUCE host → it shows a synthetic spectrum so the UI is
developable outside a DAW). Reconfigure without `-DPRISM_DEV_SERVER=ON` to go back to embedded.
## Build & run (Windows)
Prereqs: Visual Studio 2022 with the "Desktop development with C++" workload,
CMake ≥ 3.22, Node.js. Modern Win10 / Win11 ships with the Microsoft Edge WebView2
Runtime preinstalled; if it's missing, install the "Evergreen Standalone Installer"
from Microsoft.
```sh
npm install
npm run plugin-ui:build
cmake -B plugin\build -S plugin -G "Visual Studio 17 2022" -A x64
cmake --build plugin\build --config Release
```
`COPY_PLUGIN_AFTER_BUILD` targets the system VST3 folder
(`C:\Program Files\Common Files\VST3\Prism *.vst3`), which **needs admin
privileges**. Either run the `cmake --build` step from an elevated shell, or copy
the built bundles from `plugin\build\Prism*_artefacts\Release\VST3\` into your
user-local VST3 folder (`%LOCALAPPDATA%\Programs\Common\VST3\`) by hand — most
DAWs scan both.
Dev-server mode works the same as macOS: `-DPRISM_DEV_SERVER=ON` + `npm run plugin-ui:dev`.
## Build & run (Linux)
Prereqs: CMake >= 3.22, Ninja, GCC/Clang, Node.js, and JUCE's Linux GUI/WebView
dependencies. On Ubuntu 24.04+:
```sh
sudo apt-get install build-essential cmake ninja-build pkg-config \
libasound2-dev libjack-jackd2-dev ladspa-sdk libcurl4-openssl-dev \
libfreetype-dev libfontconfig1-dev libgtk-3-dev libx11-dev libxcomposite-dev \
libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev libxrender-dev \
libwebkit2gtk-4.1-dev libglu1-mesa-dev mesa-common-dev
```
```sh
npm install
npm run plugin-ui:build
cmake -B plugin/build -S plugin -G Ninja -DCMAKE_BUILD_TYPE=Release -DPRISM_PLUGIN_FORMATS=VST3
cmake --build plugin/build --target PrismInstallerPlugins --parallel
```
Built bundles land under `plugin/build/Prism*_artefacts/Release/VST3/`. Copy the
seven `Prism *.vst3` directories to one of the standard Linux VST3 scan paths:
- User-local: `$HOME/.vst3`
- System-wide: `/usr/lib/vst3`
- System-wide local: `/usr/local/lib/vst3`
The Linux `.deb` and `.rpm` release packages install Prism's VST3 bundles to
`/usr/lib/vst3` and remove only those seven bundles on package removal. The Linux
`tar.gz` release includes `resources/plugins/install-vst3.sh`, which installs to
`$HOME/.vst3` by default or `/usr/lib/vst3` with `--system`. The AppImage is
portable app-only and does not install DAW plugins.
## Notes
- **Refresh rate (macOS):** frames are emitted on `juce::VBlankAttachment` (synced to the
display, adapts to 60/120/144 Hz) and the webview renders via the `display-sync`
FrameScheduler. WKWebView otherwise throttles `requestAnimationFrame` to 60 fps regardless
of display — `Source/WebViewFrameRate.mm` lifts that by disabling WebKit's private
`PreferPageRenderingUpdatesNear60FPSEnabled` feature on the live web view (no public API
exists; fine for a non-App-Store FOSS plugin). To diagnose, set `showFpsMeter` on
`<SpectrumScope/>` to overlay `render / data` fps.
- **Windows:** `NEEDS_WEBVIEW2 TRUE` in CMake links Microsoft's Edge WebView2 runtime.
The macOS 120 Hz uncap (`Source/WebViewFrameRate.mm`, gated `if(APPLE)`) doesn't
apply — WebView2 has its own rate behavior; please report actual fps if it ever
feels low.
+57
View File
@@ -0,0 +1,57 @@
#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, true peak, and
* the same fast VU/correlation block) and emits its scalar snapshot each frame.
* The frame is plain numbers (no base64); getSnapshot() advances true-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("truePeakLDb", snap.truePeakLDb);
obj->setProperty("truePeakRDb", snap.truePeakRDb);
obj->setProperty("maxTruePeakDb", snap.maxTruePeakDb);
obj->setProperty("correlation", snap.correlation);
return juce::var(obj);
}
private:
const juce::Identifier frameId { "lufsmeterFrame" };
Visualizer::LUFSMeterAnalyzer lufs;
};
+99
View File
@@ -0,0 +1,99 @@
#pragma once
#include "ScopeEngine.h"
#include "oscilloscope.h" // reused, unmodified, from native/src
#include <vector>
#include <algorithm>
#include <cmath>
class OscilloscopeEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "oscilloscope"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 760, 300, 320, 160 }; }
void setSampleRate(double sampleRate) override
{
osc.setSampleRate((float) sampleRate);
osc.setDisplaySamples(normalizedDisplaySamples(sampleRate));
}
void configure(const juce::var& settings) override
{
pitchLock = (bool) settings.getProperty("pitchLock", true);
osc.setPitchLock(pitchLock);
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
if ((int) mono.size() < numSamples)
mono.resize((size_t) numSamples);
for (int i = 0; i < numSamples; ++i)
mono[(size_t) i] = 0.5f * (left[i] + right[i]);
osc.pushSamples(mono.data(), (size_t) numSamples);
samplesSeen += numSamples;
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("pitch", 0.0);
// Pitch lock needs samples buffered before trigger detection is meaningful.
if (pitchLock && samplesSeen < kWarmupSamples)
{
obj->setProperty("samples", juce::String());
return juce::var(obj);
}
const auto result = osc.process();
const int samplesToShow = result.samplesToShow;
if (samplesToShow <= 1)
{
obj->setProperty("samples", juce::String());
return juce::var(obj);
}
float triggerIndex = result.triggerIndex;
if (! pitchLock)
{
// Free-run: show the most recent window ending at the write head.
const size_t writePos = osc.getWritePos();
triggerIndex = (float) ((writePos + Visualizer::OSCILLOSCOPE_BUFFER_SIZE - (size_t) samplesToShow)
% Visualizer::OSCILLOSCOPE_BUFFER_SIZE);
}
if ((int) window.size() != samplesToShow)
window.resize((size_t) samplesToShow);
osc.getSamplesInterpolated(window.data(), triggerIndex, (size_t) samplesToShow);
obj->setProperty("pitch", result.detectedPitch);
obj->setProperty("samples", juce::Base64::toBase64(window.data(), window.size() * sizeof(float)));
return juce::var(obj);
}
private:
// Mirrors getNormalizedOscilloscopeDisplaySamples in the renderer.
static int normalizedDisplaySamples(double sampleRate)
{
const double base = 2048.0, rateMin = 44100.0, rateMax = 48000.0;
double samples = base;
if (sampleRate > 0.0)
{
if (sampleRate < rateMin) samples = std::round(base * (sampleRate / rateMin));
else if (sampleRate > rateMax) samples = std::round(base * (sampleRate / rateMax));
}
return (int) std::clamp(samples, 64.0, 32767.0);
}
static constexpr long long kWarmupSamples = 4096;
const juce::Identifier frameId { "oscilloscopeFrame" };
Visualizer::Oscilloscope osc;
std::vector<float> mono, window;
bool pitchLock = true;
long long samplesSeen = 0;
};
+718
View File
@@ -0,0 +1,718 @@
#include "PluginEditor.h"
#include "SpectrumEngine.h"
#include "OscilloscopeEngine.h"
#include "VUMeterEngine.h"
#include "LUFSMeterEngine.h"
#include "VectorscopeEngine.h"
#include "SpectrogramEngine.h"
#include "WaveformEngine.h"
#include <cmath>
#include <cstdlib>
#include <cstring>
#ifndef PRISM_EMBED_WEBUI
#define PRISM_EMBED_WEBUI 0
#endif
#ifndef PRISM_LINUX_UI_MODE_NAME
#define PRISM_LINUX_UI_MODE_NAME "webview"
#endif
#ifndef PRISM_LINUX_UI_DIAGNOSTICS
#define PRISM_LINUX_UI_DIAGNOSTICS 0
#endif
#if PRISM_EMBED_WEBUI
#include "BinaryData.h"
#endif
#if JUCE_MAC
#include "WebViewFrameRate.h"
#endif
namespace
{
const juce::Identifier kRestoreSettingsEvent { "prismRestoreSettings" };
constexpr int kDrainCapacity = 1 << 16;
#if JUCE_WINDOWS
constexpr int kFallbackWindowsFrameRateHz = 60;
constexpr int kMaxWindowsFrameRateHz = 240;
int resolveWindowsFrameRateHz(const juce::Component& component)
{
const auto& displays = juce::Desktop::getInstance().getDisplays();
if (const auto* display = displays.getDisplayForRect(component.getScreenBounds()))
{
const auto frequency = display->verticalFrequencyHz;
if (frequency.has_value() && std::isfinite(*frequency) && *frequency > 0.0)
return juce::jlimit(30, kMaxWindowsFrameRateHz, (int) std::lround(*frequency));
}
return kFallbackWindowsFrameRateHz;
}
#endif
#if JUCE_LINUX
constexpr int kLinuxFrameRateHz = 20;
#if PRISM_LINUX_UI_DIAGNOSTICS
juce::String envValue(const char* name)
{
if (const auto* value = std::getenv(name); value != nullptr && value[0] != '\0')
return value;
return "<unset>";
}
juce::File linuxDiagnosticLogFile()
{
juce::File baseDir;
if (const auto* xdgState = std::getenv("XDG_STATE_HOME"); xdgState != nullptr && xdgState[0] != '\0')
baseDir = juce::File(juce::String(xdgState));
else if (const auto* home = std::getenv("HOME"); home != nullptr && home[0] != '\0')
baseDir = juce::File(juce::String(home)).getChildFile(".local").getChildFile("state");
else
baseDir = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory);
return baseDir.getChildFile("prism").getChildFile("plugin-ui-diagnostics.log");
}
void logLinuxDiagnostic(const juce::String& message)
{
const auto file = linuxDiagnosticLogFile();
file.getParentDirectory().createDirectory();
file.appendText(juce::Time::getCurrentTime().toISO8601(true) + " " + message + "\n",
false,
false,
"\n");
}
juce::String wrapperTypeName()
{
switch (juce::PluginHostType::getPluginLoadedAs())
{
case juce::AudioProcessor::wrapperType_VST3: return "VST3";
case juce::AudioProcessor::wrapperType_VST: return "VST2";
case juce::AudioProcessor::wrapperType_Standalone: return "Standalone";
case juce::AudioProcessor::wrapperType_AudioUnit: return "AudioUnit";
case juce::AudioProcessor::wrapperType_AudioUnitv3:return "AudioUnitv3";
case juce::AudioProcessor::wrapperType_AAX: return "AAX";
case juce::AudioProcessor::wrapperType_LV2: return "LV2";
case juce::AudioProcessor::wrapperType_Unity: return "Unity";
default: return "Undefined";
}
}
juce::String linuxHostDisplayContext()
{
const juce::PluginHostType host;
juce::String context;
context << "mode=" << PRISM_LINUX_UI_MODE_NAME
<< " host=\"" << host.getHostDescription() << "\""
<< " wrapper=" << wrapperTypeName()
<< " display=" << envValue("DISPLAY")
<< " waylandDisplay=" << envValue("WAYLAND_DISPLAY")
<< " sessionType=" << envValue("XDG_SESSION_TYPE")
<< " gdkBackend=" << envValue("GDK_BACKEND")
<< " desktop=" << envValue("XDG_CURRENT_DESKTOP");
return context;
}
juce::String describeBounds(const juce::Rectangle<int>& bounds)
{
juce::String text;
text << bounds.getX() << "," << bounds.getY()
<< " " << bounds.getWidth() << "x" << bounds.getHeight();
return text;
}
#endif
#endif
std::unique_ptr<ScopeEngine> makeEngine()
{
#if defined(PRISM_SCOPE_WAVEFORM) && PRISM_SCOPE_WAVEFORM
return std::make_unique<WaveformEngine>();
#elif defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM
return std::make_unique<SpectrogramEngine>();
#elif defined(PRISM_SCOPE_VECTORSCOPE) && PRISM_SCOPE_VECTORSCOPE
return std::make_unique<VectorscopeEngine>();
#elif defined(PRISM_SCOPE_LUFSMETER) && PRISM_SCOPE_LUFSMETER
return std::make_unique<LUFSMeterEngine>();
#elif defined(PRISM_SCOPE_VUMETER) && PRISM_SCOPE_VUMETER
return std::make_unique<VUMeterEngine>();
#elif defined(PRISM_SCOPE_OSCILLOSCOPE) && PRISM_SCOPE_OSCILLOSCOPE
return std::make_unique<OscilloscopeEngine>();
#else
return std::make_unique<SpectrumEngine>();
#endif
}
#if PRISM_USE_DEV_SERVER
const juce::String kDevServerUrl { "http://localhost:5174" };
#elif PRISM_EMBED_WEBUI
const char* getWebResourceData(const juce::String& name, int& dataSize)
{
dataSize = 0;
for (int i = 0; i < BinaryData::namedResourceListSize; ++i)
{
if (name == juce::String(BinaryData::originalFilenames[i]))
return BinaryData::getNamedResource(BinaryData::namedResourceList[i], dataSize);
}
return nullptr;
}
juce::String mimeForExtension(const juce::String& name)
{
if (name.endsWithIgnoreCase(".html")) return "text/html";
if (name.endsWithIgnoreCase(".js")) return "text/javascript";
if (name.endsWithIgnoreCase(".css")) return "text/css";
if (name.endsWithIgnoreCase(".svg")) return "image/svg+xml";
if (name.endsWithIgnoreCase(".json")) return "application/json";
if (name.endsWithIgnoreCase(".woff2")) return "font/woff2";
if (name.endsWithIgnoreCase(".woff")) return "font/woff";
if (name.endsWithIgnoreCase(".png")) return "image/png";
return "application/octet-stream";
}
std::optional<juce::WebBrowserComponent::Resource> provideResource(const juce::String& url)
{
auto name = (url == "/") ? juce::String("index.html")
: url.fromLastOccurrenceOf("/", false, false);
name = name.upToFirstOccurrenceOf("?", false, false);
int dataSize = 0;
if (const char* data = getWebResourceData(name, dataSize))
{
std::vector<std::byte> bytes ((size_t) dataSize);
std::memcpy (bytes.data(), data, (size_t) dataSize);
return juce::WebBrowserComponent::Resource { std::move (bytes), mimeForExtension (name) };
}
return std::nullopt;
}
juce::String getResourceProviderOrigin()
{
return juce::WebBrowserComponent::getResourceProviderRoot().trimCharactersAtEnd("/");
}
#if JUCE_LINUX
juce::File writeLinuxWebViewIndexFile()
{
int dataSize = 0;
const char* data = getWebResourceData("index.html", dataSize);
if (data == nullptr || dataSize <= 0)
return {};
const auto webViewDir =
juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
.getChildFile("prism")
.getChildFile("plugin-webview");
if (! webViewDir.createDirectory())
return {};
auto indexFile = webViewDir.getChildFile("index.html");
if (! indexFile.replaceWithData(data, (size_t) dataSize))
return {};
return indexFile;
}
#endif
#endif
#if JUCE_LINUX
juce::String makeWebSmokeUrl()
{
const juce::String html =
"<!doctype html><html><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
"<style>html,body{width:100%;height:100%;margin:0;background:#000;color:rgba(255,255,255,.82);"
"font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}body{display:grid;place-items:center;}"
"#status{padding:20px;text-align:center;white-space:pre-wrap;}</style></head>"
"<body><div id=\"status\">Prism WebView smoke test\\nWaiting for JUCE bridge...</div>"
"<script>"
"function ready(){var s=document.getElementById('status');"
"try{if(window.__JUCE__&&window.__JUCE__.backend){window.__JUCE__.backend.emitEvent('prismReady',{});"
"if(s)s.textContent='Prism WebView smoke test\\nJUCE bridge emitted prismReady';return;}}"
"catch(e){if(s)s.textContent='Prism WebView smoke test\\n'+String(e&&e.message||e);return;}"
"setTimeout(ready,50);}ready();"
"</script></body></html>";
return "data:text/html;charset=utf-8," + juce::URL::addEscapeChars(html, false);
}
class FloatingWebViewWindow final : public juce::DocumentWindow
{
public:
FloatingWebViewWindow()
: juce::DocumentWindow("Prism WebView diagnostic",
juce::Colours::black,
juce::DocumentWindow::closeButton)
{
setUsingNativeTitleBar(true);
setResizable(true, true);
}
void closeButtonPressed() override
{
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("floating_webview closeButtonPressed");
#endif
setVisible(false);
}
};
#endif
juce::WebBrowserComponent::Options makeWebOptions(PrismSpectrumEditor& editor, const char* scopeId)
{
auto options = juce::WebBrowserComponent::Options{}
.withNativeIntegrationEnabled()
.withInitialisationData("prismScope", juce::String(scopeId))
.withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); })
.withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); })
.withEventListener("prismSpectrogramConfig", [&editor](juce::var v) { editor.onScopeNativeConfig(std::move(v)); })
.withEventListener("prismScopeMeasurement", [&editor](juce::var v) { editor.onScopeMeasurement(std::move(v)); })
.withEventListener("prismSettingsPanel", [&editor](juce::var v) { editor.onSettingsPanel(std::move(v)); });
#if JUCE_WINDOWS
options = options.withBackend(juce::WebBrowserComponent::Options::Backend::webview2);
// WebView2's default user-data folder is created next to the host executable.
// For plugins installed under Program Files\Common Files\VST3\..., that path
// is read-only to the (non-admin) DAW process; WebView2 may fail to
// initialise, leaving a blank webview or canceled navigation page.
// Point it at a writable per-user folder instead (JUCE's docs flag this
// explicitly for plugin projects).
const auto webView2DataDir =
juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory)
.getChildFile("prism")
.getChildFile("WebView2");
webView2DataDir.createDirectory();
options = options.withWinWebView2Options(
juce::WebBrowserComponent::Options::WinWebView2{}.withUserDataFolder(webView2DataDir));
#endif
#if PRISM_EMBED_WEBUI
options = options.withResourceProvider(
[](const auto& url) { return provideResource(url); },
getResourceProviderOrigin());
#endif
return options;
}
}
PrismSpectrumEditor::PrismSpectrumEditor(PrismSpectrumProcessor& p)
: juce::AudioProcessorEditor(&p),
processorRef(p),
engine(makeEngine())
{
drainLeft.assign((size_t) kDrainCapacity, 0.0f);
drainRight.assign((size_t) kDrainCapacity, 0.0f);
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("editor constructed scope=" + juce::String(engine->scopeId())
+ " " + linuxHostDisplayContext()
+ " logFile=" + linuxDiagnosticLogFile().getFullPathName());
#endif
#endif
loadUi();
// Per-scope sizing: open at the scope's preferred default, with a per-scope min.
const auto pref = engine->preferredSize();
setResizable(true, true);
setResizeLimits(pref.minWidth, pref.minHeight, 4096, 4096);
setSize(pref.defaultWidth, pref.defaultHeight);
}
PrismSpectrumEditor::~PrismSpectrumEditor()
{
#if JUCE_WINDOWS || JUCE_LINUX
stopTimer();
#endif
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("editor destructed scope=" + juce::String(engine->scopeId())
+ " mode=" + juce::String(PRISM_LINUX_UI_MODE_NAME));
#endif
floatingWebViewWindow.reset();
#endif
webViewReady = false;
}
void PrismSpectrumEditor::resized()
{
if (webView != nullptr && webView->getParentComponent() == this)
webView->setBounds(getLocalBounds());
webViewFallback.setBounds(getLocalBounds());
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("editor resized local=" + describeBounds(getLocalBounds())
+ " screen=" + describeBounds(getScreenBounds())
+ " mode=" + juce::String(PRISM_LINUX_UI_MODE_NAME));
#endif
#endif
}
void PrismSpectrumEditor::loadUi()
{
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("loadUi " + linuxHostDisplayContext());
#endif
#if defined(PRISM_LINUX_UI_MODE_NATIVE_SMOKE) && PRISM_LINUX_UI_MODE_NATIVE_SMOKE
loadNativeSmoke();
return;
#elif defined(PRISM_LINUX_UI_MODE_WEB_SMOKE) && PRISM_LINUX_UI_MODE_WEB_SMOKE
loadWebSmoke();
return;
#elif defined(PRISM_LINUX_UI_MODE_FLOATING_WEBVIEW) && PRISM_LINUX_UI_MODE_FLOATING_WEBVIEW
loadFloatingWebView();
return;
#endif
#endif
loadEmbeddedWebView();
}
bool PrismSpectrumEditor::createWebView()
{
auto options = makeWebOptions(*this, engine->scopeId());
#if JUCE_WINDOWS
if (! juce::WebBrowserComponent::areOptionsSupported(options))
{
showWebViewFallback();
return false;
}
#endif
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("creating WebBrowserComponent mode=" + juce::String(PRISM_LINUX_UI_MODE_NAME));
#endif
#endif
webView = std::make_unique<juce::WebBrowserComponent>(options);
return true;
}
void PrismSpectrumEditor::loadEmbeddedWebView()
{
if (! createWebView())
return;
addAndMakeVisible(*webView);
#if PRISM_USE_DEV_SERVER
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating embedded WebView url=" + kDevServerUrl);
#endif
#endif
webView->goToURL(kDevServerUrl);
#elif PRISM_EMBED_WEBUI
#if JUCE_LINUX
const auto indexFile = writeLinuxWebViewIndexFile();
if (indexFile.existsAsFile())
{
const auto url = juce::URL(indexFile).toString(false);
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating embedded WebView url=" + url);
#endif
webView->goToURL(url);
return;
}
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating embedded WebView url=" + juce::WebBrowserComponent::getResourceProviderRoot());
#endif
#endif
webView->goToURL(juce::WebBrowserComponent::getResourceProviderRoot());
#endif
}
#if JUCE_LINUX
void PrismSpectrumEditor::showLinuxDiagnosticPlaceholder(const juce::String& text)
{
webViewFallback.setText(text, juce::dontSendNotification);
webViewFallback.setJustificationType(juce::Justification::centred);
webViewFallback.setColour(juce::Label::backgroundColourId, juce::Colours::black);
webViewFallback.setColour(juce::Label::textColourId, juce::Colour(0xffe5e7eb));
webViewFallback.setMinimumHorizontalScale(0.7f);
addAndMakeVisible(webViewFallback);
}
void PrismSpectrumEditor::loadNativeSmoke()
{
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("native_smoke loaded");
#endif
webViewReady = true;
showLinuxDiagnosticPlaceholder("Prism native smoke test\n"
"Root JUCE editor rendered without WebView\n"
"Mode: native_smoke");
}
void PrismSpectrumEditor::loadWebSmoke()
{
if (! createWebView())
return;
addAndMakeVisible(*webView);
const auto url = makeWebSmokeUrl();
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating web_smoke WebView url=data:text/html;charset=utf-8,<inline smoke page>");
#endif
webView->goToURL(url);
}
void PrismSpectrumEditor::loadFloatingWebView()
{
showLinuxDiagnosticPlaceholder("Prism floating WebView diagnostic\n"
"Embedded native placeholder is visible\n"
"The real Prism WebView should open in a separate window");
if (! createWebView())
return;
floatingWebViewWindow = std::make_unique<FloatingWebViewWindow>();
floatingWebViewWindow->setContentNonOwned(webView.get(), false);
const auto pref = engine->preferredSize();
floatingWebViewWindow->setSize(pref.defaultWidth, pref.defaultHeight);
floatingWebViewWindow->centreWithSize(pref.defaultWidth, pref.defaultHeight);
floatingWebViewWindow->setVisible(true);
floatingWebViewWindow->toFront(true);
#if PRISM_USE_DEV_SERVER
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating floating WebView url=" + kDevServerUrl);
#endif
webView->goToURL(kDevServerUrl);
#elif PRISM_EMBED_WEBUI
const auto indexFile = writeLinuxWebViewIndexFile();
if (indexFile.existsAsFile())
{
const auto url = juce::URL(indexFile).toString(false);
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating floating WebView url=" + url);
#endif
webView->goToURL(url);
return;
}
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("navigating floating WebView url=" + juce::WebBrowserComponent::getResourceProviderRoot());
#endif
webView->goToURL(juce::WebBrowserComponent::getResourceProviderRoot());
#endif
}
#endif
void PrismSpectrumEditor::showWebViewFallback()
{
webViewFallback.setText(
"Prism needs the Microsoft Edge WebView2 Runtime to show this plugin on Windows.\n"
"Install the Evergreen WebView2 Runtime from Microsoft, then reopen the plugin.",
juce::dontSendNotification);
webViewFallback.setJustificationType(juce::Justification::centred);
webViewFallback.setColour(juce::Label::backgroundColourId, juce::Colour(0xff111216));
webViewFallback.setColour(juce::Label::textColourId, juce::Colour(0xfff4f6f8));
webViewFallback.setMinimumHorizontalScale(0.75f);
addAndMakeVisible(webViewFallback);
}
#if JUCE_WINDOWS || JUCE_LINUX
void PrismSpectrumEditor::timerCallback()
{
renderFrame();
}
#endif
void PrismSpectrumEditor::startFrameDriver()
{
if (webView == nullptr || ! webViewReady || frameDriverStarted)
return;
frameDriverStarted = true;
#if JUCE_WINDOWS
startTimerHz(resolveWindowsFrameRateHz(*this));
#elif JUCE_LINUX
startTimerHz(kLinuxFrameRateHz);
#else
// Drive frames at the display's refresh rate (adapts to 60/120/144 Hz).
vblank = juce::VBlankAttachment(this, [this] { renderFrame(); });
#endif
}
void PrismSpectrumEditor::onSettingsPanel(juce::var payload)
{
const int height = juce::jmax(0, (int) payload.getProperty("height", 0));
const int delta = height - settingsPanelHeight;
if (delta == 0)
return;
settingsPanelHeight = height;
// Grow/shrink the window by exactly the panel height so the scope area is
// unchanged. Deliberately NOT touching resize limits — doing so makes JUCE's
// constrainer snap the window and double the applied size.
setSize(getWidth(), getHeight() + delta);
}
void PrismSpectrumEditor::onPrismConfig(juce::var payload)
{
const auto settings = payload.getProperty("settings", juce::var());
// Persist only genuine per-instance overrides (persist=true). App-default /
// restore-driven updates carry persist=false so a non-overridden instance
// keeps re-reading the app's current settings on reopen.
if ((bool) payload.getProperty("persist", false))
processorRef.setSettingsJson(juce::JSON::toString(settings));
engine->configure(settings);
}
void PrismSpectrumEditor::onPrismReady()
{
#if JUCE_LINUX
#if PRISM_LINUX_UI_DIAGNOSTICS
logLinuxDiagnostic("prismReady received mode=" + juce::String(PRISM_LINUX_UI_MODE_NAME)
+ " scope=" + juce::String(engine->scopeId()));
#endif
#endif
webViewReady = true;
pushRestoreSettings();
sendAppDefaults();
startFrameDriver();
}
void PrismSpectrumEditor::onScopeNativeConfig(juce::var payload)
{
engine->configureNative(payload);
}
void PrismSpectrumEditor::onScopeMeasurement(juce::var payload)
{
engine->setMeasurementActive((bool) payload.getProperty("active", false));
}
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));
}
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <juce_gui_extra/juce_gui_extra.h>
#include "PluginProcessor.h"
#include "ScopeEngine.h"
#include <memory>
#include <vector>
/**
* Hosts the React webview UI and bridges the reused Prism spectrum DSP to it.
*
* Drains stereo audio buffered by the processor, feeds the selected scope engine,
* and emits frame payloads to the webview after the UI reports readiness. macOS
* uses display vblank; Windows/Linux use steady message-thread timers to avoid
* fragile host/browser embedding behavior.
*/
class PrismSpectrumEditor : public juce::AudioProcessorEditor
#if JUCE_WINDOWS || JUCE_LINUX
, private juce::Timer
#endif
{
public:
explicit PrismSpectrumEditor(PrismSpectrumProcessor&);
~PrismSpectrumEditor() override;
void resized() override;
#if JUCE_WINDOWS || JUCE_LINUX
void timerCallback() override;
#endif
// Called by the webview event listeners (message thread).
void onPrismConfig(juce::var payload);
void onPrismReady();
// Scope-specific native config (e.g. the spectrogram's canvas-derived rowCount).
void onScopeNativeConfig(juce::var payload);
// Momentary crosshair state; Spectrum uses it for temporary DSP smoothing.
void onScopeMeasurement(juce::var payload);
// The UI's settings panel opened/closed along the bottom. Grow/shrink the editor
// height by exactly the panel height so the scope area is unchanged (the window
// accommodates the panel, like the app). 0 = closed.
void onSettingsPanel(juce::var payload);
// Push the processor's saved settings to the UI (used on ready + on host
// state restore, to cover either ordering).
void pushRestoreSettings();
// Read the user's Prism app theme + active profile from disk and send them
// to the UI as defaults (per-instance overrides still win).
void sendAppDefaults();
private:
void loadUi();
bool createWebView();
void loadEmbeddedWebView();
void showWebViewFallback();
void startFrameDriver();
void renderFrame();
#if JUCE_LINUX
void loadNativeSmoke();
void loadWebSmoke();
void loadFloatingWebView();
void showLinuxDiagnosticPlaceholder(const juce::String& text);
#endif
PrismSpectrumProcessor& processorRef;
std::unique_ptr<ScopeEngine> engine;
std::vector<float> drainLeft, drainRight;
double lastSampleRate = 0.0;
// Height (px) the editor is currently grown by for the open settings panel.
int settingsPanelHeight = 0;
bool webViewReady = false;
bool frameDriverStarted = false;
// One-time attempt to lift WKWebView's private 60fps cap (macOS).
bool frameRateUncapped = false;
int uncapAttempts = 0;
std::unique_ptr<juce::WebBrowserComponent> webView;
#if JUCE_LINUX
std::unique_ptr<juce::DocumentWindow> floatingWebViewWindow;
#endif
juce::Label webViewFallback;
// Declared last so it is destroyed first; no vblank callback can fire into a
// partially-destroyed editor. Windows/Linux use juce::Timer instead.
#if ! JUCE_WINDOWS && ! JUCE_LINUX
juce::VBlankAttachment vblank;
#endif
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismSpectrumEditor)
};
+122
View File
@@ -0,0 +1,122 @@
#include "PluginProcessor.h"
#include "PluginEditor.h"
#include <cstring>
PrismSpectrumProcessor::PrismSpectrumProcessor()
: juce::AudioProcessor(BusesProperties()
.withInput("Input", juce::AudioChannelSet::stereo(), true)
.withOutput("Output", juce::AudioChannelSet::stereo(), true))
{
leftBuffer.assign((size_t) fifo.getTotalSize(), 0.0f);
rightBuffer.assign((size_t) fifo.getTotalSize(), 0.0f);
}
void PrismSpectrumProcessor::prepareToPlay(double sampleRate, int)
{
currentSampleRate.store(sampleRate);
fifo.reset();
}
bool PrismSpectrumProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const
{
const auto& mainOut = layouts.getMainOutputChannelSet();
if (mainOut != juce::AudioChannelSet::mono() && mainOut != juce::AudioChannelSet::stereo())
return false;
// Analyzer passes audio through, so the input layout must match the output.
return mainOut == layouts.getMainInputChannelSet();
}
void PrismSpectrumProcessor::pushStereoToFifo(const float* left, const float* right, int num) noexcept
{
int start1, size1, start2, size2;
fifo.prepareToWrite(num, start1, size1, start2, size2);
if (size1 > 0)
{
std::memcpy(leftBuffer.data() + start1, left, (size_t) size1 * sizeof(float));
std::memcpy(rightBuffer.data() + start1, right, (size_t) size1 * sizeof(float));
}
if (size2 > 0)
{
std::memcpy(leftBuffer.data() + start2, left + size1, (size_t) size2 * sizeof(float));
std::memcpy(rightBuffer.data() + start2, right + size1, (size_t) size2 * sizeof(float));
}
fifo.finishedWrite(size1 + size2);
}
int PrismSpectrumProcessor::drainStereo(float* destLeft, float* destRight, int maxSamples) noexcept
{
const int num = juce::jmin(maxSamples, fifo.getNumReady());
int start1, size1, start2, size2;
fifo.prepareToRead(num, start1, size1, start2, size2);
if (size1 > 0)
{
std::memcpy(destLeft, leftBuffer.data() + start1, (size_t) size1 * sizeof(float));
std::memcpy(destRight, rightBuffer.data() + start1, (size_t) size1 * sizeof(float));
}
if (size2 > 0)
{
std::memcpy(destLeft + size1, leftBuffer.data() + start2, (size_t) size2 * sizeof(float));
std::memcpy(destRight + size1, rightBuffer.data() + start2, (size_t) size2 * sizeof(float));
}
fifo.finishedRead(size1 + size2);
return size1 + size2;
}
void PrismSpectrumProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer&)
{
juce::ScopedNoDenormals noDenormals;
const int numSamples = buffer.getNumSamples();
const int numChannels = buffer.getNumChannels();
if (numSamples <= 0 || numChannels <= 0)
return;
const float* left = buffer.getReadPointer(0);
const float* right = numChannels >= 2 ? buffer.getReadPointer(1) : left;
pushStereoToFifo(left, right, numSamples);
// Pure analyzer: the audio buffer is left untouched (pass-through).
}
void PrismSpectrumProcessor::setSettingsJson(const juce::String& json)
{
const juce::ScopedLock sl(settingsLock);
settingsJson = json;
}
juce::String PrismSpectrumProcessor::getSettingsJson() const
{
const juce::ScopedLock sl(settingsLock);
return settingsJson;
}
void PrismSpectrumProcessor::getStateInformation(juce::MemoryBlock& destData)
{
const juce::String json = getSettingsJson();
destData.setSize(0);
destData.append(json.toRawUTF8(), json.getNumBytesAsUTF8());
}
void PrismSpectrumProcessor::setStateInformation(const void* data, int sizeInBytes)
{
if (data == nullptr || sizeInBytes <= 0)
return;
setSettingsJson(juce::String::fromUTF8(static_cast<const char*>(data), sizeInBytes));
// If the editor is already open (host restored state after opening it), push
// the settings to the UI now — the prismReady reply alone would have missed it.
if (auto* editor = dynamic_cast<PrismSpectrumEditor*>(getActiveEditor()))
editor->pushRestoreSettings();
}
juce::AudioProcessorEditor* PrismSpectrumProcessor::createEditor()
{
return new PrismSpectrumEditor(*this);
}
// This creates the plugin instance, called by the JUCE plugin wrappers.
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter()
{
return new PrismSpectrumProcessor();
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <juce_audio_processors/juce_audio_processors.h>
#include <atomic>
#include <vector>
/**
* Prism Spectrum — analyzer plugin.
*
* Passes audio through unchanged. On the realtime thread (processBlock) it writes
* the input's L/R into a lock-free FIFO. The editor's frame callback drains the
* FIFO off the realtime thread, runs the reused Prism DSP (native/src/spectrum.cpp)
* as stereo (so mid + side are available), and pushes magnitudes to the webview.
*
* UI settings (JSON) are owned here so they survive editor open/close and DAW
* session save/restore.
*/
class PrismSpectrumProcessor : public juce::AudioProcessor
{
public:
PrismSpectrumProcessor();
~PrismSpectrumProcessor() override = default;
void prepareToPlay(double sampleRate, int samplesPerBlock) override;
void releaseResources() override {}
bool isBusesLayoutSupported(const BusesLayout& layouts) const override;
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
juce::AudioProcessorEditor* createEditor() override;
bool hasEditor() const override { return true; }
const juce::String getName() const override { return JucePlugin_Name; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
bool isMidiEffect() const override { return false; }
double getTailLengthSeconds() const override { return 0.0; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return {}; }
void changeProgramName(int, const juce::String&) override {}
void getStateInformation(juce::MemoryBlock&) override;
void setStateInformation(const void*, int) override;
double getSampleRateHz() const noexcept { return currentSampleRate.load(); }
/** Copy up to `maxSamples` of buffered L/R audio into the destinations; returns count. */
int drainStereo(float* destLeft, float* destRight, int maxSamples) noexcept;
/** Persisted UI settings as a JSON string (set from the editor, read on save). */
void setSettingsJson(const juce::String& json);
juce::String getSettingsJson() const;
private:
void pushStereoToFifo(const float* left, const float* right, int num) noexcept;
juce::AbstractFifo fifo { 1 << 16 };
std::vector<float> leftBuffer, rightBuffer; // backing storage for `fifo`
std::atomic<double> currentSampleRate { 48000.0 };
juce::CriticalSection settingsLock;
juce::String settingsJson;
std::atomic<int> editorWidth { 0 };
std::atomic<int> editorHeight { 0 };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismSpectrumProcessor)
};
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <juce_core/juce_core.h>
/**
* Per-scope DSP + frame producer. The editor is otherwise scope-agnostic: it
* buffers stereo audio and, each frame, feeds it to the engine and emits the
* engine's frame payload to the webview. One implementation per scope; the build
* (PRISM_SCOPE_*) selects which one a given plugin product uses.
*/
class ScopeEngine
{
public:
virtual ~ScopeEngine() = default;
/** Editor window sizing (logical px). Each scope picks a shape that fits it. */
struct PreferredSize
{
int defaultWidth = 720;
int defaultHeight = 320;
int minWidth = 320;
int minHeight = 160;
};
virtual PreferredSize preferredSize() const { return {}; }
/** Stable id sent to the webview (via initialisation data) to pick the UI scope. */
virtual const char* scopeId() const = 0;
/** Event name this engine emits frames on (the webview subscribes to it). */
virtual juce::Identifier frameEventId() const = 0;
virtual void setSampleRate(double sampleRate) = 0;
/** Apply scope settings (the JS settings object) to the DSP. */
virtual void configure(const juce::var& settings) = 0;
/**
* Apply a scope-specific native config pushed from the UI on the
* "prismSpectrogramConfig" event. Default no-op; only scopes whose DSP needs
* canvas-derived parameters (e.g. the spectrogram's rowCount) override this.
*/
virtual void configureNative(const juce::var&) {}
/** Temporary UI interaction state. Most scopes do not need DSP changes. */
virtual void setMeasurementActive(bool) {}
/** 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;
};
+116
View File
@@ -0,0 +1,116 @@
#pragma once
#include "ScopeEngine.h"
#include "spectrogram.h" // reused, unmodified, from native/src
#include <vector>
#include <algorithm>
/**
* Spectrogram engine. The reused Visualizer::SpectrogramAnalyzer runs in C++ and
* produces finished display+heat columns. Unlike the other scopes, its DSP needs
* the canvas-derived rowCount (and fft/freq/db/scale/orientation), which only the
* webview knows — the UI pushes the full native config via "prismSpectrogramConfig",
* routed here through configureNative(). process() preserves stereo energy in 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;
auto result = spectro.processStereo(left, right, (size_t) numSamples);
if (result.columnCount > 0 && result.rowCount == config.rowCount)
{
pendingDisplay.insert(pendingDisplay.end(), result.display.begin(), result.display.end());
pendingHeat.insert(pendingHeat.end(), result.heat.begin(), result.heat.end());
pendingColumns += result.columnCount;
}
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("rowCount", (int) config.rowCount);
obj->setProperty("columnCount", (int) pendingColumns);
if (pendingColumns > 0)
{
obj->setProperty("display", juce::Base64::toBase64(pendingDisplay.data(), pendingDisplay.size() * sizeof(float)));
obj->setProperty("heat", juce::Base64::toBase64(pendingHeat.data(), pendingHeat.size() * sizeof(float)));
}
else
{
obj->setProperty("display", juce::String());
obj->setProperty("heat", juce::String());
}
pendingDisplay.clear();
pendingHeat.clear();
pendingColumns = 0;
return juce::var(obj);
}
private:
const juce::Identifier frameId { "spectrogramFrame" };
Visualizer::SpectrogramAnalyzer spectro;
Visualizer::SpectrogramConfig config;
bool hasConfig = false;
std::vector<float> pendingDisplay, pendingHeat;
size_t pendingColumns = 0;
};
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include "ScopeEngine.h"
#include "spectrum.h" // reused, unmodified, from native/src
#include <vector>
class SpectrumEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "spectrum"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 820, 320, 360, 180 }; }
void setSampleRate(double sampleRate) override
{
spectrum.setSampleRate((float) sampleRate);
}
void configure(const juce::var& settings) override
{
const int fftSize = (int) settings.getProperty("fftSize", 2048);
if (fftSize > 0 && (size_t) fftSize != spectrum.getFFTSize())
spectrum.setFFTSize((size_t) fftSize);
configuredSmoothing = juce::jlimit(0.0f, 0.99f,
(float) (double) settings.getProperty("smoothing", 0.9));
applySmoothing();
}
void setMeasurementActive(bool active) override
{
if (measurementActive == active)
return;
measurementActive = active;
applySmoothing();
}
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()));
obj->setProperty("channelMax", toBase64(spectrum.getChannelMaxMagnitudes()));
return juce::var(obj);
}
private:
void applySmoothing()
{
constexpr float measurementSmoothing = 0.97f;
spectrum.setSmoothing(measurementActive
? juce::jmax(configuredSmoothing, measurementSmoothing)
: configuredSmoothing);
}
static juce::String toBase64(const std::vector<float>& data)
{
return data.empty() ? juce::String()
: juce::Base64::toBase64(data.data(), data.size() * sizeof(float));
}
const juce::Identifier frameId { "spectrumFrame" };
Visualizer::Spectrum spectrum { 2048 };
float configuredSmoothing = 0.9f;
bool measurementActive = false;
};
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include "ScopeEngine.h"
#include "vumeter.h" // reused, unmodified, from native/src
/**
* VU meter engine. Pushes stereo audio into the reused `Visualizer::VUMeterAnalyzer`
* (RMS integration + ballistics + peak hold + correlation, all sample-accurate) and
* emits the resulting scalar snapshot each frame. No base64 needed — the frame is a
* handful of numbers. getSnapshot() advances peak decay on the steady clock, so the
* meter still settles when audio momentarily stops.
*/
class VUMeterEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "vumeter"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 480, 300, 280, 180 }; }
void setSampleRate(double sampleRate) override
{
vu.setSampleRate((float) sampleRate);
}
void configure(const juce::var&) override
{
// VU settings (mode/orientation/needleChannels/referenceDb) are render-side only.
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
vu.pushSamples(left, right, (size_t) numSamples);
}
juce::var buildFrame(double sampleRate) override
{
const auto snap = vu.getSnapshot();
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("vuLDb", snap.vuLDb);
obj->setProperty("vuRDb", snap.vuRDb);
obj->setProperty("barLDb", snap.barLDb);
obj->setProperty("barRDb", snap.barRDb);
obj->setProperty("peakLDb", snap.peakLDb);
obj->setProperty("peakRDb", snap.peakRDb);
obj->setProperty("correlation", snap.correlation);
return juce::var(obj);
}
private:
const juce::Identifier frameId { "vumeterFrame" };
Visualizer::VUMeterAnalyzer vu;
};
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include "ScopeEngine.h"
#include "vectorscope.h" // reused, unmodified, from native/src
#include <vector>
/**
* Vectorscope engine. Pushes stereo audio into the reused `Visualizer::Vectorscope`
* (full-band L/R + a 3-band split, both in circular buffers) and emits the
* most recent display points each frame. Two layouts share the buffers: the standard
* X/Y point cloud and the multiband (low/mid/high) cloud. Both buffers are kept warm
* so toggling is instant; the active layout (from the `multiband` setting) is flagged
* in the frame so the webview reads the right payload.
*/
class VectorscopeEngine : public ScopeEngine
{
public:
const char* scopeId() const override { return "vectorscope"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 440, 440, 240, 200 }; }
void setSampleRate(double sampleRate) override
{
vec.setSampleRate((float) sampleRate);
}
void configure(const juce::var& settings) override
{
multiband = (bool) settings.getProperty("multiband", false);
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
vec.pushSamples(left, right, (size_t) numSamples);
vec.pushMultibandSamples(left, right, (size_t) numSamples);
}
juce::var buildFrame(double sampleRate) override
{
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sampleRate);
obj->setProperty("multiband", multiband);
if (multiband)
{
constexpr size_t stride = Visualizer::MULTIBAND_POINT_STRIDE;
if (mbData.size() < (size_t) kDisplayPoints * stride)
mbData.resize((size_t) kDisplayPoints * stride);
const size_t count = vec.getMultibandPoints(mbData.data(), (size_t) kDisplayPoints);
obj->setProperty("count", (int) count);
obj->setProperty("data", juce::Base64::toBase64(mbData.data(), count * stride * sizeof(float)));
}
else
{
if (pointX.size() < (size_t) kDisplayPoints)
{
pointX.resize((size_t) kDisplayPoints);
pointY.resize((size_t) kDisplayPoints);
}
const size_t count = vec.getPoints(pointX.data(), pointY.data(), (size_t) kDisplayPoints);
obj->setProperty("count", (int) count);
obj->setProperty("x", juce::Base64::toBase64(pointX.data(), count * sizeof(float)));
obj->setProperty("y", juce::Base64::toBase64(pointY.data(), count * sizeof(float)));
}
return juce::var(obj);
}
private:
static constexpr int kDisplayPoints = 4096; // matches Vectorscope.ts default
const juce::Identifier frameId { "vectorscopeFrame" };
Visualizer::Vectorscope vec;
std::vector<float> pointX, pointY, mbData;
bool multiband = false;
};
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include "ScopeEngine.h"
#include "waveform.h" // reused, unmodified, from native/src
#include <vector>
#include <cmath>
#include <algorithm>
/**
* Waveform engine. Runs the reused Visualizer::WaveformMultibandAnalyzer, which
* summarizes audio into per-column min/max + 3-band RMS. Unlike the spectrogram, the
* column width (samplesPerColumn) is a pure function of sampleRate and scrollSpeed —
* sampleRate / (128 * scrollSpeed), the same formula the renderer uses — so the engine
* derives it itself; no canvas round-trip. mode ('stereo' vs 'mono') selects
* processStereo (stride 10) vs processMono (stride 5), flagged in the frame. multiband
* is render-only (the analyzer always emits the band RMS columns use for coloring).
*/
class WaveformEngine : public ScopeEngine
{
public:
WaveformEngine() { reconfigure(); }
const char* scopeId() const override { return "waveform"; }
juce::Identifier frameEventId() const override { return frameId; }
PreferredSize preferredSize() const override { return { 720, 260, 360, 140 }; }
void setSampleRate(double sr) override
{
if (sr > 0.0 && (float) sr != sampleRate)
{
sampleRate = (float) sr;
reconfigure();
}
}
void configure(const juce::var& settings) override
{
stereo = settings.getProperty("mode", "mono").toString() == "stereo";
const auto speed = (float) settings.getProperty("scrollSpeed", (double) scrollSpeed);
if (speed > 0.0f)
scrollSpeed = speed;
reconfigure();
}
void process(const float* left, const float* right, int numSamples) override
{
if (numSamples <= 0)
return;
if (stereo)
{
const auto& cols = wave.processStereo(left, right, (size_t) numSamples);
pending.insert(pending.end(), cols.begin(), cols.end());
}
else
{
if ((int) mono.size() < numSamples)
mono.resize((size_t) numSamples);
for (int i = 0; i < numSamples; ++i)
mono[(size_t) i] = 0.5f * (left[i] + right[i]);
const auto& cols = wave.processMono(mono.data(), (size_t) numSamples);
pending.insert(pending.end(), cols.begin(), cols.end());
}
}
juce::var buildFrame(double sr) override
{
const size_t stride = stereo ? Visualizer::WAVEFORM_STEREO_SUMMARY_STRIDE
: Visualizer::WAVEFORM_MONO_SUMMARY_STRIDE;
auto* obj = new juce::DynamicObject();
obj->setProperty("sampleRate", sr);
obj->setProperty("stereo", stereo);
obj->setProperty("columnCount", (int) (pending.size() / stride));
if (! pending.empty())
obj->setProperty("summaries", juce::Base64::toBase64(pending.data(), pending.size() * sizeof(float)));
else
obj->setProperty("summaries", juce::String());
pending.clear();
return juce::var(obj);
}
private:
void reconfigure()
{
const float pps = 128.0f * std::max(0.01f, scrollSpeed);
samplesPerColumn = (size_t) std::max(1L, std::lround(sampleRate / pps));
wave.configure(sampleRate, samplesPerColumn);
pending.clear();
}
const juce::Identifier frameId { "waveformFrame" };
Visualizer::WaveformMultibandAnalyzer wave;
std::vector<float> mono, pending;
float sampleRate = 48000.0f;
float scrollSpeed = 1.0f;
size_t samplesPerColumn = 1;
bool stereo = false;
};
+13
View File
@@ -0,0 +1,13 @@
#pragma once
/**
* macOS only. Finds the WKWebView living under the given NSView (the plugin
* editor's peer) and disables WebKit's private `PreferPageRenderingUpdatesNear60FPSEnabled`
* feature, which otherwise throttles requestAnimationFrame to 60fps regardless of
* the display refresh rate. Returns true once the feature was found and toggled.
*
* Uses private WebKit API. There is no public alternative (Apple FB16411517 is
* unresolved). Acceptable for a non-App-Store FOSS plugin; guarded by
* respondsToSelector so it degrades to a no-op if the private API changes.
*/
bool prismUncapWebViewFrameRate(void* nsViewHandle);
+62
View File
@@ -0,0 +1,62 @@
#import <AppKit/AppKit.h>
#import <WebKit/WebKit.h>
#include "WebViewFrameRate.h"
// Private WebKit API. +_features is a CLASS method; the enable setter is an
// instance method. Guarded by respondsToSelector so this degrades to a no-op if
// the private API changes.
@interface WKPreferences (PrismPrivate)
+ (NSArray *)_features;
- (void)_setEnabled:(BOOL)enabled forFeature:(id)feature;
@end
static WKWebView* prismFindWebView(NSView* view)
{
if (view == nil)
return nil;
if ([view isKindOfClass:[WKWebView class]])
return (WKWebView*) view;
for (NSView* sub in [view subviews])
if (WKWebView* found = prismFindWebView(sub))
return found;
return nil;
}
bool prismUncapWebViewFrameRate(void* nsViewHandle)
{
WKWebView* webView = (nsViewHandle != nullptr) ? prismFindWebView((NSView*) nsViewHandle) : nil;
// Fallback: search every app window's content view.
if (webView == nil)
for (NSWindow* win in [NSApp windows])
if ((webView = prismFindWebView([win contentView])) != nil)
break;
if (webView == nil)
return false; // not in the hierarchy yet — caller retries
WKPreferences* prefs = [[webView configuration] preferences];
if (prefs == nil
|| ! [WKPreferences respondsToSelector:@selector(_features)]
|| ! [prefs respondsToSelector:@selector(_setEnabled:forFeature:)])
return false;
// Disable WebKit's private "prefer ~60fps page rendering" throttle so the
// canvas repaints at the display's native rate (e.g. 120Hz). Applied live:
// a reload is NOT used because JUCE's resource provider doesn't re-serve on
// reload (which would blank the page). The preference syncs to the WebContent
// process and takes effect on the running page.
for (id feature in [WKPreferences _features])
{
NSString* key = nil;
@try { key = [feature valueForKey:@"key"]; }
@catch (NSException*) { key = nil; }
if ([key isEqualToString:@"PreferPageRenderingUpdatesNear60FPSEnabled"])
{
[prefs _setEnabled:NO forFeature:feature];
return true;
}
}
return false;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

+102
View File
@@ -0,0 +1,102 @@
#!/bin/sh
# Package post-install hook for Linux .deb/.rpm builds. The app package installs
# Prism under /opt, then this exposes prism-tui on PATH and 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}"
TUI_LINK="${PRISM_TUI_LINK_PATH:-/usr/bin/prism-tui}"
find_tui() {
if [ -n "${PRISM_TUI_SOURCE_PATH:-}" ] && [ -f "$PRISM_TUI_SOURCE_PATH" ]; then
printf '%s\n' "$PRISM_TUI_SOURCE_PATH"
return 0
fi
for executable in \
/opt/Prism/resources/tui/prism-tui \
/opt/prism/resources/tui/prism-tui
do
if [ -f "$executable" ]; then
printf '%s\n' "$executable"
return 0
fi
done
return 1
}
install_tui() {
tui_source="$(find_tui || true)"
if [ -z "$tui_source" ]; then
echo "Prism TUI install: bundled executable not found; skipping PATH link." >&2
return 0
fi
chmod 755 "$tui_source"
if [ -e "$TUI_LINK" ] || [ -L "$TUI_LINK" ]; then
if [ -L "$TUI_LINK" ] && [ "$(readlink "$TUI_LINK")" = "$tui_source" ]; then
return 0
fi
echo "Prism TUI install: $TUI_LINK already exists and was left unchanged." >&2
return 0
fi
ln -s "$tui_source" "$TUI_LINK"
echo "Prism TUI installed at $TUI_LINK"
}
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/"
}
install_tui
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"
+105
View File
@@ -0,0 +1,105 @@
#!/bin/sh
# Helper bundled in the Linux tarball under resources/plugins/. By default it
# installs Prism's native Linux VST3 bundles to the current user's VST3 folder.
set -eu
usage() {
cat <<'EOF'
Usage: install-vst3.sh [--system] [--dest PATH] [--source PATH]
Installs the bundled Prism VST3 plugins.
Options:
--system Install to /usr/lib/vst3 instead of $HOME/.vst3.
--dest PATH Install to a custom VST3 directory.
--source PATH Read Prism *.vst3 bundles from a custom source directory.
-h, --help Show this help.
EOF
}
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
SOURCE_DIR="${PRISM_VST3_SOURCE_DIR:-}"
DEST_DIR="${PRISM_VST3_DEST_DIR:-$HOME/.vst3}"
while [ "$#" -gt 0 ]; do
case "$1" in
--system)
DEST_DIR="/usr/lib/vst3"
;;
--dest)
shift
if [ "$#" -eq 0 ]; then
echo "install-vst3.sh: --dest requires a path" >&2
exit 2
fi
DEST_DIR="$1"
;;
--source)
shift
if [ "$#" -eq 0 ]; then
echo "install-vst3.sh: --source requires a path" >&2
exit 2
fi
SOURCE_DIR="$1"
;;
-h|--help)
usage
exit 0
;;
*)
echo "install-vst3.sh: unknown option: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
if [ -z "$SOURCE_DIR" ]; then
if [ -d "$SCRIPT_DIR/VST3" ]; then
SOURCE_DIR="$SCRIPT_DIR/VST3"
elif [ -d "$SCRIPT_DIR/plugins/VST3" ]; then
SOURCE_DIR="$SCRIPT_DIR/plugins/VST3"
else
SOURCE_DIR="$SCRIPT_DIR"
fi
fi
if [ ! -d "$SOURCE_DIR" ]; then
echo "install-vst3.sh: source directory not found: $SOURCE_DIR" >&2
exit 1
fi
install_plugin() {
plugin_name="$1"
source_plugin="$SOURCE_DIR/$plugin_name"
dest_plugin="$DEST_DIR/$plugin_name"
if [ ! -d "$source_plugin" ]; then
echo "install-vst3.sh: missing bundled plugin: $source_plugin" >&2
return 1
fi
rm -rf "$dest_plugin"
cp -a "$source_plugin" "$DEST_DIR/"
}
mkdir -p "$DEST_DIR"
install_plugin "Prism Spectrum.vst3"
install_plugin "Prism Oscilloscope.vst3"
install_plugin "Prism VU Meter.vst3"
install_plugin "Prism Loudness Meter.vst3"
install_plugin "Prism Vectorscope.vst3"
install_plugin "Prism Spectrogram.vst3"
install_plugin "Prism Waveform.vst3"
chmod -R u+rwX,go+rX "$DEST_DIR"/Prism*.vst3 2>/dev/null || true
cat <<EOF
Prism VST3 plugins installed to:
$DEST_DIR
Rescan VST3 plugins in your DAW if they do not appear immediately.
EOF
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# Package removal hook for Linux .deb/.rpm builds. Remove only Prism's known
# VST3 bundles from the global VST3 scan path.
set -eu
# Debian postrm reports upgrades by name; RPM postun reports the number of
# installed package versions remaining. Keep shared resources during either
# upgrade path and remove them only on a real uninstall.
remove_action="${1:-}"
case "$remove_action" in
upgrade|failed-upgrade|abort-*)
exit 0
;;
''|*[!0-9]*)
;;
*)
if [ "$remove_action" -gt 0 ]; then
exit 0
fi
;;
esac
DEST_DIR="${PRISM_VST3_DEST_DIR:-/usr/lib/vst3}"
TUI_LINK="${PRISM_TUI_LINK_PATH:-/usr/bin/prism-tui}"
remove_tui_link() {
[ -L "$TUI_LINK" ] || return 0
tui_target="$(readlink "$TUI_LINK")"
case "$tui_target" in
/opt/Prism/resources/tui/prism-tui|/opt/prism/resources/tui/prism-tui)
rm "$TUI_LINK"
;;
*)
if [ -n "${PRISM_TUI_SOURCE_PATH:-}" ] && [ "$tui_target" = "$PRISM_TUI_SOURCE_PATH" ]; then
rm "$TUI_LINK"
else
echo "Prism TUI removal: $TUI_LINK points elsewhere and was left unchanged." >&2
fi
;;
esac
}
remove_plugin() {
rm -rf "$DEST_DIR/$1"
}
remove_plugin "Prism Spectrum.vst3"
remove_plugin "Prism Oscilloscope.vst3"
remove_plugin "Prism VU Meter.vst3"
remove_plugin "Prism Loudness Meter.vst3"
remove_plugin "Prism Vectorscope.vst3"
remove_plugin "Prism Spectrogram.vst3"
remove_plugin "Prism Waveform.vst3"
remove_tui_link
echo "Prism VST3 plugins removed from $DEST_DIR"
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Postinstall: after the macOS Installer drops Prism.app into /Applications, copy
# the bundled VST3 / AU plugins out of the app's Resources into the system
# Audio Plug-Ins folders so DAWs find them, and expose prism-tui on PATH.
#
# Standard pkg postinstall args:
# $1 = full path to the installer package
# $2 = full path of the installed destination (e.g. /Applications)
# $3 = mountpoint of the target volume (typically /)
# $4 = root directory of the system being installed onto
#
# Runs as root, so writes to /Library/Audio/Plug-Ins/* succeed.
set -eu
APP_PATH="${2:-/Applications}/Prism.app"
[ -d "$APP_PATH" ] || APP_PATH="/Applications/Prism.app"
PLUGINS_SRC="$APP_PATH/Contents/Resources/plugins"
VST3_DEST="${PRISM_VST3_DEST_DIR:-/Library/Audio/Plug-Ins/VST3}"
AU_DEST="${PRISM_AU_DEST_DIR:-/Library/Audio/Plug-Ins/Components}"
TUI_SOURCE="$APP_PATH/Contents/Resources/tui/prism-tui"
TUI_LINK="${PRISM_TUI_LINK_PATH:-/usr/local/bin/prism-tui}"
mkdir -p "$VST3_DEST" "$AU_DEST"
if [ -d "$PLUGINS_SRC/VST3" ]; then
cp -R "$PLUGINS_SRC/VST3/"*.vst3 "$VST3_DEST/" 2>/dev/null || true
fi
if [ -d "$PLUGINS_SRC/AU" ]; then
cp -R "$PLUGINS_SRC/AU/"*.component "$AU_DEST/" 2>/dev/null || true
fi
if [ -f "$TUI_SOURCE" ]; then
chmod 755 "$TUI_SOURCE"
mkdir -p "$(dirname "$TUI_LINK")"
if [ -e "$TUI_LINK" ] || [ -L "$TUI_LINK" ]; then
if [ ! -L "$TUI_LINK" ] || [ "$(readlink "$TUI_LINK")" != "$TUI_SOURCE" ]; then
echo "Prism postinstall: $TUI_LINK already exists and was left unchanged." >&2
fi
else
ln -s "$TUI_SOURCE" "$TUI_LINK"
fi
else
echo "Prism postinstall: bundled prism-tui not found at $TUI_SOURCE" >&2
fi
exit 0
+162
View File
@@ -0,0 +1,162 @@
; 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).
!include "LogicLib.nsh"
!include "WinMessages.nsh"
!ifndef BUILD_UNINSTALLER
!include "nsDialogs.nsh"
Var PRISM_VST_CHECKBOX
Var PRISM_VST_STATE
!macro customHeader
; electron-builder disables the standard NSIS details view in common.nsh.
; customHeader is inserted after common.nsh, so this supported hook can
; override that default without modifying electron-builder's templates.
ShowInstDetails show
; This hidden section runs before electron-builder's main install section.
; Its message therefore remains visible while the bundled application is
; extracted and atomically copied into the selected installation folder.
Section "-Prism install status"
${IfNot} ${Silent}
SetDetailsPrint both
DetailPrint "Step 1 of 4: Unpacking Prism application files. This may take a minute..."
${EndIf}
SectionEnd
!macroend
!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
SetDetailsPrint both
${IfNot} ${FileExists} "$INSTDIR\resources\tui\prism-tui.exe"
DetailPrint "ERROR: bundled prism-tui.exe was not found"
MessageBox MB_OK|MB_ICONSTOP "The bundled prism-tui executable was not found.$\r$\n$\r$\nMissing path:$\r$\n$INSTDIR\resources\tui\prism-tui.exe" /SD IDOK
Abort
${EndIf}
DetailPrint "Step 2 of 4: Adding prism-tui to the machine PATH..."
; Pass the path through the installer's process environment. Supplying it
; after PowerShell's -Command argument loses quotes at the native command
; line boundary and truncates the default path to "C:\Program".
System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("PRISM_TUI_INSTALL_DIR", "$INSTDIR\resources\tui").r0'
${If} $0 == 0
DetailPrint "ERROR: could not prepare the Prism TUI PATH update"
MessageBox MB_OK|MB_ICONSTOP "Prism could not prepare the prism-tui PATH update." /SD IDOK
Abort
${EndIf}
; PowerShell avoids NSIS string-length truncation on machines with a large
; PATH. It removes exact duplicates, safely repairs the truncated entry
; written by older installers, appends Prism once, and verifies the write.
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference = 'Stop'; $$entry = $$env:PRISM_TUI_INSTALL_DIR; if ([string]::IsNullOrWhiteSpace($$entry)) { exit 10 }; $$legacyEntry = $$null; $$removeLegacy = $$false; if ($$entry -match '\s') { $$legacyEntry = ($$entry -split '\s', 2)[0]; $$removeLegacy = $$legacyEntry -ine $$entry -and -not (Test-Path -LiteralPath $$legacyEntry -PathType Container) }; $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry -and (-not $$removeLegacy -or $$_ -ine $$legacyEntry) }); $$entries += $$entry; [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine'); $$updatedPath = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$matches = @($$updatedPath -split ';' | Where-Object { $$_ -ieq $$entry }); if ($$matches.Count -ne 1) { exit 11 } }"`
Pop $0
${If} $0 != 0
DetailPrint "ERROR: could not add Prism TUI to the machine PATH (exit code $0)"
MessageBox MB_OK|MB_ICONSTOP "Prism could not add prism-tui to the machine PATH.$\r$\n$\r$\nPowerShell exit code: $0" /SD IDOK
Abort
${EndIf}
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
DetailPrint "Added $INSTDIR\resources\tui to the machine PATH"
${If} $PRISM_VST_STATE == ${BST_CHECKED}
DetailPrint "Step 3 of 4: Installing Prism VST3 plugins to $COMMONFILES64\VST3..."
${IfNot} ${FileExists} "$INSTDIR\resources\plugins\VST3\*.vst3"
; Plugins are optional. Local app/TUI builds may intentionally omit the
; staged bundles, so do not roll back an otherwise valid installation.
DetailPrint "WARNING: bundled Prism VST3 plugins were not found; skipping plugin installation"
MessageBox MB_OK|MB_ICONEXCLAMATION "Prism was installed, but this package does not contain the optional VST3 plugins.$\r$\n$\r$\nprism-tui and the desktop app are ready to use." /SD IDOK
${Else}
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}
DetailPrint "Installed Prism VST3 plugins."
${EndIf}
${Else}
DetailPrint "Step 3 of 4: Prism VST3 plugins skipped (opted out)."
${EndIf}
DetailPrint "Step 4 of 4: Finishing Prism installation..."
!macroend
!endif
!macro customUnInstall
; Remove only Prism's exact machine-PATH entry. A failure is non-fatal so an
; otherwise valid uninstall is never blocked.
System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("PRISM_TUI_INSTALL_DIR", "$INSTDIR\resources\tui").r0'
${If} $0 == 0
DetailPrint "WARNING: could not prepare the Prism TUI PATH removal"
${Else}
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference = 'Stop'; $$entry = $$env:PRISM_TUI_INSTALL_DIR; if ([string]::IsNullOrWhiteSpace($$entry)) { exit 10 }; $$legacyEntry = $$null; $$removeLegacy = $$false; if ($$entry -match '\s') { $$legacyEntry = ($$entry -split '\s', 2)[0]; $$removeLegacy = $$legacyEntry -ine $$entry -and -not (Test-Path -LiteralPath $$legacyEntry -PathType Container) }; $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry -and (-not $$removeLegacy -or $$_ -ine $$legacyEntry) }); [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine'); $$updatedPath = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$matches = @($$updatedPath -split ';' | Where-Object { $$_ -ieq $$entry }); if ($$matches.Count -ne 0) { exit 11 } }"`
Pop $0
${If} $0 == 0
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
${Else}
DetailPrint "WARNING: could not remove Prism TUI from the machine PATH (exit code $0)"
${EndIf}
${EndIf}
; 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

+49
View File
@@ -0,0 +1,49 @@
const { copyFileSync, existsSync, mkdirSync, chmodSync } = require('node:fs')
const { join } = require('node:path')
const { spawnSync } = require('node:child_process')
const rootDir = join(__dirname, '..', '..')
const buildDir = join(rootDir, 'tui', 'build')
const packageJson = require(join(rootDir, 'package.json'))
const shouldTest = process.argv.includes('--test')
const shouldStage = process.argv.includes('--stage')
const configureOnly = process.argv.includes('--configure-only')
function run(command, args) {
const result = spawnSync(command, args, { cwd: rootDir, stdio: 'inherit' })
if (result.status !== 0) process.exit(result.status ?? 1)
}
run('cmake', [
'-S', 'tui',
'-B', 'tui/build',
'-DCMAKE_BUILD_TYPE=Release',
`-DPRISM_VERSION=${packageJson.version}`,
])
if (configureOnly) process.exit(0)
run('cmake', ['--build', 'tui/build', '--config', 'Release', '--parallel', '4'])
if (shouldTest) {
run('ctest', ['--test-dir', 'tui/build', '-C', 'Release', '--output-on-failure'])
}
if (shouldStage) {
const executableName = process.platform === 'win32' ? 'prism-tui.exe' : 'prism-tui'
const candidates = [
join(buildDir, 'bin', executableName),
join(buildDir, 'bin', 'Release', executableName),
join(buildDir, 'Release', executableName),
]
const source = candidates.find(existsSync)
if (!source) {
console.error(`Could not find built ${executableName}. Checked:\n${candidates.join('\n')}`)
process.exit(1)
}
const stageDir = join(rootDir, 'tui', 'dist-installer')
mkdirSync(stageDir, { recursive: true })
const destination = join(stageDir, executableName)
copyFileSync(source, destination)
if (process.platform !== 'win32') chmodSync(destination, 0o755)
console.log(`Staged ${destination}`)
}
@@ -0,0 +1,42 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$packageJson = Get-Content -LiteralPath (Join-Path $repoRoot 'package.json') -Raw | ConvertFrom-Json
$productName = [string]$packageJson.build.productName
$version = [string]$packageJson.version
$outputDirectory = Join-Path $repoRoot ([string]$packageJson.build.directories.output)
$outputFiles = @(
(Join-Path $outputDirectory "$productName Setup $version.exe"),
(Join-Path $outputDirectory "$productName $version.exe"),
(Join-Path $outputDirectory "win-unpacked\$productName.exe")
)
$lockedFiles = New-Object Collections.Generic.List[string]
foreach ($outputFile in $outputFiles) {
if (-not (Test-Path -LiteralPath $outputFile -PathType Leaf)) {
continue
}
try {
$stream = [IO.File]::Open(
$outputFile,
[IO.FileMode]::Open,
[IO.FileAccess]::ReadWrite,
[IO.FileShare]::None
)
$stream.Close()
} catch {
$lockedFiles.Add($outputFile)
}
}
if ($lockedFiles.Count -gt 0) {
[Console]::Error.WriteLine('Cannot rebuild the Windows distribution because an output executable is still in use.')
[Console]::Error.WriteLine('Close the Prism app and installer, then run `npm run dist` again:')
foreach ($lockedFile in $lockedFiles) {
[Console]::Error.WriteLine(" $lockedFile")
}
exit 1
}
+32
View File
@@ -0,0 +1,32 @@
const { spawnSync } = require('node:child_process')
const scriptByPlatform = {
darwin: 'dist:mac',
linux: 'dist:linux',
win32: 'dist:win',
}
const script = scriptByPlatform[process.platform]
if (!script) {
console.error(`Packaging is not configured for ${process.platform}.`)
process.exit(1)
}
const npmCli = process.env.npm_execpath
if (!npmCli) {
console.error('Could not locate the npm CLI. Run this command through `npm run dist`.')
process.exit(1)
}
// Node 22 rejects direct child-process launches of Windows .cmd shims with
// EINVAL. Reuse the npm CLI that launched this script through the current Node
// executable instead, which is portable and avoids an extra command shell.
const result = spawnSync(process.execPath, [npmCli, 'run', script], {
stdio: 'inherit',
})
if (result.error) {
console.error(`Could not start npm run ${script}: ${result.error.message}`)
process.exit(1)
}
process.exit(result.status ?? 1)
@@ -0,0 +1,236 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { deflateSync } from 'node:zlib'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(scriptDir, '../..')
const outputDir = join(repoRoot, 'resources', 'drag')
const logicalSize = 32
const supersample = 4
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const colors = {
outline: [232, 241, 247, 255],
page: [9, 16, 23, 255],
fold: [26, 43, 55, 255],
waveform: [48, 197, 255, 255],
}
const documentOutline = [
[8.25, 2],
[18.75, 2],
[26, 9.25],
[26, 27],
[25.7, 28.25],
[24.7, 29.5],
[8.25, 29.5],
[7, 29.2],
[6, 28.2],
[6, 4.75],
[6.3, 3.5],
[7.05, 2.55],
]
const documentPage = [
[8.4, 3.7],
[18.05, 3.7],
[24.3, 9.95],
[24.3, 27.1],
[23.75, 27.8],
[8.25, 27.8],
[7.7, 27.15],
[7.7, 4.55],
]
const documentFold = [
[19.1, 4.75],
[23.25, 8.9],
[19.1, 8.9],
]
const waveformBars = [
{ x: 10.5, y: 16.5, height: 5 },
{ x: 13.25, y: 13.5, height: 11 },
{ x: 16, y: 15, height: 8 },
{ x: 18.75, y: 12.5, height: 13 },
{ x: 21.5, y: 16, height: 6 },
]
function makeCrc32Table() {
const table = new Uint32Array(256)
for (let i = 0; i < table.length; i += 1) {
let value = i
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) !== 0
? 0xedb88320 ^ (value >>> 1)
: value >>> 1
}
table[i] = value >>> 0
}
return table
}
const crc32Table = makeCrc32Table()
function crc32(buffer) {
let crc = 0xffffffff
for (const byte of buffer) {
crc = crc32Table[(crc ^ byte) & 0xff] ^ (crc >>> 8)
}
return (crc ^ 0xffffffff) >>> 0
}
function pngChunk(type, data) {
const typeBuffer = Buffer.from(type, 'ascii')
const lengthBuffer = Buffer.alloc(4)
lengthBuffer.writeUInt32BE(data.length, 0)
const crcBuffer = Buffer.alloc(4)
crcBuffer.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0)
return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer])
}
function writePng(filePath, width, height, pixels) {
const stride = width * 4
const raw = Buffer.alloc((stride + 1) * height)
for (let y = 0; y < height; y += 1) {
const rowOffset = y * (stride + 1)
raw[rowOffset] = 0
pixels.copy(raw, rowOffset + 1, y * stride, (y + 1) * stride)
}
const header = Buffer.alloc(13)
header.writeUInt32BE(width, 0)
header.writeUInt32BE(height, 4)
header.writeUInt8(8, 8)
header.writeUInt8(6, 9)
writeFileSync(filePath, Buffer.concat([
pngSignature,
pngChunk('IHDR', header),
pngChunk('IDAT', deflateSync(raw, { level: 9 })),
pngChunk('IEND', Buffer.alloc(0)),
]))
}
function pointInPolygon(x, y, points) {
let inside = false
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
const [xi, yi] = points[i]
const [xj, yj] = points[j]
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi) / (yj - yi)) + xi) {
inside = !inside
}
}
return inside
}
function setPixel(pixels, width, x, y, color) {
const offset = ((y * width) + x) * 4
pixels[offset] = color[0]
pixels[offset + 1] = color[1]
pixels[offset + 2] = color[2]
pixels[offset + 3] = color[3]
}
function fillPolygon(pixels, width, height, points, color, scale) {
const scaledPoints = points.map(([x, y]) => [x * scale, y * scale])
const xs = scaledPoints.map(([x]) => x)
const ys = scaledPoints.map(([, y]) => y)
const minX = Math.max(0, Math.floor(Math.min(...xs)))
const maxX = Math.min(width - 1, Math.ceil(Math.max(...xs)))
const minY = Math.max(0, Math.floor(Math.min(...ys)))
const maxY = Math.min(height - 1, Math.ceil(Math.max(...ys)))
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
if (pointInPolygon(x + 0.5, y + 0.5, scaledPoints)) {
setPixel(pixels, width, x, y, color)
}
}
}
}
function fillRoundedRect(pixels, width, height, rect, color, scale) {
const left = (rect.x - (rect.width / 2)) * scale
const top = rect.y * scale
const rectWidth = rect.width * scale
const rectHeight = rect.height * scale
const radius = Math.min(rectWidth / 2, rectHeight / 2)
const right = left + rectWidth
const bottom = top + rectHeight
for (let y = Math.max(0, Math.floor(top)); y < Math.min(height, Math.ceil(bottom)); y += 1) {
for (let x = Math.max(0, Math.floor(left)); x < Math.min(width, Math.ceil(right)); x += 1) {
const sampleX = x + 0.5
const sampleY = y + 0.5
const nearestX = Math.max(left + radius, Math.min(right - radius, sampleX))
const nearestY = Math.max(top + radius, Math.min(bottom - radius, sampleY))
const dx = sampleX - nearestX
const dy = sampleY - nearestY
if ((dx * dx) + (dy * dy) <= radius * radius) {
setPixel(pixels, width, x, y, color)
}
}
}
}
function downsample(source, sourceSize, targetSize) {
const ratio = sourceSize / targetSize
const target = Buffer.alloc(targetSize * targetSize * 4)
for (let y = 0; y < targetSize; y += 1) {
for (let x = 0; x < targetSize; x += 1) {
let alpha = 0
let red = 0
let green = 0
let blue = 0
for (let sampleY = 0; sampleY < ratio; sampleY += 1) {
for (let sampleX = 0; sampleX < ratio; sampleX += 1) {
const sourceOffset = ((((y * ratio) + sampleY) * sourceSize) + (x * ratio) + sampleX) * 4
const sampleAlpha = source[sourceOffset + 3]
alpha += sampleAlpha
red += source[sourceOffset] * sampleAlpha
green += source[sourceOffset + 1] * sampleAlpha
blue += source[sourceOffset + 2] * sampleAlpha
}
}
const targetOffset = ((y * targetSize) + x) * 4
const sampleCount = ratio * ratio
target[targetOffset + 3] = Math.round(alpha / sampleCount)
if (alpha > 0) {
target[targetOffset] = Math.round(red / alpha)
target[targetOffset + 1] = Math.round(green / alpha)
target[targetOffset + 2] = Math.round(blue / alpha)
}
}
}
return target
}
function renderIcon(size) {
const sourceSize = size * supersample
const scale = sourceSize / logicalSize
const source = Buffer.alloc(sourceSize * sourceSize * 4)
fillPolygon(source, sourceSize, sourceSize, documentOutline, colors.outline, scale)
fillPolygon(source, sourceSize, sourceSize, documentPage, colors.page, scale)
fillPolygon(source, sourceSize, sourceSize, documentFold, colors.fold, scale)
for (const bar of waveformBars) {
fillRoundedRect(source, sourceSize, sourceSize, {
...bar,
width: 1.7,
}, colors.waveform, scale)
}
return downsample(source, sourceSize, size)
}
mkdirSync(outputDir, { recursive: true })
for (const [filename, size] of [['audio-clip.png', 32], ['audio-clip@2x.png', 64]]) {
writePng(join(outputDir, filename), size, size, renderIcon(size))
}
+64
View File
@@ -16,6 +16,7 @@ import { deflateSync, inflateSync } from 'node:zlib'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(scriptDir, '../..')
const resourcesDir = join(repoRoot, 'resources')
const trayResourcesDir = join(resourcesDir, 'tray')
const tempDir = mkdtempSync(join(tmpdir(), 'prism-icons-'))
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const iconBackground = { r: 5, g: 7, b: 10 }
@@ -240,6 +241,28 @@ function restoreIconBackgroundAlpha(filePath) {
writePngRgba(filePath, width, height, pixels)
}
function convertTemplateToAlphaMask(filePath) {
const image = readPngRgba(filePath)
const { width, height, pixels } = image
for (let offset = 0; offset < pixels.length; offset += 4) {
const originalAlpha = pixels[offset + 3]
const luminance = Math.round(
(pixels[offset] + pixels[offset + 1] + pixels[offset + 2]) / 3,
)
// Quick Look rasterizes SVG previews over white on some macOS versions.
// Template images need that white canvas removed so macOS can tint only
// the Prism mark for light/dark menu bars.
pixels[offset] = 0
pixels[offset + 1] = 0
pixels[offset + 2] = 0
pixels[offset + 3] = Math.round(originalAlpha * ((255 - luminance) / 255))
}
writePngRgba(filePath, width, height, pixels)
}
function writeIcoFile(outputPath, images) {
const headerLength = 6
const entryLength = 16
@@ -332,6 +355,47 @@ try {
icoImages.push({ size, buffer: readFileSync(outputPath) })
}
writeIcoFile(join(resourcesDir, 'icon.ico'), icoImages)
mkdirSync(trayResourcesDir, { recursive: true })
const trayTemplateSvgPath = join(tempDir, 'prism-tray-template-source.svg')
writeFileSync(trayTemplateSvgPath, `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<g transform="translate(${symbolTranslateX} ${symbolTranslateY}) scale(${iconSymbolScale})">
<path d="${lowerPath}" fill="#000000" />
<path d="${upperPath}" fill="#000000" />
</g>
</svg>
`)
execFileSync('qlmanage', ['-t', '-s', '1024', '-o', tempDir, trayTemplateSvgPath], {
stdio: 'ignore',
})
const trayTemplateMaster = `${trayTemplateSvgPath}.png`
if (!existsSync(trayTemplateMaster)) {
throw new Error('Quick Look did not create the tray template PNG.')
}
const trayTemplatePath = join(trayResourcesDir, 'prismTrayTemplate.png')
const trayTemplate2xPath = join(trayResourcesDir, 'prismTrayTemplate@2x.png')
resizePng(trayTemplateMaster, trayTemplatePath, 16)
resizePng(trayTemplateMaster, trayTemplate2xPath, 32)
convertTemplateToAlphaMask(trayTemplatePath)
convertTemplateToAlphaMask(trayTemplate2xPath)
execFileSync('sips', ['-s', 'dpiWidth', '72', '-s', 'dpiHeight', '72', trayTemplatePath], {
stdio: 'ignore',
})
execFileSync('sips', ['-s', 'dpiWidth', '144', '-s', 'dpiHeight', '144', trayTemplate2xPath], {
stdio: 'ignore',
})
resizePng(resourcePngPath, join(trayResourcesDir, 'prism-tray.png'), 24)
const trayIcoImages = []
for (const size of [16, 20, 24, 32, 40, 48]) {
const outputPath = join(tempDir, `prism-tray-${size}.png`)
resizePng(resourcePngPath, outputPath, size)
trayIcoImages.push({ size, buffer: readFileSync(outputPath) })
}
writeIcoFile(join(trayResourcesDir, 'prism-tray.ico'), trayIcoImages)
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
+18
View File
@@ -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)
+44
View File
@@ -0,0 +1,44 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { build } from 'esbuild'
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
const tempDir = await mkdtemp(join(tmpdir(), 'prism-audio-clip-tests-'))
const bundledTestPath = join(tempDir, 'audio-clip-library.test.mjs')
const entryPoint = join(rootDir, 'test', 'audio-clip-library.test.ts')
let exitCode = 1
try {
await build({
entryPoints: [entryPoint],
outfile: bundledTestPath,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node23',
sourcemap: 'inline',
})
exitCode = await new Promise((resolve) => {
const child = spawn(process.execPath, ['--test', bundledTestPath], {
stdio: 'inherit',
cwd: rootDir,
})
child.on('exit', (code) => {
resolve(code ?? 1)
})
child.on('error', () => {
resolve(1)
})
})
} finally {
await rm(tempDir, { recursive: true, force: true })
}
process.exit(exitCode)
+38
View File
@@ -0,0 +1,38 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from 'node:child_process'
import { build } from 'esbuild'
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
const tempDir = await mkdtemp(join(tmpdir(), 'prism-desktop-integration-tests-'))
const bundledTestPath = join(tempDir, 'desktop-integration.test.mjs')
const entryPoint = join(rootDir, 'test', 'desktop-integration.test.ts')
let exitCode = 1
try {
await build({
entryPoints: [entryPoint],
outfile: bundledTestPath,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node23',
sourcemap: 'inline',
})
exitCode = await new Promise((resolve) => {
const child = spawn(process.execPath, ['--test', bundledTestPath], {
stdio: 'inherit',
cwd: rootDir,
})
child.on('exit', (code) => resolve(code ?? 1))
child.on('error', () => resolve(1))
})
} finally {
await rm(tempDir, { recursive: true, force: true })
}
process.exit(exitCode)
+126
View File
@@ -0,0 +1,126 @@
import { readFile, readdir, stat } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { lufsmeter } = require('../native/build/Release/visualizer_dsp.node')
const sourcePath = process.argv[2]
if (!sourcePath) {
console.error('Usage: npm run test:lufsmeter-ebu -- /path/to/extracted-ebu-loudness-test-set')
process.exit(2)
}
async function collectFiles(path) {
const details = await stat(path)
if (details.isFile()) return [path]
if (!details.isDirectory()) return []
const files = []
for (const entry of await readdir(path, { withFileTypes: true })) {
files.push(...await collectFiles(join(path, entry.name)))
}
return files
}
function decodeWave(buffer) {
if (buffer.length < 12 || buffer.toString('ascii', 0, 4) !== 'RIFF'
|| buffer.toString('ascii', 8, 12) !== 'WAVE') {
throw new Error('not a RIFF/WAVE file')
}
let format = null
let data = null
for (let offset = 12; offset + 8 <= buffer.length;) {
const chunkId = buffer.toString('ascii', offset, offset + 4)
const chunkSize = buffer.readUInt32LE(offset + 4)
const chunkOffset = offset + 8
if (chunkOffset + chunkSize > buffer.length) throw new Error(`truncated ${chunkId} chunk`)
if (chunkId === 'fmt ') {
if (chunkSize < 16) throw new Error('invalid fmt chunk')
let formatTag = buffer.readUInt16LE(chunkOffset)
if (formatTag === 0xfffe && chunkSize >= 40) {
formatTag = buffer.readUInt16LE(chunkOffset + 24)
}
format = {
formatTag,
channels: buffer.readUInt16LE(chunkOffset + 2),
sampleRate: buffer.readUInt32LE(chunkOffset + 4),
blockAlign: buffer.readUInt16LE(chunkOffset + 12),
bitsPerSample: buffer.readUInt16LE(chunkOffset + 14),
}
} else if (chunkId === 'data') {
data = buffer.subarray(chunkOffset, chunkOffset + chunkSize)
}
offset = chunkOffset + chunkSize + (chunkSize % 2)
}
if (!format || !data) throw new Error('missing fmt or data chunk')
if (format.channels < 1 || format.blockAlign < 1 || format.sampleRate < 1) {
throw new Error('invalid WAVE format')
}
const bytesPerSample = Math.ceil(format.bitsPerSample / 8)
const frameCount = Math.floor(data.length / format.blockAlign)
const left = new Float32Array(frameCount)
const right = new Float32Array(frameCount)
const readSample = (offset) => {
if (format.formatTag === 3 && format.bitsPerSample === 32) return data.readFloatLE(offset)
if (format.formatTag !== 1) throw new Error(`unsupported WAVE format tag ${format.formatTag}`)
if (format.bitsPerSample === 16) return data.readInt16LE(offset) / 0x8000
if (format.bitsPerSample === 24) return data.readIntLE(offset, 3) / 0x800000
if (format.bitsPerSample === 32) return data.readInt32LE(offset) / 0x80000000
throw new Error(`unsupported PCM depth ${format.bitsPerSample}`)
}
for (let frame = 0; frame < frameCount; frame += 1) {
const frameOffset = frame * format.blockAlign
left[frame] = readSample(frameOffset)
right[frame] = format.channels > 1 ? readSample(frameOffset + bytesPerSample) : left[frame]
}
return { sampleRate: format.sampleRate, left, right }
}
function expectedDbtp(caseNumber) {
if (caseNumber >= 15 && caseNumber <= 18) return -6
if (caseNumber === 19) return 3
return 0
}
const files = await collectFiles(resolve(sourcePath))
const cases = new Map()
for (const file of files) {
const match = basename(file).match(/3341[-_ ](1[5-9]|2[0-3])(?:\D|$)/i)
if (match && /\.wav(?:\.wav)?$/i.test(file)) cases.set(Number(match[1]), file)
}
const missing = []
for (let caseNumber = 15; caseNumber <= 23; caseNumber += 1) {
if (!cases.has(caseNumber)) missing.push(caseNumber)
}
if (missing.length > 0) {
console.error(`Could not find EBU Tech 3341 WAV cases: ${missing.join(', ')}`)
process.exit(2)
}
let failed = false
for (let caseNumber = 15; caseNumber <= 23; caseNumber += 1) {
const file = cases.get(caseNumber)
try {
const wave = decodeWave(await readFile(file))
lufsmeter.setSampleRate(wave.sampleRate)
lufsmeter.reset()
lufsmeter.pushSamples(wave.left, wave.right)
const actual = lufsmeter.getSnapshot().maxTruePeakDb
const expected = expectedDbtp(caseNumber)
const passed = actual >= expected - 0.4 && actual <= expected + 0.2
console.log(`${passed ? 'PASS' : 'FAIL'} EBU 3341-${caseNumber}: ${actual.toFixed(3)} dBTP (expected ${expected}, +0.2/-0.4)`)
failed ||= !passed
} catch (error) {
failed = true
console.error(`FAIL EBU 3341-${caseNumber}: ${error instanceof Error ? error.message : String(error)}`)
}
}
process.exit(failed ? 1 : 0)
+41
View File
@@ -0,0 +1,41 @@
import { spawnSync } from 'node:child_process'
const testScripts = [
'test:audio-router',
'test:audio-clips',
'test:audio-store',
'test:astra',
'test:capture-support',
'test:spotify-provider',
'test:now-playing',
'test:profiles',
'test:themes',
'test:secret-vault',
'test:window-state',
'test:desktop-integration',
'test:renderer-helpers',
'test:build-metadata',
'test:updates',
'test:spectrum-native',
'test:spectrogram-native',
'test:vectorscope-native',
'test:lufsmeter-native',
]
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
for (const testScript of testScripts) {
const result = spawnSync(npmCommand, ['run', testScript], {
cwd: process.cwd(),
stdio: 'inherit',
})
if (result.error) {
console.error(`[test] ${testScript} failed to start: ${result.error.message}`)
process.exit(1)
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
+255
View File
@@ -0,0 +1,255 @@
[CmdletBinding()]
param(
[string]$InstallerPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($env:OS -ne 'Windows_NT') {
throw 'The Windows installer smoke test can only run on Windows.'
}
if ($env:GITHUB_ACTIONS -ne 'true') {
throw 'This test changes the machine PATH and is restricted to the disposable GitHub Actions runner.'
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'The Windows installer smoke test requires an elevated process.'
}
$repoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($InstallerPath)) {
$installerCandidates = @(
Get-ChildItem -LiteralPath (Join-Path $repoRoot 'dist') -Filter '*.exe' -File |
Where-Object { $_.Name -like '*Setup*.exe' }
)
if ($installerCandidates.Count -ne 1) {
throw "Expected one NSIS setup executable in dist, found $($installerCandidates.Count)."
}
$InstallerPath = $installerCandidates[0].FullName
}
$InstallerPath = (Resolve-Path -LiteralPath $InstallerPath).Path
$tempRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
[IO.Path]::GetTempPath()
} else {
$env:RUNNER_TEMP
}
$testId = [Guid]::NewGuid().ToString('N')
$installRoot = Join-Path $tempRoot "Prism-$testId Installer Smoke"
$tuiDirectory = Join-Path $installRoot 'resources\tui'
$tuiExecutable = Join-Path $tuiDirectory 'prism-tui.exe'
$uninstallerPath = Join-Path $installRoot 'Uninstall Prism.exe'
$legacyEntry = ($tuiDirectory -split '\s', 2)[0]
$machinePathBefore = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$uninstalled = $false
$legacyDirectoryCreated = $false
function Get-PathEntries {
param([AllowNull()][string]$Value)
return @($Value -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
function Assert-PathEntriesEqual {
param(
[string[]]$Expected,
[string[]]$Actual,
[string]$Context
)
if ($Expected.Count -ne $Actual.Count) {
throw "$Context entry count mismatch. Expected $($Expected.Count), got $($Actual.Count)."
}
for ($index = 0; $index -lt $Expected.Count; $index += 1) {
if (-not [string]::Equals($Expected[$index], $Actual[$index], [StringComparison]::OrdinalIgnoreCase)) {
throw "$Context differs at index $index. Expected '$($Expected[$index])', got '$($Actual[$index])'."
}
}
}
function Invoke-CheckedProcess {
param(
[Parameter(Mandatory)][string]$FilePath,
[string]$Arguments = '',
[hashtable]$Environment = @{},
[switch]$CaptureOutput
)
$startInfo = New-Object Diagnostics.ProcessStartInfo
$startInfo.FileName = $FilePath
$startInfo.Arguments = $Arguments
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.WorkingDirectory = $repoRoot
$startInfo.RedirectStandardOutput = $CaptureOutput
$startInfo.RedirectStandardError = $CaptureOutput
foreach ($name in $Environment.Keys) {
$startInfo.EnvironmentVariables[$name] = [string]$Environment[$name]
}
$process = New-Object Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "Could not start $FilePath."
}
$stdout = ''
$stderr = ''
if ($CaptureOutput) {
$stdout = $process.StandardOutput.ReadToEnd()
$stderr = $process.StandardError.ReadToEnd()
}
$process.WaitForExit()
if ($process.ExitCode -ne 0) {
throw "$FilePath exited with code $($process.ExitCode).`n$stdout`n$stderr"
}
return [pscustomobject]@{
Stdout = $stdout
Stderr = $stderr
}
}
function Remove-DirectoryWithRetry {
param(
[Parameter(Mandatory)][string]$LiteralPath,
[int]$MaximumAttempts = 10,
[int]$DelayMilliseconds = 500
)
for ($attempt = 1; $attempt -le $MaximumAttempts; $attempt += 1) {
if (-not (Test-Path -LiteralPath $LiteralPath)) {
return
}
try {
Remove-Item -LiteralPath $LiteralPath -Recurse -Force -ErrorAction Stop
return
} catch {
if ($attempt -eq $MaximumAttempts) {
throw
}
Start-Sleep -Milliseconds $DelayMilliseconds
}
}
}
try {
if ([string]::Equals($legacyEntry, $tuiDirectory, [StringComparison]::OrdinalIgnoreCase)) {
throw "The smoke-test installation path must contain whitespace: $installRoot"
}
if (Test-Path -LiteralPath $legacyEntry -PathType Container) {
throw "Refusing to use an existing directory as the legacy PATH fixture: $legacyEntry"
}
$originalEntries = @(Get-PathEntries $machinePathBefore)
foreach ($entry in $originalEntries) {
if ([string]::Equals($entry, $tuiDirectory, [StringComparison]::OrdinalIgnoreCase) -or
[string]::Equals($entry, $legacyEntry, [StringComparison]::OrdinalIgnoreCase)) {
throw "The machine PATH already contains a smoke-test entry: $entry"
}
}
$seededEntries = @($originalEntries) + $legacyEntry
[Environment]::SetEnvironmentVariable('Path', ($seededEntries -join ';'), 'Machine')
# NSIS requires /D to be the final argument and consumes the remainder of the
# command line as the install directory, including spaces.
Invoke-CheckedProcess -FilePath $InstallerPath -Arguments "/S /D=$installRoot" | Out-Null
if (-not (Test-Path -LiteralPath $tuiExecutable -PathType Leaf)) {
throw "The installer did not install prism-tui.exe at $tuiExecutable."
}
if (-not (Test-Path -LiteralPath $uninstallerPath -PathType Leaf)) {
throw "The installer did not create the expected uninstaller at $uninstallerPath."
}
$installedEntries = @(Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'Machine')))
$expectedInstalledEntries = @($originalEntries) + $tuiDirectory
Assert-PathEntriesEqual -Expected $expectedInstalledEntries -Actual $installedEntries -Context 'Installed machine PATH'
$matchingTuiEntries = @(
$installedEntries | Where-Object {
[string]::Equals($_, $tuiDirectory, [StringComparison]::OrdinalIgnoreCase)
}
)
if ($matchingTuiEntries.Count -ne 1) {
throw "Expected one Prism TUI PATH entry, found $($matchingTuiEntries.Count)."
}
if ($installedEntries -icontains $legacyEntry) {
throw "The installer left the legacy truncated PATH entry in place: $legacyEntry"
}
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$launchPath = (@($installedEntries) + @(Get-PathEntries $userPath)) -join ';'
$launchResult = Invoke-CheckedProcess -FilePath $env:ComSpec `
-Arguments '/D /C prism-tui.exe --version' `
-Environment @{ Path = $launchPath } `
-CaptureOutput
if ($launchResult.Stdout.Trim() -notmatch '^prism-tui\s+') {
throw "prism-tui did not return the expected version output: $($launchResult.Stdout.Trim())"
}
# Reinstall with the legacy prefix backed by a real directory. The safe
# migration must now preserve that entry while continuing to deduplicate the
# complete Prism TUI path.
New-Item -ItemType Directory -Path $legacyEntry | Out-Null
$legacyDirectoryCreated = $true
[Environment]::SetEnvironmentVariable('Path', ((@($installedEntries) + $legacyEntry) -join ';'), 'Machine')
Invoke-CheckedProcess -FilePath $InstallerPath -Arguments "/S /D=$installRoot" | Out-Null
$reinstalledEntries = @(Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'Machine')))
$expectedReinstalledEntries = @($originalEntries) + $legacyEntry + $tuiDirectory
Assert-PathEntriesEqual -Expected $expectedReinstalledEntries -Actual $reinstalledEntries -Context 'Reinstalled machine PATH'
# _?= prevents NSIS from spawning a temporary uninstaller and exiting early,
# so WaitForExit observes the process that is actually removing the files.
# NSIS requires this to be the final argument and forbids quoting its value.
Invoke-CheckedProcess -FilePath $uninstallerPath -Arguments "/S _?=$installRoot" | Out-Null
$uninstalled = $true
$uninstalledEntries = @(Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'Machine')))
$expectedUninstalledEntries = @($originalEntries) + $legacyEntry
Assert-PathEntriesEqual -Expected $expectedUninstalledEntries -Actual $uninstalledEntries -Context 'Uninstalled machine PATH'
if ($uninstalledEntries -icontains $tuiDirectory) {
throw "The uninstaller left the Prism TUI PATH entry in place: $tuiDirectory"
}
Write-Host "Windows installer PATH smoke test passed for $InstallerPath"
} finally {
if (-not $uninstalled -and (Test-Path -LiteralPath $uninstallerPath -PathType Leaf)) {
try {
Invoke-CheckedProcess -FilePath $uninstallerPath -Arguments "/S _?=$installRoot" | Out-Null
} catch {
Write-Warning "Smoke-test uninstall cleanup failed: $($_.Exception.Message)"
}
}
[Environment]::SetEnvironmentVariable('Path', $machinePathBefore, 'Machine')
if (Test-Path -LiteralPath $installRoot) {
$fullTempRoot = [IO.Path]::GetFullPath($tempRoot).TrimEnd('\') + '\'
$fullInstallRoot = [IO.Path]::GetFullPath($installRoot)
if ($fullInstallRoot.StartsWith($fullTempRoot, [StringComparison]::OrdinalIgnoreCase)) {
Remove-DirectoryWithRetry -LiteralPath $fullInstallRoot
} else {
Write-Warning "Refusing to remove unexpected smoke-test directory: $fullInstallRoot"
}
}
if ($legacyDirectoryCreated -and (Test-Path -LiteralPath $legacyEntry -PathType Container)) {
$fullTempRoot = [IO.Path]::GetFullPath($tempRoot).TrimEnd('\') + '\'
$fullLegacyEntry = [IO.Path]::GetFullPath($legacyEntry)
if ($fullLegacyEntry.StartsWith($fullTempRoot, [StringComparison]::OrdinalIgnoreCase)) {
Remove-DirectoryWithRetry -LiteralPath $fullLegacyEntry
} else {
Write-Warning "Refusing to remove unexpected legacy fixture directory: $fullLegacyEntry"
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { AudioClipDragPayload } from '../types/audioClip'
const WAV_HEADER_BYTES = 44
const PCM_BITS_PER_SAMPLE = 16
const MAX_SAMPLE_RATE = 384000
const MAX_CLIP_SECONDS = 60
export function validateAudioClipDragPayload(raw: unknown): AudioClipDragPayload {
if (typeof raw !== 'object' || raw === null) {
throw new Error('The audio clip payload is invalid.')
}
const candidate = raw as Partial<AudioClipDragPayload>
if (!(candidate.pcmBytes instanceof Uint8Array)) {
throw new Error('The audio clip is missing PCM sample data.')
}
if (
!Number.isSafeInteger(candidate.sampleRate)
|| candidate.sampleRate! < 1
|| candidate.sampleRate! > MAX_SAMPLE_RATE
) {
throw new Error('The audio clip sample rate is invalid.')
}
if (candidate.channelCount !== 1 && candidate.channelCount !== 2) {
throw new Error('The audio clip channel count is invalid.')
}
if (
!Number.isSafeInteger(candidate.frameCount)
|| candidate.frameCount! < 1
|| candidate.frameCount! > candidate.sampleRate! * MAX_CLIP_SECONDS
) {
throw new Error('The audio clip duration is invalid.')
}
const expectedBytes = candidate.frameCount! * candidate.channelCount * (PCM_BITS_PER_SAMPLE / 8)
if (candidate.pcmBytes.byteLength !== expectedBytes) {
throw new Error('The audio clip PCM data length is invalid.')
}
return {
pcmBytes: candidate.pcmBytes,
sampleRate: candidate.sampleRate!,
channelCount: candidate.channelCount,
frameCount: candidate.frameCount!,
}
}
export function encodePcm16Wav(payload: AudioClipDragPayload): Buffer {
const validated = validateAudioClipDragPayload(payload)
const dataBytes = validated.pcmBytes.byteLength
const blockAlign = validated.channelCount * (PCM_BITS_PER_SAMPLE / 8)
const wav = Buffer.allocUnsafe(WAV_HEADER_BYTES + dataBytes)
wav.write('RIFF', 0, 'ascii')
wav.writeUInt32LE(36 + dataBytes, 4)
wav.write('WAVE', 8, 'ascii')
wav.write('fmt ', 12, 'ascii')
wav.writeUInt32LE(16, 16)
wav.writeUInt16LE(1, 20)
wav.writeUInt16LE(validated.channelCount, 22)
wav.writeUInt32LE(validated.sampleRate, 24)
wav.writeUInt32LE(validated.sampleRate * blockAlign, 28)
wav.writeUInt16LE(blockAlign, 32)
wav.writeUInt16LE(PCM_BITS_PER_SAMPLE, 34)
wav.write('data', 36, 'ascii')
wav.writeUInt32LE(dataBytes, 40)
wav.set(validated.pcmBytes, WAV_HEADER_BYTES)
return wav
}
export function buildAudioClipBaseName(date: Date): string {
const timestamp = date.toISOString()
.replace('T', ' ')
.replace(/:/g, '-')
.replace('Z', '')
return `Prism Clip ${timestamp}`
}
export class AudioClipLibrary {
constructor(
private readonly directory: string,
private readonly now: () => Date = () => new Date(),
) {}
getDirectory(): string {
return this.directory
}
ensureDirectory(): string {
mkdirSync(this.directory, { recursive: true })
return this.directory
}
writeClip(raw: unknown): string {
const payload = validateAudioClipDragPayload(raw)
const wav = encodePcm16Wav(payload)
this.ensureDirectory()
const baseName = buildAudioClipBaseName(this.now())
let suffix = 1
let filePath = join(this.directory, `${baseName}.wav`)
while (existsSync(filePath)) {
suffix += 1
filePath = join(this.directory, `${baseName} (${suffix}).wav`)
}
writeFileSync(filePath, wav, { flag: 'wx' })
return filePath
}
}
+895 -74
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type { NativeWindowChromeAPI } from '../types/nativeWindowChrome'
type NativeAddonModule = VisualizerDSP & NativeCaptureAPI & {
windowChrome?: NativeWindowChromeAPI
}
const require = createRequire(import.meta.url)
const currentDir = dirname(fileURLToPath(import.meta.url))
export function loadNativeWindowChromeApi(): NativeWindowChromeAPI | null {
if (process.platform !== 'win32') {
return null
}
const candidatePaths = [
join(currentDir, '../../native/build/Release/visualizer_dsp.node'),
process.resourcesPath ? join(process.resourcesPath, 'native/visualizer_dsp.node') : null,
].filter((value): value is string => value !== null)
for (const modulePath of candidatePaths) {
try {
const nativeAddon = require(modulePath) as NativeAddonModule
if (nativeAddon.windowChrome) {
return nativeAddon.windowChrome
}
} catch {
// Try the next candidate path.
}
}
return null
}
+6 -1
View File
@@ -351,7 +351,12 @@ export class FileBackedProfileLibrary {
throw new Error(`Unsupported profile format in ${basename(filePath)}.`)
}
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== PROFILE_FILE_VERSION) {
if (
candidate.version !== 1
&& candidate.version !== 2
&& candidate.version !== 3
&& candidate.version !== PROFILE_FILE_VERSION
) {
throw new Error(`Unsupported profile version in ${basename(filePath)}.`)
}
@@ -0,0 +1,64 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import {
DEFAULT_DESKTOP_INTEGRATION_PREFERENCES,
type DesktopIntegrationPreferences,
} from '../../types/desktopIntegration'
export function normalizeDesktopIntegrationPreferences(value: unknown): DesktopIntegrationPreferences {
if (!value || typeof value !== 'object') {
return { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES }
}
const candidate = value as Partial<DesktopIntegrationPreferences>
return {
closeToTray: candidate.closeToTray === true,
loginLaunchMode: candidate.loginLaunchMode === 'tray' ? 'tray' : 'show',
}
}
export async function loadDesktopIntegrationPreferences(
filePath: string,
): Promise<DesktopIntegrationPreferences> {
try {
return normalizeDesktopIntegrationPreferences(JSON.parse(await readFile(filePath, 'utf8')))
} catch {
return { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES }
}
}
export async function saveDesktopIntegrationPreferences(
filePath: string,
preferences: DesktopIntegrationPreferences,
): Promise<DesktopIntegrationPreferences> {
const normalized = normalizeDesktopIntegrationPreferences(preferences)
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
export type MainWindowCloseDisposition = 'hide-to-tray' | 'close'
export function resolveMainWindowCloseDisposition(options: {
closeToTray: boolean
isAppQuitting: boolean
trayAvailable: boolean
windowRecreationPending: boolean
}): MainWindowCloseDisposition {
return options.closeToTray
&& !options.isAppQuitting
&& !options.windowRecreationPending
&& options.trayAvailable
? 'hide-to-tray'
: 'close'
}
export function resolveStartHiddenAtLogin(options: {
isLoginLaunch: boolean
loginLaunchMode: DesktopIntegrationPreferences['loginLaunchMode']
hasPendingFileOpen: boolean
}): boolean {
return options.isLoginLaunch
&& options.loginLaunchMode === 'tray'
&& !options.hasPendingFileOpen
}
+224
View File
@@ -0,0 +1,224 @@
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join } from 'node:path'
import type {
DesktopIntegrationPreferences,
DesktopIntegrationSnapshot,
LoginItemStatus,
} from '../../types/desktopIntegration'
export const LOGIN_LAUNCH_ARG = '--prism-login-launch'
const LINUX_AUTOSTART_FILENAME = 'com.astra.prism.desktop'
interface NativeLoginItemSettings {
openAtLogin?: boolean
wasOpenedAtLogin?: boolean
status?: 'not-registered' | 'enabled' | 'requires-approval' | 'not-found'
executableWillLaunchAtLogin?: boolean
}
interface LoginItemServiceOptions {
platform: NodeJS.Platform
isPackaged: boolean
executablePath: string
appImagePath?: string
configHome?: string
homePath: string
getNativeSettings?: (options?: { path?: string; args?: string[] }) => NativeLoginItemSettings
setNativeSettings?: (settings: {
openAtLogin: boolean
path?: string
args?: string[]
type?: 'mainAppService'
}) => void
}
function getErrorMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : 'Login item operation failed.'
}
export function resolveLinuxAutostartPath(configHome: string | undefined, homePath: string): string {
const resolvedConfigHome = configHome && isAbsolute(configHome)
? configHome
: join(homePath, '.config')
return join(resolvedConfigHome, 'autostart', LINUX_AUTOSTART_FILENAME)
}
export function resolveLinuxLaunchExecutable(appImagePath: string | undefined, executablePath: string): string {
return appImagePath && isAbsolute(appImagePath) ? appImagePath : executablePath
}
export function quoteDesktopExecArgument(value: string): string {
const escaped = value.replace(/[\\"`$]/g, (character) => `\\${character}`)
return `"${escaped}"`
}
export function buildLinuxAutostartEntry(executablePath: string): string {
const executable = quoteDesktopExecArgument(executablePath)
return [
'[Desktop Entry]',
'Type=Application',
'Version=1.0',
'Name=Prism',
'Comment=Open Prism at login',
`TryExec=${executable}`,
`Exec=${executable} ${LOGIN_LAUNCH_ARG}`,
'Terminal=false',
'Hidden=false',
'X-GNOME-Autostart-enabled=true',
'',
].join('\n')
}
export function resolveNativeLoginItemStatus(
platform: NodeJS.Platform,
settings: NativeLoginItemSettings,
): LoginItemStatus {
if (platform === 'darwin') {
if (settings.status === 'requires-approval') return 'requires-approval'
return settings.openAtLogin || settings.status === 'enabled' ? 'enabled' : 'disabled'
}
if (platform === 'win32') {
if (!settings.openAtLogin) return 'disabled'
return settings.executableWillLaunchAtLogin === false ? 'blocked' : 'enabled'
}
return 'unavailable'
}
export function isLoginLaunch(platform: NodeJS.Platform, argv: string[], nativeSettings?: NativeLoginItemSettings): boolean {
return platform === 'darwin'
? nativeSettings?.wasOpenedAtLogin === true
: argv.includes(LOGIN_LAUNCH_ARG)
}
export class LoginItemService {
constructor(private readonly options: LoginItemServiceOptions) {}
isSupported(): boolean {
return this.options.isPackaged
&& (this.options.platform === 'darwin'
|| this.options.platform === 'win32'
|| this.options.platform === 'linux')
}
wasOpenedAtLogin(argv: string[]): boolean {
if (!this.isSupported()) return false
try {
return isLoginLaunch(
this.options.platform,
argv,
this.options.platform === 'darwin' ? this.options.getNativeSettings?.() : undefined,
)
} catch {
return false
}
}
async getSnapshot(
preferences: DesktopIntegrationPreferences,
): Promise<DesktopIntegrationSnapshot> {
if (!this.isSupported()) {
return {
...preferences,
openAtLogin: false,
loginItemStatus: 'unavailable',
loginItemError: null,
}
}
try {
if (this.options.platform === 'linux') {
const filePath = resolveLinuxAutostartPath(this.options.configHome, this.options.homePath)
let contents = ''
try {
contents = await readFile(filePath, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return {
...preferences,
openAtLogin: false,
loginItemStatus: 'disabled',
loginItemError: null,
}
}
const enabled = !/^\s*Hidden\s*=\s*true\s*$/im.test(contents)
return {
...preferences,
openAtLogin: enabled,
loginItemStatus: enabled ? 'enabled' : 'disabled',
loginItemError: null,
}
}
const nativeOptions = this.options.platform === 'win32'
? { path: this.options.executablePath, args: [LOGIN_LAUNCH_ARG] }
: undefined
const settings = this.options.getNativeSettings?.(nativeOptions) ?? {}
const loginItemStatus = resolveNativeLoginItemStatus(this.options.platform, settings)
return {
...preferences,
openAtLogin: settings.openAtLogin === true
|| loginItemStatus === 'enabled'
|| loginItemStatus === 'requires-approval'
|| loginItemStatus === 'blocked',
loginItemStatus,
loginItemError: null,
}
} catch (error) {
return {
...preferences,
openAtLogin: false,
loginItemStatus: 'error',
loginItemError: getErrorMessage(error),
}
}
}
async setOpenAtLogin(
enabled: boolean,
preferences: DesktopIntegrationPreferences,
): Promise<DesktopIntegrationSnapshot> {
if (!this.isSupported()) {
return this.getSnapshot(preferences)
}
try {
if (this.options.platform === 'linux') {
const filePath = resolveLinuxAutostartPath(this.options.configHome, this.options.homePath)
if (enabled) {
const executablePath = resolveLinuxLaunchExecutable(
this.options.appImagePath,
this.options.executablePath,
)
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, buildLinuxAutostartEntry(executablePath), 'utf8')
} else {
await unlink(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error
})
}
} else if (this.options.platform === 'win32') {
this.options.setNativeSettings?.({
openAtLogin: enabled,
path: this.options.executablePath,
args: [LOGIN_LAUNCH_ARG],
})
} else {
this.options.setNativeSettings?.({
openAtLogin: enabled,
type: 'mainAppService',
})
}
} catch (error) {
const current = await this.getSnapshot(preferences)
return {
...current,
loginItemStatus: 'error',
loginItemError: getErrorMessage(error),
}
}
return this.getSnapshot(preferences)
}
}
+19
View File
@@ -0,0 +1,19 @@
import { join } from 'node:path'
export function getTrayAssetFilename(platform: NodeJS.Platform): string {
if (platform === 'darwin') return 'prismTrayTemplate.png'
if (platform === 'win32') return 'prism-tray.ico'
return 'prism-tray.png'
}
export function resolveTrayAssetPath(options: {
platform: NodeJS.Platform
isPackaged: boolean
resourcesPath: string
appPath: string
}): string {
const directory = options.isPackaged
? join(options.resourcesPath, 'tray')
: join(options.appPath, 'resources', 'tray')
return join(directory, getTrayAssetFilename(options.platform))
}
+126
View File
@@ -0,0 +1,126 @@
import {
DEFAULT_TRAY_RENDERER_STATE,
type DesktopIntegrationSnapshot,
type TrayAudioSourceOption,
type TrayProfileOption,
type TrayRendererState,
} from '../../types/desktopIntegration'
import { isRollingCaptureDuration } from '../../types/audioClip'
const MAX_TRAY_ITEMS = 64
const MAX_LABEL_LENGTH = 96
export interface TrayMenuState {
mainWindowVisible: boolean
rendererReady: boolean
rendererState: TrayRendererState
desktopIntegration: DesktopIntegrationSnapshot
alwaysOnTop: boolean
supportsReposition: boolean
}
export interface TrayMenuModel extends TrayMenuState {
statusLabel: string
tooltip: string
mainWindowActionLabel: 'Show Prism' | 'Hide Prism'
captureActionLabel: 'Start Capture' | 'Stop Capture'
captureActionEnabled: boolean
}
function normalizeText(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback
const trimmed = value.trim()
if (!trimmed) return fallback
return trimmed.slice(0, MAX_LABEL_LENGTH)
}
function normalizeProfiles(value: unknown): TrayProfileOption[] {
if (!Array.isArray(value)) return []
return value.slice(0, MAX_TRAY_ITEMS).flatMap((raw) => {
if (!raw || typeof raw !== 'object') return []
const candidate = raw as Partial<TrayProfileOption>
if (typeof candidate.id !== 'string' || !candidate.id.trim()) return []
return [{
id: candidate.id,
name: normalizeText(candidate.name, 'Profile'),
}]
})
}
function normalizeSources(value: unknown): TrayAudioSourceOption[] {
if (!Array.isArray(value)) return []
return value.slice(0, MAX_TRAY_ITEMS).flatMap((raw) => {
if (!raw || typeof raw !== 'object') return []
const candidate = raw as Partial<TrayAudioSourceOption>
if (typeof candidate.id !== 'string') return []
return [{
id: candidate.id,
label: normalizeText(candidate.label, 'Audio Source'),
isDefault: candidate.isDefault === true,
}]
})
}
export function normalizeTrayRendererState(value: unknown): TrayRendererState {
if (!value || typeof value !== 'object') {
return { ...DEFAULT_TRAY_RENDERER_STATE }
}
const candidate = value as Partial<TrayRendererState>
const captureStatus = candidate.captureStatus === 'connecting'
|| candidate.captureStatus === 'capturing'
|| candidate.captureStatus === 'error'
? candidate.captureStatus
: 'idle'
return {
profiles: normalizeProfiles(candidate.profiles),
activeProfileId: typeof candidate.activeProfileId === 'string' ? candidate.activeProfileId : null,
hasUnsavedProfileChanges: candidate.hasUnsavedProfileChanges === true,
captureStatus,
activeSourceLabel: typeof candidate.activeSourceLabel === 'string'
? normalizeText(candidate.activeSourceLabel, 'Audio Source')
: null,
captureMode: candidate.captureMode === 'device' ? 'device' : 'system',
selectedSystemSourceId: typeof candidate.selectedSystemSourceId === 'string'
? candidate.selectedSystemSourceId
: null,
selectedDeviceId: typeof candidate.selectedDeviceId === 'string'
? candidate.selectedDeviceId
: null,
rollingCaptureSeconds: isRollingCaptureDuration(candidate.rollingCaptureSeconds)
? candidate.rollingCaptureSeconds
: null,
systemSources: normalizeSources(candidate.systemSources),
inputSources: normalizeSources(candidate.inputSources),
}
}
export function buildTrayMenuModel(state: TrayMenuState): TrayMenuModel {
const sourceLabel = state.rendererState.activeSourceLabel
const statusText = state.rendererState.captureStatus === 'capturing'
? sourceLabel ? `Capturing · ${sourceLabel}` : 'Capturing'
: state.rendererState.captureStatus === 'connecting'
? 'Connecting to audio…'
: state.rendererState.captureStatus === 'error'
? 'Audio capture error'
: 'Capture stopped'
return {
...state,
statusLabel: `Prism — ${statusText}`,
tooltip: sourceLabel && state.rendererState.captureStatus === 'capturing'
? `Prism — ${sourceLabel}`
: 'Prism',
mainWindowActionLabel: state.mainWindowVisible ? 'Hide Prism' : 'Show Prism',
captureActionLabel: state.rendererState.captureStatus === 'capturing'
|| state.rendererState.captureStatus === 'connecting'
? 'Stop Capture'
: 'Start Capture',
captureActionEnabled: state.rendererReady,
}
}
export function createTrayMenuStateKey(model: TrayMenuModel): string {
return JSON.stringify(model)
}
@@ -0,0 +1,20 @@
import type { TrayRendererCommand } from '../../types/desktopIntegration'
export class TrayRendererCommandQueue {
private commands: TrayRendererCommand[] = []
enqueue(command: TrayRendererCommand): void {
this.commands.push(command)
}
flush(send: (command: TrayRendererCommand) => void): void {
for (const command of this.commands) {
send(command)
}
this.commands = []
}
clear(): void {
this.commands = []
}
}
+15 -1
View File
@@ -3,7 +3,7 @@ import { dirname } from 'node:path'
import { createEmptyWindowLocalState, normalizeWindowLocalState } from '../shared/windowState'
import type { WindowBounds } from '../types/popout'
import type { ScopeKind } from '../types/scope'
import type { PrismWindowLocalStateV1 } from '../types/windowState'
import type { PrismWindowLocalStateV1, WindowBackgroundState } from '../types/windowState'
export class FileBackedWindowStateStore {
private state: PrismWindowLocalStateV1 = createEmptyWindowLocalState()
@@ -26,6 +26,10 @@ export class FileBackedWindowStateStore {
return this.state.popoutAlwaysOnTop[kind] === true
}
getWindowBackground(): WindowBackgroundState {
return { ...this.state.windowBackground }
}
getNowPlayingConfigWindowBounds(): WindowBounds | undefined {
return this.state.nowPlayingConfigWindowBounds
? { ...this.state.nowPlayingConfigWindowBounds }
@@ -59,6 +63,16 @@ export class FileBackedWindowStateStore {
await this.persistState()
}
async setWindowBackground(background: WindowBackgroundState): Promise<void> {
await this.ensureInitialized()
this.state = normalizeWindowLocalState({
...this.state,
windowBackground: background,
popoutAlwaysOnTop: { ...this.state.popoutAlwaysOnTop },
})
await this.persistState()
}
async setNowPlayingConfigWindowBounds(bounds?: WindowBounds): Promise<void> {
await this.ensureInitialized()
this.state = normalizeWindowLocalState({
+34
View File
@@ -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, true peak, fast VU/correlation) runs
* in the C++ plugin, which pushes a finished scalar snapshot each frame. This shim
* caches that snapshot and serves it through the interface `LUFSMeter` consumes.
* `pushSamples` is a no-op (audio never flows through the webview); `reset` clears
* only the cache (the C++ integrator keeps its own state).
*/
export class BridgeLUFSMeterAnalyzer implements LUFSMeterNativeAnalyzer {
private snapshot: LUFSMeterNativeSnapshot | null = null
/** Called by the bridge whenever the host emits a new LUFS frame. */
setSnapshot(snapshot: LUFSMeterNativeSnapshot): void {
this.snapshot = snapshot
}
isAvailable(): boolean {
return true
}
setSampleRate(_sampleRate: number): void {}
pushSamples(_left: Float32Array, _right: Float32Array): void {}
getSnapshot(): LUFSMeterNativeSnapshot | null {
return this.snapshot
}
reset(): void {
this.snapshot = null
}
}
@@ -0,0 +1,52 @@
import type { OscilloscopeNativeAnalyzer, OscilloscopeResult } from '../renderer/audio/native'
/**
* Drop-in `OscilloscopeNativeAnalyzer` for the plugin webview.
*
* The oscilloscope DSP (circular buffer + trigger detection) runs in the C++
* plugin, which pushes the finished, already-triggered display window each frame.
* This shim serves that window through the interface `Oscilloscope` consumes, so
* the visualizer renders it unchanged. `pushSamples` is a no-op (audio never
* flows through the webview); `processContinuous` reports the window at index 0.
*/
export class BridgeOscilloscopeAnalyzer implements OscilloscopeNativeAnalyzer {
private samples = new Float32Array(0)
private pitch = 0
/** Called by the bridge whenever the host emits a new oscilloscope frame. */
setSamples(samples: Float32Array, pitch: number): void {
if (samples.length !== this.samples.length) {
this.samples = new Float32Array(samples.length)
}
this.samples.set(samples)
this.pitch = pitch
}
isAvailable(): boolean {
return true
}
setSampleRate(_sampleRate: number): void {}
setPitchLock(_enabled: boolean): void {}
setDisplaySamples(_samples: number): void {}
pushSamples(_samples: Float32Array): void {}
processContinuous(): OscilloscopeResult {
const count = this.samples.length
// C++ already applied the trigger, so the window starts at index 0.
return { triggerIndex: 0, samplesToShow: count, detectedPitch: this.pitch, writePos: count }
}
fillSamples(_startPos: number, output: Float32Array): number {
const count = Math.min(output.length, this.samples.length)
if (count > 0) {
output.set(this.samples.subarray(0, count), 0)
}
return count
}
reset(): void {
this.samples = new Float32Array(0)
this.pitch = 0
}
}
@@ -0,0 +1,98 @@
import type { SpectrogramNativeAnalyzer, SpectrogramNativeOptions, SpectrogramNativeResult } from '../renderer/audio/native'
import { emitToHost } from './juceBridge'
const EMPTY = new Float32Array(0)
// The C++ engine emits columns every vblank (audio-driven), independently of how
// fast the webview can render them. Cap the backlog so that when the consumer falls
// behind we drop the OLDEST columns instead of accumulating unbounded latency (which
// otherwise death-spirals into stutter at high scroll speeds). One entry == one
// emitted frame, so this bounds latency to ~N display frames regardless of rate.
const MAX_QUEUED_FRAMES = 8
interface QueuedColumns {
display: Float32Array
heat: Float32Array
columnCount: number
}
/**
* Drop-in `SpectrogramNativeAnalyzer` for the plugin webview.
*
* The spectrogram DSP runs in the C++ plugin, but its output depends on the
* canvas-derived `rowCount` that only the UI knows. So this shim works in two
* directions: `configure()` forwards the full native config to C++ (event
* "prismSpectrogramConfig"), and the host streams finished display+heat columns
* back which the bridge enqueues via `pushFrame()`. `process()` ignores its audio
* argument (no samples flow through the webview) and returns all columns queued
* since the last call, concatenated into one result.
*
* Columns are only kept while their rowCount matches the rowCount the UI last
* asked for — on a resize the UI reconfigures, we drop the stale queue, and the
* C++ side catches up within a frame or two (a brief gap, never a mismatch).
*/
export class BridgeSpectrogramAnalyzer implements SpectrogramNativeAnalyzer {
private expectedRowCount = 0
private queue: QueuedColumns[] = []
private queuedColumns = 0
configure(options: SpectrogramNativeOptions): void {
if (options.rowCount !== this.expectedRowCount) {
this.expectedRowCount = options.rowCount
this.clearQueue()
}
emitToHost('prismSpectrogramConfig', options)
}
/** Called by the bridge when the host emits a spectrogram frame. */
pushFrame(display: Float32Array, heat: Float32Array, columnCount: number, rowCount: number): void {
if (rowCount !== this.expectedRowCount || columnCount <= 0) return
if (display.length < columnCount * rowCount || heat.length < columnCount * rowCount) return
this.queue.push({ display, heat, columnCount })
this.queuedColumns += columnCount
// Drop oldest backlog beyond the cap so we stay near real-time under overload.
while (this.queue.length > MAX_QUEUED_FRAMES) {
const dropped = this.queue.shift()
if (dropped) this.queuedColumns -= dropped.columnCount
}
}
/** The rowCount the UI last asked for (0 until first configure). */
getExpectedRowCount(): number {
return this.expectedRowCount
}
isAvailable(): boolean {
return true
}
process(_audioData: Float32Array): SpectrogramNativeResult {
const rowCount = this.expectedRowCount
if (this.queuedColumns === 0 || rowCount <= 0) {
return { display: EMPTY, heat: EMPTY, columnCount: 0, rowCount }
}
const total = this.queuedColumns * rowCount
const display = new Float32Array(total)
const heat = new Float32Array(total)
let offset = 0
for (const entry of this.queue) {
display.set(entry.display, offset)
heat.set(entry.heat, offset)
offset += entry.display.length
}
const columnCount = this.queuedColumns
this.clearQueue()
return { display, heat, columnCount, rowCount }
}
reset(): void {
this.clearQueue()
}
private clearQueue(): void {
this.queue = []
this.queuedColumns = 0
}
}
+140
View File
@@ -0,0 +1,140 @@
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
private channelMaxMagnitudes: 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)
this.channelMaxMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
}
/** Called by the bridge whenever the host emits a new frame. */
setMagnitudes(magnitudes: Float32Array, side?: Float32Array, channelMax?: 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)
}
const resolvedChannelMax = channelMax && channelMax.length > 0 ? channelMax : magnitudes
if (resolvedChannelMax.length !== this.channelMaxMagnitudes.length) {
this.channelMaxMagnitudes = new Float32Array(resolvedChannelMax.length)
}
this.channelMaxMagnitudes.set(resolvedChannelMax)
}
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)
this.channelMaxMagnitudes = 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
}
fillChannelMaxMagnitudes(output: Float32Array): number {
const count = Math.min(output.length, this.channelMaxMagnitudes.length)
if (count > 0) {
output.set(this.channelMaxMagnitudes.subarray(0, count), 0)
}
return count
}
getMagnitudes(): Float32Array {
return this.magnitudes
}
getRawMagnitudes(): Float32Array {
return this.magnitudes
}
getSideMagnitudes(): Float32Array {
return this.sideMagnitudes
}
getChannelMaxMagnitudes(): Float32Array {
return this.channelMaxMagnitudes
}
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)
this.channelMaxMagnitudes.fill(FFT_SILENCE_DB)
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { VUMeterNativeAnalyzer, VUMeterNativeSnapshot } from '../renderer/audio/native'
/**
* Drop-in `VUMeterNativeAnalyzer` for the plugin webview.
*
* The VU DSP (RMS integration, ballistics, peak hold, correlation) runs in the
* C++ plugin, which pushes a finished scalar snapshot each frame. This shim caches
* that snapshot and serves it through the interface `VUMeter` consumes, so the
* visualizer renders it unchanged. `pushSamples` is a no-op (audio never flows
* through the webview).
*/
export class BridgeVUMeterAnalyzer implements VUMeterNativeAnalyzer {
private snapshot: VUMeterNativeSnapshot | null = null
/** Called by the bridge whenever the host emits a new VU frame. */
setSnapshot(snapshot: VUMeterNativeSnapshot): void {
this.snapshot = snapshot
}
isAvailable(): boolean {
return true
}
setSampleRate(_sampleRate: number): void {}
pushSamples(_left: Float32Array, _right: Float32Array): void {}
getSnapshot(): VUMeterNativeSnapshot | null {
return this.snapshot
}
reset(): void {
this.snapshot = null
}
}
@@ -0,0 +1,66 @@
import type { VectorscopeNativeAnalyzer, VectorscopeMultibandPointsResult } from '../renderer/audio/native'
/**
* Drop-in `VectorscopeNativeAnalyzer` for the plugin webview.
*
* The vectorscope DSP (full-band channels + 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
}
}
+72
View File
@@ -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
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { JSX } from 'react'
// Prism's settings icon (matches the app's scope chrome).
export default function GearIcon(): JSX.Element {
return (
<svg viewBox="0 0 118 118" width="15" height="15" aria-hidden="true">
<path d="M104.811 35.1118L102.384 30.9002C100.549 27.7151 99.6313 26.1225 98.0697 25.4874C96.5082 24.8524 94.7421 25.3535 91.2105 26.3557L85.2112 28.0456C82.9564 28.5655 80.5905 28.2706 78.5319 27.2127L76.8755 26.2571C75.1099 25.1263 73.7519 23.4591 73.0002 21.4993L71.3585 16.5955C70.2788 13.3504 69.7389 11.7279 68.4537 10.7998C67.169 9.87175 65.4619 9.87175 62.0478 9.87175H56.5667C53.1531 9.87175 51.446 9.87175 50.1608 10.7998C48.8758 11.7279 48.336 13.3504 47.2564 16.5955L45.6145 21.4993C44.8628 23.4591 43.5048 25.1263 41.7394 26.2571L40.083 27.2127C38.0242 28.2706 35.6585 28.5655 33.4037 28.0456L27.4042 26.3557C23.8724 25.3535 22.1065 24.8524 20.5451 25.4874C18.9836 26.1225 18.066 27.7151 16.2306 30.9002L13.8038 35.1118C12.0834 38.0975 11.2232 39.5903 11.3902 41.1795C11.5571 42.7687 12.7087 44.0493 15.0118 46.6106L20.0811 52.2779C21.3201 53.8464 22.1997 56.58 22.1997 59.0379C22.1997 61.4967 21.3204 64.2294 20.0812 65.7983L15.0118 71.4657C12.7087 74.0273 11.5572 75.3076 11.3902 76.8972C11.2232 78.4862 12.0834 79.9789 13.8038 82.9643L16.2306 87.1759C18.0659 90.361 18.9836 91.954 20.5451 92.5887C22.1065 93.2239 23.8724 92.7229 27.4043 91.7204L33.4035 90.0306C35.6587 89.5104 38.0248 89.8059 40.0839 90.8639L41.74 91.8197C43.5051 92.9506 44.8628 94.6173 45.6143 96.5771L47.2564 101.481C48.336 104.726 48.8758 106.349 50.1608 107.277C51.446 108.205 53.1531 108.205 56.5667 108.205H62.0478C65.4619 108.205 67.169 108.205 68.4537 107.277C69.7389 106.349 70.2788 104.726 71.3585 101.481L73.0007 96.5771C73.7519 94.6173 75.1094 92.9506 76.875 91.8197L78.5309 90.8639C80.59 89.8059 82.9559 89.5104 85.2112 90.0306L91.2105 91.7204C94.7421 92.7229 96.5082 93.2239 98.0697 92.5887C99.6313 91.954 100.549 90.361 102.384 87.1759L104.811 82.9643C106.531 79.9789 107.391 78.4862 107.225 76.8972C107.057 75.3076 105.906 74.0273 103.603 71.4657L98.5334 65.7983C97.2944 64.2294 96.4148 61.4967 96.4148 59.0379C96.4148 56.58 97.2949 53.8464 98.5334 52.2779L103.603 46.6106C105.906 44.0493 107.057 42.7687 107.225 41.1795C107.391 39.5903 106.531 38.0975 104.811 35.1118Z" fill="none" stroke="currentColor" strokeWidth="8.5" strokeLinecap="round" />
<path d="M76.3042 59C76.3042 68.5039 68.5998 76.2083 59.0959 76.2083C49.592 76.2083 41.8877 68.5039 41.8877 59C41.8877 49.4961 49.592 41.7917 59.0959 41.7917C68.5998 41.7917 76.3042 49.4961 76.3042 59Z" fill="none" stroke="currentColor" strokeWidth="8.5" />
</svg>
)
}

Some files were not shown because too many files have changed in this diff Show More