Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cb7f6c126 | |||
| 9342dc51f2 | |||
| 33b39626aa | |||
| 67f1c46862 | |||
| 2d634fda17 | |||
| 6ff11aa6bd | |||
| 9e9569fcac | |||
| 54fcefb9de | |||
| f5f406b43c | |||
| 583b436600 | |||
| 6ebafcc942 | |||
| 73ade9074d | |||
| f38fe4fa29 | |||
| 217c753eb8 | |||
| e7cc6f12fd | |||
| f25b0b671d | |||
| 9e7e20606e | |||
| f3131e365b | |||
| 6dc0e43914 | |||
| 593d0e5ee8 | |||
| f9fa9b8f4f | |||
| aa4672d258 | |||
| 32d5c91092 | |||
| 6f65b69ad8 | |||
| 3f74a74d65 | |||
| 586906c8e0 | |||
| 5fe4a45df4 | |||
| 281f84ec46 | |||
| c9d741c979 | |||
| 8006764608 | |||
| 4de676a8ad | |||
| 7da5f62c5e | |||
| 434b65605a | |||
| ddae07c985 | |||
| b877541882 | |||
| 60949a9389 | |||
| 3335b4a917 |
@@ -1,15 +0,0 @@
|
||||
# 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']
|
||||
@@ -1,306 +0,0 @@
|
||||
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
|
||||
@@ -115,26 +115,15 @@ jobs:
|
||||
fi
|
||||
echo "Native module built successfully: $(ls -lh native/build/Release/visualizer_dsp.node)"
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
# Linux AppImage is app-only. Build it before staging DAW plugins so the
|
||||
# portable artifact does not carry installable VST3 resources.
|
||||
- name: Prepare empty Linux plugin resources for AppImage
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf plugin/dist-installer
|
||||
mkdir -p plugin/dist-installer
|
||||
|
||||
- name: Test native spectrum and capture exports
|
||||
run: npm run test:spectrum-native
|
||||
|
||||
- name: Test native spectrogram accuracy
|
||||
run: npm run test:spectrogram-native
|
||||
|
||||
- name: Test native vectorscope calibration
|
||||
run: npm run test:vectorscope-native
|
||||
|
||||
- name: Test native BS.1770 true-peak calibration
|
||||
run: npm run test:lufsmeter-native
|
||||
|
||||
- name: Build and test Prism TUI
|
||||
run: npm run test:tui
|
||||
|
||||
# The AppImage-specific electron-builder config excludes both the native
|
||||
# terminal frontend and DAW plugins regardless of local staging state.
|
||||
- name: Build Linux AppImage
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run dist:linux:appimage
|
||||
@@ -261,34 +250,12 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Stage Prism TUI for installer
|
||||
run: npm run stage:tui
|
||||
|
||||
- name: Smoke-test staged Prism TUI
|
||||
shell: bash
|
||||
run: |
|
||||
tui_binary="tui/dist-installer/prism-tui"
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
tui_binary="tui/dist-installer/prism-tui.exe"
|
||||
fi
|
||||
if [ ! -f "$tui_binary" ]; then
|
||||
echo "ERROR: staged prism-tui executable was not found: $tui_binary"
|
||||
exit 1
|
||||
fi
|
||||
"$tui_binary" --help
|
||||
"$tui_binary" --version
|
||||
|
||||
- name: Build distributable
|
||||
if: runner.os != 'Linux'
|
||||
run: npm run ${{ matrix.dist_cmd }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
|
||||
- name: Smoke-test Windows installer PATH integration
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: ./scripts/test-windows-installer.ps1
|
||||
|
||||
- name: Build Linux packages with VST3 plugins
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
|
||||
@@ -5,9 +5,6 @@ node_modules/
|
||||
out/
|
||||
dist/
|
||||
native/build/
|
||||
tui/build/
|
||||
tui/dist-installer/prism-tui
|
||||
tui/dist-installer/prism-tui.exe
|
||||
|
||||
# JUCE plugin build + installer staging
|
||||
plugin/build/
|
||||
|
||||
@@ -1,270 +1,65 @@
|
||||
<div align="center">
|
||||
# Prism
|
||||
|
||||
<img src="assets/prism-badge.png" alt="Prism" width="440">
|
||||
|
||||
**Real-time audio analysis and metering, wherever you work.**
|
||||
A free, open-source audio visualizer and meter rack for your desktop.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
Prism is a free, open-source audio analyzer and meter rack for Windows, macOS, and Linux.
|
||||
|
||||
Monitor system audio or an input with a configurable set of real-time scopes and meters, arrange them however you like, and save the setup as a profile. The same analyzers are also available as VST3/AU plugins, and Prism includes a native terminal interface for lightweight monitoring.
|
||||
|
||||

|
||||
Prism taps into your system audio and runs it through a rack of real-time scopes and meters. It grew out of the visualization engine in [Astra](https://github.com/Boof2015/astra), rebuilt as a standalone tool. Whether you're mixing a track, tuning a room, or just like watching your music, Prism gives you a window into what you're hearing.
|
||||
|
||||
## Scopes
|
||||
|
||||
Prism includes seven real-time scopes and meters:
|
||||
Seven visualizers driven by a native C++ analysis engine:
|
||||
|
||||
* **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
|
||||
- **Spectrum Analyzer** — FFT frequency display with heatmap and fill modes, configurable FFT size, spectral tilt, and log or linear scaling
|
||||
- **Oscilloscope** — Time-domain waveform with a pitch-lock mode that syncs the display to the fundamental frequency
|
||||
- **Vectorscope** — Stereo phase visualization in five display modes (Lissajous, polar, linear) with optional multiband RGB split
|
||||
- **Spectrogram** — Scrolling frequency-over-time display with mel, log, and linear scale modes
|
||||
- **VU Meter** — Classic loudness metering in needle or bar style, horizontal or vertical
|
||||
- **Loudness Meter** — Compact LUFS metering following ITU-R BS.1770 with fast stereo peak activity
|
||||
- **Waveform** — Scrolling time-domain view with mono, stereo, and multiband modes
|
||||
|
||||
Every scope can be configured independently. Resize and rearrange them into a rack, rotate supported scopes, pop them into separate windows, or pin them on top of other applications.
|
||||

|
||||
|
||||

|
||||
Every scope is independently configurable. Drag and resize them into whatever layout makes sense, pop any scope out into its own window, and pin windows on top so they stay visible while you work.
|
||||
|
||||
Spectrum, Spectrogram, Oscilloscope, and Waveform also include interactive measurement overlays for inspecting frequency, level, pitch, amplitude, or time directly from the display.
|
||||
## Audio Capture
|
||||
|
||||
<details> <summary><strong>Stats for nerds</strong></summary>
|
||||
Prism pulls audio at the OS level, straight from CoreAudio on macOS, WASAPI on Windows, or PulseAudio on Linux. No virtual cables or routing hacks. Flip on device input mode when you want to analyze a mic or a line-in instead.
|
||||
|
||||
Most of Prism's analysis runs in native C++, with the same DSP implementations reused across the desktop app, DAW plugins, and terminal interface where applicable.
|
||||
|
||||
### Spectrum
|
||||
|
||||
* Hann-windowed FFT with selectable sizes from 1024 to 16384 samples
|
||||
* Coherent-gain correction for calibrated dBFS magnitude rather than arbitrary FFT amplitude
|
||||
* Native calibration tests measure bin-centered tones within ±0.05 dB and off-bin interpolated peaks within 0.3 dB across all exposed FFT sizes
|
||||
* Maintains Mid, Side, Left, Right, and channel-max spectra internally
|
||||
* Logarithmic, Slaney Mel, and Linear frequency scales
|
||||
* Extended 10 Hz–24 kHz and Audible 20 Hz–20 kHz ranges, automatically clamped to Nyquist
|
||||
* Independent spectral and heatmap tilt around a 1 kHz reference
|
||||
* Peak analysis can report dBFS, frequency, musical note, octave, and cents offset
|
||||
* Interactive measurement overlay exposes frequency, level, and pitch directly from the graph
|
||||
|
||||
### Spectrogram
|
||||
|
||||
* Hann-windowed FFT with 4× zero-padding
|
||||
* Coherent amplitude normalization for calibrated spectral levels
|
||||
* Native tests keep tone levels within 0.3 dB across multiple FFT sizes, amplitudes, and off-bin frequencies
|
||||
* Logarithmic, Slaney Mel, and Linear frequency mapping
|
||||
* Frequency placement is tested across 44.1, 48, and 96 kHz sample rates
|
||||
* Classic mode provides a conventional spectral display
|
||||
* Focused mode relocates local spectral peaks using phase correction and a local spectral centroid
|
||||
* Sharp and Sharper perform phase-based frequency reassignment rather than post-process image sharpening
|
||||
* Reassignment operates on every visible contributing FFT bin instead of only local maxima, preserving quieter partials and ambience
|
||||
* Reassigned power compensates for Hann-window equivalent noise bandwidth and zero-padding so relocation remains level-calibrated
|
||||
* Stereo analysis combines L/R energy rather than summing to mono, so anti-phase material remains visible instead of cancelling out
|
||||
* Native tests verify reassigned tones stay on the correct Log, Mel, and Linear frequency rows
|
||||
* Interactive inspection reports frequency, pitch, and time history
|
||||
|
||||
### Oscilloscope
|
||||
|
||||
* FFT-based fundamental detection over a 2048-sample analysis window
|
||||
* Pitch detector currently searches from 40 to 1000 Hz
|
||||
* Pitch-lock dynamically retunes a narrow FIR band-pass around the detected fundamental
|
||||
* Triggering follows a rising zero crossing rather than simply drawing the newest block of samples
|
||||
* Trigger positions retain sub-sample precision
|
||||
* Catmull-Rom interpolation is used when reading the waveform at fractional sample positions
|
||||
* Adaptive pitch smoothing locks quickly at startup and becomes more conservative once stable
|
||||
* Interactive measurement reports time, linear amplitude, and dBFS
|
||||
|
||||
### Vectorscope
|
||||
|
||||
* Full-band L/R analysis with no hidden high-frequency low-pass
|
||||
* Native regression tests explicitly verify preservation of content above 8 kHz at 44.1, 48, and 96 kHz
|
||||
* XY mode uses the original channel samples directly: Right on X, Left on Y
|
||||
* XY and M/S Linear modes use calibrated channel-amplitude reference boundaries
|
||||
* Polar retains an intentionally amplitude-compressed radial projection so low-level stereo structure remains readable
|
||||
* Folded modes rotate negative-Mid samples rather than discarding them
|
||||
* Adjustable display zoom from −12 to +24 dB without modifying the underlying signal
|
||||
* Optional three-band stereo view with 250 Hz and 2.5 kHz crossovers
|
||||
* Multiband analysis retains separate low, mid, and high L/R signals
|
||||
|
||||
### Loudness
|
||||
|
||||
* ITU-R BS.1770-style K-weighting using the pre-filter and RLB weighting stages
|
||||
* Momentary loudness uses a 400 ms window
|
||||
* Short-term loudness uses a 3 second window
|
||||
* Integrated loudness uses overlapping 400 ms blocks with 100 ms hops
|
||||
* −70 LUFS absolute gate
|
||||
* Relative gate at −10 LU below ungated programme loudness
|
||||
* Momentary, short-term, and integrated LUFS are calculated independently
|
||||
* Stereo true-peak reconstruction uses a rate-aware 48-tap-per-phase FIR at 2× to 8× oversampling for common 44.1–192 kHz host rates
|
||||
* Held L/R true-peak markers and a reset-scoped combined maximum are reported in dBTP
|
||||
|
||||
### VU and correlation
|
||||
|
||||
* 300 ms RMS integration window
|
||||
* Adjustable 0 VU calibration from −30 to 0 dBFS
|
||||
* Default reference is 0 VU = −14 dBFS
|
||||
* Stereo correlation is calculated over the same analysis window and reported from −1 to +1
|
||||
* 750 ms sample-peak hold
|
||||
* Peak decay of 18 dB/s
|
||||
* Bar envelope uses a 5 ms attack and 180 ms release
|
||||
* Needle and bar displays can use the same underlying analysis
|
||||
|
||||
### Waveform
|
||||
|
||||
* Each rendered time column retains the minimum and maximum sample instead of selecting or averaging a single sample, helping preserve short transients while downsampling the history
|
||||
* Mono and separate stereo-lane modes
|
||||
* Optional multiband view calculates low, mid, and high RMS energy for every displayed time column
|
||||
* Uses the same 250 Hz and 2.5 kHz multiband split as the vectorscope
|
||||
* Interactive measurement reports time history, amplitude, and dBFS
|
||||
|
||||
### Capture
|
||||
|
||||
* Native CoreAudio system-output capture on macOS using Audio Hardware Taps
|
||||
* Native WASAPI loopback capture on Windows
|
||||
* Native PulseAudio monitor-source capture on Linux
|
||||
* Linux capture handles U8, 16-bit, 24-bit, 32-bit integer, and 32-bit floating-point PulseAudio formats before normalizing them for analysis
|
||||
* Microphone and interface inputs use a separate low-latency AudioWorklet capture path
|
||||
* Capture follows the active device sample rate instead of assuming a fixed 44.1 or 48 kHz analysis rate
|
||||
|
||||
### Rolling capture
|
||||
|
||||
* Keeps the previous 5, 10, 30, or 60 seconds of audio available without starting a recording beforehand
|
||||
* Buffer is stored as PCM and exported as a standard RIFF/WAV file
|
||||
* Mono and stereo capture are supported
|
||||
* Exported clips use 16-bit PCM
|
||||
* The export path supports source sample rates up to 384 kHz
|
||||
* Captured audio can be dragged directly out of Prism as a file
|
||||
|
||||
### DAW plugins
|
||||
|
||||
* All seven Prism analyzers are built from the same native DSP source used by the desktop application
|
||||
* VST3 on Windows, macOS, and Linux
|
||||
* AU on macOS
|
||||
* Mono and stereo host layouts are supported
|
||||
* Analyzer plugins are pure pass-through: Prism does not modify the host's audio buffer
|
||||
* The realtime process callback only copies samples into a FIFO
|
||||
* FFT, loudness, visualization, and other analysis work happens outside the realtime audio thread
|
||||
* Scope configuration is serialized into the DAW's project/plugin state
|
||||
* Plugins use JUCE 8 and a shared Prism web UI while keeping analysis native
|
||||
|
||||
### Terminal UI
|
||||
|
||||
* Native C++ frontend using the same Spectrum, Oscilloscope, Vectorscope, VU, Loudness, Spectrogram, Waveform, and system-capture implementations
|
||||
* 60 FPS default rendering
|
||||
* Experimental 120 FPS mode
|
||||
* Compatibility mode uses 256-color output at up to 60 FPS
|
||||
* Safe mode uses basic ANSI colors and caps rendering at 30 FPS
|
||||
* Supports live output-device switching
|
||||
* Supports input trim before every analyzer
|
||||
* Includes pitch locking, vectorscope modes, calibrated VU reference levels, spectrogram reassignment modes, mono/stereo waveform views, and multiband analysis
|
||||
* Reads Prism .iro themes and has its own shareable profile/customization system
|
||||
* Typically uses around 35 MB of RAM
|
||||
|
||||
### Rendering and measurement
|
||||
|
||||
* Desktop visualizers can target 10, 30, 60, 120, or 144 FPS, or synchronize to the display refresh rate
|
||||
* Spectrum, Spectrogram, Oscilloscope, and Waveform support interactive measurement overlays
|
||||
* Measurement coordinates account for scope rotation and mirroring, so displayed readouts continue to correspond to the underlying signal after transforming a scope
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Desktop
|
||||
|
||||
Prism can capture system output directly through CoreAudio on macOS, WASAPI on Windows, and PulseAudio on Linux. It can also switch to an input device when you want to analyze a microphone, interface, or line input.
|
||||
|
||||
No virtual audio cable is required for normal system capture.
|
||||
|
||||
The main rack and scope popouts can use solid, blurred, or clear backgrounds, making Prism usable as a normal desktop application or as a set of unobtrusive overlays.
|
||||
|
||||
Prism can also live in the system tray, start automatically with your computer, and launch either normally or out of the way when you want it running all the time.
|
||||
|
||||
## Rolling Capture
|
||||
|
||||
Prism can continuously keep the last **5, 10, 30, or 60 seconds** of audio in memory.
|
||||
|
||||
When you hear something you want to keep, drag the buffered audio out of Prism as a WAV file. There is no need to start recording beforehand.
|
||||
|
||||
## DAW Plugins
|
||||
|
||||
Every Prism scope is also available as a DAW plugin.
|
||||
|
||||
Drop a **Spectrum**, **Oscilloscope**, **Vectorscope**, **Spectrogram**, **VU Meter**, **Loudness Meter**, or **Waveform** onto a track and analyze it using the same interface and analysis engine as the desktop app.
|
||||
|
||||
* **VST3** on Windows, macOS, and Linux
|
||||
* **AU** on macOS
|
||||
* Settings are stored with your project
|
||||
* Plugins follow your Prism themes and profiles
|
||||
* Audio passes through untouched
|
||||
|
||||
Tested in Ableton Live, FL Studio, and Reaper.
|
||||
|
||||
The plugins install alongside Prism, so there is no separate download.
|
||||
|
||||
## Terminal UI
|
||||
|
||||
Prism also includes `prism-tui`, a native terminal interface built on the same capture and analysis code.
|
||||
|
||||

|
||||
|
||||
It provides real-time spectrum, metering, loudness, and other monitoring tools without needing to run the desktop interface.
|
||||
`prism-tui` is lightweight, typically using around 35 MB of RAM.
|
||||
|
||||
```bash
|
||||
prism-tui
|
||||
prism-tui --list-outputs
|
||||
prism-tui --output <id>
|
||||
prism-tui --profile <name>
|
||||
prism-tui --theme <name>
|
||||
prism-tui --help
|
||||
```
|
||||
|
||||
Profiles and `.iro` themes can be used from the TUI as well, and output devices can be changed without leaving it.
|
||||
|
||||
`prism-tui --help` lists the available controls and launch options.
|
||||
Capture-to-display latency measures under 8ms. When tested at 120fps, measured latency was 0ms. What you see is what you hear.
|
||||
|
||||
## Profiles
|
||||
|
||||
A profile stores your Prism workspace: which scopes are visible, how they are laid out, their individual settings, and window state.
|
||||
|
||||
Keep different profiles for different setups or workflows and switch between them when needed.
|
||||
|
||||
Profiles are stored as `.prsm` files and can be shared with other Prism users.
|
||||
Save the whole rack as a profile. What's visible, how it's laid out, per-scope settings, popout window positions as a `.prsm` file. Keep separate profiles for different workflows and share them with others.
|
||||
|
||||
## Themes
|
||||
|
||||
Prism's interface is themeable through editable `.iro` files.
|
||||
The whole interface is themeable through editable `.iro` files. Themes control everything, build your own in an editor and drop it into the `Prism Themes` folder, or download one from [the community](https://discord.gg/hsKK8Kr9Nj).
|
||||
|
||||
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.
|
||||
Prism also includes Chroma key friendly themes by default, so you can key out your scopes in OBS and drop them directly into your stream as transparent overlays.
|
||||
|
||||

|
||||

|
||||
|
||||
## Download
|
||||
|
||||
Prebuilt versions of Prism for Windows, macOS, and Linux are available on the [Releases](https://github.com/Boof2015/prism/releases) page.
|
||||
|
||||
Installable packages include the desktop application and the components supported by that package, including DAW plugins and `prism-tui` where applicable.
|
||||
|
||||
On Windows, the NSIS installer adds `prism-tui` to the machine `PATH`. Open a new terminal after installation so it inherits the updated environment. The portable Windows package does not modify `PATH`.
|
||||
Prebuilt binaries for Windows, macOS, and Linux are available on the [Releases](https://github.com/Boof2015/prism/releases) page.
|
||||
|
||||
## Building from Source
|
||||
|
||||
**Prerequisites:** Node.js 18+, npm, CMake 3.22+, and a C++ compiler toolchain.
|
||||
**Prerequisites:** Node.js 18+, npm, and a C++ compiler toolchain.
|
||||
|
||||
| Platform | Toolchain |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| macOS | Xcode Command Line Tools |
|
||||
| Windows | Visual Studio Build Tools |
|
||||
| Linux | `build-essential`, `python3`, `libasound2-dev`, `libpulse-dev`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` |
|
||||
| Platform | Toolchain |
|
||||
|----------|-----------|
|
||||
| macOS | Xcode Command Line Tools |
|
||||
| Windows | Visual Studio Build Tools |
|
||||
| Linux | `build-essential`, `python3`, `libasound2-dev`, `libpulse-dev`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` |
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Boof2015/prism.git
|
||||
@@ -277,40 +72,38 @@ The `postinstall` script compiles the native C++ module for your platform.
|
||||
```bash
|
||||
npm run dev # Development
|
||||
npm run build # Build application assets
|
||||
npm run configure:tui # Configure the standalone CMake project
|
||||
npm run build:tui # Build prism-tui
|
||||
npm run test:tui # Build and run native TUI tests
|
||||
npm run test:lufsmeter-native # Run generated BS.1770/EBU true-peak vectors
|
||||
npm run dist # Package for current platform
|
||||
npm run dist:mac # macOS
|
||||
npm run dist:win # Windows
|
||||
npm run dist:linux # Linux
|
||||
npm run dist:mac # macOS (DMG + ZIP)
|
||||
npm run dist:win # Windows (NSIS + Portable)
|
||||
npm run dist:linux # Linux (AppImage + DEB + RPM + tar.gz)
|
||||
```
|
||||
|
||||
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.
|
||||
Linux `.deb` and `.rpm` releases install the seven Prism VST3 plugins to
|
||||
`/usr/lib/vst3`. The Linux `tar.gz` release includes a `resources/plugins/install-vst3.sh`
|
||||
helper that installs them to `$HOME/.vst3` by default. The AppImage is app-only;
|
||||
it does not install DAW plugins. DAWs commonly scan `$HOME/.vst3`, `/usr/lib/vst3`,
|
||||
and `/usr/local/lib/vst3`.
|
||||
|
||||
## Astra Integration
|
||||
|
||||
Prism can optionally connect to [Astra](https://github.com/Boof2015/astra) through its local API to show currently playing music, cover art, track information, and playback controls alongside your scopes.
|
||||
|
||||
Prism does not require Astra and works as a standalone application.
|
||||
If you use [Astra](https://github.com/Boof2015/astra), Prism can connect to its local API to show what's playing, cover art, track info, and playback controls, alongside your scopes.
|
||||
|
||||
## Support
|
||||
|
||||
If you find Prism useful and want to support a broke college student, consider supporting development:
|
||||
|
||||
[](https://ko-fi.com/boof2015)
|
||||
[](https://ko-fi.com/boof2015)
|
||||
|
||||
## License
|
||||
|
||||
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.
|
||||
This project is licensed under the [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html). See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
Third-party license notices are recorded in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#Boof2015/prism&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Boof2015/prism&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Boof2015/prism&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Boof2015/prism&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## FTXUI
|
||||
|
||||
Prism TUI statically links [FTXUI](https://github.com/ArthurSonzogni/FTXUI),
|
||||
version 7.0.1.
|
||||
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2019 Arthur Sonzogni.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
Before Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 8.9 MiB |
|
Before Width: | Height: | Size: 15 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 7.3 MiB |
|
Before Width: | Height: | Size: 20 MiB |
|
Before Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 408 KiB |
@@ -1,10 +0,0 @@
|
||||
const packageJson = require('./package.json')
|
||||
|
||||
const excludedResources = new Set(['plugins/', 'tui/'])
|
||||
|
||||
module.exports = {
|
||||
...packageJson.build,
|
||||
extraResources: packageJson.build.extraResources.filter(
|
||||
(resource) => !excludedResources.has(resource.to),
|
||||
),
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
"cflags_cc": ["-std=c++17", "-O3", "-ffast-math"],
|
||||
"sources": [
|
||||
"src/main.cpp",
|
||||
"src/system_audio_capture_napi.cpp",
|
||||
"src/oscilloscope.cpp",
|
||||
"src/spectrum.cpp",
|
||||
"src/spectrogram.cpp",
|
||||
@@ -16,7 +15,6 @@
|
||||
"src/waveform.cpp",
|
||||
"src/vumeter.cpp",
|
||||
"src/lufsmeter.cpp",
|
||||
"src/window_chrome.cpp",
|
||||
"src/dsp_utils.cpp"
|
||||
],
|
||||
"include_dirs": [
|
||||
@@ -28,7 +26,8 @@
|
||||
["OS=='mac'", {
|
||||
"sources": [
|
||||
"src/macos_capture.mm",
|
||||
"src/windows_capture_stub.cpp"
|
||||
"src/windows_capture_stub.cpp",
|
||||
"src/linux_capture_stub.cpp"
|
||||
],
|
||||
"xcode_settings": {
|
||||
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
|
||||
@@ -44,7 +43,9 @@
|
||||
}],
|
||||
["OS=='win'", {
|
||||
"sources": [
|
||||
"src/windows_capture.cpp"
|
||||
"src/macos_capture_stub.cpp",
|
||||
"src/windows_capture.cpp",
|
||||
"src/linux_capture_stub.cpp"
|
||||
],
|
||||
"defines": [
|
||||
"WIN32_LEAN_AND_MEAN",
|
||||
@@ -54,8 +55,7 @@
|
||||
"ole32.lib",
|
||||
"avrt.lib",
|
||||
"runtimeobject.lib",
|
||||
"uuid.lib",
|
||||
"dwmapi.lib"
|
||||
"uuid.lib"
|
||||
],
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
@@ -66,6 +66,7 @@
|
||||
}],
|
||||
["OS=='linux'", {
|
||||
"sources": [
|
||||
"src/macos_capture_stub.cpp",
|
||||
"src/windows_capture_stub.cpp",
|
||||
"src/linux_capture.cpp"
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "system_audio_capture.h"
|
||||
#include "linux_capture.h"
|
||||
|
||||
#if defined(__linux__)
|
||||
|
||||
@@ -406,111 +406,150 @@ private:
|
||||
bool started_ = false;
|
||||
};
|
||||
|
||||
class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
|
||||
class LinuxNativeCaptureEngine {
|
||||
public:
|
||||
~LinuxNativeCaptureEngine() override {
|
||||
stop();
|
||||
}
|
||||
Napi::Value GetSupport(const Napi::CallbackInfo& info) const {
|
||||
Napi::Object support = Napi::Object::New(info.Env());
|
||||
|
||||
Prism::Capture::Support getSupport() const override {
|
||||
PulseContextConnection connection;
|
||||
std::string errorMessage;
|
||||
std::vector<OutputDeviceInfo> devices;
|
||||
const bool available =
|
||||
connection.connect("Prism Linux Capture Probe", &errorMessage) &&
|
||||
connection.enumerateOutputDevices(&devices, &errorMessage);
|
||||
return {
|
||||
available,
|
||||
available ? std::string() : (errorMessage.empty()
|
||||
|
||||
support.Set("available", Napi::Boolean::New(info.Env(), available));
|
||||
if (available) {
|
||||
support.Set("reason", info.Env().Null());
|
||||
} else {
|
||||
const std::string reason = errorMessage.empty()
|
||||
? "Native Linux capture is unavailable."
|
||||
: errorMessage),
|
||||
};
|
||||
: errorMessage;
|
||||
support.Set("reason", Napi::String::New(info.Env(), reason));
|
||||
}
|
||||
return support;
|
||||
}
|
||||
|
||||
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
|
||||
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) const {
|
||||
Napi::Env env = info.Env();
|
||||
Napi::Array devicesArray = Napi::Array::New(env);
|
||||
|
||||
PulseContextConnection connection;
|
||||
std::string errorMessage;
|
||||
std::vector<OutputDeviceInfo> devices;
|
||||
if (!connection.connect("Prism Linux Capture Devices", &errorMessage) ||
|
||||
!connection.enumerateOutputDevices(&devices, &errorMessage)) {
|
||||
return {};
|
||||
return devicesArray;
|
||||
}
|
||||
|
||||
std::vector<Prism::Capture::OutputDevice> result;
|
||||
result.reserve(devices.size());
|
||||
for (const auto& device : devices) {
|
||||
result.push_back({
|
||||
device.id,
|
||||
device.label,
|
||||
static_cast<double>(device.sampleSpec.rate),
|
||||
static_cast<uint32_t>(device.sampleSpec.channels),
|
||||
device.isDefault,
|
||||
});
|
||||
for (size_t index = 0; index < devices.size(); ++index) {
|
||||
const auto& device = devices[index];
|
||||
Napi::Object entry = Napi::Object::New(env);
|
||||
entry.Set("id", Napi::String::New(env, device.id));
|
||||
entry.Set("label", Napi::String::New(env, device.label));
|
||||
entry.Set("kind", Napi::String::New(env, "system"));
|
||||
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
|
||||
entry.Set(
|
||||
"sampleRate",
|
||||
Napi::Number::New(env, static_cast<double>(device.sampleSpec.rate)));
|
||||
entry.Set(
|
||||
"channelCount",
|
||||
Napi::Number::New(env, static_cast<double>(device.sampleSpec.channels)));
|
||||
devicesArray.Set(static_cast<uint32_t>(index), entry);
|
||||
}
|
||||
|
||||
return devicesArray;
|
||||
}
|
||||
|
||||
Napi::Value Start(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
std::string requestedDeviceId;
|
||||
if (info.Length() > 0 && info[0].IsString()) {
|
||||
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
|
||||
}
|
||||
|
||||
std::string errorMessage;
|
||||
if (!startInternal(requestedDeviceId, &errorMessage)) {
|
||||
const std::string reason = errorMessage.empty()
|
||||
? "Native Linux monitor capture failed to start."
|
||||
: errorMessage;
|
||||
Napi::Error::New(env, reason)
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(stateMutex_);
|
||||
Napi::Object result = Napi::Object::New(env);
|
||||
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
|
||||
result.Set("channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
|
||||
result.Set("deviceId", Napi::String::New(env, activeDeviceId_));
|
||||
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
|
||||
return result;
|
||||
}
|
||||
|
||||
bool start(const std::string& requestedDeviceId,
|
||||
Prism::Capture::StartResult* result,
|
||||
std::string* errorMessage) override {
|
||||
if (!startInternal(requestedDeviceId, errorMessage)) {
|
||||
if (errorMessage != nullptr && errorMessage->empty()) {
|
||||
*errorMessage = "Native Linux monitor capture failed to start.";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (result != nullptr) {
|
||||
std::lock_guard<std::mutex> lock(stateMutex_);
|
||||
result->sampleRate = sampleRate_;
|
||||
result->channelCount = channelCount_;
|
||||
result->deviceId = activeDeviceId_;
|
||||
result->deviceLabel = activeDeviceLabel_;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
Napi::Value Stop(const Napi::CallbackInfo& info) {
|
||||
stopInternal();
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Prism::Capture::DrainResult drain(size_t maxChunks) override {
|
||||
const size_t drainLimit = maxChunks == 0
|
||||
? kDefaultDrainChunkLimit
|
||||
: std::min(maxChunks, kMaxQueuedChunks);
|
||||
std::deque<CapturedChunk> drained;
|
||||
Prism::Capture::DrainResult result;
|
||||
Napi::Value Drain(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
const size_t maxChunks = info.Length() > 0 && info[0].IsNumber()
|
||||
? std::max<size_t>(1, info[0].As<Napi::Number>().Uint32Value())
|
||||
: kDefaultDrainChunkLimit;
|
||||
|
||||
std::deque<CapturedChunk> drainedChunks;
|
||||
size_t overwriteCount = 0;
|
||||
size_t queueDepth = 0;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(chunkMutex_);
|
||||
const size_t count = std::min(drainLimit, chunkQueue_.size());
|
||||
for (size_t index = 0; index < count; ++index) {
|
||||
drained.push_back(std::move(chunkQueue_.front()));
|
||||
const size_t chunkCount = std::min(maxChunks, chunkQueue_.size());
|
||||
for (size_t index = 0; index < chunkCount; ++index) {
|
||||
drainedChunks.push_back(std::move(chunkQueue_.front()));
|
||||
chunkQueue_.pop_front();
|
||||
}
|
||||
result.overwriteCount = overwriteCount_;
|
||||
overwriteCount = overwriteCount_;
|
||||
overwriteCount_ = 0;
|
||||
result.queueDepth = chunkQueue_.size();
|
||||
queueDepth = chunkQueue_.size();
|
||||
}
|
||||
result.chunks.reserve(drained.size());
|
||||
while (!drained.empty()) {
|
||||
auto chunk = std::move(drained.front());
|
||||
drained.pop_front();
|
||||
result.chunks.push_back({
|
||||
std::move(chunk.left),
|
||||
std::move(chunk.right),
|
||||
chunk.channelCount,
|
||||
chunk.capturedAtMilliseconds,
|
||||
chunk.sequence,
|
||||
});
|
||||
|
||||
Napi::Array chunks = Napi::Array::New(env, drainedChunks.size());
|
||||
for (size_t index = 0; index < drainedChunks.size(); ++index) {
|
||||
const auto& chunk = drainedChunks[index];
|
||||
Napi::Object entry = Napi::Object::New(env);
|
||||
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
|
||||
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
|
||||
if (!chunk.left.empty()) {
|
||||
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
|
||||
}
|
||||
if (!chunk.right.empty()) {
|
||||
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
|
||||
}
|
||||
entry.Set("left", left);
|
||||
entry.Set("right", right);
|
||||
entry.Set(
|
||||
"channelCount",
|
||||
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
|
||||
entry.Set(
|
||||
"capturedAtMilliseconds",
|
||||
Napi::Number::New(env, chunk.capturedAtMilliseconds));
|
||||
entry.Set(
|
||||
"sequence",
|
||||
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
|
||||
chunks.Set(static_cast<uint32_t>(index), entry);
|
||||
}
|
||||
|
||||
Napi::Object result = Napi::Object::New(env);
|
||||
result.Set("chunks", chunks);
|
||||
result.Set(
|
||||
"overwriteCount", Napi::Number::New(env, static_cast<double>(overwriteCount)));
|
||||
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(queueDepth)));
|
||||
return result;
|
||||
}
|
||||
|
||||
double nowMilliseconds() const override {
|
||||
return monotonicMilliseconds();
|
||||
}
|
||||
|
||||
const char* backendName() const override {
|
||||
return "PulseAudio";
|
||||
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) const {
|
||||
return Napi::Number::New(info.Env(), monotonicMilliseconds());
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -846,14 +885,47 @@ private:
|
||||
size_t overwriteCount_ = 0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace Prism::Capture {
|
||||
|
||||
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
|
||||
return std::make_unique<LinuxNativeCaptureEngine>();
|
||||
LinuxNativeCaptureEngine& GetLinuxNativeCaptureEngine() {
|
||||
static LinuxNativeCaptureEngine engine;
|
||||
return engine;
|
||||
}
|
||||
|
||||
} // namespace Prism::Capture
|
||||
Napi::Value LinuxGetSupport(const Napi::CallbackInfo& info) {
|
||||
return GetLinuxNativeCaptureEngine().GetSupport(info);
|
||||
}
|
||||
|
||||
Napi::Value LinuxListOutputDevices(const Napi::CallbackInfo& info) {
|
||||
return GetLinuxNativeCaptureEngine().ListOutputDevices(info);
|
||||
}
|
||||
|
||||
Napi::Value LinuxStart(const Napi::CallbackInfo& info) {
|
||||
return GetLinuxNativeCaptureEngine().Start(info);
|
||||
}
|
||||
|
||||
Napi::Value LinuxStop(const Napi::CallbackInfo& info) {
|
||||
return GetLinuxNativeCaptureEngine().Stop(info);
|
||||
}
|
||||
|
||||
Napi::Value LinuxDrain(const Napi::CallbackInfo& info) {
|
||||
return GetLinuxNativeCaptureEngine().Drain(info);
|
||||
}
|
||||
|
||||
Napi::Value LinuxNowMilliseconds(const Napi::CallbackInfo& info) {
|
||||
return GetLinuxNativeCaptureEngine().NowMilliseconds(info);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object captureExports = Napi::Object::New(env);
|
||||
captureExports.Set("getSupport", Napi::Function::New(env, LinuxGetSupport));
|
||||
captureExports.Set(
|
||||
"listOutputDevices", Napi::Function::New(env, LinuxListOutputDevices));
|
||||
captureExports.Set("start", Napi::Function::New(env, LinuxStart));
|
||||
captureExports.Set("stop", Napi::Function::New(env, LinuxStop));
|
||||
captureExports.Set("drain", Napi::Function::New(env, LinuxDrain));
|
||||
captureExports.Set("nowMilliseconds", Napi::Function::New(env, LinuxNowMilliseconds));
|
||||
exports.Set("linuxCapture", captureExports);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <napi.h>
|
||||
|
||||
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports);
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "linux_capture.h"
|
||||
|
||||
namespace {
|
||||
|
||||
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
|
||||
Napi::Object support = Napi::Object::New(info.Env());
|
||||
support.Set("available", Napi::Boolean::New(info.Env(), false));
|
||||
support.Set(
|
||||
"reason",
|
||||
Napi::String::New(
|
||||
info.Env(), "Native Linux output-device capture is unavailable on this platform."));
|
||||
return support;
|
||||
}
|
||||
|
||||
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
|
||||
return Napi::Array::New(info.Env());
|
||||
}
|
||||
|
||||
Napi::Value Start(const Napi::CallbackInfo& info) {
|
||||
Napi::Error::New(
|
||||
info.Env(), "Native Linux output-device capture is unavailable on this platform.")
|
||||
.ThrowAsJavaScriptException();
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value Stop(const Napi::CallbackInfo& info) {
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value Drain(const Napi::CallbackInfo& info) {
|
||||
Napi::Object result = Napi::Object::New(info.Env());
|
||||
result.Set("chunks", Napi::Array::New(info.Env()));
|
||||
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
|
||||
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
|
||||
return Napi::Number::New(info.Env(), 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object captureExports = Napi::Object::New(env);
|
||||
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
|
||||
captureExports.Set(
|
||||
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
|
||||
captureExports.Set("start", Napi::Function::New(env, Start));
|
||||
captureExports.Set("stop", Napi::Function::New(env, Stop));
|
||||
captureExports.Set("drain", Napi::Function::New(env, Drain));
|
||||
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
|
||||
exports.Set("linuxCapture", captureExports);
|
||||
}
|
||||
@@ -31,37 +31,10 @@ 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 TRUE_PEAK_HOLD_MS = 750.0;
|
||||
constexpr double TRUE_PEAK_DECAY_DB_PER_SECOND = 18.0;
|
||||
constexpr double VU_PEAK_HOLD_MS = 750.0;
|
||||
constexpr double VU_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 {
|
||||
@@ -74,7 +47,6 @@ 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,
|
||||
};
|
||||
}
|
||||
@@ -110,7 +82,6 @@ void LUFSMeterAnalyzer::configureForSampleRate(float sampleRate) {
|
||||
integratedHistogramPowerSums_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0.0);
|
||||
|
||||
configureFastMeter();
|
||||
configureTruePeakDetector();
|
||||
reset();
|
||||
}
|
||||
|
||||
@@ -131,44 +102,6 @@ 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);
|
||||
@@ -198,10 +131,6 @@ 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();
|
||||
}
|
||||
|
||||
@@ -211,35 +140,27 @@ void LUFSMeterAnalyzer::pushSamples(const float* leftChannel, const float* right
|
||||
}
|
||||
|
||||
const double nowMs = currentTimeMs();
|
||||
advanceTruePeaks(nowMs);
|
||||
advancePeaks(nowMs);
|
||||
|
||||
double maxTruePeakL = 0.0;
|
||||
double maxTruePeakR = 0.0;
|
||||
double maxPeakL = 0.0;
|
||||
double maxPeakR = 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);
|
||||
processTruePeakSample(left, right, maxTruePeakL, maxTruePeakR);
|
||||
processFastMeterSample(left, right, maxPeakL, maxPeakR);
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
maybeUpdatePeak(amplitudeToDb(maxPeakL), nowMs, true);
|
||||
maybeUpdatePeak(amplitudeToDb(maxPeakR), nowMs, false);
|
||||
recomputeFastSnapshot();
|
||||
updateMomentaryShortTermLoudness();
|
||||
snapshot_.integratedLUFS = static_cast<float>(computeGatedIntegratedLoudness());
|
||||
}
|
||||
|
||||
LUFSMeterSnapshot LUFSMeterAnalyzer::getSnapshot() {
|
||||
advanceTruePeaks(currentTimeMs());
|
||||
advancePeaks(currentTimeMs());
|
||||
recomputeFastSnapshot();
|
||||
return snapshot_;
|
||||
}
|
||||
@@ -382,7 +303,7 @@ double LUFSMeterAnalyzer::applyBiquad(const BiquadCoeffs& coeffs, BiquadState& s
|
||||
return output;
|
||||
}
|
||||
|
||||
void LUFSMeterAnalyzer::processFastMeterSample(float left, float right) {
|
||||
void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR) {
|
||||
if (fastSqL_.empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -413,44 +334,16 @@ void LUFSMeterAnalyzer::processFastMeterSample(float left, float right) {
|
||||
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::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) {
|
||||
void LUFSMeterAnalyzer::advancePeaks(double nowMs) {
|
||||
if (!std::isfinite(nowMs)) {
|
||||
return;
|
||||
}
|
||||
@@ -465,23 +358,23 @@ void LUFSMeterAnalyzer::advanceTruePeaks(double nowMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot_.truePeakLDb = static_cast<float>(applyPeakDecay(snapshot_.truePeakLDb, peakHoldUntilL_, nowMs));
|
||||
snapshot_.truePeakRDb = static_cast<float>(applyPeakDecay(snapshot_.truePeakRDb, peakHoldUntilR_, nowMs));
|
||||
snapshot_.peakLDb = static_cast<float>(applyPeakDecay(snapshot_.peakLDb, peakHoldUntilL_, nowMs));
|
||||
snapshot_.peakRDb = static_cast<float>(applyPeakDecay(snapshot_.peakRDb, peakHoldUntilR_, nowMs));
|
||||
lastPeakUpdateMs_ = nowMs;
|
||||
}
|
||||
|
||||
void LUFSMeterAnalyzer::maybeUpdateTruePeak(double peakDb, double nowMs, bool leftChannel) {
|
||||
void LUFSMeterAnalyzer::maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel) {
|
||||
if (leftChannel) {
|
||||
if (peakDb > snapshot_.truePeakLDb) {
|
||||
snapshot_.truePeakLDb = static_cast<float>(peakDb);
|
||||
peakHoldUntilL_ = nowMs + TRUE_PEAK_HOLD_MS;
|
||||
if (peakDb > snapshot_.peakLDb) {
|
||||
snapshot_.peakLDb = static_cast<float>(peakDb);
|
||||
peakHoldUntilL_ = nowMs + VU_PEAK_HOLD_MS;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (peakDb > snapshot_.truePeakRDb) {
|
||||
snapshot_.truePeakRDb = static_cast<float>(peakDb);
|
||||
peakHoldUntilR_ = nowMs + TRUE_PEAK_HOLD_MS;
|
||||
if (peakDb > snapshot_.peakRDb) {
|
||||
snapshot_.peakRDb = static_cast<float>(peakDb);
|
||||
peakHoldUntilR_ = nowMs + VU_PEAK_HOLD_MS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +384,7 @@ double LUFSMeterAnalyzer::applyPeakDecay(double currentDb, double holdUntilMs, d
|
||||
return currentDb;
|
||||
}
|
||||
|
||||
const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * TRUE_PEAK_DECAY_DB_PER_SECOND;
|
||||
const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * VU_PEAK_DECAY_DB_PER_SECOND;
|
||||
return std::max(VU_METER_MIN_DB, currentDb - decayAmount);
|
||||
}
|
||||
|
||||
@@ -531,13 +424,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ struct LUFSMeterSnapshot {
|
||||
float vuRDb;
|
||||
float barLDb;
|
||||
float barRDb;
|
||||
float truePeakLDb;
|
||||
float truePeakRDb;
|
||||
float maxTruePeakDb;
|
||||
float peakLDb;
|
||||
float peakRDb;
|
||||
float correlation;
|
||||
};
|
||||
|
||||
@@ -48,7 +47,6 @@ private:
|
||||
void configureForSampleRate(float sampleRate);
|
||||
void configureKWeighting();
|
||||
void configureFastMeter();
|
||||
void configureTruePeakDetector();
|
||||
void processLoudnessSample(float left, float right);
|
||||
void updateMomentaryShortTermLoudness();
|
||||
double computeGatedIntegratedLoudness() const;
|
||||
@@ -56,15 +54,13 @@ private:
|
||||
double histogramLufsAtIndex(size_t index) const;
|
||||
double applyBiquad(const BiquadCoeffs& coeffs, BiquadState& state, double input);
|
||||
|
||||
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);
|
||||
void processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR);
|
||||
void advancePeaks(double nowMs);
|
||||
void maybeUpdatePeak(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);
|
||||
@@ -103,12 +99,6 @@ 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_{};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <napi.h>
|
||||
|
||||
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports);
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "system_audio_capture.h"
|
||||
#include "macos_capture.h"
|
||||
|
||||
#if defined(__APPLE__)
|
||||
|
||||
@@ -319,104 +319,140 @@ std::string formatStatusMessage(const char* operation, OSStatus status) {
|
||||
return std::string(operation) + " failed (" + std::to_string(static_cast<int>(status)) + ")";
|
||||
}
|
||||
|
||||
class MacOSNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
|
||||
class MacOSNativeCaptureEngine {
|
||||
public:
|
||||
~MacOSNativeCaptureEngine() override {
|
||||
stop();
|
||||
}
|
||||
Napi::Object GetSupport(Napi::Env env) {
|
||||
Napi::Object support = Napi::Object::New(env);
|
||||
|
||||
Prism::Capture::Support getSupport() const override {
|
||||
if (@available(macOS 14.2, *)) {
|
||||
return {true, {}};
|
||||
support.Set("available", Napi::Boolean::New(env, true));
|
||||
support.Set("reason", env.Null());
|
||||
return support;
|
||||
}
|
||||
return {false, "Native output-device capture requires macOS 14.2 or newer."};
|
||||
|
||||
support.Set("available", Napi::Boolean::New(env, false));
|
||||
support.Set(
|
||||
"reason",
|
||||
Napi::String::New(env, "Native output-device capture requires macOS 14.2 or newer."));
|
||||
return support;
|
||||
}
|
||||
|
||||
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
|
||||
std::vector<Prism::Capture::OutputDevice> result;
|
||||
Napi::Array ListOutputDevices(Napi::Env env) {
|
||||
Napi::Array result = Napi::Array::New(env);
|
||||
if (!isSupported()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const auto devices = enumerateOutputDevices();
|
||||
result.reserve(devices.size());
|
||||
for (const auto& device : devices) {
|
||||
result.push_back({
|
||||
device.uid,
|
||||
device.label,
|
||||
device.sampleRate,
|
||||
static_cast<uint32_t>(device.channelCount),
|
||||
device.isDefault,
|
||||
});
|
||||
for (size_t index = 0; index < devices.size(); ++index) {
|
||||
const auto& device = devices[index];
|
||||
Napi::Object entry = Napi::Object::New(env);
|
||||
entry.Set("id", Napi::String::New(env, device.uid));
|
||||
entry.Set("label", Napi::String::New(env, device.label));
|
||||
entry.Set("kind", Napi::String::New(env, "system"));
|
||||
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
|
||||
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
|
||||
entry.Set(
|
||||
"channelCount",
|
||||
Napi::Number::New(env, static_cast<double>(device.channelCount)));
|
||||
result.Set(static_cast<uint32_t>(index), entry);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool start(const std::string& requestedDeviceId,
|
||||
Prism::Capture::StartResult* result,
|
||||
std::string* errorMessage) override {
|
||||
const auto support = getSupport();
|
||||
if (!support.available) {
|
||||
if (errorMessage != nullptr) {
|
||||
*errorMessage = support.reason;
|
||||
}
|
||||
return false;
|
||||
Napi::Object Start(Napi::Env env, const std::string& requestedDeviceUid) {
|
||||
Napi::Object result = Napi::Object::New(env);
|
||||
|
||||
const Napi::Object support = GetSupport(env);
|
||||
if (!support.Get("available").As<Napi::Boolean>().Value()) {
|
||||
Napi::Error::New(
|
||||
env, support.Get("reason").As<Napi::String>().Utf8Value())
|
||||
.ThrowAsJavaScriptException();
|
||||
return result;
|
||||
}
|
||||
if (!startInternal(requestedDeviceId, errorMessage)) {
|
||||
return false;
|
||||
|
||||
std::string errorMessage;
|
||||
if (!startInternal(requestedDeviceUid, &errorMessage)) {
|
||||
Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
|
||||
return result;
|
||||
}
|
||||
if (result != nullptr) {
|
||||
std::lock_guard<std::mutex> lock(stateMutex_);
|
||||
result->sampleRate = sampleRate_;
|
||||
result->channelCount = static_cast<uint32_t>(channelCount_);
|
||||
result->deviceId = activeDeviceUid_;
|
||||
result->deviceLabel = activeDeviceLabel_;
|
||||
}
|
||||
return true;
|
||||
|
||||
std::lock_guard<std::mutex> lock(stateMutex_);
|
||||
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
|
||||
result.Set("channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
|
||||
result.Set("deviceId", Napi::String::New(env, activeDeviceUid_));
|
||||
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
|
||||
return result;
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
void Stop() {
|
||||
std::lock_guard<std::mutex> lock(stateMutex_);
|
||||
stopLocked();
|
||||
}
|
||||
|
||||
Prism::Capture::DrainResult drain(size_t maxChunks) override {
|
||||
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
|
||||
const size_t drainLimit =
|
||||
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
|
||||
|
||||
std::deque<CapturedChunk> drained;
|
||||
Prism::Capture::DrainResult result;
|
||||
uint64_t overwriteCount = 0;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> queueLock(chunkMutex_);
|
||||
result.overwriteCount = overwriteCount_;
|
||||
overwriteCount = overwriteCount_;
|
||||
const size_t count = std::min(drainLimit, chunkQueue_.size());
|
||||
for (size_t index = 0; index < count; ++index) {
|
||||
drained.push_back(std::move(chunkQueue_.front()));
|
||||
chunkQueue_.pop_front();
|
||||
}
|
||||
result.queueDepth = chunkQueue_.size();
|
||||
}
|
||||
result.chunks.reserve(drained.size());
|
||||
while (!drained.empty()) {
|
||||
auto chunk = std::move(drained.front());
|
||||
drained.pop_front();
|
||||
result.chunks.push_back({
|
||||
std::move(chunk.left),
|
||||
std::move(chunk.right),
|
||||
static_cast<uint32_t>(chunk.channelCount),
|
||||
chunk.capturedAtMilliseconds,
|
||||
chunk.sequence,
|
||||
});
|
||||
|
||||
Napi::Array chunks = Napi::Array::New(env, drained.size());
|
||||
for (size_t index = 0; index < drained.size(); ++index) {
|
||||
CapturedChunk& chunk = drained[index];
|
||||
Napi::Object entry = Napi::Object::New(env);
|
||||
Napi::Float32Array left =
|
||||
Napi::Float32Array::New(env, chunk.left.size());
|
||||
Napi::Float32Array right =
|
||||
Napi::Float32Array::New(env, chunk.right.size());
|
||||
if (!chunk.left.empty()) {
|
||||
std::memcpy(
|
||||
left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
|
||||
}
|
||||
if (!chunk.right.empty()) {
|
||||
std::memcpy(
|
||||
right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
|
||||
}
|
||||
entry.Set("left", left);
|
||||
entry.Set("right", right);
|
||||
entry.Set(
|
||||
"channelCount",
|
||||
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
|
||||
entry.Set(
|
||||
"capturedAtMilliseconds",
|
||||
Napi::Number::New(env, chunk.capturedAtMilliseconds));
|
||||
entry.Set(
|
||||
"sequence",
|
||||
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
|
||||
chunks.Set(static_cast<uint32_t>(index), entry);
|
||||
}
|
||||
|
||||
Napi::Object result = Napi::Object::New(env);
|
||||
result.Set("chunks", chunks);
|
||||
result.Set(
|
||||
"overwriteCount",
|
||||
Napi::Number::New(env, static_cast<double>(overwriteCount)));
|
||||
result.Set(
|
||||
"queueDepth",
|
||||
Napi::Number::New(env, static_cast<double>(chunkQueue_.size())));
|
||||
return result;
|
||||
}
|
||||
|
||||
double nowMilliseconds() const override {
|
||||
double NowMilliseconds() const {
|
||||
return monotonicMilliseconds();
|
||||
}
|
||||
|
||||
const char* backendName() const override {
|
||||
return "CoreAudio";
|
||||
}
|
||||
|
||||
private:
|
||||
static OSStatus StaticIOProc(AudioObjectID inDevice,
|
||||
const AudioTimeStamp* inNow,
|
||||
@@ -744,14 +780,60 @@ private:
|
||||
UInt32 channelCount_ = 2;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace Prism::Capture {
|
||||
|
||||
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
|
||||
return std::make_unique<MacOSNativeCaptureEngine>();
|
||||
MacOSNativeCaptureEngine& engine() {
|
||||
static MacOSNativeCaptureEngine instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
} // namespace Prism::Capture
|
||||
Napi::Value MacOSGetSupport(const Napi::CallbackInfo& info) {
|
||||
return engine().GetSupport(info.Env());
|
||||
}
|
||||
|
||||
Napi::Value MacOSListOutputDevices(const Napi::CallbackInfo& info) {
|
||||
return engine().ListOutputDevices(info.Env());
|
||||
}
|
||||
|
||||
Napi::Value MacOSStart(const Napi::CallbackInfo& info) {
|
||||
std::string requestedDeviceUid;
|
||||
if (info.Length() >= 1 && info[0].IsString()) {
|
||||
requestedDeviceUid = info[0].As<Napi::String>().Utf8Value();
|
||||
}
|
||||
return engine().Start(info.Env(), requestedDeviceUid);
|
||||
}
|
||||
|
||||
Napi::Value MacOSStop(const Napi::CallbackInfo& info) {
|
||||
engine().Stop();
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value MacOSDrain(const Napi::CallbackInfo& info) {
|
||||
size_t maxChunks = kDefaultDrainChunkLimit;
|
||||
if (info.Length() >= 1 && info[0].IsNumber()) {
|
||||
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
|
||||
if (requested > 0) {
|
||||
maxChunks = static_cast<size_t>(requested);
|
||||
}
|
||||
}
|
||||
return engine().Drain(info.Env(), maxChunks);
|
||||
}
|
||||
|
||||
Napi::Value MacOSNowMilliseconds(const Napi::CallbackInfo& info) {
|
||||
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object captureExports = Napi::Object::New(env);
|
||||
captureExports.Set("getSupport", Napi::Function::New(env, MacOSGetSupport));
|
||||
captureExports.Set(
|
||||
"listOutputDevices", Napi::Function::New(env, MacOSListOutputDevices));
|
||||
captureExports.Set("start", Napi::Function::New(env, MacOSStart));
|
||||
captureExports.Set("stop", Napi::Function::New(env, MacOSStop));
|
||||
captureExports.Set("drain", Napi::Function::New(env, MacOSDrain));
|
||||
captureExports.Set(
|
||||
"nowMilliseconds", Napi::Function::New(env, MacOSNowMilliseconds));
|
||||
exports.Set("macosCapture", captureExports);
|
||||
}
|
||||
|
||||
#endif // defined(__APPLE__)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "macos_capture.h"
|
||||
|
||||
namespace {
|
||||
|
||||
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
|
||||
Napi::Object support = Napi::Object::New(info.Env());
|
||||
support.Set("available", Napi::Boolean::New(info.Env(), false));
|
||||
support.Set(
|
||||
"reason",
|
||||
Napi::String::New(
|
||||
info.Env(), "Native macOS output-device capture is unavailable on this platform."));
|
||||
return support;
|
||||
}
|
||||
|
||||
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
|
||||
return Napi::Array::New(info.Env());
|
||||
}
|
||||
|
||||
Napi::Value Start(const Napi::CallbackInfo& info) {
|
||||
Napi::Error::New(
|
||||
info.Env(), "Native macOS output-device capture is unavailable on this platform.")
|
||||
.ThrowAsJavaScriptException();
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value Stop(const Napi::CallbackInfo& info) {
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value Drain(const Napi::CallbackInfo& info) {
|
||||
Napi::Object result = Napi::Object::New(info.Env());
|
||||
result.Set("chunks", Napi::Array::New(info.Env()));
|
||||
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
|
||||
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
|
||||
return Napi::Number::New(info.Env(), 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object captureExports = Napi::Object::New(env);
|
||||
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
|
||||
captureExports.Set(
|
||||
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
|
||||
captureExports.Set("start", Napi::Function::New(env, Start));
|
||||
captureExports.Set("stop", Napi::Function::New(env, Stop));
|
||||
captureExports.Set("drain", Napi::Function::New(env, Drain));
|
||||
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
|
||||
exports.Set("macosCapture", captureExports);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include "system_audio_capture_napi.h"
|
||||
#include "linux_capture.h"
|
||||
#include "macos_capture.h"
|
||||
#include "windows_capture.h"
|
||||
#include "oscilloscope.h"
|
||||
#include "spectrum.h"
|
||||
@@ -11,7 +12,6 @@
|
||||
#include "waveform.h"
|
||||
#include "vumeter.h"
|
||||
#include "lufsmeter.h"
|
||||
#include "window_chrome.h"
|
||||
|
||||
// Global instances
|
||||
static Visualizer::Oscilloscope oscilloscope;
|
||||
@@ -211,14 +211,6 @@ Napi::Value SpectrumGetSideMagnitudes(const Napi::CallbackInfo& info) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value SpectrumGetChannelMaxMagnitudes(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
const auto& magnitudes = spectrum.getChannelMaxMagnitudes();
|
||||
Napi::Float32Array result = Napi::Float32Array::New(env, magnitudes.size());
|
||||
memcpy(result.Data(), magnitudes.data(), magnitudes.size() * sizeof(float));
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value SpectrumFillRawMagnitudes(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 1 || !info[0].IsTypedArray()) {
|
||||
@@ -267,22 +259,6 @@ Napi::Value SpectrumFillSideMagnitudes(const Napi::CallbackInfo& info) {
|
||||
return Napi::Number::New(env, static_cast<double>(count));
|
||||
}
|
||||
|
||||
Napi::Value SpectrumFillChannelMaxMagnitudes(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 1 || !info[0].IsTypedArray()) {
|
||||
Napi::TypeError::New(env, "Expected output Float32Array").ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
Napi::Float32Array output = info[0].As<Napi::Float32Array>();
|
||||
const auto& magnitudes = spectrum.getChannelMaxMagnitudes();
|
||||
const size_t count = std::min(output.ElementLength(), magnitudes.size());
|
||||
if (count > 0) {
|
||||
memcpy(output.Data(), magnitudes.data(), count * sizeof(float));
|
||||
}
|
||||
return Napi::Number::New(env, static_cast<double>(count));
|
||||
}
|
||||
|
||||
Napi::Value SpectrumProcess(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 1 || !info[0].IsTypedArray()) {
|
||||
@@ -327,27 +303,6 @@ std::string GetObjectString(const Napi::Object& obj, const char* key, const std:
|
||||
Napi::Value value = obj.Get(key);
|
||||
return value.IsString() ? value.As<Napi::String>().Utf8Value() : fallback;
|
||||
}
|
||||
|
||||
Napi::Object SpectrogramResultToJs(
|
||||
Napi::Env env,
|
||||
const Visualizer::SpectrogramProcessResult& result
|
||||
) {
|
||||
Napi::Float32Array display = Napi::Float32Array::New(env, result.display.size());
|
||||
Napi::Float32Array heat = Napi::Float32Array::New(env, result.heat.size());
|
||||
if (!result.display.empty()) {
|
||||
memcpy(display.Data(), result.display.data(), result.display.size() * sizeof(float));
|
||||
}
|
||||
if (!result.heat.empty()) {
|
||||
memcpy(heat.Data(), result.heat.data(), result.heat.size() * sizeof(float));
|
||||
}
|
||||
|
||||
Napi::Object obj = Napi::Object::New(env);
|
||||
obj.Set("display", display);
|
||||
obj.Set("heat", heat);
|
||||
obj.Set("columnCount", Napi::Number::New(env, static_cast<double>(result.columnCount)));
|
||||
obj.Set("rowCount", Napi::Number::New(env, static_cast<double>(result.rowCount)));
|
||||
return obj;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Napi::Value SpectrogramConfigure(const Napi::CallbackInfo& info) {
|
||||
@@ -386,21 +341,22 @@ Napi::Value SpectrogramProcess(const Napi::CallbackInfo& info) {
|
||||
|
||||
Napi::Float32Array audioData = info[0].As<Napi::Float32Array>();
|
||||
auto result = spectrogramAnalyzer.process(audioData.Data(), audioData.ElementLength());
|
||||
return SpectrogramResultToJs(env, result);
|
||||
}
|
||||
|
||||
Napi::Value SpectrogramProcessStereo(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) {
|
||||
Napi::TypeError::New(env, "Expected left and right Float32Arrays").ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
Napi::Float32Array display = Napi::Float32Array::New(env, result.display.size());
|
||||
Napi::Float32Array heat = Napi::Float32Array::New(env, result.heat.size());
|
||||
if (!result.display.empty()) {
|
||||
memcpy(display.Data(), result.display.data(), result.display.size() * sizeof(float));
|
||||
}
|
||||
if (!result.heat.empty()) {
|
||||
memcpy(heat.Data(), result.heat.data(), result.heat.size() * sizeof(float));
|
||||
}
|
||||
|
||||
Napi::Float32Array left = info[0].As<Napi::Float32Array>();
|
||||
Napi::Float32Array right = info[1].As<Napi::Float32Array>();
|
||||
const size_t length = std::min(left.ElementLength(), right.ElementLength());
|
||||
auto result = spectrogramAnalyzer.processStereo(left.Data(), right.Data(), length);
|
||||
return SpectrogramResultToJs(env, result);
|
||||
Napi::Object obj = Napi::Object::New(env);
|
||||
obj.Set("display", display);
|
||||
obj.Set("heat", heat);
|
||||
obj.Set("columnCount", Napi::Number::New(env, static_cast<double>(result.columnCount)));
|
||||
obj.Set("rowCount", Napi::Number::New(env, static_cast<double>(result.rowCount)));
|
||||
return obj;
|
||||
}
|
||||
|
||||
Napi::Value SpectrogramReset(const Napi::CallbackInfo& info) {
|
||||
@@ -690,9 +646,8 @@ 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("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("peakLDb", Napi::Number::New(env, snapshot.peakLDb));
|
||||
obj.Set("peakRDb", Napi::Number::New(env, snapshot.peakRDb));
|
||||
obj.Set("correlation", Napi::Number::New(env, snapshot.correlation));
|
||||
return obj;
|
||||
}
|
||||
@@ -730,11 +685,9 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
specExports.Set("fillRawMagnitudes", Napi::Function::New(env, SpectrumFillRawMagnitudes));
|
||||
specExports.Set("fillMagnitudes", Napi::Function::New(env, SpectrumFillMagnitudes));
|
||||
specExports.Set("fillSideMagnitudes", Napi::Function::New(env, SpectrumFillSideMagnitudes));
|
||||
specExports.Set("fillChannelMaxMagnitudes", Napi::Function::New(env, SpectrumFillChannelMaxMagnitudes));
|
||||
specExports.Set("getRawMagnitudes", Napi::Function::New(env, SpectrumGetRawMagnitudes));
|
||||
specExports.Set("getMagnitudes", Napi::Function::New(env, SpectrumGetMagnitudes));
|
||||
specExports.Set("getSideMagnitudes", Napi::Function::New(env, SpectrumGetSideMagnitudes));
|
||||
specExports.Set("getChannelMaxMagnitudes", Napi::Function::New(env, SpectrumGetChannelMaxMagnitudes));
|
||||
specExports.Set("process", Napi::Function::New(env, SpectrumProcess));
|
||||
specExports.Set("binToFrequency", Napi::Function::New(env, SpectrumBinToFrequency));
|
||||
specExports.Set("reset", Napi::Function::New(env, SpectrumReset));
|
||||
@@ -744,7 +697,6 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object spectrogramExports = Napi::Object::New(env);
|
||||
spectrogramExports.Set("configure", Napi::Function::New(env, SpectrogramConfigure));
|
||||
spectrogramExports.Set("process", Napi::Function::New(env, SpectrogramProcess));
|
||||
spectrogramExports.Set("processStereo", Napi::Function::New(env, SpectrogramProcessStereo));
|
||||
spectrogramExports.Set("reset", Napi::Function::New(env, SpectrogramReset));
|
||||
exports.Set("spectrogram", spectrogramExports);
|
||||
|
||||
@@ -786,9 +738,9 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
lufsExports.Set("reset", Napi::Function::New(env, LUFSMeterReset));
|
||||
exports.Set("lufsmeter", lufsExports);
|
||||
|
||||
RegisterSystemAudioCapture(env, exports);
|
||||
RegisterWindowsMedia(env, exports);
|
||||
RegisterWindowChrome(env, exports);
|
||||
RegisterMacOSCapture(env, exports);
|
||||
RegisterWindowsCapture(env, exports);
|
||||
RegisterLinuxCapture(env, exports);
|
||||
|
||||
return exports;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ Oscilloscope::Oscilloscope()
|
||||
, lastFilterPitch_(200.0f)
|
||||
, lastTrigger_(0)
|
||||
, smoothedPitch_(200.0f)
|
||||
, latestDetectedPitch_(0.0f)
|
||||
, pitchSamplesProcessed_(0) {
|
||||
|
||||
// Initialize circular buffers
|
||||
@@ -145,10 +144,6 @@ OscilloscopeResult Oscilloscope::process() {
|
||||
result.detectedPitch = smoothedPitch_;
|
||||
|
||||
if (!pitchLock_) {
|
||||
const size_t samples = static_cast<size_t>(displaySamples_);
|
||||
result.triggerIndex = static_cast<float>(
|
||||
(writePos_ + OSCILLOSCOPE_BUFFER_SIZE - samples) %
|
||||
OSCILLOSCOPE_BUFFER_SIZE);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -177,7 +172,6 @@ OscilloscopeResult Oscilloscope::process() {
|
||||
|
||||
float newPitch = DSP::detectPitchFFT(recentSamples.data(), 2048, sampleRate_, 40.0f, 1000.0f);
|
||||
if (newPitch > 0.0f) {
|
||||
latestDetectedPitch_ = newPitch;
|
||||
pitchSamplesProcessed_++;
|
||||
|
||||
// Adaptive smoothing: fast convergence initially, then conservative
|
||||
@@ -313,7 +307,6 @@ void Oscilloscope::reset() {
|
||||
writePos_ = 0;
|
||||
lastTrigger_ = 0.0f;
|
||||
smoothedPitch_ = 200.0f;
|
||||
latestDetectedPitch_ = 0.0f;
|
||||
lastFilterPitch_ = 200.0f;
|
||||
pitchSamplesProcessed_ = 0; // Reset warmup counter for fast convergence on next use
|
||||
|
||||
|
||||
@@ -36,10 +36,6 @@ public:
|
||||
// Get current write position
|
||||
size_t getWritePos() const { return writePos_; }
|
||||
|
||||
// Latest unsmoothed detector result. The stable detectedPitch returned by
|
||||
// process() remains the value used for pitch-locked triggering.
|
||||
float getLatestDetectedPitch() const { return latestDetectedPitch_; }
|
||||
|
||||
// Get samples from circular buffer (for rendering)
|
||||
void getSamples(float* output, size_t startPos, size_t count) const;
|
||||
|
||||
@@ -79,7 +75,6 @@ private:
|
||||
|
||||
float lastTrigger_;
|
||||
float smoothedPitch_;
|
||||
float latestDetectedPitch_;
|
||||
int pitchSamplesProcessed_; // Track samples for adaptive smoothing
|
||||
|
||||
// Internal helpers
|
||||
|
||||
@@ -9,10 +9,10 @@ namespace Visualizer {
|
||||
|
||||
namespace {
|
||||
constexpr size_t FFT_PAD_FACTOR = 4;
|
||||
constexpr float HANN_EQUIVALENT_NOISE_BANDWIDTH_BINS = 1.5f;
|
||||
constexpr float REASSIGNED_POWER_NORMALIZATION = static_cast<float>(FFT_PAD_FACTOR)
|
||||
* HANN_EQUIVALENT_NOISE_BANDWIDTH_BINS;
|
||||
constexpr float DISPLAY_GAIN_DB = 2.0f;
|
||||
constexpr float SPECTROGRAM_HEAT_GAMMA = 1.45f;
|
||||
constexpr float SPECTROGRAM_DISPLAY_STROKE_WEIGHT = 0.42f;
|
||||
constexpr float SPECTROGRAM_HEAT_STROKE_WEIGHT = 0.32f;
|
||||
constexpr float TILT_REFERENCE_HZ = 1000.0f;
|
||||
constexpr float HEAT_MIN_DB = -100.0f;
|
||||
constexpr float HEAT_MAX_DB = -20.0f;
|
||||
@@ -64,8 +64,7 @@ SpectrogramAnalyzer::SpectrogramAnalyzer()
|
||||
: fftSize_(0)
|
||||
, paddedSize_(0)
|
||||
, frameFill_(0)
|
||||
, haveLastPhase_(false)
|
||||
, magnitudeScale_(1.0f) {
|
||||
, haveLastPhase_(false) {
|
||||
configureFft(config_.fftSize);
|
||||
rebuildFrequencyMapping();
|
||||
}
|
||||
@@ -97,10 +96,7 @@ void SpectrogramAnalyzer::configure(const SpectrogramConfig& config) {
|
||||
if (next.orientation != "vertical") {
|
||||
next.orientation = "horizontal";
|
||||
}
|
||||
if (next.clarityMode != "classic"
|
||||
&& next.clarityMode != "focused"
|
||||
&& next.clarityMode != "sharp"
|
||||
&& next.clarityMode != "sharper") {
|
||||
if (next.clarityMode != "classic" && next.clarityMode != "sharp" && next.clarityMode != "sharper") {
|
||||
next.clarityMode = "sharper";
|
||||
}
|
||||
|
||||
@@ -132,19 +128,13 @@ void SpectrogramAnalyzer::configureFft(size_t fftSize) {
|
||||
paddedSize_ = fftSize_ * FFT_PAD_FACTOR;
|
||||
fft_ = std::make_unique<DSP::FFT>(paddedSize_);
|
||||
frameBuffer_.assign(fftSize_, 0.0f);
|
||||
rightFrameBuffer_.assign(fftSize_, 0.0f);
|
||||
window_.assign(fftSize_, 1.0f);
|
||||
windowedInput_.assign(paddedSize_, 0.0f);
|
||||
rightWindowedInput_.assign(paddedSize_, 0.0f);
|
||||
fftOutput_.assign(paddedSize_, std::complex<float>(0.0f, 0.0f));
|
||||
rightFftOutput_.assign(paddedSize_, std::complex<float>(0.0f, 0.0f));
|
||||
magnitudesDb_.assign(paddedSize_ / 2, -200.0f);
|
||||
magnitudesLinear_.assign(paddedSize_ / 2, 0.0f);
|
||||
phases_.assign(paddedSize_ / 2, 0.0f);
|
||||
lastPhases_.assign(paddedSize_ / 2, 0.0f);
|
||||
rightPhases_.assign(paddedSize_ / 2, 0.0f);
|
||||
rightLastPhases_.assign(paddedSize_ / 2, 0.0f);
|
||||
dominantRight_.assign(paddedSize_ / 2, 0);
|
||||
frameFill_ = 0;
|
||||
haveLastPhase_ = false;
|
||||
|
||||
@@ -155,21 +145,11 @@ void SpectrogramAnalyzer::configureFft(size_t fftSize) {
|
||||
for (size_t index = 0; index < fftSize_; index += 1) {
|
||||
window_[index] = 0.5f * (1.0f - std::cos((2.0f * static_cast<float>(M_PI) * index) / (fftSize_ - 1)));
|
||||
}
|
||||
|
||||
float windowSum = 0.0f;
|
||||
for (const float coefficient : window_) {
|
||||
windowSum += coefficient;
|
||||
}
|
||||
magnitudeScale_ = windowSum > std::numeric_limits<float>::epsilon()
|
||||
? 2.0f / windowSum
|
||||
: 1.0f;
|
||||
}
|
||||
|
||||
void SpectrogramAnalyzer::reset() {
|
||||
std::fill(frameBuffer_.begin(), frameBuffer_.end(), 0.0f);
|
||||
std::fill(rightFrameBuffer_.begin(), rightFrameBuffer_.end(), 0.0f);
|
||||
std::fill(lastPhases_.begin(), lastPhases_.end(), 0.0f);
|
||||
std::fill(rightLastPhases_.begin(), rightLastPhases_.end(), 0.0f);
|
||||
frameFill_ = 0;
|
||||
haveLastPhase_ = false;
|
||||
}
|
||||
@@ -186,7 +166,7 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() {
|
||||
const float sampleRate = std::max(1.0f, config_.sampleRate);
|
||||
const float nyquist = sampleRate * 0.5f;
|
||||
const float minFrequency = std::max(1.0f, std::min(config_.minFrequency, nyquist));
|
||||
const float maxFrequency = std::max(minFrequency, std::min(config_.maxFrequency, nyquist));
|
||||
const float maxFrequency = std::max(minFrequency + 1.0f, std::min(config_.maxFrequency, nyquist));
|
||||
config_.minFrequency = minFrequency;
|
||||
config_.maxFrequency = maxFrequency;
|
||||
|
||||
@@ -197,9 +177,8 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() {
|
||||
standardRaw_.assign(rowCount, 0.0f);
|
||||
standardHeat_.assign(rowCount, 0.0f);
|
||||
reassignedPower_.assign(rowCount, 0.0f);
|
||||
focusedPower_.assign(rowCount, 0.0f);
|
||||
sourceRaw_.assign(rowCount, 0.0f);
|
||||
sourceHeat_.assign(rowCount, 0.0f);
|
||||
blendedRaw_.assign(rowCount, 0.0f);
|
||||
blendedHeat_.assign(rowCount, 0.0f);
|
||||
shapedDisplay_.assign(rowCount, 0.0f);
|
||||
shapedHeat_.assign(rowCount, 0.0f);
|
||||
strokedDisplay_.assign(rowCount, 0.0f);
|
||||
@@ -239,11 +218,7 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() {
|
||||
float SpectrogramAnalyzer::frequencyFromScale(float normalizedPosition) const {
|
||||
const float t = clamp01(normalizedPosition);
|
||||
const float minFrequency = std::max(1.0f, config_.minFrequency);
|
||||
const float maxFrequency = std::max(minFrequency, config_.maxFrequency);
|
||||
|
||||
if (maxFrequency <= minFrequency) {
|
||||
return minFrequency;
|
||||
}
|
||||
const float maxFrequency = std::max(minFrequency + 1.0f, config_.maxFrequency);
|
||||
|
||||
if (config_.scaleMode == "linear") {
|
||||
return minFrequency + (t * (maxFrequency - minFrequency));
|
||||
@@ -261,15 +236,8 @@ float SpectrogramAnalyzer::frequencyFromScale(float normalizedPosition) const {
|
||||
}
|
||||
|
||||
float SpectrogramAnalyzer::frequencyToRow(float frequency) const {
|
||||
if (config_.rowCount <= 1) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const float minFrequency = std::max(1.0f, config_.minFrequency);
|
||||
const float maxFrequency = std::max(minFrequency, config_.maxFrequency);
|
||||
if (maxFrequency <= minFrequency) {
|
||||
return config_.orientation == "vertical" ? 0.0f : static_cast<float>(config_.rowCount - 1);
|
||||
}
|
||||
const float maxFrequency = std::max(minFrequency + 1.0f, config_.maxFrequency);
|
||||
const float clampedFrequency = std::clamp(frequency, minFrequency, maxFrequency);
|
||||
float normalized = 0.0f;
|
||||
|
||||
@@ -294,7 +262,7 @@ float SpectrogramAnalyzer::frequencyToRow(float frequency) const {
|
||||
float SpectrogramAnalyzer::applyDisplayTilt(float db, float frequency) const {
|
||||
const float safeFrequency = std::max(1.0f, frequency);
|
||||
const float tiltAmount = config_.tiltDbPerOctave * std::log2(safeFrequency / TILT_REFERENCE_HZ);
|
||||
return db + tiltAmount;
|
||||
return db + tiltAmount + DISPLAY_GAIN_DB;
|
||||
}
|
||||
|
||||
float SpectrogramAnalyzer::displayDbToIntensity(float db) const {
|
||||
@@ -328,44 +296,10 @@ float SpectrogramAnalyzer::sampleDbAtBin(float bin) const {
|
||||
);
|
||||
}
|
||||
|
||||
float SpectrogramAnalyzer::samplePeakDbInBand(float startBin, float endBin, float& peakBin) const {
|
||||
if (magnitudesDb_.empty()) {
|
||||
peakBin = 0.0f;
|
||||
return -200.0f;
|
||||
}
|
||||
|
||||
const float lastBin = static_cast<float>(magnitudesDb_.size() - 1);
|
||||
const float lo = std::clamp(std::min(startBin, endBin), 0.0f, lastBin);
|
||||
const float hi = std::clamp(std::max(startBin, endBin), 0.0f, lastBin);
|
||||
peakBin = 0.5f * (lo + hi);
|
||||
float peakDb = sampleDbAtBin(peakBin);
|
||||
|
||||
const auto consider = [&](float candidateBin) {
|
||||
const float candidateDb = sampleDbAtBin(candidateBin);
|
||||
if (candidateDb > peakDb) {
|
||||
peakDb = candidateDb;
|
||||
peakBin = candidateBin;
|
||||
}
|
||||
};
|
||||
|
||||
consider(lo);
|
||||
consider(hi);
|
||||
const size_t firstWholeBin = static_cast<size_t>(std::ceil(lo));
|
||||
const size_t lastWholeBin = static_cast<size_t>(std::floor(hi));
|
||||
for (size_t bin = firstWholeBin; bin <= lastWholeBin && bin < magnitudesDb_.size(); bin += 1) {
|
||||
consider(static_cast<float>(bin));
|
||||
}
|
||||
|
||||
return peakDb;
|
||||
}
|
||||
|
||||
void SpectrogramAnalyzer::computeStandardSpectrum() {
|
||||
const size_t rowCount = config_.rowCount;
|
||||
const float binWidth = std::max(1.0f, config_.sampleRate) / static_cast<float>(paddedSize_);
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
float peakBin = rowCenterBins_[row];
|
||||
const float rawDb = samplePeakDbInBand(rowBandStartBins_[row], rowBandEndBins_[row], peakBin);
|
||||
const float displayDb = applyDisplayTilt(rawDb, peakBin * binWidth);
|
||||
const float displayDb = applyDisplayTilt(sampleDbAtBin(rowCenterBins_[row]), rowCenterFrequencies_[row]);
|
||||
standardRaw_[row] = displayDbToIntensity(displayDb);
|
||||
standardHeat_[row] = normalizeHeatDb(displayDb);
|
||||
}
|
||||
@@ -380,11 +314,7 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() {
|
||||
const float sampleRate = std::max(1.0f, config_.sampleRate);
|
||||
const float binWidth = sampleRate / static_cast<float>(paddedSize_);
|
||||
const float hopDt = static_cast<float>(resolveHopSize()) / sampleRate;
|
||||
// Reassign every bin that can contribute to either output. Limiting this to
|
||||
// local maxima throws away low-level partials and ambience — exactly the
|
||||
// detail a sharpened spectrogram is meant to retain.
|
||||
const float visibleFloorDb = std::min(config_.minDecibels, HEAT_MIN_DB);
|
||||
const float ampThreshold = std::pow(10.0f, visibleFloorDb / 20.0f);
|
||||
const float ampThreshold = std::pow(10.0f, config_.minDecibels / 20.0f);
|
||||
const float twoPi = static_cast<float>(2.0 * M_PI);
|
||||
|
||||
for (size_t bin = 1; bin + 1 < magnitudesLinear_.size(); bin += 1) {
|
||||
@@ -392,59 +322,7 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() {
|
||||
if (mag <= ampThreshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const float nominalFrequency = static_cast<float>(bin) * binWidth;
|
||||
if (nominalFrequency < config_.minFrequency || nominalFrequency > config_.maxFrequency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const float expected = twoPi * nominalFrequency * hopDt;
|
||||
const bool useRightPhase = dominantRight_[bin] != 0;
|
||||
const float currentPhase = useRightPhase ? rightPhases_[bin] : phases_[bin];
|
||||
const float previousPhase = useRightPhase ? rightLastPhases_[bin] : lastPhases_[bin];
|
||||
const float correctionHz = wrapPhase(currentPhase - previousPhase - expected) / (twoPi * hopDt);
|
||||
const float reassignedFrequency = nominalFrequency + correctionHz;
|
||||
|
||||
if (reassignedFrequency < config_.minFrequency || reassignedFrequency > config_.maxFrequency) {
|
||||
continue;
|
||||
}
|
||||
const float rowF = frequencyToRow(reassignedFrequency);
|
||||
const size_t row0 = static_cast<size_t>(std::floor(std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))));
|
||||
const float frac = rowF - static_cast<float>(row0);
|
||||
// A coherently-normalized Hann spectrum contains 1.5 bins of equivalent
|
||||
// noise bandwidth. With 4x zero padding, a bin-centered sinusoid therefore
|
||||
// contributes 6x its signal power across the positive-frequency bins.
|
||||
// Divide that back out so relocation conserves calibrated signal power.
|
||||
const float power = (mag * mag) / REASSIGNED_POWER_NORMALIZATION;
|
||||
|
||||
reassignedPower_[row0] += power * (1.0f - frac);
|
||||
if (row0 + 1 < config_.rowCount) {
|
||||
reassignedPower_[row0 + 1] += power * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SpectrogramAnalyzer::computeFocusedSpectrum() {
|
||||
std::fill(focusedPower_.begin(), focusedPower_.end(), 0.0f);
|
||||
if (!haveLastPhase_ || magnitudesLinear_.size() < 3 || config_.rowCount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const float sampleRate = std::max(1.0f, config_.sampleRate);
|
||||
const float binWidth = sampleRate / static_cast<float>(paddedSize_);
|
||||
const float hopDt = static_cast<float>(resolveHopSize()) / sampleRate;
|
||||
const float ampThreshold = std::pow(10.0f, config_.minDecibels / 20.0f);
|
||||
const float twoPi = static_cast<float>(2.0 * M_PI);
|
||||
|
||||
// Focused intentionally restores Prism's former peak-isolation aesthetic:
|
||||
// only local FFT maxima are relocated, with conservative phase correction
|
||||
// and a local spectral centroid. It is kept separate from Sharp/Sharper so
|
||||
// their energy-preserving reassignment cannot silently lose texture.
|
||||
for (size_t bin = 1; bin + 1 < magnitudesLinear_.size(); bin += 1) {
|
||||
const float mag = magnitudesLinear_[bin];
|
||||
if (mag <= ampThreshold
|
||||
|| mag < magnitudesLinear_[bin - 1]
|
||||
|| mag < magnitudesLinear_[bin + 1]) {
|
||||
if (mag < magnitudesLinear_[bin - 1] || mag < magnitudesLinear_[bin + 1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -454,12 +332,9 @@ void SpectrogramAnalyzer::computeFocusedSpectrum() {
|
||||
}
|
||||
|
||||
const float expected = twoPi * nominalFrequency * hopDt;
|
||||
const bool useRightPhase = dominantRight_[bin] != 0;
|
||||
const float currentPhase = useRightPhase ? rightPhases_[bin] : phases_[bin];
|
||||
const float previousPhase = useRightPhase ? rightLastPhases_[bin] : lastPhases_[bin];
|
||||
float correctionHz = wrapPhase(currentPhase - previousPhase - expected) / (twoPi * hopDt);
|
||||
float correctionHz = wrapPhase(phases_[bin] - lastPhases_[bin] - expected) / (twoPi * hopDt);
|
||||
correctionHz = std::clamp(correctionHz, -1.5f * binWidth, 1.5f * binWidth);
|
||||
float focusedFrequency = nominalFrequency + correctionHz;
|
||||
float reassignedFrequency = nominalFrequency + correctionHz;
|
||||
|
||||
const float leftWeight = magnitudesLinear_[bin - 1];
|
||||
const float centerWeight = mag;
|
||||
@@ -471,163 +346,97 @@ void SpectrogramAnalyzer::computeFocusedSpectrum() {
|
||||
+ (nominalFrequency * centerWeight)
|
||||
+ (static_cast<float>(bin + 1) * binWidth * rightWeight)
|
||||
) / weightSum;
|
||||
focusedFrequency = 0.5f * focusedFrequency + 0.5f * centroidFrequency;
|
||||
reassignedFrequency = 0.5f * reassignedFrequency + 0.5f * centroidFrequency;
|
||||
}
|
||||
|
||||
if (focusedFrequency < config_.minFrequency || focusedFrequency > config_.maxFrequency) {
|
||||
continue;
|
||||
}
|
||||
const float rowF = frequencyToRow(focusedFrequency);
|
||||
const size_t row0 = static_cast<size_t>(std::floor(
|
||||
std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))
|
||||
));
|
||||
reassignedFrequency = std::clamp(reassignedFrequency, config_.minFrequency, config_.maxFrequency);
|
||||
const float rowF = frequencyToRow(reassignedFrequency);
|
||||
const size_t row0 = static_cast<size_t>(std::floor(std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))));
|
||||
const float frac = rowF - static_cast<float>(row0);
|
||||
const float power = mag * mag;
|
||||
|
||||
focusedPower_[row0] += power * (1.0f - frac);
|
||||
reassignedPower_[row0] += power * (1.0f - frac);
|
||||
if (row0 + 1 < config_.rowCount) {
|
||||
focusedPower_[row0 + 1] += power * frac;
|
||||
reassignedPower_[row0 + 1] += power * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SpectrogramAnalyzer::ClarityProfile SpectrogramAnalyzer::clarityProfile(const std::string& mode) {
|
||||
if (mode == "classic") {
|
||||
return {1.4f, 0.42f, 0.32f, false};
|
||||
return {1.4f, 0.0f, 3.0f};
|
||||
}
|
||||
if (mode == "sharp") {
|
||||
return {1.25f, 0.22f, 0.16f, true};
|
||||
return {1.5f, 2.5f, 3.0f};
|
||||
}
|
||||
return {1.1f, 0.08f, 0.06f, true};
|
||||
return {2.0f, 5.0f, 2.0f};
|
||||
}
|
||||
|
||||
void SpectrogramAnalyzer::shapeColumn(std::vector<float>& display, std::vector<float>& heat) {
|
||||
if (config_.clarityMode == "focused") {
|
||||
shapeFocusedColumn(display, heat);
|
||||
return;
|
||||
}
|
||||
|
||||
void SpectrogramAnalyzer::blendAndShapeColumn(std::vector<float>& display, std::vector<float>& heat) {
|
||||
const size_t rowCount = config_.rowCount;
|
||||
const ClarityProfile clarity = clarityProfile(config_.clarityMode);
|
||||
const bool useReassignedColumn = clarity.useReassignment && haveLastPhase_;
|
||||
const float standardWeight = config_.clarityMode == "classic" ? 0.8f : (config_.clarityMode == "sharp" ? 0.6f : 0.45f);
|
||||
const float reassignedWeight = config_.clarityMode == "classic" ? 0.85f : 1.0f;
|
||||
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
if (useReassignedColumn && reassignedPower_[row] > 0.0f) {
|
||||
float reassignedRaw = 0.0f;
|
||||
float reassignedHeat = 0.0f;
|
||||
if (reassignedPower_[row] > 0.0f) {
|
||||
const float reassignedMag = std::sqrt(reassignedPower_[row]);
|
||||
const float reassignedDb = 20.0f * std::log10(std::max(reassignedMag, 1.0e-10f));
|
||||
const float displayDb = applyDisplayTilt(reassignedDb, rowCenterFrequencies_[row]);
|
||||
sourceRaw_[row] = displayDbToIntensity(displayDb);
|
||||
sourceHeat_[row] = normalizeHeatDb(displayDb);
|
||||
} else if (!useReassignedColumn) {
|
||||
// The first phase-history frame falls back to Classic. Subsequent Sharp
|
||||
// and Sharper frames contain only reassigned energy — no hidden Classic
|
||||
// layer and no local-contrast gate.
|
||||
sourceRaw_[row] = standardRaw_[row];
|
||||
sourceHeat_[row] = standardHeat_[row];
|
||||
} else {
|
||||
sourceRaw_[row] = 0.0f;
|
||||
sourceHeat_[row] = 0.0f;
|
||||
reassignedRaw = displayDbToIntensity(displayDb);
|
||||
reassignedHeat = normalizeHeatDb(displayDb);
|
||||
}
|
||||
|
||||
blendedRaw_[row] = std::max(standardRaw_[row] * standardWeight, reassignedRaw * reassignedWeight);
|
||||
blendedHeat_[row] = std::max(standardHeat_[row] * standardWeight, reassignedHeat * reassignedWeight);
|
||||
}
|
||||
|
||||
if (clarity.sharpness > 0.0f) {
|
||||
const std::vector<float> peakSource = blendedRaw_;
|
||||
const float mainlobePaddedBins = 4.0f * static_cast<float>(FFT_PAD_FACTOR);
|
||||
const float detailPreserve = config_.clarityMode == "sharp" ? 0.18f : 0.14f;
|
||||
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
const float bandWidthPerRow = std::max(0.1f, rowBandEndBins_[row] - rowBandStartBins_[row]);
|
||||
const float mainlobePixels = mainlobePaddedBins / bandWidthPerRow;
|
||||
const int halfWindow = std::max(2, std::min(50, static_cast<int>(std::lround(mainlobePixels * 0.5f))));
|
||||
const float scaleFactor = std::max(1.0f, mainlobePixels / clarity.lineWidth);
|
||||
const float effectiveSharpness = clarity.sharpness * scaleFactor;
|
||||
|
||||
float localMax = peakSource[row];
|
||||
for (int offset = 1; offset <= halfWindow; offset += 1) {
|
||||
if (row >= static_cast<size_t>(offset)) {
|
||||
localMax = std::max(localMax, peakSource[row - static_cast<size_t>(offset)]);
|
||||
}
|
||||
if (row + static_cast<size_t>(offset) < rowCount) {
|
||||
localMax = std::max(localMax, peakSource[row + static_cast<size_t>(offset)]);
|
||||
}
|
||||
}
|
||||
|
||||
if (localMax > 1.0e-6f) {
|
||||
const float ratio = blendedRaw_[row] / localMax;
|
||||
const float suppression = std::pow(clamp01(ratio), effectiveSharpness);
|
||||
const float rawBefore = blendedRaw_[row];
|
||||
const float heatBefore = blendedHeat_[row];
|
||||
blendedRaw_[row] = std::max(rawBefore * suppression, rawBefore * detailPreserve);
|
||||
blendedHeat_[row] = std::max(heatBefore * suppression, heatBefore * detailPreserve);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const float effectiveGamma = clarity.gamma * config_.contrast;
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
shapedDisplay_[row] = std::pow(clamp01(sourceRaw_[row]), effectiveGamma);
|
||||
shapedHeat_[row] = std::pow(clamp01(sourceHeat_[row]), SPECTROGRAM_HEAT_GAMMA);
|
||||
shapedDisplay_[row] = std::pow(clamp01(blendedRaw_[row]), effectiveGamma);
|
||||
shapedHeat_[row] = std::pow(clamp01(blendedHeat_[row]), SPECTROGRAM_HEAT_GAMMA);
|
||||
strokedDisplay_[row] = shapedDisplay_[row];
|
||||
strokedHeat_[row] = shapedHeat_[row];
|
||||
}
|
||||
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
const float displayShoulder = shapedDisplay_[row] * clarity.displayShoulder;
|
||||
const float heatShoulder = shapedHeat_[row] * clarity.heatShoulder;
|
||||
if (row > 0) {
|
||||
strokedDisplay_[row - 1] = std::max(strokedDisplay_[row - 1], displayShoulder);
|
||||
strokedHeat_[row - 1] = std::max(strokedHeat_[row - 1], heatShoulder);
|
||||
}
|
||||
if (row + 1 < rowCount) {
|
||||
strokedDisplay_[row + 1] = std::max(strokedDisplay_[row + 1], displayShoulder);
|
||||
strokedHeat_[row + 1] = std::max(strokedHeat_[row + 1], heatShoulder);
|
||||
}
|
||||
}
|
||||
|
||||
const size_t offset = display.size();
|
||||
display.resize(offset + rowCount);
|
||||
heat.resize(offset + rowCount);
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
display[offset + row] = strokedDisplay_[row];
|
||||
heat[offset + row] = strokedHeat_[row];
|
||||
}
|
||||
}
|
||||
|
||||
void SpectrogramAnalyzer::shapeFocusedColumn(std::vector<float>& display, std::vector<float>& heat) {
|
||||
const size_t rowCount = config_.rowCount;
|
||||
constexpr float standardWeight = 0.45f;
|
||||
constexpr float reassignedWeight = 1.0f;
|
||||
constexpr float sharpness = 5.0f;
|
||||
constexpr float lineWidth = 2.0f;
|
||||
constexpr float detailPreserve = 0.14f;
|
||||
constexpr float gamma = 2.0f;
|
||||
constexpr float displayShoulderWeight = 0.42f;
|
||||
constexpr float heatShoulderWeight = 0.32f;
|
||||
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
float focusedRaw = 0.0f;
|
||||
float focusedHeat = 0.0f;
|
||||
if (focusedPower_[row] > 0.0f) {
|
||||
const float focusedMag = std::sqrt(focusedPower_[row]);
|
||||
const float focusedDb = 20.0f * std::log10(std::max(focusedMag, 1.0e-10f));
|
||||
const float displayDb = applyDisplayTilt(focusedDb, rowCenterFrequencies_[row]);
|
||||
focusedRaw = displayDbToIntensity(displayDb);
|
||||
focusedHeat = normalizeHeatDb(displayDb);
|
||||
}
|
||||
|
||||
sourceRaw_[row] = std::max(standardRaw_[row] * standardWeight, focusedRaw * reassignedWeight);
|
||||
sourceHeat_[row] = std::max(standardHeat_[row] * standardWeight, focusedHeat * reassignedWeight);
|
||||
}
|
||||
|
||||
const std::vector<float> peakSource = sourceRaw_;
|
||||
const float mainlobePaddedBins = 4.0f * static_cast<float>(FFT_PAD_FACTOR);
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
const float bandWidthPerRow = std::max(0.1f, rowBandEndBins_[row] - rowBandStartBins_[row]);
|
||||
const float mainlobePixels = mainlobePaddedBins / bandWidthPerRow;
|
||||
const int halfWindow = std::max(
|
||||
2,
|
||||
std::min(50, static_cast<int>(std::lround(mainlobePixels * 0.5f)))
|
||||
);
|
||||
const float scaleFactor = std::max(1.0f, mainlobePixels / lineWidth);
|
||||
const float effectiveSharpness = sharpness * scaleFactor;
|
||||
|
||||
float localMax = peakSource[row];
|
||||
for (int offset = 1; offset <= halfWindow; offset += 1) {
|
||||
if (row >= static_cast<size_t>(offset)) {
|
||||
localMax = std::max(localMax, peakSource[row - static_cast<size_t>(offset)]);
|
||||
}
|
||||
if (row + static_cast<size_t>(offset) < rowCount) {
|
||||
localMax = std::max(localMax, peakSource[row + static_cast<size_t>(offset)]);
|
||||
}
|
||||
}
|
||||
|
||||
if (localMax > 1.0e-6f) {
|
||||
const float suppression = std::pow(clamp01(sourceRaw_[row] / localMax), effectiveSharpness);
|
||||
const float rawBefore = sourceRaw_[row];
|
||||
const float heatBefore = sourceHeat_[row];
|
||||
sourceRaw_[row] = std::max(rawBefore * suppression, rawBefore * detailPreserve);
|
||||
sourceHeat_[row] = std::max(heatBefore * suppression, heatBefore * detailPreserve);
|
||||
}
|
||||
}
|
||||
|
||||
const float effectiveGamma = gamma * config_.contrast;
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
shapedDisplay_[row] = std::pow(clamp01(sourceRaw_[row]), effectiveGamma);
|
||||
shapedHeat_[row] = std::pow(clamp01(sourceHeat_[row]), SPECTROGRAM_HEAT_GAMMA);
|
||||
strokedDisplay_[row] = shapedDisplay_[row];
|
||||
strokedHeat_[row] = shapedHeat_[row];
|
||||
}
|
||||
|
||||
for (size_t row = 0; row < rowCount; row += 1) {
|
||||
const float displayShoulder = shapedDisplay_[row] * displayShoulderWeight;
|
||||
const float heatShoulder = shapedHeat_[row] * heatShoulderWeight;
|
||||
const float displayShoulder = shapedDisplay_[row] * SPECTROGRAM_DISPLAY_STROKE_WEIGHT;
|
||||
const float heatShoulder = shapedHeat_[row] * SPECTROGRAM_HEAT_STROKE_WEIGHT;
|
||||
if (row > 0) {
|
||||
strokedDisplay_[row - 1] = std::max(strokedDisplay_[row - 1], displayShoulder);
|
||||
strokedHeat_[row - 1] = std::max(strokedHeat_[row - 1], heatShoulder);
|
||||
@@ -649,54 +458,35 @@ void SpectrogramAnalyzer::shapeFocusedColumn(std::vector<float>& display, std::v
|
||||
|
||||
void SpectrogramAnalyzer::processFrame(std::vector<float>& display, std::vector<float>& heat) {
|
||||
std::fill(windowedInput_.begin(), windowedInput_.end(), 0.0f);
|
||||
std::fill(rightWindowedInput_.begin(), rightWindowedInput_.end(), 0.0f);
|
||||
for (size_t index = 0; index < fftSize_; index += 1) {
|
||||
windowedInput_[index] = frameBuffer_[index] * window_[index];
|
||||
rightWindowedInput_[index] = rightFrameBuffer_[index] * window_[index];
|
||||
}
|
||||
|
||||
fft_->forward(windowedInput_.data(), fftOutput_.data());
|
||||
fft_->forward(rightWindowedInput_.data(), rightFftOutput_.data());
|
||||
|
||||
const size_t numBins = paddedSize_ / 2;
|
||||
const float scale = 2.0f / static_cast<float>(fftSize_);
|
||||
for (size_t bin = 0; bin < numBins; bin += 1) {
|
||||
const float leftRe = fftOutput_[bin].real();
|
||||
const float leftIm = fftOutput_[bin].imag();
|
||||
const float rightRe = rightFftOutput_[bin].real();
|
||||
const float rightIm = rightFftOutput_[bin].imag();
|
||||
const float leftMagnitude = std::sqrt((leftRe * leftRe) + (leftIm * leftIm)) * magnitudeScale_;
|
||||
const float rightMagnitude = std::sqrt((rightRe * rightRe) + (rightIm * rightIm)) * magnitudeScale_;
|
||||
const float stereoMagnitude = std::sqrt(
|
||||
0.5f * ((leftMagnitude * leftMagnitude) + (rightMagnitude * rightMagnitude))
|
||||
);
|
||||
magnitudesLinear_[bin] = stereoMagnitude;
|
||||
magnitudesDb_[bin] = 20.0f * std::log10(std::max(stereoMagnitude, 1.0e-10f));
|
||||
phases_[bin] = std::atan2(leftIm, leftRe);
|
||||
rightPhases_[bin] = std::atan2(rightIm, rightRe);
|
||||
dominantRight_[bin] = rightMagnitude > leftMagnitude ? 1 : 0;
|
||||
const float re = fftOutput_[bin].real();
|
||||
const float im = fftOutput_[bin].imag();
|
||||
const float magnitude = std::sqrt((re * re) + (im * im)) * scale;
|
||||
magnitudesLinear_[bin] = magnitude;
|
||||
magnitudesDb_[bin] = 20.0f * std::log10(std::max(magnitude, 1.0e-10f));
|
||||
phases_[bin] = std::atan2(im, re);
|
||||
}
|
||||
|
||||
computeStandardSpectrum();
|
||||
if (config_.clarityMode == "focused") {
|
||||
computeFocusedSpectrum();
|
||||
} else {
|
||||
computeReassignedSpectrum();
|
||||
}
|
||||
shapeColumn(display, heat);
|
||||
computeReassignedSpectrum();
|
||||
blendAndShapeColumn(display, heat);
|
||||
|
||||
lastPhases_ = phases_;
|
||||
rightLastPhases_ = rightPhases_;
|
||||
haveLastPhase_ = true;
|
||||
}
|
||||
|
||||
SpectrogramProcessResult SpectrogramAnalyzer::process(const float* samples, size_t length) {
|
||||
return processStereo(samples, samples, length);
|
||||
}
|
||||
|
||||
SpectrogramProcessResult SpectrogramAnalyzer::processStereo(const float* left, const float* right, size_t length) {
|
||||
SpectrogramProcessResult result;
|
||||
result.rowCount = config_.rowCount;
|
||||
if (!left || !right || length == 0 || fftSize_ == 0 || config_.rowCount == 0) {
|
||||
if (!samples || length == 0 || fftSize_ == 0 || config_.rowCount == 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -704,8 +494,7 @@ SpectrogramProcessResult SpectrogramAnalyzer::processStereo(const float* left, c
|
||||
const size_t overlapSamples = fftSize_ - hopSize;
|
||||
|
||||
for (size_t index = 0; index < length; index += 1) {
|
||||
frameBuffer_[frameFill_] = left[index];
|
||||
rightFrameBuffer_[frameFill_] = right[index];
|
||||
frameBuffer_[frameFill_] = samples[index];
|
||||
frameFill_ += 1;
|
||||
|
||||
if (frameFill_ >= fftSize_) {
|
||||
@@ -714,7 +503,6 @@ SpectrogramProcessResult SpectrogramAnalyzer::processStereo(const float* left, c
|
||||
|
||||
if (overlapSamples > 0) {
|
||||
std::memmove(frameBuffer_.data(), frameBuffer_.data() + hopSize, overlapSamples * sizeof(float));
|
||||
std::memmove(rightFrameBuffer_.data(), rightFrameBuffer_.data() + hopSize, overlapSamples * sizeof(float));
|
||||
}
|
||||
frameFill_ = overlapSamples;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "dsp_utils.h"
|
||||
#include <cstdint>
|
||||
#include <complex>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -38,15 +37,13 @@ public:
|
||||
|
||||
void configure(const SpectrogramConfig& config);
|
||||
SpectrogramProcessResult process(const float* samples, size_t length);
|
||||
SpectrogramProcessResult processStereo(const float* left, const float* right, size_t length);
|
||||
void reset();
|
||||
|
||||
private:
|
||||
struct ClarityProfile {
|
||||
float gamma;
|
||||
float displayShoulder;
|
||||
float heatShoulder;
|
||||
bool useReassignment;
|
||||
float sharpness;
|
||||
float lineWidth;
|
||||
};
|
||||
|
||||
SpectrogramConfig config_;
|
||||
@@ -54,23 +51,16 @@ private:
|
||||
size_t paddedSize_;
|
||||
size_t frameFill_;
|
||||
bool haveLastPhase_;
|
||||
float magnitudeScale_;
|
||||
|
||||
std::unique_ptr<DSP::FFT> fft_;
|
||||
std::vector<float> frameBuffer_;
|
||||
std::vector<float> rightFrameBuffer_;
|
||||
std::vector<float> window_;
|
||||
std::vector<float> windowedInput_;
|
||||
std::vector<float> rightWindowedInput_;
|
||||
std::vector<std::complex<float>> fftOutput_;
|
||||
std::vector<std::complex<float>> rightFftOutput_;
|
||||
std::vector<float> magnitudesDb_;
|
||||
std::vector<float> magnitudesLinear_;
|
||||
std::vector<float> phases_;
|
||||
std::vector<float> lastPhases_;
|
||||
std::vector<float> rightPhases_;
|
||||
std::vector<float> rightLastPhases_;
|
||||
std::vector<uint8_t> dominantRight_;
|
||||
|
||||
std::vector<float> rowCenterBins_;
|
||||
std::vector<float> rowBandStartBins_;
|
||||
@@ -79,9 +69,8 @@ private:
|
||||
std::vector<float> standardRaw_;
|
||||
std::vector<float> standardHeat_;
|
||||
std::vector<float> reassignedPower_;
|
||||
std::vector<float> focusedPower_;
|
||||
std::vector<float> sourceRaw_;
|
||||
std::vector<float> sourceHeat_;
|
||||
std::vector<float> blendedRaw_;
|
||||
std::vector<float> blendedHeat_;
|
||||
std::vector<float> shapedDisplay_;
|
||||
std::vector<float> shapedHeat_;
|
||||
std::vector<float> strokedDisplay_;
|
||||
@@ -92,12 +81,9 @@ private:
|
||||
void processFrame(std::vector<float>& display, std::vector<float>& heat);
|
||||
void computeStandardSpectrum();
|
||||
void computeReassignedSpectrum();
|
||||
void computeFocusedSpectrum();
|
||||
void shapeColumn(std::vector<float>& display, std::vector<float>& heat);
|
||||
void shapeFocusedColumn(std::vector<float>& display, std::vector<float>& heat);
|
||||
void blendAndShapeColumn(std::vector<float>& display, std::vector<float>& heat);
|
||||
size_t resolveHopSize() const;
|
||||
float sampleDbAtBin(float bin) const;
|
||||
float samplePeakDbInBand(float startBin, float endBin, float& peakBin) const;
|
||||
float frequencyFromScale(float normalizedPosition) const;
|
||||
float frequencyToRow(float frequency) const;
|
||||
float applyDisplayTilt(float db, float frequency) const;
|
||||
|
||||
@@ -15,16 +15,12 @@ Spectrum::Spectrum(size_t fftSize)
|
||||
historyBuffer_.resize(fftSize, 0.0f);
|
||||
sideHistoryBuffer_.resize(fftSize, 0.0f);
|
||||
windowedInput_.resize(fftSize);
|
||||
midSpectrum_.resize(fftSize);
|
||||
sideSpectrum_.resize(fftSize);
|
||||
magnitudes_.resize(fftSize / 2);
|
||||
rawMagnitudes_.resize(fftSize / 2, -100.0f);
|
||||
// Initialize to silence (-100.0f dB)
|
||||
smoothedMagnitudes_.resize(fftSize / 2, -100.0f);
|
||||
sideRawMagnitudes_.resize(fftSize / 2, -100.0f);
|
||||
sideSmoothedMagnitudes_.resize(fftSize / 2, -100.0f);
|
||||
leftSmoothedMagnitudes_.resize(fftSize / 2, -100.0f);
|
||||
rightSmoothedMagnitudes_.resize(fftSize / 2, -100.0f);
|
||||
channelMaxMagnitudes_.resize(fftSize / 2, -100.0f);
|
||||
}
|
||||
|
||||
void Spectrum::setFFTSize(size_t size) {
|
||||
@@ -34,16 +30,12 @@ void Spectrum::setFFTSize(size_t size) {
|
||||
historyBuffer_.assign(size, 0.0f);
|
||||
sideHistoryBuffer_.assign(size, 0.0f);
|
||||
windowedInput_.resize(size);
|
||||
midSpectrum_.resize(size);
|
||||
sideSpectrum_.resize(size);
|
||||
magnitudes_.resize(size / 2);
|
||||
rawMagnitudes_.assign(size / 2, -100.0f);
|
||||
// Initialize to silence (-100.0f dB)
|
||||
smoothedMagnitudes_.assign(size / 2, -100.0f);
|
||||
sideRawMagnitudes_.assign(size / 2, -100.0f);
|
||||
sideSmoothedMagnitudes_.assign(size / 2, -100.0f);
|
||||
leftSmoothedMagnitudes_.assign(size / 2, -100.0f);
|
||||
rightSmoothedMagnitudes_.assign(size / 2, -100.0f);
|
||||
channelMaxMagnitudes_.assign(size / 2, -100.0f);
|
||||
bufferedSamples_ = 0;
|
||||
}
|
||||
}
|
||||
@@ -102,52 +94,69 @@ void Spectrum::pushZeroHistory(std::vector<float>& history, size_t length) {
|
||||
std::fill(history.begin() + keep, history.end(), 0.0f);
|
||||
}
|
||||
|
||||
float Spectrum::magnitudeToDb(float magnitude, float correctionDb) const {
|
||||
const float db = 20.0f * log10f(std::max(magnitude, 1e-10f)) + correctionDb;
|
||||
return std::clamp(db, -120.0f, 12.0f);
|
||||
}
|
||||
|
||||
void Spectrum::updateSmoothedMagnitude(float db, float& smoothedMagnitude) {
|
||||
if (bufferedSamples_ < fftSize_) {
|
||||
smoothedMagnitude = db;
|
||||
} else {
|
||||
smoothedMagnitude = smoothing_ * smoothedMagnitude + (1.0f - smoothing_) * db;
|
||||
void Spectrum::updateMagnitudesForHistory(
|
||||
const std::vector<float>& history,
|
||||
std::vector<float>& rawMagnitudes,
|
||||
std::vector<float>& smoothedMagnitudes
|
||||
) {
|
||||
if (history.empty() || magnitudes_.empty()) {
|
||||
return;
|
||||
}
|
||||
if (!std::isfinite(smoothedMagnitude)) {
|
||||
smoothedMagnitude = -100.0f;
|
||||
|
||||
// Always analyze a full FFT frame from the rolling buffer.
|
||||
applyWindow(history.data(), windowedInput_.data(), fftSize_);
|
||||
|
||||
// Perform FFT
|
||||
fft_->forward(windowedInput_.data(), magnitudes_.data());
|
||||
|
||||
// Convert to dB and apply smoothing
|
||||
for (size_t i = 0; i < magnitudes_.size(); i++) {
|
||||
float mag = magnitudes_[i];
|
||||
|
||||
// Convert to dB
|
||||
// Add epsilon to avoid log(0)
|
||||
float db = 20.0f * log10f(std::max(mag, 1e-10f));
|
||||
|
||||
// Compensate Hann window coherent gain (about -6 dB).
|
||||
db += 6.0f;
|
||||
|
||||
// Clamp to a stable display range.
|
||||
db = std::clamp(db, -120.0f, 12.0f);
|
||||
rawMagnitudes[i] = db;
|
||||
|
||||
if (bufferedSamples_ < fftSize_) {
|
||||
smoothedMagnitudes[i] = db;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply temporal smoothing only (no bin-to-bin averaging).
|
||||
smoothedMagnitudes[i] = smoothing_ * smoothedMagnitudes[i] + (1.0f - smoothing_) * db;
|
||||
|
||||
// Safety check
|
||||
if (!std::isfinite(smoothedMagnitudes[i])) {
|
||||
smoothedMagnitudes[i] = -100.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Spectrum::updateMagnitudes() {
|
||||
if (historyBuffer_.empty() || rawMagnitudes_.empty()) {
|
||||
return;
|
||||
}
|
||||
updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_);
|
||||
updateMagnitudesForHistory(sideHistoryBuffer_, sideRawMagnitudes_, sideSmoothedMagnitudes_);
|
||||
}
|
||||
|
||||
applyWindow(historyBuffer_.data(), windowedInput_.data(), fftSize_);
|
||||
fft_->forward(windowedInput_.data(), midSpectrum_.data());
|
||||
applyWindow(sideHistoryBuffer_.data(), windowedInput_.data(), fftSize_);
|
||||
fft_->forward(windowedInput_.data(), sideSpectrum_.data());
|
||||
void Spectrum::updateSilentSideMagnitudes() {
|
||||
const float silentDb = -120.0f;
|
||||
for (size_t i = 0; i < sideSmoothedMagnitudes_.size(); i++) {
|
||||
sideRawMagnitudes_[i] = silentDb;
|
||||
if (bufferedSamples_ < fftSize_) {
|
||||
sideSmoothedMagnitudes_[i] = silentDb;
|
||||
continue;
|
||||
}
|
||||
|
||||
const float scale = 2.0f / static_cast<float>(fftSize_);
|
||||
const float coherentGain = fftSize_ > 1
|
||||
? static_cast<float>(fftSize_ - 1) / (2.0f * static_cast<float>(fftSize_))
|
||||
: 1.0f;
|
||||
const float correctionDb = -20.0f * log10f(coherentGain);
|
||||
for (size_t i = 0; i < rawMagnitudes_.size(); i++) {
|
||||
const std::complex<float> mid = midSpectrum_[i];
|
||||
const std::complex<float> side = sideSpectrum_[i];
|
||||
const float midDb = magnitudeToDb(std::abs(mid) * scale, correctionDb);
|
||||
const float sideDb = magnitudeToDb(std::abs(side) * scale, correctionDb);
|
||||
const float leftDb = magnitudeToDb(std::abs(mid + side) * scale, correctionDb);
|
||||
const float rightDb = magnitudeToDb(std::abs(mid - side) * scale, correctionDb);
|
||||
|
||||
rawMagnitudes_[i] = midDb;
|
||||
sideRawMagnitudes_[i] = sideDb;
|
||||
updateSmoothedMagnitude(midDb, smoothedMagnitudes_[i]);
|
||||
updateSmoothedMagnitude(sideDb, sideSmoothedMagnitudes_[i]);
|
||||
updateSmoothedMagnitude(leftDb, leftSmoothedMagnitudes_[i]);
|
||||
updateSmoothedMagnitude(rightDb, rightSmoothedMagnitudes_[i]);
|
||||
channelMaxMagnitudes_[i] = std::max(leftSmoothedMagnitudes_[i], rightSmoothedMagnitudes_[i]);
|
||||
sideSmoothedMagnitudes_[i] = smoothing_ * sideSmoothedMagnitudes_[i] + (1.0f - smoothing_) * silentDb;
|
||||
if (!std::isfinite(sideSmoothedMagnitudes_[i])) {
|
||||
sideSmoothedMagnitudes_[i] = -100.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +165,8 @@ void Spectrum::pushSamples(const float* input, size_t length) {
|
||||
pushHistory(historyBuffer_, input, length);
|
||||
pushZeroHistory(sideHistoryBuffer_, length);
|
||||
bufferedSamples_ = length >= fftSize_ ? fftSize_ : std::min(fftSize_, bufferedSamples_ + length);
|
||||
updateMagnitudes();
|
||||
updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_);
|
||||
updateSilentSideMagnitudes();
|
||||
return;
|
||||
}
|
||||
updateMagnitudes();
|
||||
@@ -205,9 +215,6 @@ void Spectrum::reset() {
|
||||
std::fill(smoothedMagnitudes_.begin(), smoothedMagnitudes_.end(), -100.0f);
|
||||
std::fill(sideRawMagnitudes_.begin(), sideRawMagnitudes_.end(), -100.0f);
|
||||
std::fill(sideSmoothedMagnitudes_.begin(), sideSmoothedMagnitudes_.end(), -100.0f);
|
||||
std::fill(leftSmoothedMagnitudes_.begin(), leftSmoothedMagnitudes_.end(), -100.0f);
|
||||
std::fill(rightSmoothedMagnitudes_.begin(), rightSmoothedMagnitudes_.end(), -100.0f);
|
||||
std::fill(channelMaxMagnitudes_.begin(), channelMaxMagnitudes_.end(), -100.0f);
|
||||
bufferedSamples_ = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ public:
|
||||
// Read the latest smoothed magnitudes without mutating analyzer state.
|
||||
const std::vector<float>& getMagnitudes() const { return smoothedMagnitudes_; }
|
||||
const std::vector<float>& getSideMagnitudes() const { return sideSmoothedMagnitudes_; }
|
||||
const std::vector<float>& getChannelMaxMagnitudes() const { return channelMaxMagnitudes_; }
|
||||
|
||||
// Process audio and get spectrum data
|
||||
// Returns magnitude data (size = fftSize / 2)
|
||||
@@ -47,22 +46,22 @@ private:
|
||||
std::vector<float> historyBuffer_;
|
||||
std::vector<float> sideHistoryBuffer_;
|
||||
std::vector<float> windowedInput_;
|
||||
std::vector<std::complex<float>> midSpectrum_;
|
||||
std::vector<std::complex<float>> sideSpectrum_;
|
||||
std::vector<float> magnitudes_;
|
||||
std::vector<float> rawMagnitudes_;
|
||||
std::vector<float> smoothedMagnitudes_;
|
||||
std::vector<float> sideRawMagnitudes_;
|
||||
std::vector<float> sideSmoothedMagnitudes_;
|
||||
std::vector<float> leftSmoothedMagnitudes_;
|
||||
std::vector<float> rightSmoothedMagnitudes_;
|
||||
std::vector<float> channelMaxMagnitudes_;
|
||||
size_t bufferedSamples_;
|
||||
|
||||
void applyWindow(const float* input, float* output, size_t length);
|
||||
void pushHistory(std::vector<float>& history, const float* input, size_t length);
|
||||
void pushZeroHistory(std::vector<float>& history, size_t length);
|
||||
float magnitudeToDb(float magnitude, float correctionDb) const;
|
||||
void updateSmoothedMagnitude(float db, float& smoothedMagnitude);
|
||||
void updateMagnitudesForHistory(
|
||||
const std::vector<float>& history,
|
||||
std::vector<float>& rawMagnitudes,
|
||||
std::vector<float>& smoothedMagnitudes
|
||||
);
|
||||
void updateSilentSideMagnitudes();
|
||||
void updateMagnitudes();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Prism::Capture {
|
||||
|
||||
struct Support {
|
||||
bool available = false;
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
struct OutputDevice {
|
||||
std::string id;
|
||||
std::string label;
|
||||
double sampleRate = 48000.0;
|
||||
uint32_t channelCount = 2;
|
||||
bool isDefault = false;
|
||||
};
|
||||
|
||||
struct StartResult {
|
||||
double sampleRate = 48000.0;
|
||||
uint32_t channelCount = 2;
|
||||
std::string deviceId;
|
||||
std::string deviceLabel;
|
||||
};
|
||||
|
||||
struct AudioChunk {
|
||||
std::vector<float> left;
|
||||
std::vector<float> right;
|
||||
uint32_t channelCount = 2;
|
||||
double capturedAtMilliseconds = 0.0;
|
||||
uint64_t sequence = 0;
|
||||
};
|
||||
|
||||
struct DrainResult {
|
||||
std::vector<AudioChunk> chunks;
|
||||
uint64_t overwriteCount = 0;
|
||||
size_t queueDepth = 0;
|
||||
};
|
||||
|
||||
class SystemAudioCapture {
|
||||
public:
|
||||
virtual ~SystemAudioCapture() = default;
|
||||
|
||||
virtual Support getSupport() const = 0;
|
||||
virtual std::vector<OutputDevice> listOutputDevices() = 0;
|
||||
virtual bool start(const std::string& requestedDeviceId,
|
||||
StartResult* result,
|
||||
std::string* errorMessage) = 0;
|
||||
virtual void stop() = 0;
|
||||
virtual DrainResult drain(size_t maxChunks = 64) = 0;
|
||||
virtual double nowMilliseconds() const = 0;
|
||||
virtual const char* backendName() const = 0;
|
||||
};
|
||||
|
||||
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture();
|
||||
|
||||
} // namespace Prism::Capture
|
||||
@@ -1,206 +0,0 @@
|
||||
#include "system_audio_capture_napi.h"
|
||||
|
||||
#include "system_audio_capture.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr size_t kDefaultDrainChunkLimit = 64;
|
||||
constexpr size_t kMaxDrainChunkLimit = 256;
|
||||
|
||||
std::unique_ptr<Prism::Capture::SystemAudioCapture>& activeCapture() {
|
||||
static std::unique_ptr<Prism::Capture::SystemAudioCapture> capture =
|
||||
Prism::Capture::createSystemAudioCapture();
|
||||
return capture;
|
||||
}
|
||||
|
||||
const char* activeExportName() {
|
||||
#if defined(__APPLE__)
|
||||
return "macosCapture";
|
||||
#elif defined(_WIN32)
|
||||
return "windowsCapture";
|
||||
#else
|
||||
return "linuxCapture";
|
||||
#endif
|
||||
}
|
||||
|
||||
Napi::Object supportToNapi(Napi::Env env, const Prism::Capture::Support& support) {
|
||||
Napi::Object result = Napi::Object::New(env);
|
||||
result.Set("available", Napi::Boolean::New(env, support.available));
|
||||
if (support.available || support.reason.empty()) {
|
||||
result.Set("reason", env.Null());
|
||||
} else {
|
||||
result.Set("reason", Napi::String::New(env, support.reason));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Object drainToNapi(Napi::Env env, Prism::Capture::DrainResult&& drained) {
|
||||
Napi::Array chunks = Napi::Array::New(env, drained.chunks.size());
|
||||
for (size_t index = 0; index < drained.chunks.size(); ++index) {
|
||||
auto& chunk = drained.chunks[index];
|
||||
Napi::Object entry = Napi::Object::New(env);
|
||||
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
|
||||
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
|
||||
if (!chunk.left.empty()) {
|
||||
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
|
||||
}
|
||||
if (!chunk.right.empty()) {
|
||||
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
|
||||
}
|
||||
entry.Set("left", left);
|
||||
entry.Set("right", right);
|
||||
entry.Set("channelCount", Napi::Number::New(env, chunk.channelCount));
|
||||
entry.Set("capturedAtMilliseconds", Napi::Number::New(env, chunk.capturedAtMilliseconds));
|
||||
entry.Set("sequence", Napi::Number::New(env, static_cast<double>(chunk.sequence)));
|
||||
chunks.Set(static_cast<uint32_t>(index), entry);
|
||||
}
|
||||
|
||||
Napi::Object result = Napi::Object::New(env);
|
||||
result.Set("chunks", chunks);
|
||||
result.Set("overwriteCount", Napi::Number::New(env, static_cast<double>(drained.overwriteCount)));
|
||||
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(drained.queueDepth)));
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
|
||||
return supportToNapi(info.Env(), activeCapture()->getSupport());
|
||||
}
|
||||
|
||||
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
const auto devices = activeCapture()->listOutputDevices();
|
||||
Napi::Array result = Napi::Array::New(env, devices.size());
|
||||
for (size_t index = 0; index < devices.size(); ++index) {
|
||||
const auto& device = devices[index];
|
||||
Napi::Object entry = Napi::Object::New(env);
|
||||
entry.Set("id", Napi::String::New(env, device.id));
|
||||
entry.Set("label", Napi::String::New(env, device.label));
|
||||
entry.Set("kind", Napi::String::New(env, "system"));
|
||||
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
|
||||
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
|
||||
entry.Set("channelCount", Napi::Number::New(env, device.channelCount));
|
||||
result.Set(static_cast<uint32_t>(index), entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value Start(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
std::string requestedDeviceId;
|
||||
if (info.Length() >= 1 && info[0].IsString()) {
|
||||
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
|
||||
}
|
||||
|
||||
Prism::Capture::StartResult started;
|
||||
std::string errorMessage;
|
||||
if (!activeCapture()->start(requestedDeviceId, &started, &errorMessage)) {
|
||||
Napi::Error::New(env, errorMessage.empty() ? "System audio capture failed to start." : errorMessage)
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Object result = Napi::Object::New(env);
|
||||
result.Set("sampleRate", Napi::Number::New(env, started.sampleRate));
|
||||
result.Set("channelCount", Napi::Number::New(env, started.channelCount));
|
||||
result.Set("deviceId", Napi::String::New(env, started.deviceId));
|
||||
result.Set("deviceLabel", Napi::String::New(env, started.deviceLabel));
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value Stop(const Napi::CallbackInfo& info) {
|
||||
activeCapture()->stop();
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value Drain(const Napi::CallbackInfo& info) {
|
||||
size_t maxChunks = kDefaultDrainChunkLimit;
|
||||
if (info.Length() >= 1 && info[0].IsNumber()) {
|
||||
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
|
||||
if (requested > 0) {
|
||||
maxChunks = std::min(static_cast<size_t>(requested), kMaxDrainChunkLimit);
|
||||
}
|
||||
}
|
||||
return drainToNapi(info.Env(), activeCapture()->drain(maxChunks));
|
||||
}
|
||||
|
||||
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
|
||||
return Napi::Number::New(info.Env(), activeCapture()->nowMilliseconds());
|
||||
}
|
||||
|
||||
void RegisterCaptureObject(Napi::Env env, Napi::Object exports, const char* exportName) {
|
||||
Napi::Object capture = Napi::Object::New(env);
|
||||
capture.Set("getSupport", Napi::Function::New(env, GetSupport));
|
||||
capture.Set("listOutputDevices", Napi::Function::New(env, ListOutputDevices));
|
||||
capture.Set("start", Napi::Function::New(env, Start));
|
||||
capture.Set("stop", Napi::Function::New(env, Stop));
|
||||
capture.Set("drain", Napi::Function::New(env, Drain));
|
||||
capture.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
|
||||
exports.Set(exportName, capture);
|
||||
}
|
||||
|
||||
Napi::Value UnavailableGetSupport(const Napi::CallbackInfo& info) {
|
||||
Napi::Object support = Napi::Object::New(info.Env());
|
||||
support.Set("available", Napi::Boolean::New(info.Env(), false));
|
||||
support.Set(
|
||||
"reason",
|
||||
Napi::String::New(info.Env(), "This system-audio capture backend is unavailable on the current platform."));
|
||||
return support;
|
||||
}
|
||||
|
||||
Napi::Value UnavailableListOutputDevices(const Napi::CallbackInfo& info) {
|
||||
return Napi::Array::New(info.Env());
|
||||
}
|
||||
|
||||
Napi::Value UnavailableStart(const Napi::CallbackInfo& info) {
|
||||
Napi::Error::New(
|
||||
info.Env(), "This system-audio capture backend is unavailable on the current platform.")
|
||||
.ThrowAsJavaScriptException();
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value UnavailableStop(const Napi::CallbackInfo& info) {
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value UnavailableDrain(const Napi::CallbackInfo& info) {
|
||||
Napi::Object result = Napi::Object::New(info.Env());
|
||||
result.Set("chunks", Napi::Array::New(info.Env()));
|
||||
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
|
||||
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value UnavailableNowMilliseconds(const Napi::CallbackInfo& info) {
|
||||
return Napi::Number::New(info.Env(), 0);
|
||||
}
|
||||
|
||||
void RegisterUnavailableCaptureObject(Napi::Env env, Napi::Object exports, const char* exportName) {
|
||||
Napi::Object capture = Napi::Object::New(env);
|
||||
capture.Set("getSupport", Napi::Function::New(env, UnavailableGetSupport));
|
||||
capture.Set("listOutputDevices", Napi::Function::New(env, UnavailableListOutputDevices));
|
||||
capture.Set("start", Napi::Function::New(env, UnavailableStart));
|
||||
capture.Set("stop", Napi::Function::New(env, UnavailableStop));
|
||||
capture.Set("drain", Napi::Function::New(env, UnavailableDrain));
|
||||
capture.Set("nowMilliseconds", Napi::Function::New(env, UnavailableNowMilliseconds));
|
||||
exports.Set(exportName, capture);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RegisterSystemAudioCapture(Napi::Env env, Napi::Object exports) {
|
||||
RegisterCaptureObject(env, exports, activeExportName());
|
||||
#if !defined(__APPLE__)
|
||||
RegisterUnavailableCaptureObject(env, exports, "macosCapture");
|
||||
#endif
|
||||
#if !defined(_WIN32)
|
||||
RegisterUnavailableCaptureObject(env, exports, "windowsCapture");
|
||||
#endif
|
||||
#if defined(__APPLE__) || defined(_WIN32)
|
||||
RegisterUnavailableCaptureObject(env, exports, "linuxCapture");
|
||||
#endif
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <napi.h>
|
||||
|
||||
void RegisterSystemAudioCapture(Napi::Env env, Napi::Object exports);
|
||||
@@ -22,11 +22,23 @@ Vectorscope::Vectorscope()
|
||||
highRightBuffer_.resize(VECTORSCOPE_BUFFER_SIZE, 0.0f);
|
||||
points_.reserve(1024);
|
||||
|
||||
// Cascaded lowpass at 8kHz, Butterworth (Q=0.707)
|
||||
// Two stages per channel = 4th order = 24 dB/oct rolloff
|
||||
// Removes HF noise that causes erratic Lissajous motion
|
||||
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
multibandSplitter_.configure(sampleRate_);
|
||||
}
|
||||
|
||||
void Vectorscope::setSampleRate(float sampleRate) {
|
||||
sampleRate_ = sampleRate;
|
||||
// Redesign all filters with new sample rate
|
||||
leftLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
leftLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
rightLowpass1_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
rightLowpass2_.setLowpass(8000.0f, sampleRate_, 0.707f);
|
||||
multibandSplitter_.configure(sampleRate_);
|
||||
}
|
||||
|
||||
@@ -41,8 +53,15 @@ void Vectorscope::pushSamples(
|
||||
size_t length
|
||||
) {
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
leftBuffer_[writePos_] = leftChannel[i];
|
||||
rightBuffer_[writePos_] = rightChannel[i];
|
||||
// Apply cascaded lowpass filtering
|
||||
float filteredL = leftLowpass1_.process(leftChannel[i]);
|
||||
filteredL = leftLowpass2_.process(filteredL);
|
||||
|
||||
float filteredR = rightLowpass1_.process(rightChannel[i]);
|
||||
filteredR = rightLowpass2_.process(filteredR);
|
||||
|
||||
leftBuffer_[writePos_] = filteredL;
|
||||
rightBuffer_[writePos_] = filteredR;
|
||||
|
||||
writePos_ = (writePos_ + 1) % VECTORSCOPE_BUFFER_SIZE;
|
||||
if (validSamples_ < VECTORSCOPE_BUFFER_SIZE) {
|
||||
@@ -109,7 +128,7 @@ const std::vector<VectorscopePoint>& Vectorscope::process(
|
||||
const float* rightChannel,
|
||||
size_t length
|
||||
) {
|
||||
// Push through the full-band point pipeline
|
||||
// Push through the filtering pipeline
|
||||
pushSamples(leftChannel, rightChannel, length);
|
||||
|
||||
// Build legacy output from buffer
|
||||
@@ -138,6 +157,10 @@ void Vectorscope::reset() {
|
||||
std::fill(midRightBuffer_.begin(), midRightBuffer_.end(), 0.0f);
|
||||
std::fill(highLeftBuffer_.begin(), highLeftBuffer_.end(), 0.0f);
|
||||
std::fill(highRightBuffer_.begin(), highRightBuffer_.end(), 0.0f);
|
||||
leftLowpass1_.reset();
|
||||
leftLowpass2_.reset();
|
||||
rightLowpass1_.reset();
|
||||
rightLowpass2_.reset();
|
||||
multibandSplitter_.reset();
|
||||
points_.clear();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "dsp_utils.h"
|
||||
#include "multiband.h"
|
||||
#include <vector>
|
||||
#include <cstddef>
|
||||
@@ -51,7 +52,7 @@ private:
|
||||
size_t writePos_;
|
||||
size_t validSamples_;
|
||||
|
||||
// Circular buffers for full-band L/R
|
||||
// Circular buffers for filtered L/R
|
||||
std::vector<float> leftBuffer_;
|
||||
std::vector<float> rightBuffer_;
|
||||
std::vector<float> lowLeftBuffer_;
|
||||
@@ -61,6 +62,11 @@ private:
|
||||
std::vector<float> highLeftBuffer_;
|
||||
std::vector<float> highRightBuffer_;
|
||||
|
||||
// Cascaded lowpass filters (4th order Butterworth at 8kHz per channel)
|
||||
DSP::BiquadFilter leftLowpass1_;
|
||||
DSP::BiquadFilter leftLowpass2_;
|
||||
DSP::BiquadFilter rightLowpass1_;
|
||||
DSP::BiquadFilter rightLowpass2_;
|
||||
MultibandSplitter multibandSplitter_;
|
||||
size_t multibandWritePos_;
|
||||
size_t multibandValidSamples_;
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
#include "window_chrome.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <windows.h>
|
||||
#include <dwmapi.h>
|
||||
|
||||
// These DWM attributes/values exist on Windows 11 (build 22000+) but may be
|
||||
// missing from older SDK headers, so provide fallback definitions.
|
||||
#ifndef DWMWA_NCRENDERING_POLICY
|
||||
#define DWMWA_NCRENDERING_POLICY 2
|
||||
#endif
|
||||
#ifndef DWMNCRP_DISABLED
|
||||
#define DWMNCRP_DISABLED 1
|
||||
#endif
|
||||
#ifndef DWMWA_WINDOW_CORNER_PREFERENCE
|
||||
#define DWMWA_WINDOW_CORNER_PREFERENCE 33
|
||||
#endif
|
||||
#ifndef DWMWA_BORDER_COLOR
|
||||
#define DWMWA_BORDER_COLOR 34
|
||||
#endif
|
||||
#ifndef DWMWCP_DONOTROUND
|
||||
#define DWMWCP_DONOTROUND 1
|
||||
#endif
|
||||
#ifndef DWMWA_COLOR_NONE
|
||||
#define DWMWA_COLOR_NONE 0xFFFFFFFE
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
// Undocumented but long-stable user32 API used for accent-policy acrylic.
|
||||
// Unlike DWMWA_SYSTEMBACKDROP_TYPE acrylic, this blur stays active while the
|
||||
// window is unfocused and renders on borderless transparent windows, which is
|
||||
// exactly what an always-visible visualizer overlay needs.
|
||||
struct AccentPolicy {
|
||||
int32_t AccentState;
|
||||
int32_t AccentFlags;
|
||||
uint32_t GradientColor; // AABBGGRR
|
||||
int32_t AnimationId;
|
||||
};
|
||||
|
||||
struct WindowCompositionAttribData {
|
||||
int32_t Attrib;
|
||||
void* pvData;
|
||||
SIZE_T cbData;
|
||||
};
|
||||
|
||||
using SetWindowCompositionAttributeFn = BOOL(WINAPI*)(HWND, WindowCompositionAttribData*);
|
||||
|
||||
constexpr int32_t kAccentDisabled = 0;
|
||||
constexpr int32_t kAccentEnableAcrylicBlurBehind = 4;
|
||||
constexpr int32_t kWcaAccentPolicy = 19;
|
||||
// A barely-visible tint keeps some Windows builds from optimizing the blur
|
||||
// away entirely when the requested tint alpha is zero.
|
||||
constexpr uint32_t kNearTransparentTint = 0x01000000u;
|
||||
|
||||
HWND ReadWindowHandle(const Napi::CallbackInfo& info, Napi::Env env) {
|
||||
if (info.Length() < 1 || !info[0].IsBuffer()) {
|
||||
Napi::TypeError::New(env, "Expected native window handle Buffer")
|
||||
.ThrowAsJavaScriptException();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Napi::Buffer<uint8_t> handle = info[0].As<Napi::Buffer<uint8_t>>();
|
||||
if (handle.Length() < sizeof(HWND)) {
|
||||
Napi::TypeError::New(env, "Window handle Buffer is too small")
|
||||
.ThrowAsJavaScriptException();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
HWND hwnd = *reinterpret_cast<HWND*>(handle.Data());
|
||||
if (hwnd == nullptr || !IsWindow(hwnd)) {
|
||||
return nullptr;
|
||||
}
|
||||
return hwnd;
|
||||
}
|
||||
|
||||
Napi::Value SetAcrylicBlurBehind(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
HWND hwnd = ReadWindowHandle(info, env);
|
||||
if (env.IsExceptionPending() || hwnd == nullptr) {
|
||||
return env.IsExceptionPending() ? env.Undefined() : Napi::Boolean::New(env, false);
|
||||
}
|
||||
|
||||
const bool enable = info.Length() > 1 && info[1].IsBoolean()
|
||||
? info[1].As<Napi::Boolean>().Value()
|
||||
: true;
|
||||
|
||||
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||
if (user32 == nullptr) {
|
||||
return Napi::Boolean::New(env, false);
|
||||
}
|
||||
|
||||
auto setWindowCompositionAttribute = reinterpret_cast<SetWindowCompositionAttributeFn>(
|
||||
GetProcAddress(user32, "SetWindowCompositionAttribute"));
|
||||
if (setWindowCompositionAttribute == nullptr) {
|
||||
return Napi::Boolean::New(env, false);
|
||||
}
|
||||
|
||||
AccentPolicy policy = {};
|
||||
policy.AccentState = enable ? kAccentEnableAcrylicBlurBehind : kAccentDisabled;
|
||||
policy.AccentFlags = 0;
|
||||
policy.GradientColor = enable ? kNearTransparentTint : 0u;
|
||||
policy.AnimationId = 0;
|
||||
|
||||
WindowCompositionAttribData data = {};
|
||||
data.Attrib = kWcaAccentPolicy;
|
||||
data.pvData = &policy;
|
||||
data.cbData = sizeof(policy);
|
||||
|
||||
return Napi::Boolean::New(env, setWindowCompositionAttribute(hwnd, &data) != FALSE);
|
||||
}
|
||||
|
||||
Napi::Value ApplyFlatFrame(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 1 || !info[0].IsBuffer()) {
|
||||
Napi::TypeError::New(env, "Expected native window handle Buffer")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
Napi::Buffer<uint8_t> handle = info[0].As<Napi::Buffer<uint8_t>>();
|
||||
if (handle.Length() < sizeof(HWND)) {
|
||||
Napi::TypeError::New(env, "Window handle Buffer is too small")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Undefined();
|
||||
}
|
||||
|
||||
HWND hwnd = *reinterpret_cast<HWND*>(handle.Data());
|
||||
if (hwnd == nullptr || !IsWindow(hwnd)) {
|
||||
return Napi::Boolean::New(env, false);
|
||||
}
|
||||
|
||||
// The window keeps WS_THICKFRAME so native Aero Snap and edge resize work, but
|
||||
// on Windows 11 that style makes DWM paint a visible border and a drop shadow
|
||||
// ("depth"). Disabling DWM non-client rendering removes that shadow/border,
|
||||
// and squaring the corners + clearing the accent border color restores the old
|
||||
// flat, borderless look. All four calls are no-ops/benign on Windows 10 and
|
||||
// fail harmlessly on unsupported builds.
|
||||
DWORD ncRendering = DWMNCRP_DISABLED;
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_NCRENDERING_POLICY, &ncRendering, sizeof(ncRendering));
|
||||
|
||||
DWORD cornerPreference = DWMWCP_DONOTROUND;
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &cornerPreference, sizeof(cornerPreference));
|
||||
|
||||
COLORREF borderColor = DWMWA_COLOR_NONE;
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, &borderColor, sizeof(borderColor));
|
||||
|
||||
MARGINS margins = {0, 0, 0, 0};
|
||||
DwmExtendFrameIntoClientArea(hwnd, &margins);
|
||||
|
||||
return Napi::Boolean::New(env, true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RegisterWindowChrome(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object chrome = Napi::Object::New(env);
|
||||
chrome.Set("applyFlatFrame", Napi::Function::New(env, ApplyFlatFrame));
|
||||
chrome.Set("setAcrylicBlurBehind", Napi::Function::New(env, SetAcrylicBlurBehind));
|
||||
exports.Set("windowChrome", chrome);
|
||||
}
|
||||
|
||||
#else // !_WIN32
|
||||
|
||||
namespace {
|
||||
|
||||
Napi::Value ApplyFlatFrameNoop(const Napi::CallbackInfo& info) {
|
||||
return Napi::Boolean::New(info.Env(), false);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RegisterWindowChrome(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object chrome = Napi::Object::New(env);
|
||||
chrome.Set("applyFlatFrame", Napi::Function::New(env, ApplyFlatFrameNoop));
|
||||
chrome.Set("setAcrylicBlurBehind", Napi::Function::New(env, ApplyFlatFrameNoop));
|
||||
exports.Set("windowChrome", chrome);
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -1,9 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <napi.h>
|
||||
|
||||
// Registers the `windowChrome` export. On Windows it exposes `applyFlatFrame`,
|
||||
// which strips the visible native frame/shadow from a frameless WS_THICKFRAME
|
||||
// window while preserving native Aero Snap and edge resize. On other platforms
|
||||
// it registers a no-op so the JS surface stays uniform.
|
||||
void RegisterWindowChrome(Napi::Env env, Napi::Object exports);
|
||||
@@ -1,7 +1,4 @@
|
||||
#ifndef PRISM_CAPTURE_CORE_ONLY
|
||||
#include "windows_capture.h"
|
||||
#endif
|
||||
#include "system_audio_capture.h"
|
||||
|
||||
#if defined(_WIN32)
|
||||
|
||||
@@ -13,16 +10,14 @@
|
||||
#include <mmreg.h>
|
||||
#include <propidl.h>
|
||||
#include <avrt.h>
|
||||
#include <roapi.h>
|
||||
#include <wrl/client.h>
|
||||
#include <windows.h>
|
||||
#ifndef PRISM_CAPTURE_CORE_ONLY
|
||||
#include <roapi.h>
|
||||
#include <winrt/base.h>
|
||||
#include <winrt/Windows.Foundation.h>
|
||||
#include <winrt/Windows.Foundation.Collections.h>
|
||||
#include <winrt/Windows.Media.Control.h>
|
||||
#include <winrt/Windows.Storage.Streams.h>
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
@@ -44,11 +39,9 @@
|
||||
namespace {
|
||||
|
||||
using Microsoft::WRL::ComPtr;
|
||||
#ifndef PRISM_CAPTURE_CORE_ONLY
|
||||
using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSession;
|
||||
using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager;
|
||||
using winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus;
|
||||
#endif
|
||||
|
||||
constexpr size_t kMaxQueuedChunks = 256;
|
||||
constexpr size_t kDefaultDrainChunkLimit = 64;
|
||||
@@ -138,7 +131,6 @@ std::string hresultMessage(const char* operation, HRESULT hr) {
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
#ifndef PRISM_CAPTURE_CORE_ONLY
|
||||
std::string winrtErrorMessage(const char* operation, const winrt::hresult_error& error) {
|
||||
std::string message = hresultMessage(operation, error.code().value);
|
||||
const std::wstring detailWide = error.message().c_str();
|
||||
@@ -173,7 +165,6 @@ std::string playbackStatusToString(GlobalSystemMediaTransportControlsSessionPlay
|
||||
return "Closed";
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
class ScopedCoInit {
|
||||
public:
|
||||
@@ -200,7 +191,6 @@ private:
|
||||
bool usable_;
|
||||
};
|
||||
|
||||
#ifndef PRISM_CAPTURE_CORE_ONLY
|
||||
class ScopedRoInit {
|
||||
public:
|
||||
ScopedRoInit()
|
||||
@@ -354,7 +344,6 @@ std::string getOrFetchThumbnail(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
std::string getDeviceId(IMMDevice* device) {
|
||||
if (device == nullptr) {
|
||||
@@ -595,90 +584,114 @@ std::vector<OutputDeviceInfo> enumerateOutputDevices() {
|
||||
return devices;
|
||||
}
|
||||
|
||||
class WindowsNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
|
||||
class WindowsNativeCaptureEngine {
|
||||
public:
|
||||
~WindowsNativeCaptureEngine() override {
|
||||
stop();
|
||||
Napi::Object GetSupport(Napi::Env env) {
|
||||
Napi::Object support = Napi::Object::New(env);
|
||||
support.Set("available", Napi::Boolean::New(env, true));
|
||||
support.Set("reason", env.Null());
|
||||
return support;
|
||||
}
|
||||
|
||||
Prism::Capture::Support getSupport() const override {
|
||||
return {true, {}};
|
||||
}
|
||||
|
||||
std::vector<Prism::Capture::OutputDevice> listOutputDevices() override {
|
||||
Napi::Array ListOutputDevices(Napi::Env env) {
|
||||
const auto devices = enumerateOutputDevices();
|
||||
std::vector<Prism::Capture::OutputDevice> result;
|
||||
result.reserve(devices.size());
|
||||
for (const auto& device : devices) {
|
||||
result.push_back({
|
||||
device.id,
|
||||
device.label,
|
||||
device.sampleRate,
|
||||
static_cast<uint32_t>(device.channelCount),
|
||||
device.isDefault,
|
||||
});
|
||||
Napi::Array result = Napi::Array::New(env, devices.size());
|
||||
|
||||
for (size_t index = 0; index < devices.size(); ++index) {
|
||||
const auto& device = devices[index];
|
||||
Napi::Object entry = Napi::Object::New(env);
|
||||
entry.Set("id", Napi::String::New(env, device.id));
|
||||
entry.Set("label", Napi::String::New(env, device.label));
|
||||
entry.Set("kind", Napi::String::New(env, "system"));
|
||||
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
|
||||
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
|
||||
entry.Set(
|
||||
"channelCount",
|
||||
Napi::Number::New(env, static_cast<double>(device.channelCount)));
|
||||
result.Set(static_cast<uint32_t>(index), entry);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool start(const std::string& requestedDeviceId,
|
||||
Prism::Capture::StartResult* result,
|
||||
std::string* errorMessage) override {
|
||||
if (!startInternal(requestedDeviceId, errorMessage)) {
|
||||
return false;
|
||||
Napi::Object Start(Napi::Env env, const std::string& requestedDeviceId) {
|
||||
std::string errorMessage;
|
||||
if (!startInternal(requestedDeviceId, &errorMessage)) {
|
||||
Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
|
||||
return Napi::Object::New(env);
|
||||
}
|
||||
if (result != nullptr) {
|
||||
std::lock_guard<std::mutex> lock(stateMutex_);
|
||||
result->sampleRate = sampleRate_;
|
||||
result->channelCount = static_cast<uint32_t>(channelCount_);
|
||||
result->deviceId = activeDeviceId_;
|
||||
result->deviceLabel = activeDeviceLabel_;
|
||||
}
|
||||
return true;
|
||||
|
||||
std::lock_guard<std::mutex> lock(stateMutex_);
|
||||
Napi::Object result = Napi::Object::New(env);
|
||||
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
|
||||
result.Set(
|
||||
"channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
|
||||
result.Set("deviceId", Napi::String::New(env, activeDeviceId_));
|
||||
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
|
||||
return result;
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
void Stop() {
|
||||
stopInternal();
|
||||
}
|
||||
|
||||
Prism::Capture::DrainResult drain(size_t maxChunks) override {
|
||||
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
|
||||
const size_t drainLimit =
|
||||
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
|
||||
|
||||
std::deque<CapturedChunk> drained;
|
||||
Prism::Capture::DrainResult result;
|
||||
uint64_t overwriteCount = 0;
|
||||
size_t queueDepth = 0;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(chunkMutex_);
|
||||
result.overwriteCount = overwriteCount_;
|
||||
overwriteCount = overwriteCount_;
|
||||
const size_t count = std::min(drainLimit, chunkQueue_.size());
|
||||
for (size_t index = 0; index < count; ++index) {
|
||||
drained.push_back(std::move(chunkQueue_.front()));
|
||||
chunkQueue_.pop_front();
|
||||
}
|
||||
result.queueDepth = chunkQueue_.size();
|
||||
queueDepth = chunkQueue_.size();
|
||||
}
|
||||
result.chunks.reserve(drained.size());
|
||||
while (!drained.empty()) {
|
||||
auto chunk = std::move(drained.front());
|
||||
drained.pop_front();
|
||||
result.chunks.push_back({
|
||||
std::move(chunk.left),
|
||||
std::move(chunk.right),
|
||||
static_cast<uint32_t>(chunk.channelCount),
|
||||
chunk.capturedAtMilliseconds,
|
||||
chunk.sequence,
|
||||
});
|
||||
|
||||
Napi::Array chunks = Napi::Array::New(env, drained.size());
|
||||
for (size_t index = 0; index < drained.size(); ++index) {
|
||||
auto& chunk = drained[index];
|
||||
Napi::Object entry = Napi::Object::New(env);
|
||||
Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size());
|
||||
Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size());
|
||||
if (!chunk.left.empty()) {
|
||||
std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
|
||||
}
|
||||
if (!chunk.right.empty()) {
|
||||
std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
|
||||
}
|
||||
entry.Set("left", left);
|
||||
entry.Set("right", right);
|
||||
entry.Set(
|
||||
"channelCount",
|
||||
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
|
||||
entry.Set(
|
||||
"capturedAtMilliseconds",
|
||||
Napi::Number::New(env, chunk.capturedAtMilliseconds));
|
||||
entry.Set(
|
||||
"sequence",
|
||||
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
|
||||
chunks.Set(static_cast<uint32_t>(index), entry);
|
||||
}
|
||||
|
||||
Napi::Object result = Napi::Object::New(env);
|
||||
result.Set("chunks", chunks);
|
||||
result.Set(
|
||||
"overwriteCount", Napi::Number::New(env, static_cast<double>(overwriteCount)));
|
||||
result.Set("queueDepth", Napi::Number::New(env, static_cast<double>(queueDepth)));
|
||||
return result;
|
||||
}
|
||||
|
||||
double nowMilliseconds() const override {
|
||||
double NowMilliseconds() const {
|
||||
return monotonicMilliseconds();
|
||||
}
|
||||
|
||||
const char* backendName() const override {
|
||||
return "WASAPI";
|
||||
}
|
||||
|
||||
private:
|
||||
bool startInternal(const std::string& requestedDeviceId, std::string* outErrorMessage) {
|
||||
stopInternal();
|
||||
@@ -1050,7 +1063,47 @@ private:
|
||||
UINT32 channelCount_ = 2;
|
||||
};
|
||||
|
||||
#ifndef PRISM_CAPTURE_CORE_ONLY
|
||||
WindowsNativeCaptureEngine& engine() {
|
||||
static WindowsNativeCaptureEngine instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
Napi::Value WindowsGetSupport(const Napi::CallbackInfo& info) {
|
||||
return engine().GetSupport(info.Env());
|
||||
}
|
||||
|
||||
Napi::Value WindowsListOutputDevices(const Napi::CallbackInfo& info) {
|
||||
return engine().ListOutputDevices(info.Env());
|
||||
}
|
||||
|
||||
Napi::Value WindowsStart(const Napi::CallbackInfo& info) {
|
||||
std::string requestedDeviceId;
|
||||
if (info.Length() >= 1 && info[0].IsString()) {
|
||||
requestedDeviceId = info[0].As<Napi::String>().Utf8Value();
|
||||
}
|
||||
return engine().Start(info.Env(), requestedDeviceId);
|
||||
}
|
||||
|
||||
Napi::Value WindowsStop(const Napi::CallbackInfo& info) {
|
||||
engine().Stop();
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value WindowsDrain(const Napi::CallbackInfo& info) {
|
||||
size_t maxChunks = kDefaultDrainChunkLimit;
|
||||
if (info.Length() >= 1 && info[0].IsNumber()) {
|
||||
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
|
||||
if (requested > 0) {
|
||||
maxChunks = static_cast<size_t>(requested);
|
||||
}
|
||||
}
|
||||
return engine().Drain(info.Env(), maxChunks);
|
||||
}
|
||||
|
||||
Napi::Value WindowsNowMilliseconds(const Napi::CallbackInfo& info) {
|
||||
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
|
||||
}
|
||||
|
||||
Napi::Value WindowsMediaGetSupport(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
|
||||
@@ -1211,12 +1264,20 @@ Napi::Value WindowsMediaSendSpotifyControl(const Napi::CallbackInfo& info) {
|
||||
return env.Null();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
#ifndef PRISM_CAPTURE_CORE_ONLY
|
||||
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
|
||||
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object captureExports = Napi::Object::New(env);
|
||||
captureExports.Set("getSupport", Napi::Function::New(env, WindowsGetSupport));
|
||||
captureExports.Set(
|
||||
"listOutputDevices", Napi::Function::New(env, WindowsListOutputDevices));
|
||||
captureExports.Set("start", Napi::Function::New(env, WindowsStart));
|
||||
captureExports.Set("stop", Napi::Function::New(env, WindowsStop));
|
||||
captureExports.Set("drain", Napi::Function::New(env, WindowsDrain));
|
||||
captureExports.Set("nowMilliseconds", Napi::Function::New(env, WindowsNowMilliseconds));
|
||||
exports.Set("windowsCapture", captureExports);
|
||||
|
||||
Napi::Object mediaExports = Napi::Object::New(env);
|
||||
mediaExports.Set("getSupport", Napi::Function::New(env, WindowsMediaGetSupport));
|
||||
mediaExports.Set(
|
||||
@@ -1227,14 +1288,5 @@ void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Function::New(env, WindowsMediaSendSpotifyControl));
|
||||
exports.Set("windowsMedia", mediaExports);
|
||||
}
|
||||
#endif
|
||||
|
||||
namespace Prism::Capture {
|
||||
|
||||
std::unique_ptr<SystemAudioCapture> createSystemAudioCapture() {
|
||||
return std::make_unique<WindowsNativeCaptureEngine>();
|
||||
}
|
||||
|
||||
} // namespace Prism::Capture
|
||||
|
||||
#endif // defined(_WIN32)
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
#include <napi.h>
|
||||
|
||||
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports);
|
||||
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports);
|
||||
|
||||
@@ -2,6 +2,43 @@
|
||||
|
||||
namespace {
|
||||
|
||||
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
|
||||
Napi::Object support = Napi::Object::New(info.Env());
|
||||
support.Set("available", Napi::Boolean::New(info.Env(), false));
|
||||
support.Set(
|
||||
"reason",
|
||||
Napi::String::New(
|
||||
info.Env(), "Native Windows output-device capture is unavailable on this platform."));
|
||||
return support;
|
||||
}
|
||||
|
||||
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
|
||||
return Napi::Array::New(info.Env());
|
||||
}
|
||||
|
||||
Napi::Value Start(const Napi::CallbackInfo& info) {
|
||||
Napi::Error::New(
|
||||
info.Env(), "Native Windows output-device capture is unavailable on this platform.")
|
||||
.ThrowAsJavaScriptException();
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value Stop(const Napi::CallbackInfo& info) {
|
||||
return info.Env().Undefined();
|
||||
}
|
||||
|
||||
Napi::Value Drain(const Napi::CallbackInfo& info) {
|
||||
Napi::Object result = Napi::Object::New(info.Env());
|
||||
result.Set("chunks", Napi::Array::New(info.Env()));
|
||||
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
|
||||
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
|
||||
return Napi::Number::New(info.Env(), 0);
|
||||
}
|
||||
|
||||
Napi::Value MediaGetSupport(const Napi::CallbackInfo& info) {
|
||||
Napi::Object support = Napi::Object::New(info.Env());
|
||||
support.Set("available", Napi::Boolean::New(info.Env(), false));
|
||||
@@ -25,7 +62,17 @@ Napi::Value SendSpotifyControl(const Napi::CallbackInfo& info) {
|
||||
|
||||
} // namespace
|
||||
|
||||
void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) {
|
||||
void RegisterWindowsCapture(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object captureExports = Napi::Object::New(env);
|
||||
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
|
||||
captureExports.Set(
|
||||
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
|
||||
captureExports.Set("start", Napi::Function::New(env, Start));
|
||||
captureExports.Set("stop", Napi::Function::New(env, Stop));
|
||||
captureExports.Set("drain", Napi::Function::New(env, Drain));
|
||||
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
|
||||
exports.Set("windowsCapture", captureExports);
|
||||
|
||||
Napi::Object mediaExports = Napi::Object::New(env);
|
||||
mediaExports.Set("getSupport", Napi::Function::New(env, MediaGetSupport));
|
||||
mediaExports.Set(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "prism",
|
||||
"version": "0.3.0-beta",
|
||||
"version": "0.2.0-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "prism",
|
||||
"version": "0.3.0-beta",
|
||||
"version": "0.2.0-beta",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "prism",
|
||||
"version": "0.3.0-beta",
|
||||
"version": "0.2.0-beta",
|
||||
"description": "Open-source audio metering and visualization tool",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
@@ -10,10 +10,7 @@
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:icons": "node scripts/build/generate-prism-icons.mjs",
|
||||
"build:clip-drag-icons": "node scripts/build/generate-audio-clip-drag-icons.mjs",
|
||||
"test": "node scripts/run-tests.mjs",
|
||||
"test:audio-router": "node scripts/run-audio-router-tests.mjs",
|
||||
"test:audio-clips": "node scripts/run-audio-clip-tests.mjs",
|
||||
"test:audio-store": "node scripts/run-audio-store-tests.mjs",
|
||||
"test:astra": "node scripts/run-astra-integration-tests.mjs",
|
||||
"test:capture-support": "node scripts/run-capture-support-tests.mjs",
|
||||
@@ -23,30 +20,20 @@
|
||||
"test:themes": "node scripts/run-theme-library-tests.mjs",
|
||||
"test:secret-vault": "node scripts/run-secret-vault-tests.mjs",
|
||||
"test:window-state": "node scripts/run-window-state-tests.mjs",
|
||||
"test:desktop-integration": "node scripts/run-desktop-integration-tests.mjs",
|
||||
"test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs",
|
||||
"test:spectrum-native": "node --test test/spectrum-native.test.mjs",
|
||||
"test:spectrogram-native": "node --test test/spectrogram-native.test.mjs",
|
||||
"test:vectorscope-native": "node --test test/vectorscope-native.test.mjs",
|
||||
"test:lufsmeter-native": "node --test test/lufsmeter-native.test.mjs",
|
||||
"test:lufsmeter-ebu": "node scripts/run-ebu-true-peak-tests.mjs",
|
||||
"test:tui": "node scripts/build/build-tui.cjs --test",
|
||||
"test:build-metadata": "node scripts/run-build-metadata-tests.mjs",
|
||||
"test:updates": "node scripts/run-update-tests.mjs",
|
||||
"plugin-ui:dev": "vite --config vite.plugin-ui.config.ts",
|
||||
"plugin-ui:build": "vite build --config vite.plugin-ui.config.ts",
|
||||
"build:native": "cd native && node-gyp rebuild",
|
||||
"configure:tui": "node scripts/build/build-tui.cjs --configure-only",
|
||||
"build:tui": "node scripts/build/build-tui.cjs",
|
||||
"stage:tui": "node scripts/build/build-tui.cjs --stage",
|
||||
"rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"",
|
||||
"postinstall": "node scripts/build/postinstall-native.cjs",
|
||||
"dist": "node scripts/build/dist-current.cjs",
|
||||
"dist:mac": "npm run stage:tui && npm run build && electron-builder --mac --publish never",
|
||||
"dist:win": "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/build/check-windows-dist-output.ps1 && npm run stage:tui && npm run build && electron-builder --win --publish never",
|
||||
"dist:linux": "npm run dist:linux:appimage && npm run stage:tui && electron-builder --linux deb rpm tar.gz --publish never",
|
||||
"dist:linux:appimage": "npm run build && electron-builder --config electron-builder.appimage.cjs --linux AppImage --publish never",
|
||||
"dist:linux:packages": "npm run stage:tui && electron-builder --linux deb rpm tar.gz --publish never"
|
||||
"dist": "npm run build && electron-builder --publish never",
|
||||
"dist:mac": "npm run build && electron-builder --mac --publish never",
|
||||
"dist:win": "npm run build && electron-builder --win --publish never",
|
||||
"dist:linux": "npm run build && electron-builder --linux AppImage deb rpm tar.gz --publish never",
|
||||
"dist:linux:appimage": "npm run build && electron-builder --linux AppImage --publish never",
|
||||
"dist:linux:packages": "electron-builder --linux deb rpm tar.gz --publish never"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -108,25 +95,9 @@
|
||||
"from": "resources/icon.png",
|
||||
"to": "icon.png"
|
||||
},
|
||||
{
|
||||
"from": "resources/drag/",
|
||||
"to": "drag/"
|
||||
},
|
||||
{
|
||||
"from": "resources/tray/",
|
||||
"to": "tray/"
|
||||
},
|
||||
{
|
||||
"from": "plugin/dist-installer/",
|
||||
"to": "plugins/"
|
||||
},
|
||||
{
|
||||
"from": "tui/dist-installer/",
|
||||
"to": "tui/"
|
||||
},
|
||||
{
|
||||
"from": "THIRD_PARTY_NOTICES.md",
|
||||
"to": "THIRD_PARTY_NOTICES.md"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -172,7 +143,6 @@
|
||||
"libatspi2.0-0",
|
||||
"libuuid1",
|
||||
"libsecret-1-0",
|
||||
"libpulse0",
|
||||
"libwebkit2gtk-4.1-0"
|
||||
]
|
||||
},
|
||||
@@ -189,7 +159,6 @@
|
||||
"at-spi2-core",
|
||||
"libuuid",
|
||||
"libsecret",
|
||||
"pulseaudio-libs",
|
||||
"webkit2gtk4.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
/**
|
||||
* Loudness (LUFS) meter engine. Pushes stereo audio into the reused
|
||||
* `Visualizer::LUFSMeterAnalyzer` (K-weighting, gated integration, true peak, and
|
||||
* the same fast VU/correlation block) and emits its scalar snapshot each frame.
|
||||
* The frame is plain numbers (no base64); getSnapshot() advances true-peak decay
|
||||
* `Visualizer::LUFSMeterAnalyzer` (K-weighting + gated integration + the same fast
|
||||
* VU/peak/correlation block) and emits its scalar snapshot each frame. Like the VU
|
||||
* engine the frame is plain numbers (no base64); getSnapshot() advances peak decay
|
||||
* on the steady clock, so it's safe to call every frame. configure() is a no-op —
|
||||
* LUFS settings (mode/readout) are render-side.
|
||||
*/
|
||||
@@ -44,9 +44,8 @@ public:
|
||||
obj->setProperty("vuRDb", snap.vuRDb);
|
||||
obj->setProperty("barLDb", snap.barLDb);
|
||||
obj->setProperty("barRDb", snap.barRDb);
|
||||
obj->setProperty("truePeakLDb", snap.truePeakLDb);
|
||||
obj->setProperty("truePeakRDb", snap.truePeakRDb);
|
||||
obj->setProperty("maxTruePeakDb", snap.maxTruePeakDb);
|
||||
obj->setProperty("peakLDb", snap.peakLDb);
|
||||
obj->setProperty("peakRDb", snap.peakRDb);
|
||||
obj->setProperty("correlation", snap.correlation);
|
||||
return juce::var(obj);
|
||||
}
|
||||
|
||||
@@ -277,7 +277,6 @@ namespace
|
||||
.withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); })
|
||||
.withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); })
|
||||
.withEventListener("prismSpectrogramConfig", [&editor](juce::var v) { editor.onScopeNativeConfig(std::move(v)); })
|
||||
.withEventListener("prismScopeMeasurement", [&editor](juce::var v) { editor.onScopeMeasurement(std::move(v)); })
|
||||
.withEventListener("prismSettingsPanel", [&editor](juce::var v) { editor.onSettingsPanel(std::move(v)); });
|
||||
|
||||
#if JUCE_WINDOWS
|
||||
@@ -606,11 +605,6 @@ void PrismSpectrumEditor::onScopeNativeConfig(juce::var payload)
|
||||
engine->configureNative(payload);
|
||||
}
|
||||
|
||||
void PrismSpectrumEditor::onScopeMeasurement(juce::var payload)
|
||||
{
|
||||
engine->setMeasurementActive((bool) payload.getProperty("active", false));
|
||||
}
|
||||
|
||||
void PrismSpectrumEditor::pushRestoreSettings()
|
||||
{
|
||||
if (webView == nullptr)
|
||||
|
||||
@@ -36,9 +36,6 @@ public:
|
||||
// Scope-specific native config (e.g. the spectrogram's canvas-derived rowCount).
|
||||
void onScopeNativeConfig(juce::var payload);
|
||||
|
||||
// Momentary crosshair state; Spectrum uses it for temporary DSP smoothing.
|
||||
void onScopeMeasurement(juce::var payload);
|
||||
|
||||
// The UI's settings panel opened/closed along the bottom. Grow/shrink the editor
|
||||
// height by exactly the panel height so the scope area is unchanged (the window
|
||||
// accommodates the panel, like the app). 0 = closed.
|
||||
|
||||
@@ -41,9 +41,6 @@ public:
|
||||
*/
|
||||
virtual void configureNative(const juce::var&) {}
|
||||
|
||||
/** Temporary UI interaction state. Most scopes do not need DSP changes. */
|
||||
virtual void setMeasurementActive(bool) {}
|
||||
|
||||
/** Feed audio (called off the realtime thread). numSamples may be 0. */
|
||||
virtual void process(const float* left, const float* right, int numSamples) = 0;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* produces finished display+heat columns. Unlike the other scopes, its DSP needs
|
||||
* the canvas-derived rowCount (and fft/freq/db/scale/orientation), which only the
|
||||
* webview knows — the UI pushes the full native config via "prismSpectrogramConfig",
|
||||
* routed here through configureNative(). process() preserves stereo energy in the DSP;
|
||||
* routed here through configureNative(). process() mixes to mono and runs the DSP;
|
||||
* buildFrame() emits the columns produced since the last frame (base64) tagged with
|
||||
* rowCount, so the bridge can match them to the config the UI currently expects.
|
||||
* configure() (scope settings) is a no-op — every DSP parameter arrives in the
|
||||
@@ -75,7 +75,12 @@ public:
|
||||
{
|
||||
if (! hasConfig || numSamples <= 0)
|
||||
return;
|
||||
auto result = spectro.processStereo(left, right, (size_t) numSamples);
|
||||
if ((int) mono.size() < numSamples)
|
||||
mono.resize((size_t) numSamples);
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
mono[(size_t) i] = 0.5f * (left[i] + right[i]);
|
||||
|
||||
auto result = spectro.process(mono.data(), (size_t) numSamples);
|
||||
if (result.columnCount > 0 && result.rowCount == config.rowCount)
|
||||
{
|
||||
pendingDisplay.insert(pendingDisplay.end(), result.display.begin(), result.display.end());
|
||||
@@ -111,6 +116,7 @@ private:
|
||||
Visualizer::SpectrogramAnalyzer spectro;
|
||||
Visualizer::SpectrogramConfig config;
|
||||
bool hasConfig = false;
|
||||
std::vector<float> mono;
|
||||
std::vector<float> pendingDisplay, pendingHeat;
|
||||
size_t pendingColumns = 0;
|
||||
};
|
||||
|
||||
@@ -22,18 +22,7 @@ public:
|
||||
if (fftSize > 0 && (size_t) fftSize != spectrum.getFFTSize())
|
||||
spectrum.setFFTSize((size_t) fftSize);
|
||||
|
||||
configuredSmoothing = juce::jlimit(0.0f, 0.99f,
|
||||
(float) (double) settings.getProperty("smoothing", 0.9));
|
||||
applySmoothing();
|
||||
}
|
||||
|
||||
void setMeasurementActive(bool active) override
|
||||
{
|
||||
if (measurementActive == active)
|
||||
return;
|
||||
|
||||
measurementActive = active;
|
||||
applySmoothing();
|
||||
spectrum.setSmoothing((float) (double) settings.getProperty("smoothing", 0.9));
|
||||
}
|
||||
|
||||
void process(const float* left, const float* right, int numSamples) override
|
||||
@@ -50,19 +39,10 @@ public:
|
||||
obj->setProperty("sampleRate", sampleRate);
|
||||
obj->setProperty("magnitudes", toBase64(spectrum.getMagnitudes()));
|
||||
obj->setProperty("side", toBase64(spectrum.getSideMagnitudes()));
|
||||
obj->setProperty("channelMax", toBase64(spectrum.getChannelMaxMagnitudes()));
|
||||
return juce::var(obj);
|
||||
}
|
||||
|
||||
private:
|
||||
void applySmoothing()
|
||||
{
|
||||
constexpr float measurementSmoothing = 0.97f;
|
||||
spectrum.setSmoothing(measurementActive
|
||||
? juce::jmax(configuredSmoothing, measurementSmoothing)
|
||||
: configuredSmoothing);
|
||||
}
|
||||
|
||||
static juce::String toBase64(const std::vector<float>& data)
|
||||
{
|
||||
return data.empty() ? juce::String()
|
||||
@@ -71,6 +51,4 @@ private:
|
||||
|
||||
const juce::Identifier frameId { "spectrumFrame" };
|
||||
Visualizer::Spectrum spectrum { 2048 };
|
||||
float configuredSmoothing = 0.9f;
|
||||
bool measurementActive = false;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/**
|
||||
* Vectorscope engine. Pushes stereo audio into the reused `Visualizer::Vectorscope`
|
||||
* (full-band L/R + a 3-band split, both in circular buffers) and emits the
|
||||
* (lowpass-filtered L/R + a 3-band split, both in circular buffers) and emits the
|
||||
* most recent display points each frame. Two layouts share the buffers: the standard
|
||||
* X/Y point cloud and the multiband (low/mid/high) cloud. Both buffers are kept warm
|
||||
* so toggling is instant; the active layout (from the `multiband` setting) is flagged
|
||||
|
||||
|
Before Width: | Height: | Size: 312 B |
|
Before Width: | Height: | Size: 504 B |
@@ -1,51 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Package post-install hook for Linux .deb/.rpm builds. The app package installs
|
||||
# Prism under /opt, then this exposes prism-tui on PATH and copies the bundled
|
||||
# native Linux VST3 bundles into the global VST3 scan path used by Linux DAWs.
|
||||
# Prism under /opt, then this copies the bundled native Linux VST3 bundles into
|
||||
# the global VST3 scan path used by Linux DAWs.
|
||||
|
||||
set -eu
|
||||
|
||||
DEST_DIR="${PRISM_VST3_DEST_DIR:-/usr/lib/vst3}"
|
||||
TUI_LINK="${PRISM_TUI_LINK_PATH:-/usr/bin/prism-tui}"
|
||||
|
||||
find_tui() {
|
||||
if [ -n "${PRISM_TUI_SOURCE_PATH:-}" ] && [ -f "$PRISM_TUI_SOURCE_PATH" ]; then
|
||||
printf '%s\n' "$PRISM_TUI_SOURCE_PATH"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for executable in \
|
||||
/opt/Prism/resources/tui/prism-tui \
|
||||
/opt/prism/resources/tui/prism-tui
|
||||
do
|
||||
if [ -f "$executable" ]; then
|
||||
printf '%s\n' "$executable"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
install_tui() {
|
||||
tui_source="$(find_tui || true)"
|
||||
if [ -z "$tui_source" ]; then
|
||||
echo "Prism TUI install: bundled executable not found; skipping PATH link." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
chmod 755 "$tui_source"
|
||||
if [ -e "$TUI_LINK" ] || [ -L "$TUI_LINK" ]; then
|
||||
if [ -L "$TUI_LINK" ] && [ "$(readlink "$TUI_LINK")" = "$tui_source" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo "Prism TUI install: $TUI_LINK already exists and was left unchanged." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
ln -s "$tui_source" "$TUI_LINK"
|
||||
echo "Prism TUI installed at $TUI_LINK"
|
||||
}
|
||||
|
||||
find_source_dir() {
|
||||
if [ -n "${PRISM_VST3_SOURCE_DIR:-}" ] && [ -d "$PRISM_VST3_SOURCE_DIR" ]; then
|
||||
@@ -80,8 +40,6 @@ install_plugin() {
|
||||
cp -a "$source_plugin" "$DEST_DIR/"
|
||||
}
|
||||
|
||||
install_tui
|
||||
|
||||
SOURCE_DIR="$(find_source_dir || true)"
|
||||
if [ -z "$SOURCE_DIR" ]; then
|
||||
echo "Prism VST3 install: bundled VST3 directory not found; skipping plugin install." >&2
|
||||
|
||||
@@ -4,42 +4,7 @@
|
||||
|
||||
set -eu
|
||||
|
||||
# Debian postrm reports upgrades by name; RPM postun reports the number of
|
||||
# installed package versions remaining. Keep shared resources during either
|
||||
# upgrade path and remove them only on a real uninstall.
|
||||
remove_action="${1:-}"
|
||||
case "$remove_action" in
|
||||
upgrade|failed-upgrade|abort-*)
|
||||
exit 0
|
||||
;;
|
||||
''|*[!0-9]*)
|
||||
;;
|
||||
*)
|
||||
if [ "$remove_action" -gt 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
DEST_DIR="${PRISM_VST3_DEST_DIR:-/usr/lib/vst3}"
|
||||
TUI_LINK="${PRISM_TUI_LINK_PATH:-/usr/bin/prism-tui}"
|
||||
|
||||
remove_tui_link() {
|
||||
[ -L "$TUI_LINK" ] || return 0
|
||||
tui_target="$(readlink "$TUI_LINK")"
|
||||
case "$tui_target" in
|
||||
/opt/Prism/resources/tui/prism-tui|/opt/prism/resources/tui/prism-tui)
|
||||
rm "$TUI_LINK"
|
||||
;;
|
||||
*)
|
||||
if [ -n "${PRISM_TUI_SOURCE_PATH:-}" ] && [ "$tui_target" = "$PRISM_TUI_SOURCE_PATH" ]; then
|
||||
rm "$TUI_LINK"
|
||||
else
|
||||
echo "Prism TUI removal: $TUI_LINK points elsewhere and was left unchanged." >&2
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
remove_plugin() {
|
||||
rm -rf "$DEST_DIR/$1"
|
||||
@@ -52,6 +17,5 @@ remove_plugin "Prism Loudness Meter.vst3"
|
||||
remove_plugin "Prism Vectorscope.vst3"
|
||||
remove_plugin "Prism Spectrogram.vst3"
|
||||
remove_plugin "Prism Waveform.vst3"
|
||||
remove_tui_link
|
||||
|
||||
echo "Prism VST3 plugins removed from $DEST_DIR"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Postinstall: after the macOS Installer drops Prism.app into /Applications, copy
|
||||
# the bundled VST3 / AU plugins out of the app's Resources into the system
|
||||
# Audio Plug-Ins folders so DAWs find them, and expose prism-tui on PATH.
|
||||
# Audio Plug-Ins folders so DAWs find them.
|
||||
#
|
||||
# Standard pkg postinstall args:
|
||||
# $1 = full path to the installer package
|
||||
@@ -17,10 +17,8 @@ APP_PATH="${2:-/Applications}/Prism.app"
|
||||
[ -d "$APP_PATH" ] || APP_PATH="/Applications/Prism.app"
|
||||
|
||||
PLUGINS_SRC="$APP_PATH/Contents/Resources/plugins"
|
||||
VST3_DEST="${PRISM_VST3_DEST_DIR:-/Library/Audio/Plug-Ins/VST3}"
|
||||
AU_DEST="${PRISM_AU_DEST_DIR:-/Library/Audio/Plug-Ins/Components}"
|
||||
TUI_SOURCE="$APP_PATH/Contents/Resources/tui/prism-tui"
|
||||
TUI_LINK="${PRISM_TUI_LINK_PATH:-/usr/local/bin/prism-tui}"
|
||||
VST3_DEST="/Library/Audio/Plug-Ins/VST3"
|
||||
AU_DEST="/Library/Audio/Plug-Ins/Components"
|
||||
|
||||
mkdir -p "$VST3_DEST" "$AU_DEST"
|
||||
|
||||
@@ -31,18 +29,4 @@ if [ -d "$PLUGINS_SRC/AU" ]; then
|
||||
cp -R "$PLUGINS_SRC/AU/"*.component "$AU_DEST/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -f "$TUI_SOURCE" ]; then
|
||||
chmod 755 "$TUI_SOURCE"
|
||||
mkdir -p "$(dirname "$TUI_LINK")"
|
||||
if [ -e "$TUI_LINK" ] || [ -L "$TUI_LINK" ]; then
|
||||
if [ ! -L "$TUI_LINK" ] || [ "$(readlink "$TUI_LINK")" != "$TUI_SOURCE" ]; then
|
||||
echo "Prism postinstall: $TUI_LINK already exists and was left unchanged." >&2
|
||||
fi
|
||||
else
|
||||
ln -s "$TUI_SOURCE" "$TUI_LINK"
|
||||
fi
|
||||
else
|
||||
echo "Prism postinstall: bundled prism-tui not found at $TUI_SOURCE" >&2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -11,32 +11,13 @@
|
||||
; to Common Files succeed without a second elevation. On uninstall, the bundles
|
||||
; are removed unconditionally (harmless if the user opted out at install time).
|
||||
|
||||
!include "LogicLib.nsh"
|
||||
!include "WinMessages.nsh"
|
||||
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
!include "nsDialogs.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
|
||||
Var PRISM_VST_CHECKBOX
|
||||
Var PRISM_VST_STATE
|
||||
|
||||
!macro customHeader
|
||||
; electron-builder disables the standard NSIS details view in common.nsh.
|
||||
; customHeader is inserted after common.nsh, so this supported hook can
|
||||
; override that default without modifying electron-builder's templates.
|
||||
ShowInstDetails show
|
||||
|
||||
; This hidden section runs before electron-builder's main install section.
|
||||
; Its message therefore remains visible while the bundled application is
|
||||
; extracted and atomically copied into the selected installation folder.
|
||||
Section "-Prism install status"
|
||||
${IfNot} ${Silent}
|
||||
SetDetailsPrint both
|
||||
DetailPrint "Step 1 of 4: Unpacking Prism application files. This may take a minute..."
|
||||
${EndIf}
|
||||
SectionEnd
|
||||
!macroend
|
||||
|
||||
!macro customInit
|
||||
; Silent installs skip the options page, so default to installing plugins.
|
||||
StrCpy $PRISM_VST_STATE ${BST_CHECKED}
|
||||
@@ -72,84 +53,32 @@
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
SetDetailsPrint both
|
||||
|
||||
${IfNot} ${FileExists} "$INSTDIR\resources\tui\prism-tui.exe"
|
||||
DetailPrint "ERROR: bundled prism-tui.exe was not found"
|
||||
MessageBox MB_OK|MB_ICONSTOP "The bundled prism-tui executable was not found.$\r$\n$\r$\nMissing path:$\r$\n$INSTDIR\resources\tui\prism-tui.exe" /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
DetailPrint "Step 2 of 4: Adding prism-tui to the machine PATH..."
|
||||
|
||||
; Pass the path through the installer's process environment. Supplying it
|
||||
; after PowerShell's -Command argument loses quotes at the native command
|
||||
; line boundary and truncates the default path to "C:\Program".
|
||||
System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("PRISM_TUI_INSTALL_DIR", "$INSTDIR\resources\tui").r0'
|
||||
${If} $0 == 0
|
||||
DetailPrint "ERROR: could not prepare the Prism TUI PATH update"
|
||||
MessageBox MB_OK|MB_ICONSTOP "Prism could not prepare the prism-tui PATH update." /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
; PowerShell avoids NSIS string-length truncation on machines with a large
|
||||
; PATH. It removes exact duplicates, safely repairs the truncated entry
|
||||
; written by older installers, appends Prism once, and verifies the write.
|
||||
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference = 'Stop'; $$entry = $$env:PRISM_TUI_INSTALL_DIR; if ([string]::IsNullOrWhiteSpace($$entry)) { exit 10 }; $$legacyEntry = $$null; $$removeLegacy = $$false; if ($$entry -match '\s') { $$legacyEntry = ($$entry -split '\s', 2)[0]; $$removeLegacy = $$legacyEntry -ine $$entry -and -not (Test-Path -LiteralPath $$legacyEntry -PathType Container) }; $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry -and (-not $$removeLegacy -or $$_ -ine $$legacyEntry) }); $$entries += $$entry; [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine'); $$updatedPath = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$matches = @($$updatedPath -split ';' | Where-Object { $$_ -ieq $$entry }); if ($$matches.Count -ne 1) { exit 11 } }"`
|
||||
Pop $0
|
||||
${If} $0 != 0
|
||||
DetailPrint "ERROR: could not add Prism TUI to the machine PATH (exit code $0)"
|
||||
MessageBox MB_OK|MB_ICONSTOP "Prism could not add prism-tui to the machine PATH.$\r$\n$\r$\nPowerShell exit code: $0" /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
|
||||
DetailPrint "Added $INSTDIR\resources\tui to the machine PATH"
|
||||
|
||||
${If} $PRISM_VST_STATE == ${BST_CHECKED}
|
||||
DetailPrint "Step 3 of 4: Installing Prism VST3 plugins to $COMMONFILES64\VST3..."
|
||||
DetailPrint "Installing Prism VST3 plugins to $COMMONFILES64\VST3"
|
||||
|
||||
${IfNot} ${FileExists} "$INSTDIR\resources\plugins\VST3\*.vst3"
|
||||
; Plugins are optional. Local app/TUI builds may intentionally omit the
|
||||
; staged bundles, so do not roll back an otherwise valid installation.
|
||||
DetailPrint "WARNING: bundled Prism VST3 plugins were not found; skipping plugin installation"
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "Prism was installed, but this package does not contain the optional VST3 plugins.$\r$\n$\r$\nprism-tui and the desktop app are ready to use." /SD IDOK
|
||||
${Else}
|
||||
CreateDirectory "$COMMONFILES64\VST3"
|
||||
; xcopy /E recurse, /I treat dest as dir, /Y overwrite without prompt.
|
||||
; The trailing "\*" + "/E" copies each *.vst3 bundle subfolder verbatim.
|
||||
nsExec::ExecToLog 'cmd.exe /c xcopy /E /I /Y "$INSTDIR\resources\plugins\VST3\*" "$COMMONFILES64\VST3\"'
|
||||
Pop $0
|
||||
${If} $0 != 0
|
||||
DetailPrint "ERROR: Prism VST3 plugin copy failed with xcopy exit code $0"
|
||||
MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugin installation failed while copying files to:$\r$\n$COMMONFILES64\VST3$\r$\n$\r$\nxcopy exit code: $0" /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
DetailPrint "Installed Prism VST3 plugins."
|
||||
DetailPrint "ERROR: bundled Prism VST3 plugins were not found at $INSTDIR\resources\plugins\VST3"
|
||||
MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugins were selected, but the bundled plugin files were not found.$\r$\n$\r$\nMissing path:$\r$\n$INSTDIR\resources\plugins\VST3" /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
CreateDirectory "$COMMONFILES64\VST3"
|
||||
; xcopy /E recurse, /I treat dest as dir, /Y overwrite without prompt.
|
||||
; The trailing "\*" + "/E" copies each *.vst3 bundle subfolder verbatim.
|
||||
nsExec::ExecToLog 'cmd.exe /c xcopy /E /I /Y "$INSTDIR\resources\plugins\VST3\*" "$COMMONFILES64\VST3\"'
|
||||
Pop $0
|
||||
${If} $0 != 0
|
||||
DetailPrint "ERROR: Prism VST3 plugin copy failed with xcopy exit code $0"
|
||||
MessageBox MB_OK|MB_ICONSTOP "Prism VST3 plugin installation failed while copying files to:$\r$\n$COMMONFILES64\VST3$\r$\n$\r$\nxcopy exit code: $0" /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
${Else}
|
||||
DetailPrint "Step 3 of 4: Prism VST3 plugins skipped (opted out)."
|
||||
DetailPrint "Prism VST3 plugins: skipped (opted out)."
|
||||
${EndIf}
|
||||
|
||||
DetailPrint "Step 4 of 4: Finishing Prism installation..."
|
||||
!macroend
|
||||
!endif
|
||||
|
||||
!macro customUnInstall
|
||||
; Remove only Prism's exact machine-PATH entry. A failure is non-fatal so an
|
||||
; otherwise valid uninstall is never blocked.
|
||||
System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("PRISM_TUI_INSTALL_DIR", "$INSTDIR\resources\tui").r0'
|
||||
${If} $0 == 0
|
||||
DetailPrint "WARNING: could not prepare the Prism TUI PATH removal"
|
||||
${Else}
|
||||
nsExec::ExecToLog `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { $$ErrorActionPreference = 'Stop'; $$entry = $$env:PRISM_TUI_INSTALL_DIR; if ([string]::IsNullOrWhiteSpace($$entry)) { exit 10 }; $$legacyEntry = $$null; $$removeLegacy = $$false; if ($$entry -match '\s') { $$legacyEntry = ($$entry -split '\s', 2)[0]; $$removeLegacy = $$legacyEntry -ine $$entry -and -not (Test-Path -LiteralPath $$legacyEntry -PathType Container) }; $$path = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$entries = @($$path -split ';' | Where-Object { $$_ -and $$_ -ine $$entry -and (-not $$removeLegacy -or $$_ -ine $$legacyEntry) }); [Environment]::SetEnvironmentVariable('Path', ($$entries -join ';'), 'Machine'); $$updatedPath = [Environment]::GetEnvironmentVariable('Path', 'Machine'); $$matches = @($$updatedPath -split ';' | Where-Object { $$_ -ieq $$entry }); if ($$matches.Count -ne 0) { exit 11 } }"`
|
||||
Pop $0
|
||||
${If} $0 == 0
|
||||
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
|
||||
${Else}
|
||||
DetailPrint "WARNING: could not remove Prism TUI from the machine PATH (exit code $0)"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
; Always attempt removal — harmless RMDir if the bundle isn't there (user opted
|
||||
; out at install or removed manually).
|
||||
RMDir /r "$COMMONFILES64\VST3\Prism Spectrum.vst3"
|
||||
|
||||
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 419 B |
|
Before Width: | Height: | Size: 606 B |
@@ -1,49 +0,0 @@
|
||||
const { copyFileSync, existsSync, mkdirSync, chmodSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const rootDir = join(__dirname, '..', '..')
|
||||
const buildDir = join(rootDir, 'tui', 'build')
|
||||
const packageJson = require(join(rootDir, 'package.json'))
|
||||
const shouldTest = process.argv.includes('--test')
|
||||
const shouldStage = process.argv.includes('--stage')
|
||||
const configureOnly = process.argv.includes('--configure-only')
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, { cwd: rootDir, stdio: 'inherit' })
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
run('cmake', [
|
||||
'-S', 'tui',
|
||||
'-B', 'tui/build',
|
||||
'-DCMAKE_BUILD_TYPE=Release',
|
||||
`-DPRISM_VERSION=${packageJson.version}`,
|
||||
])
|
||||
if (configureOnly) process.exit(0)
|
||||
|
||||
run('cmake', ['--build', 'tui/build', '--config', 'Release', '--parallel', '4'])
|
||||
|
||||
if (shouldTest) {
|
||||
run('ctest', ['--test-dir', 'tui/build', '-C', 'Release', '--output-on-failure'])
|
||||
}
|
||||
|
||||
if (shouldStage) {
|
||||
const executableName = process.platform === 'win32' ? 'prism-tui.exe' : 'prism-tui'
|
||||
const candidates = [
|
||||
join(buildDir, 'bin', executableName),
|
||||
join(buildDir, 'bin', 'Release', executableName),
|
||||
join(buildDir, 'Release', executableName),
|
||||
]
|
||||
const source = candidates.find(existsSync)
|
||||
if (!source) {
|
||||
console.error(`Could not find built ${executableName}. Checked:\n${candidates.join('\n')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const stageDir = join(rootDir, 'tui', 'dist-installer')
|
||||
mkdirSync(stageDir, { recursive: true })
|
||||
const destination = join(stageDir, executableName)
|
||||
copyFileSync(source, destination)
|
||||
if (process.platform !== 'win32') chmodSync(destination, 0o755)
|
||||
console.log(`Staged ${destination}`)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const scriptByPlatform = {
|
||||
darwin: 'dist:mac',
|
||||
linux: 'dist:linux',
|
||||
win32: 'dist:win',
|
||||
}
|
||||
|
||||
const script = scriptByPlatform[process.platform]
|
||||
if (!script) {
|
||||
console.error(`Packaging is not configured for ${process.platform}.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const npmCli = process.env.npm_execpath
|
||||
if (!npmCli) {
|
||||
console.error('Could not locate the npm CLI. Run this command through `npm run dist`.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Node 22 rejects direct child-process launches of Windows .cmd shims with
|
||||
// EINVAL. Reuse the npm CLI that launched this script through the current Node
|
||||
// executable instead, which is portable and avoids an extra command shell.
|
||||
const result = spawnSync(process.execPath, [npmCli, 'run', script], {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) {
|
||||
console.error(`Could not start npm run ${script}: ${result.error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -1,236 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { deflateSync, inflateSync } from 'node:zlib'
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(scriptDir, '../..')
|
||||
const resourcesDir = join(repoRoot, 'resources')
|
||||
const trayResourcesDir = join(resourcesDir, 'tray')
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'prism-icons-'))
|
||||
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
const iconBackground = { r: 5, g: 7, b: 10 }
|
||||
@@ -241,28 +240,6 @@ function restoreIconBackgroundAlpha(filePath) {
|
||||
writePngRgba(filePath, width, height, pixels)
|
||||
}
|
||||
|
||||
function convertTemplateToAlphaMask(filePath) {
|
||||
const image = readPngRgba(filePath)
|
||||
const { width, height, pixels } = image
|
||||
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
const originalAlpha = pixels[offset + 3]
|
||||
const luminance = Math.round(
|
||||
(pixels[offset] + pixels[offset + 1] + pixels[offset + 2]) / 3,
|
||||
)
|
||||
|
||||
// Quick Look rasterizes SVG previews over white on some macOS versions.
|
||||
// Template images need that white canvas removed so macOS can tint only
|
||||
// the Prism mark for light/dark menu bars.
|
||||
pixels[offset] = 0
|
||||
pixels[offset + 1] = 0
|
||||
pixels[offset + 2] = 0
|
||||
pixels[offset + 3] = Math.round(originalAlpha * ((255 - luminance) / 255))
|
||||
}
|
||||
|
||||
writePngRgba(filePath, width, height, pixels)
|
||||
}
|
||||
|
||||
function writeIcoFile(outputPath, images) {
|
||||
const headerLength = 6
|
||||
const entryLength = 16
|
||||
@@ -355,47 +332,6 @@ try {
|
||||
icoImages.push({ size, buffer: readFileSync(outputPath) })
|
||||
}
|
||||
writeIcoFile(join(resourcesDir, 'icon.ico'), icoImages)
|
||||
|
||||
mkdirSync(trayResourcesDir, { recursive: true })
|
||||
|
||||
const trayTemplateSvgPath = join(tempDir, 'prism-tray-template-source.svg')
|
||||
writeFileSync(trayTemplateSvgPath, `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<g transform="translate(${symbolTranslateX} ${symbolTranslateY}) scale(${iconSymbolScale})">
|
||||
<path d="${lowerPath}" fill="#000000" />
|
||||
<path d="${upperPath}" fill="#000000" />
|
||||
</g>
|
||||
</svg>
|
||||
`)
|
||||
execFileSync('qlmanage', ['-t', '-s', '1024', '-o', tempDir, trayTemplateSvgPath], {
|
||||
stdio: 'ignore',
|
||||
})
|
||||
|
||||
const trayTemplateMaster = `${trayTemplateSvgPath}.png`
|
||||
if (!existsSync(trayTemplateMaster)) {
|
||||
throw new Error('Quick Look did not create the tray template PNG.')
|
||||
}
|
||||
|
||||
const trayTemplatePath = join(trayResourcesDir, 'prismTrayTemplate.png')
|
||||
const trayTemplate2xPath = join(trayResourcesDir, 'prismTrayTemplate@2x.png')
|
||||
resizePng(trayTemplateMaster, trayTemplatePath, 16)
|
||||
resizePng(trayTemplateMaster, trayTemplate2xPath, 32)
|
||||
convertTemplateToAlphaMask(trayTemplatePath)
|
||||
convertTemplateToAlphaMask(trayTemplate2xPath)
|
||||
execFileSync('sips', ['-s', 'dpiWidth', '72', '-s', 'dpiHeight', '72', trayTemplatePath], {
|
||||
stdio: 'ignore',
|
||||
})
|
||||
execFileSync('sips', ['-s', 'dpiWidth', '144', '-s', 'dpiHeight', '144', trayTemplate2xPath], {
|
||||
stdio: 'ignore',
|
||||
})
|
||||
|
||||
resizePng(resourcePngPath, join(trayResourcesDir, 'prism-tray.png'), 24)
|
||||
const trayIcoImages = []
|
||||
for (const size of [16, 20, 24, 32, 40, 48]) {
|
||||
const outputPath = join(tempDir, `prism-tray-${size}.png`)
|
||||
resizePng(resourcePngPath, outputPath, size)
|
||||
trayIcoImages.push({ size, buffer: readFileSync(outputPath) })
|
||||
}
|
||||
writeIcoFile(join(trayResourcesDir, 'prism-tray.ico'), trayIcoImages)
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'prism-audio-clip-tests-'))
|
||||
const bundledTestPath = join(tempDir, 'audio-clip-library.test.mjs')
|
||||
const entryPoint = join(rootDir, 'test', 'audio-clip-library.test.ts')
|
||||
|
||||
let exitCode = 1
|
||||
|
||||
try {
|
||||
await build({
|
||||
entryPoints: [entryPoint],
|
||||
outfile: bundledTestPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node23',
|
||||
sourcemap: 'inline',
|
||||
})
|
||||
|
||||
exitCode = await new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, ['--test', bundledTestPath], {
|
||||
stdio: 'inherit',
|
||||
cwd: rootDir,
|
||||
})
|
||||
|
||||
child.on('exit', (code) => {
|
||||
resolve(code ?? 1)
|
||||
})
|
||||
|
||||
child.on('error', () => {
|
||||
resolve(1)
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
process.exit(exitCode)
|
||||
@@ -1,38 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'prism-desktop-integration-tests-'))
|
||||
const bundledTestPath = join(tempDir, 'desktop-integration.test.mjs')
|
||||
const entryPoint = join(rootDir, 'test', 'desktop-integration.test.ts')
|
||||
|
||||
let exitCode = 1
|
||||
|
||||
try {
|
||||
await build({
|
||||
entryPoints: [entryPoint],
|
||||
outfile: bundledTestPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node23',
|
||||
sourcemap: 'inline',
|
||||
})
|
||||
|
||||
exitCode = await new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, ['--test', bundledTestPath], {
|
||||
stdio: 'inherit',
|
||||
cwd: rootDir,
|
||||
})
|
||||
child.on('exit', (code) => resolve(code ?? 1))
|
||||
child.on('error', () => resolve(1))
|
||||
})
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
process.exit(exitCode)
|
||||
@@ -1,126 +0,0 @@
|
||||
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)
|
||||
@@ -1,41 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
[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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { AudioClipDragPayload } from '../types/audioClip'
|
||||
|
||||
const WAV_HEADER_BYTES = 44
|
||||
const PCM_BITS_PER_SAMPLE = 16
|
||||
const MAX_SAMPLE_RATE = 384000
|
||||
const MAX_CLIP_SECONDS = 60
|
||||
|
||||
export function validateAudioClipDragPayload(raw: unknown): AudioClipDragPayload {
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
throw new Error('The audio clip payload is invalid.')
|
||||
}
|
||||
|
||||
const candidate = raw as Partial<AudioClipDragPayload>
|
||||
if (!(candidate.pcmBytes instanceof Uint8Array)) {
|
||||
throw new Error('The audio clip is missing PCM sample data.')
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(candidate.sampleRate)
|
||||
|| candidate.sampleRate! < 1
|
||||
|| candidate.sampleRate! > MAX_SAMPLE_RATE
|
||||
) {
|
||||
throw new Error('The audio clip sample rate is invalid.')
|
||||
}
|
||||
if (candidate.channelCount !== 1 && candidate.channelCount !== 2) {
|
||||
throw new Error('The audio clip channel count is invalid.')
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(candidate.frameCount)
|
||||
|| candidate.frameCount! < 1
|
||||
|| candidate.frameCount! > candidate.sampleRate! * MAX_CLIP_SECONDS
|
||||
) {
|
||||
throw new Error('The audio clip duration is invalid.')
|
||||
}
|
||||
|
||||
const expectedBytes = candidate.frameCount! * candidate.channelCount * (PCM_BITS_PER_SAMPLE / 8)
|
||||
if (candidate.pcmBytes.byteLength !== expectedBytes) {
|
||||
throw new Error('The audio clip PCM data length is invalid.')
|
||||
}
|
||||
|
||||
return {
|
||||
pcmBytes: candidate.pcmBytes,
|
||||
sampleRate: candidate.sampleRate!,
|
||||
channelCount: candidate.channelCount,
|
||||
frameCount: candidate.frameCount!,
|
||||
}
|
||||
}
|
||||
|
||||
export function encodePcm16Wav(payload: AudioClipDragPayload): Buffer {
|
||||
const validated = validateAudioClipDragPayload(payload)
|
||||
const dataBytes = validated.pcmBytes.byteLength
|
||||
const blockAlign = validated.channelCount * (PCM_BITS_PER_SAMPLE / 8)
|
||||
const wav = Buffer.allocUnsafe(WAV_HEADER_BYTES + dataBytes)
|
||||
|
||||
wav.write('RIFF', 0, 'ascii')
|
||||
wav.writeUInt32LE(36 + dataBytes, 4)
|
||||
wav.write('WAVE', 8, 'ascii')
|
||||
wav.write('fmt ', 12, 'ascii')
|
||||
wav.writeUInt32LE(16, 16)
|
||||
wav.writeUInt16LE(1, 20)
|
||||
wav.writeUInt16LE(validated.channelCount, 22)
|
||||
wav.writeUInt32LE(validated.sampleRate, 24)
|
||||
wav.writeUInt32LE(validated.sampleRate * blockAlign, 28)
|
||||
wav.writeUInt16LE(blockAlign, 32)
|
||||
wav.writeUInt16LE(PCM_BITS_PER_SAMPLE, 34)
|
||||
wav.write('data', 36, 'ascii')
|
||||
wav.writeUInt32LE(dataBytes, 40)
|
||||
wav.set(validated.pcmBytes, WAV_HEADER_BYTES)
|
||||
|
||||
return wav
|
||||
}
|
||||
|
||||
export function buildAudioClipBaseName(date: Date): string {
|
||||
const timestamp = date.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/:/g, '-')
|
||||
.replace('Z', '')
|
||||
return `Prism Clip ${timestamp}`
|
||||
}
|
||||
|
||||
export class AudioClipLibrary {
|
||||
constructor(
|
||||
private readonly directory: string,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
getDirectory(): string {
|
||||
return this.directory
|
||||
}
|
||||
|
||||
ensureDirectory(): string {
|
||||
mkdirSync(this.directory, { recursive: true })
|
||||
return this.directory
|
||||
}
|
||||
|
||||
writeClip(raw: unknown): string {
|
||||
const payload = validateAudioClipDragPayload(raw)
|
||||
const wav = encodePcm16Wav(payload)
|
||||
this.ensureDirectory()
|
||||
|
||||
const baseName = buildAudioClipBaseName(this.now())
|
||||
let suffix = 1
|
||||
let filePath = join(this.directory, `${baseName}.wav`)
|
||||
while (existsSync(filePath)) {
|
||||
suffix += 1
|
||||
filePath = join(this.directory, `${baseName} (${suffix}).wav`)
|
||||
}
|
||||
|
||||
writeFileSync(filePath, wav, { flag: 'wx' })
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
|
||||
import type { NativeCaptureAPI } from '../types/nativeCapture'
|
||||
import type { NativeWindowChromeAPI } from '../types/nativeWindowChrome'
|
||||
|
||||
type NativeAddonModule = VisualizerDSP & NativeCaptureAPI & {
|
||||
windowChrome?: NativeWindowChromeAPI
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export function loadNativeWindowChromeApi(): NativeWindowChromeAPI | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidatePaths = [
|
||||
join(currentDir, '../../native/build/Release/visualizer_dsp.node'),
|
||||
process.resourcesPath ? join(process.resourcesPath, 'native/visualizer_dsp.node') : null,
|
||||
].filter((value): value is string => value !== null)
|
||||
|
||||
for (const modulePath of candidatePaths) {
|
||||
try {
|
||||
const nativeAddon = require(modulePath) as NativeAddonModule
|
||||
if (nativeAddon.windowChrome) {
|
||||
return nativeAddon.windowChrome
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate path.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -351,12 +351,7 @@ export class FileBackedProfileLibrary {
|
||||
throw new Error(`Unsupported profile format in ${basename(filePath)}.`)
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.version !== 1
|
||||
&& candidate.version !== 2
|
||||
&& candidate.version !== 3
|
||||
&& candidate.version !== PROFILE_FILE_VERSION
|
||||
) {
|
||||
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== PROFILE_FILE_VERSION) {
|
||||
throw new Error(`Unsupported profile version in ${basename(filePath)}.`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import {
|
||||
DEFAULT_DESKTOP_INTEGRATION_PREFERENCES,
|
||||
type DesktopIntegrationPreferences,
|
||||
} from '../../types/desktopIntegration'
|
||||
|
||||
export function normalizeDesktopIntegrationPreferences(value: unknown): DesktopIntegrationPreferences {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES }
|
||||
}
|
||||
|
||||
const candidate = value as Partial<DesktopIntegrationPreferences>
|
||||
return {
|
||||
closeToTray: candidate.closeToTray === true,
|
||||
loginLaunchMode: candidate.loginLaunchMode === 'tray' ? 'tray' : 'show',
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDesktopIntegrationPreferences(
|
||||
filePath: string,
|
||||
): Promise<DesktopIntegrationPreferences> {
|
||||
try {
|
||||
return normalizeDesktopIntegrationPreferences(JSON.parse(await readFile(filePath, 'utf8')))
|
||||
} catch {
|
||||
return { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDesktopIntegrationPreferences(
|
||||
filePath: string,
|
||||
preferences: DesktopIntegrationPreferences,
|
||||
): Promise<DesktopIntegrationPreferences> {
|
||||
const normalized = normalizeDesktopIntegrationPreferences(preferences)
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
|
||||
return normalized
|
||||
}
|
||||
|
||||
export type MainWindowCloseDisposition = 'hide-to-tray' | 'close'
|
||||
|
||||
export function resolveMainWindowCloseDisposition(options: {
|
||||
closeToTray: boolean
|
||||
isAppQuitting: boolean
|
||||
trayAvailable: boolean
|
||||
windowRecreationPending: boolean
|
||||
}): MainWindowCloseDisposition {
|
||||
return options.closeToTray
|
||||
&& !options.isAppQuitting
|
||||
&& !options.windowRecreationPending
|
||||
&& options.trayAvailable
|
||||
? 'hide-to-tray'
|
||||
: 'close'
|
||||
}
|
||||
|
||||
export function resolveStartHiddenAtLogin(options: {
|
||||
isLoginLaunch: boolean
|
||||
loginLaunchMode: DesktopIntegrationPreferences['loginLaunchMode']
|
||||
hasPendingFileOpen: boolean
|
||||
}): boolean {
|
||||
return options.isLoginLaunch
|
||||
&& options.loginLaunchMode === 'tray'
|
||||
&& !options.hasPendingFileOpen
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import type {
|
||||
DesktopIntegrationPreferences,
|
||||
DesktopIntegrationSnapshot,
|
||||
LoginItemStatus,
|
||||
} from '../../types/desktopIntegration'
|
||||
|
||||
export const LOGIN_LAUNCH_ARG = '--prism-login-launch'
|
||||
const LINUX_AUTOSTART_FILENAME = 'com.astra.prism.desktop'
|
||||
|
||||
interface NativeLoginItemSettings {
|
||||
openAtLogin?: boolean
|
||||
wasOpenedAtLogin?: boolean
|
||||
status?: 'not-registered' | 'enabled' | 'requires-approval' | 'not-found'
|
||||
executableWillLaunchAtLogin?: boolean
|
||||
}
|
||||
|
||||
interface LoginItemServiceOptions {
|
||||
platform: NodeJS.Platform
|
||||
isPackaged: boolean
|
||||
executablePath: string
|
||||
appImagePath?: string
|
||||
configHome?: string
|
||||
homePath: string
|
||||
getNativeSettings?: (options?: { path?: string; args?: string[] }) => NativeLoginItemSettings
|
||||
setNativeSettings?: (settings: {
|
||||
openAtLogin: boolean
|
||||
path?: string
|
||||
args?: string[]
|
||||
type?: 'mainAppService'
|
||||
}) => void
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : 'Login item operation failed.'
|
||||
}
|
||||
|
||||
export function resolveLinuxAutostartPath(configHome: string | undefined, homePath: string): string {
|
||||
const resolvedConfigHome = configHome && isAbsolute(configHome)
|
||||
? configHome
|
||||
: join(homePath, '.config')
|
||||
return join(resolvedConfigHome, 'autostart', LINUX_AUTOSTART_FILENAME)
|
||||
}
|
||||
|
||||
export function resolveLinuxLaunchExecutable(appImagePath: string | undefined, executablePath: string): string {
|
||||
return appImagePath && isAbsolute(appImagePath) ? appImagePath : executablePath
|
||||
}
|
||||
|
||||
export function quoteDesktopExecArgument(value: string): string {
|
||||
const escaped = value.replace(/[\\"`$]/g, (character) => `\\${character}`)
|
||||
return `"${escaped}"`
|
||||
}
|
||||
|
||||
export function buildLinuxAutostartEntry(executablePath: string): string {
|
||||
const executable = quoteDesktopExecArgument(executablePath)
|
||||
return [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Version=1.0',
|
||||
'Name=Prism',
|
||||
'Comment=Open Prism at login',
|
||||
`TryExec=${executable}`,
|
||||
`Exec=${executable} ${LOGIN_LAUNCH_ARG}`,
|
||||
'Terminal=false',
|
||||
'Hidden=false',
|
||||
'X-GNOME-Autostart-enabled=true',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function resolveNativeLoginItemStatus(
|
||||
platform: NodeJS.Platform,
|
||||
settings: NativeLoginItemSettings,
|
||||
): LoginItemStatus {
|
||||
if (platform === 'darwin') {
|
||||
if (settings.status === 'requires-approval') return 'requires-approval'
|
||||
return settings.openAtLogin || settings.status === 'enabled' ? 'enabled' : 'disabled'
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
if (!settings.openAtLogin) return 'disabled'
|
||||
return settings.executableWillLaunchAtLogin === false ? 'blocked' : 'enabled'
|
||||
}
|
||||
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
export function isLoginLaunch(platform: NodeJS.Platform, argv: string[], nativeSettings?: NativeLoginItemSettings): boolean {
|
||||
return platform === 'darwin'
|
||||
? nativeSettings?.wasOpenedAtLogin === true
|
||||
: argv.includes(LOGIN_LAUNCH_ARG)
|
||||
}
|
||||
|
||||
export class LoginItemService {
|
||||
constructor(private readonly options: LoginItemServiceOptions) {}
|
||||
|
||||
isSupported(): boolean {
|
||||
return this.options.isPackaged
|
||||
&& (this.options.platform === 'darwin'
|
||||
|| this.options.platform === 'win32'
|
||||
|| this.options.platform === 'linux')
|
||||
}
|
||||
|
||||
wasOpenedAtLogin(argv: string[]): boolean {
|
||||
if (!this.isSupported()) return false
|
||||
try {
|
||||
return isLoginLaunch(
|
||||
this.options.platform,
|
||||
argv,
|
||||
this.options.platform === 'darwin' ? this.options.getNativeSettings?.() : undefined,
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getSnapshot(
|
||||
preferences: DesktopIntegrationPreferences,
|
||||
): Promise<DesktopIntegrationSnapshot> {
|
||||
if (!this.isSupported()) {
|
||||
return {
|
||||
...preferences,
|
||||
openAtLogin: false,
|
||||
loginItemStatus: 'unavailable',
|
||||
loginItemError: null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.options.platform === 'linux') {
|
||||
const filePath = resolveLinuxAutostartPath(this.options.configHome, this.options.homePath)
|
||||
let contents = ''
|
||||
try {
|
||||
contents = await readFile(filePath, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
return {
|
||||
...preferences,
|
||||
openAtLogin: false,
|
||||
loginItemStatus: 'disabled',
|
||||
loginItemError: null,
|
||||
}
|
||||
}
|
||||
const enabled = !/^\s*Hidden\s*=\s*true\s*$/im.test(contents)
|
||||
return {
|
||||
...preferences,
|
||||
openAtLogin: enabled,
|
||||
loginItemStatus: enabled ? 'enabled' : 'disabled',
|
||||
loginItemError: null,
|
||||
}
|
||||
}
|
||||
|
||||
const nativeOptions = this.options.platform === 'win32'
|
||||
? { path: this.options.executablePath, args: [LOGIN_LAUNCH_ARG] }
|
||||
: undefined
|
||||
const settings = this.options.getNativeSettings?.(nativeOptions) ?? {}
|
||||
const loginItemStatus = resolveNativeLoginItemStatus(this.options.platform, settings)
|
||||
return {
|
||||
...preferences,
|
||||
openAtLogin: settings.openAtLogin === true
|
||||
|| loginItemStatus === 'enabled'
|
||||
|| loginItemStatus === 'requires-approval'
|
||||
|| loginItemStatus === 'blocked',
|
||||
loginItemStatus,
|
||||
loginItemError: null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
...preferences,
|
||||
openAtLogin: false,
|
||||
loginItemStatus: 'error',
|
||||
loginItemError: getErrorMessage(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setOpenAtLogin(
|
||||
enabled: boolean,
|
||||
preferences: DesktopIntegrationPreferences,
|
||||
): Promise<DesktopIntegrationSnapshot> {
|
||||
if (!this.isSupported()) {
|
||||
return this.getSnapshot(preferences)
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.options.platform === 'linux') {
|
||||
const filePath = resolveLinuxAutostartPath(this.options.configHome, this.options.homePath)
|
||||
if (enabled) {
|
||||
const executablePath = resolveLinuxLaunchExecutable(
|
||||
this.options.appImagePath,
|
||||
this.options.executablePath,
|
||||
)
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, buildLinuxAutostartEntry(executablePath), 'utf8')
|
||||
} else {
|
||||
await unlink(filePath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
})
|
||||
}
|
||||
} else if (this.options.platform === 'win32') {
|
||||
this.options.setNativeSettings?.({
|
||||
openAtLogin: enabled,
|
||||
path: this.options.executablePath,
|
||||
args: [LOGIN_LAUNCH_ARG],
|
||||
})
|
||||
} else {
|
||||
this.options.setNativeSettings?.({
|
||||
openAtLogin: enabled,
|
||||
type: 'mainAppService',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const current = await this.getSnapshot(preferences)
|
||||
return {
|
||||
...current,
|
||||
loginItemStatus: 'error',
|
||||
loginItemError: getErrorMessage(error),
|
||||
}
|
||||
}
|
||||
|
||||
return this.getSnapshot(preferences)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { join } from 'node:path'
|
||||
|
||||
export function getTrayAssetFilename(platform: NodeJS.Platform): string {
|
||||
if (platform === 'darwin') return 'prismTrayTemplate.png'
|
||||
if (platform === 'win32') return 'prism-tray.ico'
|
||||
return 'prism-tray.png'
|
||||
}
|
||||
|
||||
export function resolveTrayAssetPath(options: {
|
||||
platform: NodeJS.Platform
|
||||
isPackaged: boolean
|
||||
resourcesPath: string
|
||||
appPath: string
|
||||
}): string {
|
||||
const directory = options.isPackaged
|
||||
? join(options.resourcesPath, 'tray')
|
||||
: join(options.appPath, 'resources', 'tray')
|
||||
return join(directory, getTrayAssetFilename(options.platform))
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import {
|
||||
DEFAULT_TRAY_RENDERER_STATE,
|
||||
type DesktopIntegrationSnapshot,
|
||||
type TrayAudioSourceOption,
|
||||
type TrayProfileOption,
|
||||
type TrayRendererState,
|
||||
} from '../../types/desktopIntegration'
|
||||
import { isRollingCaptureDuration } from '../../types/audioClip'
|
||||
|
||||
const MAX_TRAY_ITEMS = 64
|
||||
const MAX_LABEL_LENGTH = 96
|
||||
|
||||
export interface TrayMenuState {
|
||||
mainWindowVisible: boolean
|
||||
rendererReady: boolean
|
||||
rendererState: TrayRendererState
|
||||
desktopIntegration: DesktopIntegrationSnapshot
|
||||
alwaysOnTop: boolean
|
||||
supportsReposition: boolean
|
||||
}
|
||||
|
||||
export interface TrayMenuModel extends TrayMenuState {
|
||||
statusLabel: string
|
||||
tooltip: string
|
||||
mainWindowActionLabel: 'Show Prism' | 'Hide Prism'
|
||||
captureActionLabel: 'Start Capture' | 'Stop Capture'
|
||||
captureActionEnabled: boolean
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') return fallback
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return fallback
|
||||
return trimmed.slice(0, MAX_LABEL_LENGTH)
|
||||
}
|
||||
|
||||
function normalizeProfiles(value: unknown): TrayProfileOption[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.slice(0, MAX_TRAY_ITEMS).flatMap((raw) => {
|
||||
if (!raw || typeof raw !== 'object') return []
|
||||
const candidate = raw as Partial<TrayProfileOption>
|
||||
if (typeof candidate.id !== 'string' || !candidate.id.trim()) return []
|
||||
return [{
|
||||
id: candidate.id,
|
||||
name: normalizeText(candidate.name, 'Profile'),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeSources(value: unknown): TrayAudioSourceOption[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.slice(0, MAX_TRAY_ITEMS).flatMap((raw) => {
|
||||
if (!raw || typeof raw !== 'object') return []
|
||||
const candidate = raw as Partial<TrayAudioSourceOption>
|
||||
if (typeof candidate.id !== 'string') return []
|
||||
return [{
|
||||
id: candidate.id,
|
||||
label: normalizeText(candidate.label, 'Audio Source'),
|
||||
isDefault: candidate.isDefault === true,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeTrayRendererState(value: unknown): TrayRendererState {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return { ...DEFAULT_TRAY_RENDERER_STATE }
|
||||
}
|
||||
|
||||
const candidate = value as Partial<TrayRendererState>
|
||||
const captureStatus = candidate.captureStatus === 'connecting'
|
||||
|| candidate.captureStatus === 'capturing'
|
||||
|| candidate.captureStatus === 'error'
|
||||
? candidate.captureStatus
|
||||
: 'idle'
|
||||
|
||||
return {
|
||||
profiles: normalizeProfiles(candidate.profiles),
|
||||
activeProfileId: typeof candidate.activeProfileId === 'string' ? candidate.activeProfileId : null,
|
||||
hasUnsavedProfileChanges: candidate.hasUnsavedProfileChanges === true,
|
||||
captureStatus,
|
||||
activeSourceLabel: typeof candidate.activeSourceLabel === 'string'
|
||||
? normalizeText(candidate.activeSourceLabel, 'Audio Source')
|
||||
: null,
|
||||
captureMode: candidate.captureMode === 'device' ? 'device' : 'system',
|
||||
selectedSystemSourceId: typeof candidate.selectedSystemSourceId === 'string'
|
||||
? candidate.selectedSystemSourceId
|
||||
: null,
|
||||
selectedDeviceId: typeof candidate.selectedDeviceId === 'string'
|
||||
? candidate.selectedDeviceId
|
||||
: null,
|
||||
rollingCaptureSeconds: isRollingCaptureDuration(candidate.rollingCaptureSeconds)
|
||||
? candidate.rollingCaptureSeconds
|
||||
: null,
|
||||
systemSources: normalizeSources(candidate.systemSources),
|
||||
inputSources: normalizeSources(candidate.inputSources),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTrayMenuModel(state: TrayMenuState): TrayMenuModel {
|
||||
const sourceLabel = state.rendererState.activeSourceLabel
|
||||
const statusText = state.rendererState.captureStatus === 'capturing'
|
||||
? sourceLabel ? `Capturing · ${sourceLabel}` : 'Capturing'
|
||||
: state.rendererState.captureStatus === 'connecting'
|
||||
? 'Connecting to audio…'
|
||||
: state.rendererState.captureStatus === 'error'
|
||||
? 'Audio capture error'
|
||||
: 'Capture stopped'
|
||||
|
||||
return {
|
||||
...state,
|
||||
statusLabel: `Prism — ${statusText}`,
|
||||
tooltip: sourceLabel && state.rendererState.captureStatus === 'capturing'
|
||||
? `Prism — ${sourceLabel}`
|
||||
: 'Prism',
|
||||
mainWindowActionLabel: state.mainWindowVisible ? 'Hide Prism' : 'Show Prism',
|
||||
captureActionLabel: state.rendererState.captureStatus === 'capturing'
|
||||
|| state.rendererState.captureStatus === 'connecting'
|
||||
? 'Stop Capture'
|
||||
: 'Start Capture',
|
||||
captureActionEnabled: state.rendererReady,
|
||||
}
|
||||
}
|
||||
|
||||
export function createTrayMenuStateKey(model: TrayMenuModel): string {
|
||||
return JSON.stringify(model)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { TrayRendererCommand } from '../../types/desktopIntegration'
|
||||
|
||||
export class TrayRendererCommandQueue {
|
||||
private commands: TrayRendererCommand[] = []
|
||||
|
||||
enqueue(command: TrayRendererCommand): void {
|
||||
this.commands.push(command)
|
||||
}
|
||||
|
||||
flush(send: (command: TrayRendererCommand) => void): void {
|
||||
for (const command of this.commands) {
|
||||
send(command)
|
||||
}
|
||||
this.commands = []
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.commands = []
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { dirname } from 'node:path'
|
||||
import { createEmptyWindowLocalState, normalizeWindowLocalState } from '../shared/windowState'
|
||||
import type { WindowBounds } from '../types/popout'
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
import type { PrismWindowLocalStateV1, WindowBackgroundState } from '../types/windowState'
|
||||
import type { PrismWindowLocalStateV1 } from '../types/windowState'
|
||||
|
||||
export class FileBackedWindowStateStore {
|
||||
private state: PrismWindowLocalStateV1 = createEmptyWindowLocalState()
|
||||
@@ -26,10 +26,6 @@ export class FileBackedWindowStateStore {
|
||||
return this.state.popoutAlwaysOnTop[kind] === true
|
||||
}
|
||||
|
||||
getWindowBackground(): WindowBackgroundState {
|
||||
return { ...this.state.windowBackground }
|
||||
}
|
||||
|
||||
getNowPlayingConfigWindowBounds(): WindowBounds | undefined {
|
||||
return this.state.nowPlayingConfigWindowBounds
|
||||
? { ...this.state.nowPlayingConfigWindowBounds }
|
||||
@@ -63,16 +59,6 @@ export class FileBackedWindowStateStore {
|
||||
await this.persistState()
|
||||
}
|
||||
|
||||
async setWindowBackground(background: WindowBackgroundState): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
this.state = normalizeWindowLocalState({
|
||||
...this.state,
|
||||
windowBackground: background,
|
||||
popoutAlwaysOnTop: { ...this.state.popoutAlwaysOnTop },
|
||||
})
|
||||
await this.persistState()
|
||||
}
|
||||
|
||||
async setNowPlayingConfigWindowBounds(bounds?: WindowBounds): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
this.state = normalizeWindowLocalState({
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { LUFSMeterNativeAnalyzer, LUFSMeterNativeSnapshot } from '../render
|
||||
/**
|
||||
* Drop-in `LUFSMeterNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* The loudness DSP (K-weighting, gated integration, true peak, fast VU/correlation) runs
|
||||
* The loudness DSP (K-weighting, gated integration, fast VU/peak/correlation) runs
|
||||
* in the C++ plugin, which pushes a finished scalar snapshot each frame. This shim
|
||||
* caches that snapshot and serves it through the interface `LUFSMeter` consumes.
|
||||
* `pushSamples` is a no-op (audio never flows through the webview); `reset` clears
|
||||
|
||||
@@ -20,17 +20,15 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
|
||||
private sampleRate = 48000
|
||||
private magnitudes: Float32Array
|
||||
private sideMagnitudes: Float32Array
|
||||
private channelMaxMagnitudes: Float32Array
|
||||
|
||||
constructor(fftSize = 2048) {
|
||||
this.fftSize = fftSize
|
||||
this.magnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
|
||||
this.sideMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
|
||||
this.channelMaxMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB)
|
||||
}
|
||||
|
||||
/** Called by the bridge whenever the host emits a new frame. */
|
||||
setMagnitudes(magnitudes: Float32Array, side?: Float32Array, channelMax?: Float32Array): void {
|
||||
setMagnitudes(magnitudes: Float32Array, side?: Float32Array): void {
|
||||
if (magnitudes.length !== this.magnitudes.length) {
|
||||
this.magnitudes = new Float32Array(magnitudes.length)
|
||||
}
|
||||
@@ -42,12 +40,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
|
||||
}
|
||||
this.sideMagnitudes.set(side)
|
||||
}
|
||||
|
||||
const resolvedChannelMax = channelMax && channelMax.length > 0 ? channelMax : magnitudes
|
||||
if (resolvedChannelMax.length !== this.channelMaxMagnitudes.length) {
|
||||
this.channelMaxMagnitudes = new Float32Array(resolvedChannelMax.length)
|
||||
}
|
||||
this.channelMaxMagnitudes.set(resolvedChannelMax)
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
@@ -59,7 +51,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
|
||||
this.fftSize = size
|
||||
this.magnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
|
||||
this.sideMagnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
|
||||
this.channelMaxMagnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +91,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
|
||||
return count
|
||||
}
|
||||
|
||||
fillChannelMaxMagnitudes(output: Float32Array): number {
|
||||
const count = Math.min(output.length, this.channelMaxMagnitudes.length)
|
||||
if (count > 0) {
|
||||
output.set(this.channelMaxMagnitudes.subarray(0, count), 0)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
getMagnitudes(): Float32Array {
|
||||
return this.magnitudes
|
||||
}
|
||||
@@ -120,10 +103,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
|
||||
return this.sideMagnitudes
|
||||
}
|
||||
|
||||
getChannelMaxMagnitudes(): Float32Array {
|
||||
return this.channelMaxMagnitudes
|
||||
}
|
||||
|
||||
process(_audioData: Float32Array): Float32Array {
|
||||
return this.magnitudes
|
||||
}
|
||||
@@ -135,6 +114,5 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer {
|
||||
reset(): void {
|
||||
this.magnitudes.fill(FFT_SILENCE_DB)
|
||||
this.sideMagnitudes.fill(FFT_SILENCE_DB)
|
||||
this.channelMaxMagnitudes.fill(FFT_SILENCE_DB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { VectorscopeNativeAnalyzer, VectorscopeMultibandPointsResult } from
|
||||
/**
|
||||
* Drop-in `VectorscopeNativeAnalyzer` for the plugin webview.
|
||||
*
|
||||
* The vectorscope DSP (full-band channels + 3-band split, circular buffers) runs in
|
||||
* The vectorscope DSP (channel lowpass + 3-band split, circular buffers) runs in
|
||||
* the C++ plugin, which pushes the most recent display points each frame — either
|
||||
* a standard X/Y cloud or a multiband (6 floats/point: lowL,lowR,midL,midR,highL,
|
||||
* highR) cloud, depending on the active mode. This shim caches whichever arrived
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useEffect, useLayoutEffect, useRef, type JSX } from 'react'
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { Oscilloscope } from '../renderer/visualizers/Oscilloscope'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedOscilloscopeTheme } from '../types/theme'
|
||||
import type { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { oscilloscopeSettingsToOptions } from './oscilloscopeOptions'
|
||||
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
||||
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
||||
import {
|
||||
ScopeMeasurementOverlay,
|
||||
useScopeMeasurement,
|
||||
} from '../renderer/components/ScopeMeasurementOverlay'
|
||||
|
||||
interface OscilloscopeScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
@@ -28,16 +22,6 @@ export default function OscilloscopeScope({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Oscilloscope | null>(null)
|
||||
const rotationRef = useRef(settings.rotation)
|
||||
const applySizeRef = useRef<(() => void) | null>(null)
|
||||
rotationRef.current = settings.rotation
|
||||
const measurementController = useScopeMeasurement({
|
||||
containerRef,
|
||||
enabled: true,
|
||||
rotation: settings.rotation,
|
||||
mirrorHorizontal: settings.mirrorHorizontal,
|
||||
getSource: () => vizRef.current,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
@@ -52,12 +36,16 @@ export default function OscilloscopeScope({
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const { changed } = applyPluginScopeCanvasLayout(container, canvas, rotationRef.current)
|
||||
if (changed) {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const pixelWidth = Math.max(1, Math.floor(rect.width * dpr))
|
||||
const pixelHeight = Math.max(1, Math.floor(rect.height * dpr))
|
||||
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
||||
canvas.width = pixelWidth
|
||||
canvas.height = pixelHeight
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
applySizeRef.current = applySize
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
@@ -66,7 +54,6 @@ export default function OscilloscopeScope({
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
applySizeRef.current = null
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
@@ -78,25 +65,9 @@ export default function OscilloscopeScope({
|
||||
vizRef.current?.setOptions(oscilloscopeSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
applySizeRef.current?.()
|
||||
}, [settings.rotation])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
|
||||
{...measurementController.pointerBindings}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="spectrum-scope__canvas"
|
||||
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
||||
/>
|
||||
<ScopeMeasurementOverlay
|
||||
containerRef={containerRef}
|
||||
measurement={measurementController.measurement}
|
||||
/>
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, type JSX } from 'react'
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { Spectrogram } from '../renderer/visualizers/Spectrogram'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedSpectrogramTheme } from '../types/theme'
|
||||
@@ -6,12 +6,6 @@ import type { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { spectrogramSettingsToOptions } from './spectrogramOptions'
|
||||
import { resolveScrollingCanvasSize } from './scrollingCanvas'
|
||||
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
||||
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
||||
import {
|
||||
ScopeMeasurementOverlay,
|
||||
useScopeMeasurement,
|
||||
} from '../renderer/components/ScopeMeasurementOverlay'
|
||||
|
||||
interface SpectrogramScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
@@ -29,16 +23,6 @@ export default function SpectrogramScope({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Spectrogram | null>(null)
|
||||
const rotationRef = useRef(settings.rotation)
|
||||
const applySizeRef = useRef<(() => void) | null>(null)
|
||||
rotationRef.current = settings.rotation
|
||||
const measurementController = useScopeMeasurement({
|
||||
containerRef,
|
||||
enabled: true,
|
||||
rotation: settings.rotation,
|
||||
mirrorHorizontal: settings.mirrorHorizontal,
|
||||
getSource: () => vizRef.current,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
@@ -53,17 +37,15 @@ export default function SpectrogramScope({
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const { changed } = applyPluginScopeCanvasLayout(
|
||||
container,
|
||||
canvas,
|
||||
rotationRef.current,
|
||||
resolveScrollingCanvasSize,
|
||||
)
|
||||
if (changed) {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr)
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
applySizeRef.current = applySize
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
@@ -72,7 +54,6 @@ export default function SpectrogramScope({
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
applySizeRef.current = null
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
@@ -84,25 +65,9 @@ export default function SpectrogramScope({
|
||||
vizRef.current?.setOptions(spectrogramSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
applySizeRef.current?.()
|
||||
}, [settings.rotation])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
|
||||
{...measurementController.pointerBindings}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="spectrum-scope__canvas"
|
||||
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
||||
/>
|
||||
<ScopeMeasurementOverlay
|
||||
containerRef={containerRef}
|
||||
measurement={measurementController.measurement}
|
||||
/>
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,10 @@ import type { SpectrumPeakInfo } from '../types/spectrum'
|
||||
import type { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { spectrumSettingsToOptions } from './spectrumOptions'
|
||||
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
||||
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
||||
import {
|
||||
ScopeMeasurementOverlay,
|
||||
useScopeMeasurement,
|
||||
} from '../renderer/components/ScopeMeasurementOverlay'
|
||||
import { emitToHost } from './juceBridge'
|
||||
import {
|
||||
formatSpectrumPeakDbfs,
|
||||
formatSpectrumPeakDb,
|
||||
formatSpectrumPeakFrequency,
|
||||
measureCanvasResizeState,
|
||||
resolveFollowingPeakOverlayStyle,
|
||||
type CanvasResizeState,
|
||||
type SizeMeasurement,
|
||||
@@ -38,24 +32,11 @@ export default function SpectrumScope({
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const analyzerRef = useRef<SpectrumAnalyzer | null>(null)
|
||||
const resizeStateRef = useRef<CanvasResizeState | null>(null)
|
||||
const rotationRef = useRef(settings.rotation)
|
||||
const applySizeRef = useRef<(() => void) | null>(null)
|
||||
const peakOverlayRef = useRef<HTMLDivElement | null>(null)
|
||||
const [peak, setPeak] = useState<SpectrumPeakInfo | null>(null)
|
||||
const [overlaySize, setOverlaySize] = useState<SizeMeasurement | null>(null)
|
||||
|
||||
const peakMode = settings.peakInfoMode
|
||||
rotationRef.current = settings.rotation
|
||||
const measurementController = useScopeMeasurement({
|
||||
containerRef,
|
||||
enabled: true,
|
||||
rotation: settings.rotation,
|
||||
mirrorHorizontal: settings.mirrorHorizontal,
|
||||
getSource: () => analyzerRef.current,
|
||||
onActiveChange: (active) => {
|
||||
emitToHost('prismScopeMeasurement', { active })
|
||||
},
|
||||
})
|
||||
|
||||
// Create the analyzer once per data source / shim.
|
||||
useEffect(() => {
|
||||
@@ -73,13 +54,14 @@ export default function SpectrumScope({
|
||||
analyzerRef.current = analyzer
|
||||
|
||||
const applySize = (): void => {
|
||||
const { changed, layout } = applyPluginScopeCanvasLayout(container, canvas, rotationRef.current)
|
||||
resizeStateRef.current = layout
|
||||
if (changed) {
|
||||
const state = measureCanvasResizeState(container)
|
||||
resizeStateRef.current = state
|
||||
if (canvas.width !== state.pixelWidth || canvas.height !== state.pixelHeight) {
|
||||
canvas.width = state.pixelWidth
|
||||
canvas.height = state.pixelHeight
|
||||
analyzer.resize()
|
||||
}
|
||||
}
|
||||
applySizeRef.current = applySize
|
||||
|
||||
applySize()
|
||||
analyzer.start()
|
||||
@@ -88,7 +70,6 @@ export default function SpectrumScope({
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
applySizeRef.current = null
|
||||
analyzer.dispose()
|
||||
analyzerRef.current = null
|
||||
}
|
||||
@@ -106,10 +87,6 @@ export default function SpectrumScope({
|
||||
})
|
||||
}, [settings, theme])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
applySizeRef.current?.()
|
||||
}, [settings.rotation])
|
||||
|
||||
// Measure the overlay so "following" placement can avoid the screen edges.
|
||||
useLayoutEffect(() => {
|
||||
const overlay = peakOverlayRef.current
|
||||
@@ -130,37 +107,19 @@ export default function SpectrumScope({
|
||||
const showPeak = peakMode !== 'off' && peak !== null
|
||||
const overlayStyle: CSSProperties | undefined =
|
||||
peakMode === 'following' && peak
|
||||
? resolveFollowingPeakOverlayStyle(
|
||||
peak,
|
||||
resizeStateRef.current,
|
||||
overlaySize,
|
||||
settings.rotation,
|
||||
settings.mirrorHorizontal,
|
||||
)
|
||||
? resolveFollowingPeakOverlayStyle(peak, resizeStateRef.current, overlaySize)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
|
||||
{...measurementController.pointerBindings}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="spectrum-scope__canvas"
|
||||
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
||||
/>
|
||||
<ScopeMeasurementOverlay
|
||||
containerRef={containerRef}
|
||||
measurement={measurementController.measurement}
|
||||
/>
|
||||
{!measurementController.active && showPeak && peak && (
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
{showPeak && peak && (
|
||||
<div
|
||||
ref={peakMode === 'following' ? peakOverlayRef : null}
|
||||
className={['scope-module__peak-info', peakMode === 'following' ? 'is-following' : 'is-corner'].join(' ')}
|
||||
style={overlayStyle}
|
||||
>
|
||||
<span className="scope-module__peak-info-value">{formatSpectrumPeakDbfs(peak.dbfs)}</span>
|
||||
<span className="scope-module__peak-info-value">{formatSpectrumPeakDb(peak.db)}</span>
|
||||
<span className="scope-module__peak-info-separator">/</span>
|
||||
<span className="scope-module__peak-info-value">{formatSpectrumPeakFrequency(peak.frequencyHz)}</span>
|
||||
<span className="scope-module__peak-info-separator">/</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, type JSX } from 'react'
|
||||
import { useEffect, useRef, type JSX } from 'react'
|
||||
import { Waveform } from '../renderer/visualizers/Waveform'
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedWaveformTheme } from '../types/theme'
|
||||
@@ -6,12 +6,6 @@ import type { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer'
|
||||
import type { PluginWebViewDataSource } from './PluginWebViewDataSource'
|
||||
import { waveformSettingsToOptions } from './waveformOptions'
|
||||
import { resolveScrollingCanvasSize } from './scrollingCanvas'
|
||||
import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform'
|
||||
import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout'
|
||||
import {
|
||||
ScopeMeasurementOverlay,
|
||||
useScopeMeasurement,
|
||||
} from '../renderer/components/ScopeMeasurementOverlay'
|
||||
|
||||
interface WaveformScopeProps {
|
||||
dataSource: PluginWebViewDataSource
|
||||
@@ -29,16 +23,6 @@ export default function WaveformScope({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const vizRef = useRef<Waveform | null>(null)
|
||||
const rotationRef = useRef(settings.rotation)
|
||||
const applySizeRef = useRef<(() => void) | null>(null)
|
||||
rotationRef.current = settings.rotation
|
||||
const measurementController = useScopeMeasurement({
|
||||
containerRef,
|
||||
enabled: true,
|
||||
rotation: settings.rotation,
|
||||
mirrorHorizontal: settings.mirrorHorizontal,
|
||||
getSource: () => vizRef.current,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
@@ -53,17 +37,15 @@ export default function WaveformScope({
|
||||
vizRef.current = viz
|
||||
|
||||
const applySize = (): void => {
|
||||
const { changed } = applyPluginScopeCanvasLayout(
|
||||
container,
|
||||
canvas,
|
||||
rotationRef.current,
|
||||
resolveScrollingCanvasSize,
|
||||
)
|
||||
if (changed) {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const { width, height } = resolveScrollingCanvasSize(rect.width, rect.height, dpr)
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
viz.resize()
|
||||
}
|
||||
}
|
||||
applySizeRef.current = applySize
|
||||
|
||||
applySize()
|
||||
viz.start()
|
||||
@@ -72,7 +54,6 @@ export default function WaveformScope({
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
applySizeRef.current = null
|
||||
viz.dispose()
|
||||
vizRef.current = null
|
||||
}
|
||||
@@ -84,25 +65,9 @@ export default function WaveformScope({
|
||||
vizRef.current?.setOptions(waveformSettingsToOptions(settings, theme))
|
||||
}, [settings, theme])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
applySizeRef.current?.()
|
||||
}, [settings.rotation])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`spectrum-scope scope-measurement-surface ${measurementController.active ? 'is-measuring' : ''}`.trim()}
|
||||
{...measurementController.pointerBindings}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="spectrum-scope__canvas"
|
||||
style={getScopeCanvasTransformStyle(settings.rotation, settings.mirrorHorizontal)}
|
||||
/>
|
||||
<ScopeMeasurementOverlay
|
||||
containerRef={containerRef}
|
||||
measurement={measurementController.measurement}
|
||||
/>
|
||||
<div ref={containerRef} className="spectrum-scope">
|
||||
<canvas ref={canvasRef} className="spectrum-scope__canvas" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,15 +16,12 @@ export interface SpectrumFrame {
|
||||
magnitudes: Float32Array
|
||||
/** Side magnitudes in dB (same length); empty if unavailable. */
|
||||
side: Float32Array
|
||||
/** Smoothed max(L, R) magnitudes in dBFS (same length). */
|
||||
channelMax: Float32Array
|
||||
}
|
||||
|
||||
interface SpectrumFramePayload {
|
||||
sampleRate?: number
|
||||
magnitudes?: string
|
||||
side?: string
|
||||
channelMax?: string
|
||||
}
|
||||
|
||||
type JuceBackend = {
|
||||
@@ -103,19 +100,14 @@ export function base64ToFloat32Array(b64: string): Float32Array {
|
||||
return new Float32Array(bytes.buffer, 0, byteLength >> 2)
|
||||
}
|
||||
|
||||
export function decodeSpectrumFrame(payload: unknown): SpectrumFrame | null {
|
||||
function decodeFrame(payload: unknown): SpectrumFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const { sampleRate, magnitudes, side, channelMax } = payload as SpectrumFramePayload
|
||||
const { sampleRate, magnitudes, side } = payload as SpectrumFramePayload
|
||||
if (typeof magnitudes !== 'string' || magnitudes.length === 0) return null
|
||||
const decodedMagnitudes = base64ToFloat32Array(magnitudes)
|
||||
const decodedChannelMax = typeof channelMax === 'string'
|
||||
? base64ToFloat32Array(channelMax)
|
||||
: new Float32Array(0)
|
||||
return {
|
||||
sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000,
|
||||
magnitudes: decodedMagnitudes,
|
||||
magnitudes: base64ToFloat32Array(magnitudes),
|
||||
side: typeof side === 'string' ? base64ToFloat32Array(side) : new Float32Array(0),
|
||||
channelMax: decodedChannelMax.length > 0 ? decodedChannelMax : decodedMagnitudes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +128,6 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v
|
||||
const sampleRate = 48000
|
||||
const mid = new Float32Array(binCount)
|
||||
const side = new Float32Array(binCount).fill(-100)
|
||||
const channelMax = new Float32Array(binCount)
|
||||
let phase = 0
|
||||
const tick = (): void => {
|
||||
if (disposed) return
|
||||
@@ -147,9 +138,8 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v
|
||||
const peak2 = Math.exp(-Math.pow((t - 0.5) * 18, 2)) * 50
|
||||
mid[i] = -100 + peak1 + peak2 + Math.random() * 6
|
||||
side[i] = -100 + peak2 * 0.4 + Math.random() * 4
|
||||
channelMax[i] = mid[i]
|
||||
}
|
||||
handlers.onFrame({ sampleRate, magnitudes: mid, side, channelMax })
|
||||
handlers.onFrame({ sampleRate, magnitudes: mid, side })
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
}
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
@@ -159,7 +149,7 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('spectrumFrame', (payload) => {
|
||||
const frame = decodeSpectrumFrame(payload)
|
||||
const frame = decodeFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
@@ -275,25 +265,16 @@ export interface LUFSMeterFrame {
|
||||
vuRDb: number
|
||||
barLDb: number
|
||||
barRDb: number
|
||||
truePeakLDb: number
|
||||
truePeakRDb: number
|
||||
maxTruePeakDb: number
|
||||
peakLDb: number
|
||||
peakRDb: number
|
||||
correlation: number
|
||||
}
|
||||
|
||||
export function decodeLUFSMeterFrame(
|
||||
payload: unknown,
|
||||
previousMaxTruePeakDb = -60,
|
||||
): LUFSMeterFrame | null {
|
||||
function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null
|
||||
const p = payload as Record<string, unknown>
|
||||
const num = (key: string, fallback: number): number =>
|
||||
typeof p[key] === 'number' && Number.isFinite(p[key]) ? (p[key] as number) : fallback
|
||||
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),
|
||||
@@ -303,13 +284,8 @@ export function decodeLUFSMeterFrame(
|
||||
vuRDb: num('vuRDb', -60),
|
||||
barLDb: num('barLDb', -60),
|
||||
barRDb: num('barRDb', -60),
|
||||
truePeakLDb,
|
||||
truePeakRDb,
|
||||
maxTruePeakDb: explicitMaximum ?? Math.max(
|
||||
Number.isFinite(previousMaxTruePeakDb) ? previousMaxTruePeakDb : -60,
|
||||
truePeakLDb,
|
||||
truePeakRDb,
|
||||
),
|
||||
peakLDb: num('peakLDb', -60),
|
||||
peakRDb: num('peakRDb', -60),
|
||||
correlation: num('correlation', 0),
|
||||
}
|
||||
}
|
||||
@@ -323,8 +299,6 @@ 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)
|
||||
@@ -336,9 +310,6 @@ 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),
|
||||
@@ -348,9 +319,8 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
|
||||
vuRDb: vuR,
|
||||
barLDb: vuL,
|
||||
barRDb: vuR,
|
||||
truePeakLDb,
|
||||
truePeakRDb,
|
||||
maxTruePeakDb,
|
||||
peakLDb: vuL + 3,
|
||||
peakRDb: vuR + 3,
|
||||
correlation: Math.sin(phase * 0.3),
|
||||
})
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
@@ -362,15 +332,8 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('lufsmeterFrame', (payload) => {
|
||||
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)
|
||||
}
|
||||
const frame = decodeLUFSMeterFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
})
|
||||
handlers.onConnected?.(false)
|
||||
console.log('[prism-plugin] connected to JUCE host (lufsmeter)')
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { StrictMode, type JSX } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '@fontsource/jetbrains-mono/latin-400.css'
|
||||
import '@fontsource/jetbrains-mono/latin-600.css'
|
||||
import '@fontsource/jetbrains-mono/latin-700.css'
|
||||
import '../renderer/styles/globals.css'
|
||||
import './styles.css'
|
||||
import ScopeApp from './ScopeApp'
|
||||
@@ -188,7 +185,7 @@ function buildApp(): JSX.Element {
|
||||
const analyzer = new BridgeSpectrumAnalyzer(2048)
|
||||
connectSpectrumBridge({
|
||||
onFrame: (frame) => {
|
||||
analyzer.setMagnitudes(frame.magnitudes, frame.side, frame.channelMax)
|
||||
analyzer.setMagnitudes(frame.magnitudes, frame.side)
|
||||
dataSource.setSampleRate(frame.sampleRate)
|
||||
dataSource.setPlaying(true)
|
||||
},
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { SpectrumPeakInfo } from '../types/spectrum'
|
||||
import type { ScopeDisplayRotation } from '../types/scopeTransform'
|
||||
import {
|
||||
measureScopeCanvasLayout,
|
||||
transformNormalizedScopePoint,
|
||||
type ScopeCanvasLayout,
|
||||
} from '../renderer/scopeCanvasTransform'
|
||||
|
||||
/**
|
||||
* Peak-overlay positioning + formatting, mirroring ScopeModule.tsx so the plugin's
|
||||
@@ -13,7 +7,13 @@ import {
|
||||
* copy (pure functions) so the plugin doesn't import the heavy ScopeModule.
|
||||
*/
|
||||
|
||||
export type CanvasResizeState = ScopeCanvasLayout
|
||||
export interface CanvasResizeState {
|
||||
cssWidth: number
|
||||
cssHeight: number
|
||||
pixelWidth: number
|
||||
pixelHeight: number
|
||||
dpr: number
|
||||
}
|
||||
|
||||
export interface SizeMeasurement {
|
||||
width: number
|
||||
@@ -28,11 +28,11 @@ function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function formatSpectrumPeakDbfs(value: number): string {
|
||||
export function formatSpectrumPeakDb(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '--'
|
||||
}
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dBFS`
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dB`
|
||||
}
|
||||
|
||||
export function formatSpectrumPeakFrequency(value: number): string {
|
||||
@@ -45,19 +45,25 @@ export function formatSpectrumPeakFrequency(value: number): string {
|
||||
return `${value.toFixed(2)}Hz`
|
||||
}
|
||||
|
||||
export function measureCanvasResizeState(
|
||||
container: HTMLElement,
|
||||
rotation: ScopeDisplayRotation = 0,
|
||||
): CanvasResizeState {
|
||||
return measureScopeCanvasLayout(container, rotation)
|
||||
export function measureCanvasResizeState(container: HTMLElement): CanvasResizeState {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const cssWidth = Math.max(1, Math.floor(rect.width))
|
||||
const cssHeight = Math.max(1, Math.floor(rect.height))
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
return {
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
pixelWidth: Math.max(1, Math.floor(cssWidth * dpr)),
|
||||
pixelHeight: Math.max(1, Math.floor(cssHeight * dpr)),
|
||||
dpr,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFollowingPeakOverlayStyle(
|
||||
peakInfo: SpectrumPeakInfo,
|
||||
resizeState: CanvasResizeState | null,
|
||||
overlaySize: SizeMeasurement | null,
|
||||
rotation: ScopeDisplayRotation = 0,
|
||||
mirrorHorizontal = false,
|
||||
): CSSProperties {
|
||||
if (!resizeState) {
|
||||
return {
|
||||
@@ -66,17 +72,12 @@ export function resolveFollowingPeakOverlayStyle(
|
||||
}
|
||||
}
|
||||
|
||||
const width = resizeState.viewportCssWidth
|
||||
const height = resizeState.viewportCssHeight
|
||||
const width = resizeState.cssWidth
|
||||
const height = resizeState.cssHeight
|
||||
const overlayWidth = overlaySize?.width ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_WIDTH_PX
|
||||
const overlayHeight = overlaySize?.height ?? SPECTRUM_PEAK_OVERLAY_FALLBACK_HEIGHT_PX
|
||||
const transformedPeak = transformNormalizedScopePoint(
|
||||
{ x: peakInfo.normalizedX, y: peakInfo.normalizedY },
|
||||
rotation,
|
||||
mirrorHorizontal,
|
||||
)
|
||||
const peakX = transformedPeak.x * width
|
||||
const peakY = transformedPeak.y * height
|
||||
const peakX = peakInfo.normalizedX * width
|
||||
const peakY = peakInfo.normalizedY * height
|
||||
const maxLeft = Math.max(
|
||||
SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
|
||||
width - overlayWidth - SPECTRUM_PEAK_OVERLAY_MARGIN_PX,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { ScopeDisplayRotation } from '../types/scopeTransform'
|
||||
import {
|
||||
measureScopeCanvasLayout,
|
||||
type ScopeCanvasLayout,
|
||||
} from '../renderer/scopeCanvasTransform'
|
||||
|
||||
type PixelSizeResolver = (
|
||||
cssWidth: number,
|
||||
cssHeight: number,
|
||||
dpr: number,
|
||||
) => { width: number; height: number }
|
||||
|
||||
export function applyPluginScopeCanvasLayout(
|
||||
container: HTMLElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
rotation: ScopeDisplayRotation,
|
||||
pixelSizeResolver?: PixelSizeResolver,
|
||||
): { changed: boolean; layout: ScopeCanvasLayout } {
|
||||
const layout = measureScopeCanvasLayout(container, rotation)
|
||||
const resolvedPixels = pixelSizeResolver
|
||||
? pixelSizeResolver(layout.cssWidth, layout.cssHeight, layout.dpr)
|
||||
: { width: layout.pixelWidth, height: layout.pixelHeight }
|
||||
|
||||
canvas.style.width = `${layout.cssWidth}px`
|
||||
canvas.style.height = `${layout.cssHeight}px`
|
||||
|
||||
const changed = canvas.width !== resolvedPixels.width || canvas.height !== resolvedPixels.height
|
||||
if (changed) {
|
||||
canvas.width = resolvedPixels.width
|
||||
canvas.height = resolvedPixels.height
|
||||
}
|
||||
|
||||
return { changed, layout }
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedSpectrogramTheme } from '../types/theme'
|
||||
import type { SpectrogramOptions } from '../renderer/visualizers/Spectrogram'
|
||||
import { nominalFrequencyBoundsForRange } from '../types/frequencyScale'
|
||||
|
||||
/**
|
||||
* Map Prism's spectrogram settings + resolved theme to Spectrogram options.
|
||||
@@ -11,23 +10,17 @@ export function spectrogramSettingsToOptions(
|
||||
settings: ScopeSettings['spectrogram'],
|
||||
theme: ResolvedSpectrogramTheme,
|
||||
): SpectrogramOptions {
|
||||
const range = nominalFrequencyBoundsForRange(settings.frequencyRangeMode)
|
||||
return {
|
||||
lineColor: theme.mono,
|
||||
heatColors: theme.heatColors,
|
||||
backgroundColor: theme.background,
|
||||
gridColor: theme.guides,
|
||||
labelColor: theme.labels,
|
||||
minFrequency: range.minFrequency,
|
||||
maxFrequency: range.maxFrequency,
|
||||
fftSize: settings.fftSize,
|
||||
tiltDbPerOctave: settings.tiltDbPerOctave,
|
||||
scrollSpeed: settings.scrollSpeed,
|
||||
contrast: settings.contrast,
|
||||
clarityMode: settings.clarityMode,
|
||||
scaleMode: settings.scaleMode,
|
||||
showGrid: settings.showGrid,
|
||||
orientation: 'horizontal',
|
||||
orientation: settings.orientation,
|
||||
colorScheme: settings.colorScheme,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ScopeSettings } from '../types/settings'
|
||||
import type { ResolvedSpectrumTheme } from '../types/theme'
|
||||
import type { SpectrumAnalyzerOptions } from '../renderer/visualizers/SpectrumAnalyzer'
|
||||
import { nominalFrequencyBoundsForRange } from '../types/frequencyScale'
|
||||
|
||||
/**
|
||||
* Map Prism's spectrum settings + resolved theme to SpectrumAnalyzer options.
|
||||
@@ -12,7 +11,6 @@ export function spectrumSettingsToOptions(
|
||||
settings: ScopeSettings['spectrum'],
|
||||
theme: ResolvedSpectrumTheme,
|
||||
): SpectrumAnalyzerOptions {
|
||||
const range = nominalFrequencyBoundsForRange(settings.frequencyRangeMode)
|
||||
return {
|
||||
lineColor: theme.line,
|
||||
secondaryLineColor: theme.sideLine,
|
||||
@@ -21,9 +19,6 @@ export function spectrumSettingsToOptions(
|
||||
heatBaseColor: theme.heatBase,
|
||||
backgroundColor: theme.background,
|
||||
gridColor: theme.guides,
|
||||
scaleType: settings.scaleMode,
|
||||
minFrequency: range.minFrequency,
|
||||
maxFrequency: range.maxFrequency,
|
||||
fftSize: settings.fftSize,
|
||||
tiltDbPerOctave: settings.tiltDbPerOctave,
|
||||
heatmapFill: settings.heatmap,
|
||||
|
||||
@@ -3,13 +3,21 @@ import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
import type { PrismResolvedTheme } from '../types/theme'
|
||||
import { createBundledThemes, createDefaultTheme, parseThemeFileContent, resolveTheme } from '../shared/themeState'
|
||||
import { mergeScopeSettings as normalizeAllScopeSettings } from '../shared/profileState'
|
||||
import { emitToHost, onHostEvent } from './juceBridge'
|
||||
|
||||
const DEFAULT_THEME = resolveTheme(createDefaultTheme())
|
||||
|
||||
function mergeScopeSettings<K extends ScopeKind>(kind: K, raw: unknown): ScopeSettings[K] {
|
||||
return normalizeAllScopeSettings({ [kind]: raw })[kind]
|
||||
const defaults = DEFAULT_SCOPE_SETTINGS[kind] as Record<string, unknown>
|
||||
if (typeof raw !== 'object' || raw === null) return { ...defaults } as ScopeSettings[K]
|
||||
const parsed = raw as Record<string, unknown>
|
||||
const next: Record<string, unknown> = { ...defaults }
|
||||
for (const key of Object.keys(defaults)) {
|
||||
if (key in parsed && typeof parsed[key] === typeof defaults[key]) {
|
||||
next[key] = parsed[key]
|
||||
}
|
||||
}
|
||||
return next as ScopeSettings[K]
|
||||
}
|
||||
|
||||
function resolveAppTheme(themeId: string, themeFile: string): PrismResolvedTheme {
|
||||
|
||||
@@ -16,14 +16,12 @@ export function vectorscopeSettingsToOptions(
|
||||
gridMajorColor: theme.guides,
|
||||
gridMinorColor: theme.guidesSecondary,
|
||||
labelColor: theme.labels,
|
||||
phaseRiskColor: theme.phaseRisk,
|
||||
bandColors: {
|
||||
low: theme.bandLow,
|
||||
mid: theme.bandMid,
|
||||
high: theme.bandHigh,
|
||||
},
|
||||
mode: settings.mode,
|
||||
zoomDb: settings.zoomDb,
|
||||
multiband: settings.multiband,
|
||||
showGrid: settings.showGrid,
|
||||
persistence: settings.persistence,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { AppBuildInfo } from '../types/appBuildInfo'
|
||||
import type { AudioClipDragPayload } from '../types/audioClip'
|
||||
import type { CaptureBackendSupport } from '../types/capture'
|
||||
import type { NativeCaptureAPI } from '../types/nativeCapture'
|
||||
import type {
|
||||
@@ -33,13 +32,6 @@ import type { DialogOptions, DialogResult } from '../types/dialog'
|
||||
import type { UpdateCheckResult } from '../types/updates'
|
||||
import type { WindowCapabilities } from '../types/windowCapabilities'
|
||||
import type { ResizeDirection } from '../types/windowResize'
|
||||
import type { WindowBackgroundSnapshot, WindowBackgroundState } from '../types/windowState'
|
||||
import type {
|
||||
DesktopIntegrationSnapshot,
|
||||
LoginLaunchMode,
|
||||
TrayRendererCommand,
|
||||
TrayRendererState,
|
||||
} from '../types/desktopIntegration'
|
||||
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
|
||||
import { resolveWindowCapabilities } from '../shared/windowCapabilities'
|
||||
import { getCaptureBackendSupport } from './captureSupport'
|
||||
@@ -49,7 +41,6 @@ const windowCapabilities: WindowCapabilities = resolveWindowCapabilities({
|
||||
platform: process.platform,
|
||||
argv: process.argv,
|
||||
env: process.env,
|
||||
osVersion: process.getSystemVersion(),
|
||||
})
|
||||
|
||||
// Expose Electron API to renderer
|
||||
@@ -59,38 +50,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getAppBuildInfo: () => ipcRenderer.invoke('app:get-build-info') as Promise<AppBuildInfo>,
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
desktopIntegration: {
|
||||
get: () => ipcRenderer.invoke('desktop-integration:get') as Promise<DesktopIntegrationSnapshot>,
|
||||
setCloseToTray: (enabled: boolean) => ipcRenderer.invoke(
|
||||
'desktop-integration:set-close-to-tray',
|
||||
enabled,
|
||||
) as Promise<DesktopIntegrationSnapshot>,
|
||||
setOpenAtLogin: (enabled: boolean) => ipcRenderer.invoke(
|
||||
'desktop-integration:set-open-at-login',
|
||||
enabled,
|
||||
) as Promise<DesktopIntegrationSnapshot>,
|
||||
setLoginLaunchMode: (mode: LoginLaunchMode) => ipcRenderer.invoke(
|
||||
'desktop-integration:set-login-launch-mode',
|
||||
mode,
|
||||
) as Promise<DesktopIntegrationSnapshot>,
|
||||
onChanged: (callback: (snapshot: DesktopIntegrationSnapshot) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, snapshot: DesktopIntegrationSnapshot): void => {
|
||||
callback(snapshot)
|
||||
}
|
||||
ipcRenderer.on('desktop-integration:changed', handler)
|
||||
return () => ipcRenderer.removeListener('desktop-integration:changed', handler)
|
||||
},
|
||||
},
|
||||
trayControls: {
|
||||
markReady: () => ipcRenderer.send('tray-controls:renderer-ready'),
|
||||
markNotReady: () => ipcRenderer.send('tray-controls:renderer-not-ready'),
|
||||
publishState: (state: TrayRendererState) => ipcRenderer.send('tray-controls:publish-state', state),
|
||||
onCommand: (callback: (command: TrayRendererCommand) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, command: TrayRendererCommand): void => callback(command)
|
||||
ipcRenderer.on('tray-controls:command', handler)
|
||||
return () => ipcRenderer.removeListener('tray-controls:command', handler)
|
||||
},
|
||||
},
|
||||
startWindowMove: () => ipcRenderer.send('window:start-move'),
|
||||
stopWindowMove: () => ipcRenderer.send('window:stop-move'),
|
||||
startWindowResize: (edge: ResizeDirection) => ipcRenderer.send('window:start-resize', edge),
|
||||
@@ -100,19 +59,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position),
|
||||
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
|
||||
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
|
||||
getWindowBackground: () => ipcRenderer.invoke('window:get-background') as Promise<WindowBackgroundSnapshot>,
|
||||
setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise<WindowBackgroundSnapshot>,
|
||||
isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise<boolean>,
|
||||
getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport,
|
||||
audioClips: {
|
||||
startDrag: (payload: AudioClipDragPayload) => ipcRenderer.send('audio-clips:start-drag', payload),
|
||||
revealFolder: () => ipcRenderer.invoke('audio-clips:reveal-folder') as Promise<void>,
|
||||
onDragError: (callback: (message: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, message: string): void => callback(message)
|
||||
ipcRenderer.on('audio-clips:drag-error', handler)
|
||||
return () => ipcRenderer.removeListener('audio-clips:drag-error', handler)
|
||||
},
|
||||
},
|
||||
getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise<NowPlayingState>,
|
||||
setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise<NowPlayingState>,
|
||||
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => {
|
||||
@@ -172,11 +120,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('window:always-on-top-changed', handler)
|
||||
return () => ipcRenderer.removeListener('window:always-on-top-changed', handler)
|
||||
},
|
||||
onWindowBackgroundChanged: (callback: (snapshot: WindowBackgroundSnapshot) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, snapshot: WindowBackgroundSnapshot): void => callback(snapshot)
|
||||
ipcRenderer.on('window:background-changed', handler)
|
||||
return () => ipcRenderer.removeListener('window:background-changed', handler)
|
||||
},
|
||||
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, bounds: WindowBounds): void => callback(bounds)
|
||||
ipcRenderer.on('window:bounds-changed', handler)
|
||||
|
||||
@@ -5,25 +5,19 @@ import SettingsPanel from './components/SettingsPanel'
|
||||
import BottomBar from './components/BottomBar'
|
||||
import ScopePopoutBridge from './components/ScopePopoutBridge'
|
||||
import AppBanner from './components/AppBanner'
|
||||
import WindowResizeOverlay from './components/WindowResizeOverlay'
|
||||
import TrayControlBridge from './components/TrayControlBridge'
|
||||
import { resolveMainWindowSettingsHeight } from './mainWindowSettings'
|
||||
import { useSettingsStore } from './stores/settingsStore'
|
||||
import { startAudioDeviceWatcher, useAudioStore } from './stores/audioStore'
|
||||
import { useNowPlayingStore } from './stores/nowPlayingStore'
|
||||
import { useThemeStore } from './stores/themeStore'
|
||||
import { useUiStore } from './stores/uiStore'
|
||||
import { useWindowBackgroundStore } from './stores/windowBackgroundStore'
|
||||
import { useUpdateStore } from './stores/updateStore'
|
||||
import { useDesktopIntegrationStore } from './stores/desktopIntegrationStore'
|
||||
import { getRendererWindowCapabilities } from './windowCapabilities'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const [toolbarVisible, setToolbarVisible] = useState(false)
|
||||
const [measurementActive, setMeasurementActive] = useState(false)
|
||||
const [settingsPanelHeight, setSettingsPanelHeight] = useState(0)
|
||||
const [bottomBarHeight, setBottomBarHeight] = useState(0)
|
||||
const [trayReady, setTrayReady] = useState(false)
|
||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const settingsOpenRef = useRef(false)
|
||||
const externalProfileOpenQueueRef = useRef(Promise.resolve())
|
||||
@@ -44,11 +38,7 @@ export default function App(): JSX.Element {
|
||||
const toggleSettings = useUiStore((s) => s.toggleSettings)
|
||||
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
|
||||
const showBanner = useUiStore((s) => s.showBanner)
|
||||
const initializeWindowBackground = useWindowBackgroundStore((s) => s.initialize)
|
||||
const windowBackgroundMode = useWindowBackgroundStore((s) => s.effective.mode)
|
||||
const useNativeDragRegions = getRendererWindowCapabilities().useNativeDragRegions
|
||||
const initializeDesktopIntegration = useDesktopIntegrationStore((s) => s.initialize)
|
||||
const applyDesktopIntegrationSnapshot = useDesktopIntegrationStore((s) => s.applySnapshot)
|
||||
|
||||
const isNowPlayingVisible = !hiddenScopes.has('nowPlaying')
|
||||
&& (scopeOrder.includes('nowPlaying') || scopePopouts.nowPlaying?.poppedOut === true)
|
||||
@@ -69,10 +59,6 @@ export default function App(): JSX.Element {
|
||||
void useUpdateStore.getState().checkForUpdates()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void initializeWindowBackground()
|
||||
}, [initializeWindowBackground])
|
||||
|
||||
useEffect(() => {
|
||||
let isDisposed = false
|
||||
|
||||
@@ -81,7 +67,6 @@ export default function App(): JSX.Element {
|
||||
await initializeProfiles()
|
||||
await initializeNowPlaying()
|
||||
if (!isDisposed) {
|
||||
setTrayReady(true)
|
||||
window.electronAPI.notifyRendererReady()
|
||||
}
|
||||
})()
|
||||
@@ -162,11 +147,6 @@ export default function App(): JSX.Element {
|
||||
updateMainWindowBounds,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
void initializeDesktopIntegration()
|
||||
return window.electronAPI.desktopIntegration.onChanged(applyDesktopIntegrationSnapshot)
|
||||
}, [applyDesktopIntegrationSnapshot, initializeDesktopIntegration])
|
||||
|
||||
useEffect(() => {
|
||||
void initializeNowPlaying()
|
||||
.then(() => setNowPlayingConsumerActive(isNowPlayingVisible))
|
||||
@@ -303,17 +283,16 @@ export default function App(): JSX.Element {
|
||||
onMouseUp={useNativeDragRegions ? undefined : handleAltDragEnd}
|
||||
>
|
||||
<div
|
||||
className={`prism-toolbar-layer ${toolbarVisible && !measurementActive ? 'is-visible' : ''}`.trim()}
|
||||
className={`prism-toolbar-layer ${toolbarVisible ? 'is-visible' : ''}`.trim()}
|
||||
>
|
||||
<Toolbar onOpenSettings={handleToggleSettings} settingsOpen={settingsOpen} />
|
||||
</div>
|
||||
|
||||
<div className="prism-strip-region">
|
||||
<Strip onMeasurementActiveChange={setMeasurementActive} />
|
||||
<Strip />
|
||||
</div>
|
||||
|
||||
<ScopePopoutBridge />
|
||||
<TrayControlBridge ready={trayReady} />
|
||||
|
||||
<AppBanner />
|
||||
|
||||
@@ -326,7 +305,6 @@ export default function App(): JSX.Element {
|
||||
<BottomBar onClose={handleCloseSettings} onHeightChange={setBottomBarHeight} />
|
||||
</div>
|
||||
|
||||
{windowBackgroundMode !== 'solid' && <WindowResizeOverlay />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
import { MIN_LOUDNESS_METER_WIDTH_PX } from '../shared/scopeSizing'
|
||||
|
||||
const DEFAULT_COLLAPSED_SCOPE_WEIGHT = 1
|
||||
export const LOCKED_LOUDNESS_METER_WIDTH_PX = 150
|
||||
|
||||
function usesCollapsedDefaultWeight(scope: ScopeKind): boolean {
|
||||
return scope === 'spectrogram'
|
||||
@@ -27,8 +27,7 @@ export function buildAnalyzerGridTemplateColumns(
|
||||
}
|
||||
|
||||
if (scope === 'lufsmeter') {
|
||||
const resolvedWeight = weight > 0 ? weight : DEFAULT_COLLAPSED_SCOPE_WEIGHT
|
||||
return `minmax(${MIN_LOUDNESS_METER_WIDTH_PX}px, ${resolvedWeight}fr)`
|
||||
return `minmax(${LOCKED_LOUDNESS_METER_WIDTH_PX}px, ${LOCKED_LOUDNESS_METER_WIDTH_PX}px)`
|
||||
}
|
||||
|
||||
if (usesCollapsedDefaultWeight(scope) && weight <= 0) {
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||
|
||||
import { audioRouter } from './AudioRouter'
|
||||
import { nativeVisualizerTransport } from './NativeVisualizerTransport'
|
||||
import { RollingAudioBuffer } from './RollingAudioBuffer'
|
||||
import { applyInputGainToStereoSamples, inputGainDbToLinear } from './inputGain'
|
||||
import type {
|
||||
RollingAudioSnapshot,
|
||||
RollingCaptureDurationSeconds,
|
||||
RollingCaptureStatus,
|
||||
} from '../../types/audioClip'
|
||||
import type {
|
||||
CaptureBackendKind,
|
||||
CaptureBackendSupport,
|
||||
@@ -155,7 +149,6 @@ export interface CaptureManagerStatus {
|
||||
}
|
||||
|
||||
type StatusListener = (status: CaptureManagerStatus) => void
|
||||
type RollingCaptureStatusListener = (status: RollingCaptureStatus) => void
|
||||
|
||||
const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__'
|
||||
|
||||
@@ -648,9 +641,6 @@ class AudioCapture {
|
||||
private inputGainDb = 0
|
||||
private inputGainLinear = 1
|
||||
private statusListeners = new Set<StatusListener>()
|
||||
private rollingCaptureSeconds: RollingCaptureDurationSeconds | null = null
|
||||
private rollingAudioBuffer: RollingAudioBuffer | null = null
|
||||
private rollingCaptureStatusListeners = new Set<RollingCaptureStatusListener>()
|
||||
|
||||
constructor() {
|
||||
this.deviceInputBackend = new DeviceInputCaptureBackend(this.deviceInputRuntime)
|
||||
@@ -673,14 +663,6 @@ class AudioCapture {
|
||||
}
|
||||
}
|
||||
|
||||
subscribeRollingCaptureStatus(listener: RollingCaptureStatusListener): () => void {
|
||||
this.rollingCaptureStatusListeners.add(listener)
|
||||
listener(this.getRollingCaptureStatus())
|
||||
return () => {
|
||||
this.rollingCaptureStatusListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshBackendSupport(): Promise<CaptureBackendSupport> {
|
||||
this.backendSupport = null
|
||||
this.backendSupportPromise = null
|
||||
@@ -729,10 +711,6 @@ class AudioCapture {
|
||||
backendStatus.channelCount,
|
||||
backendStatus.kind,
|
||||
)
|
||||
this.beginRollingCaptureSession(
|
||||
backendStatus.sampleRate,
|
||||
backendStatus.channelCount,
|
||||
)
|
||||
nativeVisualizerTransport.reset(audioRouter.getSessionState())
|
||||
this.emitStatus()
|
||||
}
|
||||
@@ -808,43 +786,6 @@ class AudioCapture {
|
||||
}
|
||||
}
|
||||
|
||||
getRollingCaptureStatus(): RollingCaptureStatus {
|
||||
const buffer = this.rollingAudioBuffer
|
||||
return {
|
||||
durationSeconds: this.rollingCaptureSeconds,
|
||||
hasAudio: Boolean(buffer && buffer.frameCount > 0),
|
||||
ready: Boolean(buffer?.isReady),
|
||||
allocatedBytes: buffer?.allocatedBytes ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
setRollingCaptureSeconds(durationSeconds: RollingCaptureDurationSeconds | null): void {
|
||||
if (durationSeconds === this.rollingCaptureSeconds) return
|
||||
|
||||
this.rollingCaptureSeconds = durationSeconds
|
||||
if (durationSeconds === null) {
|
||||
this.rollingAudioBuffer = null
|
||||
this.emitRollingCaptureStatus()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.rollingAudioBuffer) {
|
||||
this.rollingAudioBuffer.resize(durationSeconds)
|
||||
} else if (this.sessionId !== null && this.activeBackend) {
|
||||
const status = this.activeBackend.getStatus()
|
||||
this.rollingAudioBuffer = new RollingAudioBuffer(
|
||||
durationSeconds,
|
||||
status.sampleRate,
|
||||
status.channelCount,
|
||||
)
|
||||
}
|
||||
this.emitRollingCaptureStatus()
|
||||
}
|
||||
|
||||
takeRollingCaptureSnapshot(): RollingAudioSnapshot | null {
|
||||
return this.rollingAudioBuffer?.snapshot() ?? null
|
||||
}
|
||||
|
||||
private async ensureBackendSupport(): Promise<CaptureBackendSupport> {
|
||||
if (this.backendSupport) {
|
||||
return this.backendSupport
|
||||
@@ -895,11 +836,6 @@ class AudioCapture {
|
||||
}
|
||||
|
||||
private async stopActiveCapture(): Promise<void> {
|
||||
if (this.rollingAudioBuffer) {
|
||||
this.rollingAudioBuffer = null
|
||||
this.emitRollingCaptureStatus()
|
||||
}
|
||||
|
||||
if (this.sessionId !== null) {
|
||||
audioRouter.endSession()
|
||||
nativeVisualizerTransport.reset(audioRouter.getSessionState())
|
||||
@@ -928,31 +864,6 @@ class AudioCapture {
|
||||
capturedAt: chunk.capturedAt,
|
||||
sequence: chunk.sequence,
|
||||
})
|
||||
|
||||
let rollingAudioBuffer = this.rollingAudioBuffer
|
||||
if (rollingAudioBuffer) {
|
||||
const normalizedChannelCount = chunk.channelCount > 1 ? 2 : 1
|
||||
if (rollingAudioBuffer.channelCount !== normalizedChannelCount) {
|
||||
const sampleRate = this.activeBackend.getStatus().sampleRate
|
||||
rollingAudioBuffer = new RollingAudioBuffer(
|
||||
this.rollingCaptureSeconds!,
|
||||
sampleRate,
|
||||
normalizedChannelCount,
|
||||
)
|
||||
this.rollingAudioBuffer = rollingAudioBuffer
|
||||
this.emitRollingCaptureStatus()
|
||||
}
|
||||
|
||||
const hadAudio = rollingAudioBuffer.frameCount > 0
|
||||
const wasReady = rollingAudioBuffer.isReady
|
||||
rollingAudioBuffer.append(chunk.left, chunk.right, chunk.channelCount)
|
||||
if (
|
||||
hadAudio !== (rollingAudioBuffer.frameCount > 0)
|
||||
|| wasReady !== rollingAudioBuffer.isReady
|
||||
) {
|
||||
this.emitRollingCaptureStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInputGain(db: number): void {
|
||||
@@ -967,20 +878,6 @@ class AudioCapture {
|
||||
listener(status)
|
||||
}
|
||||
}
|
||||
|
||||
private beginRollingCaptureSession(sampleRate: number, channelCount: number): void {
|
||||
this.rollingAudioBuffer = this.rollingCaptureSeconds === null
|
||||
? null
|
||||
: new RollingAudioBuffer(this.rollingCaptureSeconds, sampleRate, channelCount)
|
||||
this.emitRollingCaptureStatus()
|
||||
}
|
||||
|
||||
private emitRollingCaptureStatus(): void {
|
||||
const status = this.getRollingCaptureStatus()
|
||||
for (const listener of this.rollingCaptureStatusListeners) {
|
||||
listener(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const audioCapture = new AudioCapture()
|
||||
|
||||
@@ -93,7 +93,7 @@ type ScopeRingMap = {
|
||||
spectrum: FixedChunkRing<StereoChunkRecord>
|
||||
oscilloscope: FixedChunkRing<MonoChunkRecord>
|
||||
vectorscope: FixedChunkRing<StereoChunkRecord>
|
||||
spectrogram: FixedChunkRing<StereoChunkRecord>
|
||||
spectrogram: FixedChunkRing<MonoChunkRecord>
|
||||
vumeter: FixedChunkRing<StereoChunkRecord>
|
||||
lufsmeter: FixedChunkRing<StereoChunkRecord>
|
||||
waveform: FixedChunkRing<StereoChunkRecord>
|
||||
@@ -215,7 +215,7 @@ export class AudioRouter {
|
||||
spectrum: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.spectrum),
|
||||
oscilloscope: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.oscilloscope),
|
||||
vectorscope: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vectorscope),
|
||||
spectrogram: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.spectrogram),
|
||||
spectrogram: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.spectrogram),
|
||||
vumeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vumeter),
|
||||
lufsmeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.lufsmeter),
|
||||
waveform: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.waveform),
|
||||
@@ -363,10 +363,11 @@ export class AudioRouter {
|
||||
|
||||
const activeDemand = this.getActiveDemand()
|
||||
const needsSpectrum = Boolean(activeDemand.spectrum)
|
||||
const needsStereo = Boolean(activeDemand.spectrogram || activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform)
|
||||
const needsMono = Boolean(activeDemand.spectrogram)
|
||||
const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform)
|
||||
const needsLeft = Boolean(activeDemand.oscilloscope)
|
||||
|
||||
if (!needsSpectrum && !needsStereo && !needsLeft) {
|
||||
if (!needsSpectrum && !needsMono && !needsStereo && !needsLeft) {
|
||||
this.undemandedChunks += 1
|
||||
return
|
||||
}
|
||||
@@ -376,6 +377,14 @@ export class AudioRouter {
|
||||
const leftSamples = left.length === len ? left : left.subarray(0, len)
|
||||
const rightSamples = resolvedRight.length === len ? resolvedRight : resolvedRight.subarray(0, len)
|
||||
|
||||
let mono: Float32Array | null = null
|
||||
if (needsMono) {
|
||||
mono = new Float32Array(len)
|
||||
for (let index = 0; index < len; index += 1) {
|
||||
mono[index] = (leftSamples[index] + rightSamples[index]) * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
if (activeDemand.oscilloscope) {
|
||||
this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence })
|
||||
}
|
||||
@@ -384,8 +393,8 @@ export class AudioRouter {
|
||||
this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence })
|
||||
}
|
||||
|
||||
if (activeDemand.spectrogram) {
|
||||
this.rings.spectrogram.push({ left: leftSamples, right: rightSamples, capturedAt, sequence })
|
||||
if (activeDemand.spectrogram && mono) {
|
||||
this.rings.spectrogram.push({ samples: mono, capturedAt, sequence })
|
||||
}
|
||||
|
||||
if (activeDemand.vectorscope) {
|
||||
@@ -433,20 +442,7 @@ export class AudioRouter {
|
||||
flushPendingSpectrogramSamples(): Float32Array[] {
|
||||
const records = this.rings.spectrogram.drain()
|
||||
this.recordScopeDrain('spectrogram', records)
|
||||
return records.map((record) => {
|
||||
const length = Math.min(record.left.length, record.right.length)
|
||||
const mono = new Float32Array(length)
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
mono[index] = (record.left[index] + record.right[index]) * 0.5
|
||||
}
|
||||
return mono
|
||||
})
|
||||
}
|
||||
|
||||
flushPendingSpectrogramStereoSamples(): { left: Float32Array; right: Float32Array }[] {
|
||||
const records = this.rings.spectrogram.drain()
|
||||
this.recordScopeDrain('spectrogram', records)
|
||||
return records.map((record) => ({ left: record.left, right: record.right }))
|
||||
return records.map((record) => record.samples)
|
||||
}
|
||||
|
||||
flushPendingVectorscopeSamples(): { left: Float32Array; right: Float32Array }[] {
|
||||
|
||||