15 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
33 changed files with 2091 additions and 117 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
+8
View File
@@ -127,6 +127,9 @@ jobs:
- 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
@@ -281,6 +284,11 @@ jobs:
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
+163 -13
View File
@@ -20,19 +20,159 @@ Monitor system audio or an input with a configurable set of real-time scopes and
## Scopes
Prism includes seven scopes and meters:
Prism includes seven real-time scopes and meters:
* **Spectrum Analyzer** — Frequency spectrum with heatmap and fill modes, configurable FFT size, spectral tilt, multiple frequency scales, and peak information
* **Oscilloscope** — Time-domain waveform with an optional pitch-lock mode that follows the fundamental frequency
* **Vectorscope** — Stereo image and phase visualization with XY, Polar, and M/S Linear views, adjustable zoom, and an optional multiband RGB split
* **Spectrogram** — Scrolling frequency-over-time display with Log, Mel, and Linear scales and several detail modes
* **VU Meter** — Classic VU metering with needle and bar styles in horizontal or vertical layouts
* **Loudness Meter** — LUFS metering following ITU-R BS.1770 alongside stereo peak activity
* **Waveform** — Scrolling waveform 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
Every scope can be configured independently. Resize and rearrange them into a rack, rotate them, pop individual scopes into their own windows, or pin them on top of other applications.
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.
![Prism customize](assets/prism-showcase-layout-readme.gif)
Spectrum, Spectrogram, Oscilloscope, and Waveform also include interactive measurement overlays for inspecting frequency, level, pitch, amplitude, or time directly from the display.
<details> <summary><strong>Stats for nerds</strong></summary>
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>
Compatible scopes also include an interactive measurement overlay for inspecting the display directly.
## Desktop
@@ -73,6 +213,7 @@ Prism also includes `prism-tui`, a native terminal interface built on the same c
![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
@@ -101,7 +242,7 @@ Prism's interface is themeable through editable `.iro` files.
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/themes.png)
![Prism themes](assets/prism-themes-readme-10fps.gif)
Prism also includes chroma-key-friendly themes for using scopes as overlays in OBS or other video software.
@@ -113,6 +254,8 @@ Prebuilt versions of Prism for Windows, macOS, and Linux are available on the [R
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, CMake 3.22+, and a C++ compiler toolchain.
@@ -137,12 +280,19 @@ 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
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.
@@ -155,7 +305,7 @@ Prism does not require Astra and works as a standalone application.
## Support
If you find Prism useful and want to support development:
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)
@@ -163,4 +313,4 @@ If you find Prism useful and want to support development:
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.
Third-party license notices are recorded in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
Third-party license notices are recorded in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

