mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-28 16:50:57 +02:00
Compare commits
15 Commits
transparency
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| df0755b8ca | |||
| 6fc488d5df | |||
| 6e761909a8 | |||
| 2e35c2aa29 | |||
| 3bd11d6b7a | |||
| 8d6916f203 | |||
| eda0fbef51 | |||
| 741de975bb | |||
| 425e3cde68 | |||
| 209f0a1dcf | |||
| 325ec155c0 | |||
| 0db46f7bb3 | |||
| a6dbc737e9 | |||
| b31089764b | |||
| 8d5f758784 |
@@ -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']
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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
|
||||

|
||||
|
||||
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 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:
|
||||
|
||||
[](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
@@ -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
@@ -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
@@ -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
@@ -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/"
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user