+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_{};
};
+3 -2
View File
@@ -690,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;
}
+9 -1
View File
@@ -10,6 +10,8 @@
"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",
@@ -26,6 +28,8 @@
"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",
@@ -39,7 +43,7 @@
"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": "npm run stage:tui && npm run build && electron-builder --win --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"
@@ -104,6 +108,10 @@
"from": "resources/icon.png",
"to": "icon.png"
},
{
"from": "resources/drag/",
"to": "drag/"
},
{
"from": "resources/tray/",
"to": "tray/"
+6 -5
View File
@@ -5,9 +5,9 @@
/**
* Loudness (LUFS) meter engine. Pushes stereo audio into the reused
* `Visualizer::LUFSMeterAnalyzer` (K-weighting + gated integration + the same fast
* VU/peak/correlation block) and emits its scalar snapshot each frame. Like the VU
* engine the frame is plain numbers (no base64); getSnapshot() advances peak decay
* `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.
*/
@@ -44,8 +44,9 @@ public:
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("truePeakLDb", snap.truePeakLDb);
obj->setProperty("truePeakRDb", snap.truePeakRDb);
obj->setProperty("maxTruePeakDb", snap.maxTruePeakDb);
obj->setProperty("correlation", snap.correlation);
return juce::var(obj);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

+63 -23
View File
@@ -20,6 +20,23 @@
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}
@@ -55,16 +72,30 @@
!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, appends Prism once, and leaves every
; other entry intact. PowerShell comparisons are case-insensitive here.
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param([string]$$entry) $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry }); $$entries += $$entry; [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine') }" "$INSTDIR\resources\tui"`
; 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)"
@@ -75,39 +106,48 @@
DetailPrint "Added $INSTDIR\resources\tui to the machine PATH"
${If} $PRISM_VST_STATE == ${BST_CHECKED}
DetailPrint "Installing Prism VST3 plugins to $COMMONFILES64\VST3"
DetailPrint "Step 3 of 4: Installing Prism VST3 plugins to $COMMONFILES64\VST3..."
${IfNot} ${FileExists} "$INSTDIR\resources\plugins\VST3\*.vst3"
DetailPrint "ERROR: bundled Prism VST3 plugins were not found at $INSTDIR\resources\plugins\VST3"
MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugins were selected, but the bundled plugin files were not found.$\r$\n$\r$\nMissing path:$\r$\n$INSTDIR\resources\plugins\VST3" /SD IDOK
Abort
${EndIf}
CreateDirectory "$COMMONFILES64\VST3"
; xcopy /E recurse, /I treat dest as dir, /Y overwrite without prompt.
; The trailing "\*" + "/E" copies each *.vst3 bundle subfolder verbatim.
nsExec::ExecToLog 'cmd.exe /c xcopy /E /I /Y "$INSTDIR\resources\plugins\VST3\*" "$COMMONFILES64\VST3\"'
Pop $0
${If} $0 != 0
DetailPrint "ERROR: Prism VST3 plugin copy failed with xcopy exit code $0"
MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugin installation failed while copying files to:$\r$\n$COMMONFILES64\VST3$\r$\n$\r$\nxcopy exit code: $0" /SD IDOK
Abort
; 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 "Prism VST3 plugins: skipped (opted out)."
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.
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param([string]$$entry) $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry }); [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine') }" "$INSTDIR\resources\tui"`
Pop $0
System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("PRISM_TUI_INSTALL_DIR", "$INSTDIR\resources\tui").r0'
${If} $0 == 0
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
DetailPrint "WARNING: could not prepare the Prism TUI PATH removal"
${Else}
DetailPrint "WARNING: could not remove Prism TUI from the machine PATH (exit code $0)"
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
@@ -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
}
+17 -2
View File
@@ -12,6 +12,21 @@ if (!script) {
process.exit(1)
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const result = spawnSync(npmCommand, ['run', script], { stdio: 'inherit' })
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))
}
+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"
}
}
}
+25 -2
View File
@@ -153,6 +153,8 @@ const NOW_PLAYING_CONFIG_DEFAULTS = {
}
const STATIC_APP_ICON_FILENAME = 'icon.png'
const AUDIO_CLIP_DRAG_ICON_FILENAME = 'audio-clip.png'
const AUDIO_CLIP_DRAG_ICON_SIZE = 32
const DESKTOP_INTEGRATION_PREFS_FILENAME = 'desktop-integration.json'
const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180
const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64
@@ -377,11 +379,32 @@ function getStaticWindowIconOptions(): Pick<BrowserWindowConstructorOptions, 'ic
return icon ? { icon } : {}
}
function getAudioClipDragIconPath(): string | undefined {
const candidates = app.isPackaged
? [join(process.resourcesPath, 'drag', AUDIO_CLIP_DRAG_ICON_FILENAME)]
: [
join(process.cwd(), 'resources', 'drag', AUDIO_CLIP_DRAG_ICON_FILENAME),
join(__dirname, '../../resources/drag', AUDIO_CLIP_DRAG_ICON_FILENAME),
]
return candidates.find((candidate) => existsSync(candidate))
}
function getAudioClipDragIcon() {
const iconPath = getStaticAppIconPath()
const iconPath = getAudioClipDragIconPath()
if (iconPath) {
const icon = nativeImage.createFromPath(iconPath)
if (!icon.isEmpty()) return icon
if (!icon.isEmpty()) {
const size = icon.getSize()
if (size.width > AUDIO_CLIP_DRAG_ICON_SIZE || size.height > AUDIO_CLIP_DRAG_ICON_SIZE) {
return icon.resize({
width: AUDIO_CLIP_DRAG_ICON_SIZE,
height: AUDIO_CLIP_DRAG_ICON_SIZE,
quality: 'best',
})
}
return icon
}
}
const fallback = nativeImage.createFromDataURL(
+1 -1
View File
@@ -3,7 +3,7 @@ import type { LUFSMeterNativeAnalyzer, LUFSMeterNativeSnapshot } from '../render
/**
* Drop-in `LUFSMeterNativeAnalyzer` for the plugin webview.
*
* The loudness DSP (K-weighting, gated integration, fast VU/peak/correlation) runs
* 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
+36 -9
View File
@@ -275,16 +275,25 @@ export interface LUFSMeterFrame {
vuRDb: number
barLDb: number
barRDb: number
peakLDb: number
peakRDb: number
truePeakLDb: number
truePeakRDb: number
maxTruePeakDb: number
correlation: number
}
function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | null {
export function decodeLUFSMeterFrame(
payload: unknown,
previousMaxTruePeakDb = -60,
): LUFSMeterFrame | null {
if (typeof payload !== 'object' || payload === null) return null
const p = payload as Record<string, unknown>
const num = (key: string, fallback: number): number =>
typeof p[key] === 'number' && Number.isFinite(p[key]) ? (p[key] as number) : fallback
const truePeakLDb = num('truePeakLDb', num('peakLDb', -60))
const truePeakRDb = num('truePeakRDb', num('peakRDb', -60))
const explicitMaximum = typeof p.maxTruePeakDb === 'number' && Number.isFinite(p.maxTruePeakDb)
? p.maxTruePeakDb
: null
return {
sampleRate: num('sampleRate', 48000) > 0 ? num('sampleRate', 48000) : 48000,
momentaryLUFS: num('momentaryLUFS', -70),
@@ -294,8 +303,13 @@ function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | null {
vuRDb: num('vuRDb', -60),
barLDb: num('barLDb', -60),
barRDb: num('barRDb', -60),
peakLDb: num('peakLDb', -60),
peakRDb: num('peakRDb', -60),
truePeakLDb,
truePeakRDb,
maxTruePeakDb: explicitMaximum ?? Math.max(
Number.isFinite(previousMaxTruePeakDb) ? previousMaxTruePeakDb : -60,
truePeakLDb,
truePeakRDb,
),
correlation: num('correlation', 0),
}
}
@@ -309,6 +323,8 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
let disposed = false
let listenerId: number | null = null
let mockRaf: number | null = null
let maxTruePeakDb = -60
let previousSampleRate = 0
const startMock = (): void => {
handlers.onConnected?.(true)
@@ -320,6 +336,9 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
const lufs = (offset: number): number => -24 + Math.sin(phase + offset) * 6
const vuL = -36 + (Math.sin(phase) * 0.5 + 0.5) * 30
const vuR = -36 + (Math.sin(phase + 0.7) * 0.5 + 0.5) * 30
const truePeakLDb = vuL + 3
const truePeakRDb = vuR + 3
maxTruePeakDb = Math.max(maxTruePeakDb, truePeakLDb, truePeakRDb)
handlers.onFrame({
sampleRate: 48000,
momentaryLUFS: lufs(0),
@@ -329,8 +348,9 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
vuRDb: vuR,
barLDb: vuL,
barRDb: vuR,
peakLDb: vuL + 3,
peakRDb: vuR + 3,
truePeakLDb,
truePeakRDb,
maxTruePeakDb,
correlation: Math.sin(phase * 0.3),
})
mockRaf = requestAnimationFrame(tick)
@@ -342,8 +362,15 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
if (disposed) return
if (backend) {
listenerId = backend.addEventListener('lufsmeterFrame', (payload) => {
const frame = decodeLUFSMeterFrame(payload)
if (frame) handlers.onFrame(frame)
let frame = decodeLUFSMeterFrame(payload, maxTruePeakDb)
if (frame && previousSampleRate > 0 && Math.abs(frame.sampleRate - previousSampleRate) > 1) {
frame = decodeLUFSMeterFrame(payload, -60)
}
if (frame) {
previousSampleRate = frame.sampleRate
maxTruePeakDb = frame.maxTruePeakDb
handlers.onFrame(frame)
}
})
handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (lufsmeter)')
+3 -2
View File
@@ -54,8 +54,9 @@ export interface LUFSMeterNativeSnapshot {
vuRDb: number;
barLDb: number;
barRDb: number;
peakLDb: number;
peakRDb: number;
truePeakLDb: number;
truePeakRDb: number;
maxTruePeakDb: number;
correlation: number;
}
+44 -7
View File
@@ -73,6 +73,7 @@ const TARGET_LUFS = -14
const METER_MIN_DB = -60
const MAX_METER_VIEWPORT_CSS_WIDTH = 240
const FULL_READOUT_WIDTH_SAMPLE = '-00.0LUFS'
const TRUE_PEAK_EMPTY_DB = METER_MIN_DB
const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
momentaryLUFS: METER_MIN_LUFS,
@@ -82,8 +83,9 @@ const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
vuRDb: METER_MIN_DB,
barLDb: METER_MIN_DB,
barRDb: METER_MIN_DB,
peakLDb: METER_MIN_DB,
peakRDb: METER_MIN_DB,
truePeakLDb: METER_MIN_DB,
truePeakRDb: METER_MIN_DB,
maxTruePeakDb: TRUE_PEAK_EMPTY_DB,
correlation: 0,
}
@@ -104,12 +106,20 @@ function normalizeNativeSnapshot(snapshot: LUFSMeterNativeSnapshot | null): LUFS
vuRDb: finiteNumber(snapshot.vuRDb, METER_MIN_DB),
barLDb: finiteNumber(snapshot.barLDb, METER_MIN_DB),
barRDb: finiteNumber(snapshot.barRDb, METER_MIN_DB),
peakLDb: finiteNumber(snapshot.peakLDb, METER_MIN_DB),
peakRDb: finiteNumber(snapshot.peakRDb, METER_MIN_DB),
truePeakLDb: finiteNumber(snapshot.truePeakLDb, METER_MIN_DB),
truePeakRDb: finiteNumber(snapshot.truePeakRDb, METER_MIN_DB),
maxTruePeakDb: finiteNumber(snapshot.maxTruePeakDb, TRUE_PEAK_EMPTY_DB),
correlation: finiteNumber(snapshot.correlation, 0),
}
}
export function formatMaxTruePeakDb(maxTruePeakDb: number, compact = false): string {
const value = Number.isFinite(maxTruePeakDb) && maxTruePeakDb > TRUE_PEAK_EMPTY_DB
? `${maxTruePeakDb >= 0 ? '+' : '−'}${Math.abs(maxTruePeakDb).toFixed(1)}`
: '−∞'
return compact ? `${value}dBTP` : `MAX TP ${value} dBTP`
}
// ---- Loudness meter class ----
export class LUFSMeter {
@@ -383,8 +393,12 @@ export class LUFSMeter {
const paddingX = Math.round(Math.max(4, Math.floor(cssWidth * 0.012)) * dpr)
const paddingY = Math.round(Math.max(4, Math.floor(cssHeight * 0.025)) * dpr)
const footerFontSizeCss = Math.max(7, Math.min(11, Math.floor(cssHeight * 0.052)))
const footerFontSize = Math.max(1, Math.round(footerFontSizeCss * dpr))
const footerHeight = Math.max(1, Math.round((footerFontSizeCss + 4) * dpr))
const footerGap = Math.max(1, Math.round(4 * dpr))
const meterTop = paddingY
const meterBottom = height - paddingY
const meterBottom = Math.max(meterTop + 1, height - paddingY - footerHeight - footerGap)
const meterHeight = Math.max(1, meterBottom - meterTop)
const availableViewportWidth = Math.max(1, width - paddingX * 2)
const viewportWidth = Math.max(1, Math.min(
@@ -448,7 +462,7 @@ export class LUFSMeter {
barWidth,
meterHeight,
this.snapshot.barLDb,
this.snapshot.peakLDb,
this.snapshot.truePeakLDb,
tint,
dpr,
)
@@ -458,7 +472,7 @@ export class LUFSMeter {
barWidth,
meterHeight,
this.snapshot.barRDb,
this.snapshot.peakRDb,
this.snapshot.truePeakRDb,
tint,
dpr,
)
@@ -509,6 +523,29 @@ export class LUFSMeter {
ctx.textBaseline = 'middle'
ctx.fillStyle = this.contrastForLevelColor()
ctx.fillText(readoutLayout.text, tagX + tagPadding, tagY + tagHeight / 2)
const fullTruePeakText = formatMaxTruePeakDb(this.snapshot.maxTruePeakDb)
const compactTruePeakText = formatMaxTruePeakDb(this.snapshot.maxTruePeakDb, true)
ctx.font = `700 ${footerFontSize}px "JetBrains Mono", "SF Mono", monospace`
const truePeakText = viewportCssWidth >= 160
&& ctx.measureText(fullTruePeakText).width <= viewportWidth
? fullTruePeakText
: compactTruePeakText
const truePeakLayout = this.resolveReadoutTextLayout(
[truePeakText],
viewportWidth,
footerFontSize,
Math.max(1, Math.round(7 * dpr)),
)
ctx.font = `700 ${truePeakLayout.fontSize}px "JetBrains Mono", "SF Mono", monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = this.options.labelColor
ctx.fillText(
truePeakLayout.text,
Math.round(width / 2),
Math.round(meterBottom + footerGap + footerHeight / 2),
)
}
dispose(): void {
+151
View File
@@ -1008,6 +1008,128 @@ button_surface = 72, 21, 39, 13
button_border = 172, 192, 222, 31
status_ok = 127, 255, 127
status_error = 230, 0, 69
`,
},
{
name: 'NeutralDark',
content: `[Theme]
format = prism-theme
version = 2
credit = Boof2015
website = https://astramusic.dev/
description = A neutral dark theme
[App]
accent = 190, 210, 238
success = 160, 220, 180
warning = 220, 198, 140
danger = 230, 145, 145
background = 6, 10, 14
surface = 10, 15, 21, 235
surface_alt = 5, 8, 12, 250
border = 190, 210, 238, 24
text = 245, 247, 250
text_muted = 142, 157, 181
toolbar_bg = 10, 15, 21, 199
settings_bg_top = 10, 15, 21, 235
settings_bg_bottom = 5, 8, 12, 250
bottom_bar_bg = 5, 8, 12, 250
[Controls]
surface = 255, 255, 255, 8
surface_hover = 255, 255, 255, 15
surface_active = 190, 210, 238, 31
border = 190, 210, 238, 20
border_active = 190, 210, 238, 71
text = 245, 247, 250, 235
input_surface = 6, 10, 14, 245
input_border = 190, 210, 238, 23
menu_surface = 10, 15, 21, 245
menu_border = 190, 210, 238, 26
slider = 190, 210, 238, 209
[Scopes]
background = 6, 10, 14
guides = 48, 58, 70
overlay_surface = 10, 15, 21, 224
overlay_text = 245, 247, 250, 194
overlay_border = 190, 210, 238, 31
resize_handle = 190, 210, 238, 31
[Spectrum]
background = 6, 10, 14
line = 196, 216, 247
side_line = 196, 216, 247, 128
fill = 196, 216, 247, 78
heat_low = 42, 10, 82, 195
heat_mid = 206, 0, 126, 235
heat_high = 255, 82, 16
heat_base = 6, 10, 14
guides = 48, 58, 70
labels = 76, 90, 108
[Oscilloscope]
background = 6, 10, 14
line = 196, 216, 247
fill = 255, 58, 26, 36
guides = 48, 58, 70
[Vectorscope]
background = 6, 10, 14
trace = 196, 216, 247
band_low = 255, 40, 30
band_mid = 0, 255, 80
band_high = 58, 92, 255
guides = 48, 58, 70
labels = 76, 90, 108
[Spectrogram]
background = 6, 10, 14
mono = 196, 216, 247
heat_low = 42, 10, 82, 195
heat_mid = 206, 0, 126, 235
heat_high = 255, 82, 16
[VUMeter]
background = 6, 10, 14
level = 196, 216, 247
track = 196, 216, 247, 20
peak = 255, 131, 0
clip = 230, 145, 145, 230
scale = 100, 119, 145
labels = 174, 191, 216
needle_left = 196, 216, 247
needle_right = 255, 76, 38
needle_combined = 255, 144, 0
[LUFSMeter]
background = 6, 10, 14
level = 196, 216, 247
track = 196, 216, 247, 20
target = 255, 144, 0, 72
scale = 100, 119, 145
labels = 174, 191, 216
[Waveform]
background = 6, 10, 14
line = 196, 216, 247
band_low = 255, 60, 34
band_mid = 255, 0, 132
band_high = 92, 208, 255
guides = 100, 119, 145
[Now Playing]
accent = 190, 210, 238
background = 6, 10, 14, 230
surface = 10, 15, 21, 235
border = 190, 210, 238, 31
text = 245, 247, 250
progress_track = 100, 119, 145
progress_fill = 255, 82, 16
button_surface = 255, 255, 255, 13
button_border = 190, 210, 238, 31
status_ok = 160, 220, 180
status_error = 230, 145, 145
`,
},
{
@@ -1055,6 +1177,35 @@ line = 86, 25, 230
band_low = 177, 105, 219
band_mid = 108, 31, 196
band_high = 69, 20, 184
`,
},
{
name: 'Vy',
content: `[Theme]
format = prism-theme
version = 2
credit = Lillith Rose
website = https://lilyy.gay
description = im so porpl
[App]
accent = 142, 77, 165
background = 45, 33, 45
surface = 51, 37, 51
surface_alt = 57, 42, 57
border = 255, 255, 255, 23
text = 255, 255, 255
[Vectorscope]
phase_risk = 255, 255, 255, 100
band_low = 37, 13, 45
band_mid = 101, 45, 122
band_high = 142, 77, 165
[Spectrogram]
heat_low = 37, 13, 45
heat_mid = 101, 45, 122
heat_high = 142, 77, 165
`,
},
] as const
+234
View File
@@ -0,0 +1,234 @@
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import test from 'node:test'
const require = createRequire(import.meta.url)
const { lufsmeter } = require('../native/build/Release/visualizer_dsp.node')
const EBU_SAMPLE_RATE = 48000
const EBU_TOLERANCE_BELOW_DB = 0.4
const EBU_TOLERANCE_ABOVE_DB = 0.2
const SILENCE_DB = -60
function assertEbuTolerance(actual, expected, label) {
assert.ok(
actual >= expected - EBU_TOLERANCE_BELOW_DB
&& actual <= expected + EBU_TOLERANCE_ABOVE_DB,
`${label}: expected ${expected} dBTP (+${EBU_TOLERANCE_ABOVE_DB}/-${EBU_TOLERANCE_BELOW_DB}), got ${actual}`,
)
}
function createPhaseTone(
sampleRate,
frequency,
amplitude,
phaseDegrees,
durationSeconds = 0.1,
) {
const length = Math.max(1, Math.round(sampleRate * durationSeconds))
const taperSamples = Math.max(1, Math.round(sampleRate * 0.01))
const phaseRadians = phaseDegrees * Math.PI / 180
const samples = new Float32Array(length)
for (let index = 0; index < length; index += 1) {
const fadeIn = Math.min(1, index / taperSamples)
const fadeOut = Math.min(1, (length - 1 - index) / taperSamples)
const taper = Math.max(0, Math.min(fadeIn, fadeOut))
samples[index] = amplitude
* Math.sin((2 * Math.PI * frequency * index) / sampleRate + phaseRadians)
* taper
}
return samples
}
function pushStereo(left, right = left, chunkSizes = null) {
if (!chunkSizes) {
lufsmeter.pushSamples(left, right)
return
}
let offset = 0
let chunkIndex = 0
while (offset < Math.min(left.length, right.length)) {
const requestedSize = chunkSizes[chunkIndex % chunkSizes.length]
const length = Math.min(requestedSize, left.length - offset, right.length - offset)
lufsmeter.pushSamples(
left.subarray(offset, offset + length),
right.subarray(offset, offset + length),
)
offset += length
chunkIndex += 1
}
}
function measure(sampleRate, left, right = left, chunkSizes = null) {
lufsmeter.setSampleRate(sampleRate)
lufsmeter.reset()
pushStereo(left, right, chunkSizes)
return lufsmeter.getSnapshot()
}
function besselI0(value) {
let sum = 1
let term = 1
const squaredQuarter = value * value / 4
for (let index = 1; index < 40; index += 1) {
term *= squaredQuarter / (index * index)
sum += term
if (term < sum * 1e-15) break
}
return sum
}
function createTransientReferenceAt4x(sampleRate = EBU_SAMPLE_RATE) {
const oversample = 4
const highRate = sampleRate * oversample
const durationSeconds = 0.12
const length = Math.round(highRate * durationSeconds)
const source = new Float64Array(length)
const taperSamples = Math.round(highRate * 0.01)
for (let index = 0; index < length; index += 1) {
const taper = Math.max(0, Math.min(
1,
index / taperSamples,
(length - 1 - index) / taperSamples,
))
source[index] = 0.5 * Math.sin((2 * Math.PI * (sampleRate / 6) * index) / highRate) * taper
}
const transientStart = Math.floor(length / 2) - 8
for (let index = 0; index < 16; index += 1) {
source[transientStart + index] += Math.sin(Math.PI * index / 8)
}
const tapCount = 129
const half = (tapCount - 1) / 2
const cutoff = 0.5 / oversample
const beta = 8.6
const inverseI0Beta = 1 / besselI0(beta)
const coefficients = new Float64Array(tapCount)
let coefficientSum = 0
for (let tap = 0; tap < tapCount; tap += 1) {
const distance = tap - half
const sincArgument = 2 * cutoff * distance
const sinc = Math.abs(sincArgument) < 1e-12
? 1
: Math.sin(Math.PI * sincArgument) / (Math.PI * sincArgument)
const ratio = distance / half
const window = besselI0(beta * Math.sqrt(Math.max(0, 1 - ratio * ratio))) * inverseI0Beta
coefficients[tap] = 2 * cutoff * sinc * window
coefficientSum += coefficients[tap]
}
for (let tap = 0; tap < tapCount; tap += 1) coefficients[tap] /= coefficientSum
const filtered = new Float64Array(length)
for (let index = half; index < length - half; index += 1) {
let value = 0
for (let tap = 0; tap < tapCount; tap += 1) {
value += coefficients[tap] * source[index + tap - half]
}
filtered[index] = value
}
let maximum = 0
for (const value of filtered) maximum = Math.max(maximum, Math.abs(value))
for (let index = 0; index < filtered.length; index += 1) filtered[index] /= maximum
return filtered
}
function downsample4x(reference, offset) {
const length = Math.floor((reference.length - offset) / 4)
const samples = new Float32Array(length)
for (let index = 0; index < length; index += 1) {
samples[index] = reference[index * 4 + offset]
}
return samples
}
test('BS.1770 true peak passes EBU Tech 3341 phase-tone cases 15-19', () => {
const cases = [
{ number: 15, divisor: 4, amplitude: 0.5, phase: 0, expected: -6 },
{ number: 16, divisor: 4, amplitude: 0.5, phase: 45, expected: -6 },
{ number: 17, divisor: 6, amplitude: 0.5, phase: 60, expected: -6 },
{ number: 18, divisor: 8, amplitude: 0.5, phase: 67.5, expected: -6 },
{ number: 19, divisor: 4, amplitude: 1.41, phase: 45, expected: 3 },
]
for (const vector of cases) {
const tone = createPhaseTone(
EBU_SAMPLE_RATE,
EBU_SAMPLE_RATE / vector.divisor,
vector.amplitude,
vector.phase,
)
const snapshot = measure(EBU_SAMPLE_RATE, tone)
assertEbuTolerance(snapshot.maxTruePeakDb, vector.expected, `EBU case ${vector.number}`)
assertEbuTolerance(snapshot.truePeakLDb, vector.expected, `EBU case ${vector.number} left marker`)
assertEbuTolerance(snapshot.truePeakRDb, vector.expected, `EBU case ${vector.number} right marker`)
}
})
test('BS.1770 true peak passes four phase offsets of a filtered transient (EBU cases 20-23)', () => {
const reference = createTransientReferenceAt4x()
for (let offset = 0; offset < 4; offset += 1) {
const samples = downsample4x(reference, offset)
const snapshot = measure(EBU_SAMPLE_RATE, samples)
assertEbuTolerance(snapshot.maxTruePeakDb, 0, `EBU transient case ${20 + offset}`)
}
})
test('true-peak state is independent of arbitrary audio chunk boundaries', () => {
const tone = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45)
const contiguous = measure(EBU_SAMPLE_RATE, tone).maxTruePeakDb
const irregular = measure(EBU_SAMPLE_RATE, tone, tone, [1, 7, 113, 2, 509, 31]).maxTruePeakDb
assert.ok(Math.abs(contiguous - irregular) < 1e-6, `${contiguous} vs ${irregular}`)
})
test('true peak supports common host rates with rate-aware oversampling', () => {
for (const sampleRate of [44100, 48000, 96000, 192000]) {
const tone = createPhaseTone(sampleRate, sampleRate / 4, 1.41, 45)
const snapshot = measure(sampleRate, tone)
assertEbuTolerance(snapshot.maxTruePeakDb, 3, `${sampleRate} Hz`)
}
})
test('true-peak channels preserve polarity and remain independent', () => {
const positive = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45)
const negative = Float32Array.from(positive, (sample) => -sample)
const silence = new Float32Array(positive.length)
const positiveSnapshot = measure(EBU_SAMPLE_RATE, positive, silence)
assertEbuTolerance(positiveSnapshot.truePeakLDb, 3, 'left channel')
assert.equal(positiveSnapshot.truePeakRDb, SILENCE_DB)
const negativeSnapshot = measure(EBU_SAMPLE_RATE, negative, silence)
assert.ok(Math.abs(negativeSnapshot.truePeakLDb - positiveSnapshot.truePeakLDb) < 1e-6)
assert.equal(negativeSnapshot.truePeakRDb, SILENCE_DB)
})
test('maximum true peak persists across quieter input and clears on reset', () => {
const loud = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45)
const quiet = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 0.1, 45)
const silence = new Float32Array(1024)
lufsmeter.setSampleRate(EBU_SAMPLE_RATE)
lufsmeter.reset()
pushStereo(loud)
const loudSnapshot = lufsmeter.getSnapshot()
assert.ok(loudSnapshot.maxTruePeakDb > 0)
pushStereo(quiet)
assert.equal(lufsmeter.getSnapshot().maxTruePeakDb, loudSnapshot.maxTruePeakDb)
lufsmeter.reset()
assert.deepEqual(
{
truePeakLDb: lufsmeter.getSnapshot().truePeakLDb,
truePeakRDb: lufsmeter.getSnapshot().truePeakRDb,
maxTruePeakDb: lufsmeter.getSnapshot().maxTruePeakDb,
},
{ truePeakLDb: SILENCE_DB, truePeakRDb: SILENCE_DB, maxTruePeakDb: SILENCE_DB },
)
pushStereo(silence)
assert.equal(lufsmeter.getSnapshot().maxTruePeakDb, SILENCE_DB)
})
+95 -10
View File
@@ -115,11 +115,11 @@ import {
HEAT_MIN_DB,
normalizeHeatDb,
} from '../src/renderer/visualizers/heatScale'
import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter'
import { LUFSMeter, formatMaxTruePeakDb } from '../src/renderer/visualizers/LUFSMeter'
import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope'
import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer'
import { BridgeSpectrumAnalyzer } from '../src/plugin-ui/BridgeSpectrumAnalyzer'
import { decodeSpectrumFrame } from '../src/plugin-ui/juceBridge'
import { decodeLUFSMeterFrame, decodeSpectrumFrame } from '../src/plugin-ui/juceBridge'
import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay'
import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions'
import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions'
@@ -4492,6 +4492,36 @@ test('rolling capture exposes persisted duration controls and a native toolbar d
assert.match(trayBridgeSource, /rollingCaptureSeconds,/)
})
test('rolling capture uses a dedicated bounded audio-file drag preview', async () => {
const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8')
const packageSource = await readFile(join(process.cwd(), 'package.json'), 'utf8')
const icon1x = await readFile(join(process.cwd(), 'resources', 'drag', 'audio-clip.png'))
const icon2x = await readFile(join(process.cwd(), 'resources', 'drag', 'audio-clip@2x.png'))
const packageJson = JSON.parse(packageSource) as {
build: {
extraResources: Array<{ from: string; to: string }>
}
}
const pngDimensions = (buffer: Buffer): [number, number] => {
assert.deepEqual([...buffer.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10])
return [buffer.readUInt32BE(16), buffer.readUInt32BE(20)]
}
assert.deepEqual(pngDimensions(icon1x), [32, 32])
assert.deepEqual(pngDimensions(icon2x), [64, 64])
assert.match(mainSource, /const AUDIO_CLIP_DRAG_ICON_FILENAME = 'audio-clip\.png'/)
assert.match(mainSource, /const AUDIO_CLIP_DRAG_ICON_SIZE = 32/)
assert.match(mainSource, /function getAudioClipDragIconPath\(\)/)
assert.match(mainSource, /const iconPath = getAudioClipDragIconPath\(\)/)
assert.match(mainSource, /icon\.resize\(\{[\s\S]*width: AUDIO_CLIP_DRAG_ICON_SIZE,[\s\S]*height: AUDIO_CLIP_DRAG_ICON_SIZE/)
assert.doesNotMatch(mainSource, /function getAudioClipDragIcon\(\) \{\s*const iconPath = getStaticAppIconPath\(\)/)
assert.match(mainSource, /event\.sender\.startDrag\(\{\s*file: filePath,\s*icon: getAudioClipDragIcon\(\),\s*\}\)/)
assert.ok(packageJson.build.extraResources.some((entry) => {
return entry.from === 'resources/drag/' && entry.to === 'drag/'
}))
})
test('resolveWindowCapabilities detects native Wayland sessions on Linux', () => {
assert.deepEqual(
resolveWindowCapabilities({
@@ -6296,8 +6326,9 @@ function createFakeLUFSMeterNativeAnalyzer(
vuRDb: -13,
barLDb: -10,
barRDb: -12,
peakLDb: -4,
peakRDb: -5,
truePeakLDb: -4,
truePeakRDb: -5,
maxTruePeakDb: -4,
correlation: 0.5,
...snapshotOverrides,
} satisfies LUFSMeterNativeSnapshot,
@@ -6319,7 +6350,45 @@ function createFakeLUFSMeterNativeAnalyzer(
return analyzer
}
test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and attached readout', () => {
test('LUFSMeter formats positive, negative, compact, and reset true-peak maxima', () => {
assert.equal(formatMaxTruePeakDb(3), 'MAX TP +3.0 dBTP')
assert.equal(formatMaxTruePeakDb(-4.25), 'MAX TP −4.3 dBTP')
assert.equal(formatMaxTruePeakDb(3, true), '+3.0dBTP')
assert.equal(formatMaxTruePeakDb(-60), 'MAX TP −∞ dBTP')
assert.equal(formatMaxTruePeakDb(Number.NaN, true), '−∞dBTP')
})
test('loudness bridge decodes true-peak fields and accumulates legacy peak maxima', () => {
const current = decodeLUFSMeterFrame({
sampleRate: 96000,
truePeakLDb: 1.25,
truePeakRDb: -2,
maxTruePeakDb: 2.5,
})
assert.ok(current)
assert.equal(current.sampleRate, 96000)
assert.equal(current.truePeakLDb, 1.25)
assert.equal(current.truePeakRDb, -2)
assert.equal(current.maxTruePeakDb, 2.5)
const firstLegacy = decodeLUFSMeterFrame({ peakLDb: -3, peakRDb: -5 }, -60)
assert.ok(firstLegacy)
assert.equal(firstLegacy.truePeakLDb, -3)
assert.equal(firstLegacy.truePeakRDb, -5)
assert.equal(firstLegacy.maxTruePeakDb, -3)
const quieterLegacy = decodeLUFSMeterFrame({ peakLDb: -9, peakRDb: -7 }, firstLegacy.maxTruePeakDb)
assert.equal(quieterLegacy?.maxTruePeakDb, -3)
const explicitReset = decodeLUFSMeterFrame({
truePeakLDb: -60,
truePeakRDb: -60,
maxTruePeakDb: -60,
}, 2.5)
assert.equal(explicitReset?.maxTruePeakDb, -60)
})
test('LUFSMeter draws fast bars, true-peak markers, LUFS readout, and maximum dBTP footer', () => {
const dom = installFakeCanvasDom()
const recorder = createFakeCanvasRecorder()
const leftChunk = new Float32Array(4800)
@@ -6343,7 +6412,12 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -18.4 })
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({
momentaryLUFS: -18.4,
truePeakLDb: 3,
truePeakRDb: -5,
maxTruePeakDb: 3,
})
const meter = new LUFSMeter(createFakeCanvas(recorder), {
dataSource,
nativeAnalyzer,
@@ -6392,6 +6466,10 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a
recorder.fillTexts.some((text) => text.text.endsWith('LUFS') && text.fillStyle === 'rgba(0, 0, 0, 0.9)'),
true,
)
assert.equal(
recorder.fillTexts.some((text) => text.text === 'MAX TP +3.0 dBTP' && text.fillStyle === 'rgb(7, 8, 9)'),
true,
)
} finally {
meter.dispose()
dom.restore()
@@ -6419,7 +6497,7 @@ test('LUFSMeter fits readout text inside narrow tags', () => {
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1 })
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1, maxTruePeakDb: 3 })
const meter = new LUFSMeter(createFakeCanvas(recorder, 180, 360), {
dataSource,
nativeAnalyzer,
@@ -6444,6 +6522,7 @@ test('LUFSMeter fits readout text inside narrow tags', () => {
const fontSize = Number(readout.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0)
const estimatedTextWidth = readout.text.length * fontSize * 0.62
assert.equal(estimatedTextWidth <= tagRect.width - 8, true)
assert.equal(recorder.fillTexts.some((text) => text.text === 'MAX TP +3.0 dBTP'), true)
} finally {
meter.dispose()
dom.restore()
@@ -6475,7 +6554,7 @@ test('LUFSMeter centers wide layouts in a bounded viewport', () => {
const tracks = recorder.fillRects.filter((rect) => rect.fillStyle === '#111111')
const target = recorder.fillRects.find((rect) => rect.fillStyle === '#222222')
const tag = recorder.fillRects.find((rect) => rect.fillStyle === '#123456' && rect.height <= 22)
const readout = recorder.fillTexts[recorder.fillTexts.length - 1]
const readout = recorder.fillTexts.find((text) => text.text.endsWith('LUFS'))
const viewportLeft = (canvas.width - 240) / 2
const viewportRight = viewportLeft + 240
@@ -6503,7 +6582,7 @@ test('LUFSMeter keeps minimum-width layout and compact readout inside canvas bou
const dom = installFakeCanvasDom()
const recorder = createFakeCanvasRecorder()
const canvas = createFakeCssSizedCanvas(recorder, 112, 196)
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ shortTermLUFS: -8.7 })
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ shortTermLUFS: -8.7, maxTruePeakDb: 3 })
const meter = new LUFSMeter(canvas, {
dataSource: {
getPendingLUFSMeterSamples: () => [],
@@ -6527,11 +6606,17 @@ test('LUFSMeter keeps minimum-width layout and compact readout inside canvas bou
&& rect.height > 2
&& rect.height <= 22
))
const readout = recorder.fillTexts[recorder.fillTexts.length - 1]
const readout = recorder.fillTexts.find((text) => text.text === '-8.7')
assert.deepEqual(tracks.map((rect) => rect.width), [6, 6, 12])
assert.ok(tag)
assert.equal(readout?.text, '-8.7')
const truePeakReadout = recorder.fillTexts.find((text) => text.text.endsWith('dBTP'))
assert.equal(truePeakReadout?.text, '+3.0dBTP')
const truePeakFontSize = Number(truePeakReadout?.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0)
const truePeakWidth = (truePeakReadout?.text.length ?? 0) * truePeakFontSize * 0.62
assert.ok((truePeakReadout?.x ?? 0) - truePeakWidth / 2 >= 0)
assert.ok((truePeakReadout?.x ?? 0) + truePeakWidth / 2 <= canvas.width)
for (const rect of recorder.fillRects) {
assert.ok(rect.x >= 0, `rectangle starts before canvas: ${JSON.stringify(rect)}`)
+20
View File
@@ -23,8 +23,10 @@ const BUNDLED_THEME_FILE_NAMES = [
'Chroma Blue.iro',
'Chroma Green.iro',
'Default.iro',
'NeutralDark.iro',
'Redshift.iro',
'Stanky Leg.iro',
'Vy.iro',
'_TEMPLATE.iro',
].sort()
@@ -386,7 +388,9 @@ test('bundled tester themes preserve authored tokens and migrated accent themes
'Chroma Blue',
'Chroma Green',
'Redshift',
'NeutralDark',
'Stanky Leg',
'Vy',
])
const defaultTheme = bundledThemes.find((theme) => theme.name === DEFAULT_THEME_NAME)
@@ -414,6 +418,22 @@ test('bundled tester themes preserve authored tokens and migrated accent themes
assert.equal(redshift.oscilloscope.line, redshift.app.accent)
assert.equal(redshift.vectorscope.trace, redshift.app.accent)
const neutralDark = bundledThemes.find((theme) => theme.name === 'NeutralDark')
assert.ok(neutralDark)
assert.equal(neutralDark.credit, 'Boof2015')
assert.equal(neutralDark.description, 'A neutral dark theme')
assert.equal(neutralDark.app.accent, 'rgb(190, 210, 238)')
assert.equal(neutralDark.vumeter.needleCombined, 'rgb(255, 144, 0)')
const vy = bundledThemes.find((theme) => theme.name === 'Vy')
assert.ok(vy)
assert.equal(vy.credit, 'Lillith Rose')
assert.equal(vy.website, 'https://lilyy.gay')
assert.equal(vy.description, 'im so porpl')
assert.equal(vy.app.accent, 'rgb(142, 77, 165)')
assert.equal(vy.vectorscope.phaseRisk, 'rgba(255, 255, 255, 0.392)')
assert.equal(vy.spectrogram.heatHigh, 'rgb(142, 77, 165)')
const migrated = createMigratedAccentTheme('#4ade80')
assert.ok(migrated)
assert.equal(migrated.oscilloscope.line, migrated.app.accent)
+10
View File
@@ -151,4 +151,14 @@ std::string formatLufs(float value) {
return formatDb(value, 1);
}
std::string formatMaxTruePeakDb(float value, bool compact) {
std::string formatted = formatDb(value, 1);
if (std::isfinite(value) && value > -60.0f && value >= 0.0f) {
formatted.insert(formatted.begin(), '+');
}
return compact
? formatted + "dBTP"
: "MAX TP " + formatted + " dBTP";
}
} // namespace Prism::Tui
+1
View File
@@ -26,5 +26,6 @@ std::string buildFrequencyAxis(size_t columns, float maxFrequency);
std::string buildMeterBar(float levelDb, float peakDb, size_t columns);
std::string formatDb(float value, int precision = 1);
std::string formatLufs(float value);
std::string formatMaxTruePeakDb(float value, bool compact = false);
} // namespace Prism::Tui
+14 -7
View File
@@ -1230,11 +1230,18 @@ ftxui::Element renderLUFSMeterPanel(const DisplayFrame& frame,
static_cast<float>(std::max(0, meterRows - 1))));
Elements rows;
rows.push_back(hbox({
text(" L R LUFS") | dim,
filler(),
text("target -14") | color(terminalColor(palette().lufsTarget)),
}));
Elements heading;
heading.push_back(text(" L R LUFS") | dim);
heading.push_back(filler());
heading.push_back(text(formatMaxTruePeakDb(
frame.lufs.maxTruePeakDb, width < 60)) |
color(terminalColor(palette().lufsLabels)));
if (width >= 52) {
heading.push_back(text(" "));
heading.push_back(text("target -14") |
color(terminalColor(palette().lufsTarget)));
}
rows.push_back(hbox(std::move(heading)));
for (int row = 0; row < meterRows; ++row) {
Elements parts;
const auto gap = [&]() {
@@ -1246,11 +1253,11 @@ ftxui::Element renderLUFSMeterPanel(const DisplayFrame& frame,
parts.push_back(text(lufsScaleLabel(row, meterRows)) | dim);
parts.push_back(gap());
parts.push_back(renderLufsBarCell(
frame.lufs.barLDb, frame.lufs.peakLDb,
frame.lufs.barLDb, frame.lufs.truePeakLDb,
row, meterRows, 2, true, row == targetRow));
parts.push_back(gap());
parts.push_back(renderLufsBarCell(
frame.lufs.barRDb, frame.lufs.peakRDb,
frame.lufs.barRDb, frame.lufs.truePeakRDb,
row, meterRows, 2, true, row == targetRow));
parts.push_back(gap());
parts.push_back(renderLufsBarCell(
+10
View File
@@ -429,6 +429,10 @@ void testMeterDisplayModels() {
require(Prism::Tui::selectLufsReadout(
-10.0f, -12.0f, -14.0f, Prism::Tui::LUFSReadout::Integrated) == -14.0f,
"LUFS readout selection should drive the dedicated loudness bar");
require(Prism::Tui::formatMaxTruePeakDb(3.0f) == "MAX TP +3.0 dBTP" &&
Prism::Tui::formatMaxTruePeakDb(3.0f, true) == "+3.0dBTP" &&
Prism::Tui::formatMaxTruePeakDb(-60.0f) == "MAX TP -inf dBTP",
"true-peak maximum formatting should cover normal, compact, and reset states");
}
void testSpectrumPeakModel() {
@@ -1040,6 +1044,10 @@ void testPipelineAndFakeCapture() {
"momentary LUFS should match the deterministic stereo tone");
require(std::abs(frame.lufs.integratedLUFS + 12.03f) < 0.5f,
"integrated LUFS should match the deterministic stereo tone");
require(std::abs(frame.lufs.maxTruePeakDb + 12.04f) < 0.2f &&
std::abs(frame.lufs.truePeakLDb - frame.lufs.maxTruePeakDb) < 0.1f &&
std::abs(frame.lufs.truePeakRDb - frame.lufs.maxTruePeakDb) < 0.1f,
"TUI loudness snapshots should expose calibrated stereo true peak");
Prism::Tui::AnalysisPipeline trimmedPipeline(48000.0f);
trimmedPipeline.setInputTrimDb(6.0f);
@@ -1072,6 +1080,8 @@ void testPipelineAndFakeCapture() {
pipeline.reset();
const auto reset = pipeline.snapshot();
require(reset.lufs.integratedLUFS <= -59.0f, "reset should clear integrated loudness");
require(reset.lufs.maxTruePeakDb <= -59.0f,
"reset should clear the maximum true-peak readout");
require(!reset.oscilloscope.signalPresent,
"reset should clear the oscilloscope display window");
require(reset.oscilloscope.detectedPitch == 0.0f,