diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..f04272a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+*.sh text eol=lf
+resources/installer/macos-scripts/* text eol=lf
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index fb3928c..ce6362d 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -55,7 +55,9 @@ jobs:
ninja-build \
pkg-config \
python3 \
- libpulse-dev
+ libpulse-dev \
+ pulseaudio \
+ pulseaudio-utils
- name: Install dependencies
env:
@@ -80,6 +82,9 @@ jobs:
- name: Test
run: npm test
+ - name: Test isolated Linux multichannel capture
+ run: npm run test:linux-capture
+
- name: Build and test Prism TUI
run: npm run test:tui
@@ -97,7 +102,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, macos-latest, windows-2022]
+ # Keep the Linux plugin ABI baseline stable for older DAW runtimes.
+ os: [ubuntu-24.04, macos-latest, windows-2022]
steps:
- uses: actions/checkout@v6
@@ -169,6 +175,8 @@ jobs:
libxrandr-dev \
libxrender-dev \
libwebkit2gtk-4.1-dev \
+ xvfb \
+ xauth \
libglu1-mesa-dev \
mesa-common-dev
@@ -187,6 +195,9 @@ jobs:
- name: Test native spectrum and capture exports
run: npm run test:spectrum-native
+ - name: Test native channel routing and PCM formats
+ run: npm run test:capture-channel-selection-native
+
- name: Test native spectrogram accuracy
run: npm run test:spectrogram-native
@@ -232,10 +243,11 @@ jobs:
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_PLUGIN_FORMATS="AU;VST3;CLAP" \
-DPRISM_COPY_PLUGIN_AFTER_BUILD=OFF \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
+ node scripts/check-plugin-formats.mjs
- name: Configure JUCE plugins (Linux)
if: runner.os == 'Linux'
@@ -243,8 +255,9 @@ jobs:
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_PLUGIN_FORMATS="VST3;CLAP" \
-DPRISM_COPY_PLUGIN_AFTER_BUILD=OFF
+ node scripts/check-plugin-formats.mjs
- name: Configure JUCE plugins (Windows)
if: runner.os == 'Windows'
@@ -252,7 +265,8 @@ jobs:
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
+ 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;CLAP" -DPRISM_COPY_PLUGIN_AFTER_BUILD=OFF -DJUCE_WEBVIEW2_PACKAGE_LOCATION="%JUCE_WEBVIEW2_PACKAGE_LOCATION%" -DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache
+ node scripts/check-plugin-formats.mjs
- name: Build JUCE plugins (macOS)
if: runner.os == 'macOS'
@@ -262,6 +276,12 @@ jobs:
if: runner.os == 'Linux'
run: cmake --build plugin/build --target PrismInstallerPlugins --parallel 4
+ - name: Test Linux browser helper isolation
+ if: runner.os == 'Linux'
+ run: |
+ cmake --build plugin/build --target PrismLinuxWebViewHelperProbe --parallel 4
+ ctest --test-dir plugin/build --output-on-failure -R '^PrismLinuxWebViewHelperTests$'
+
- name: Build JUCE plugins (Windows)
if: runner.os == 'Windows'
shell: cmd
@@ -269,23 +289,60 @@ jobs:
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
+ cmake --build plugin/build --target PrismReferenceTests PrismBridgeTests PrismWaterfallTests --config Release --parallel 4
+ ctest --test-dir plugin/build -C Release --output-on-failure -R "Prism(Reference|BridgeProcessor|Waterfall)Tests"
- - name: Stage plugins for Windows installer
- if: runner.os == 'Windows'
+ - name: Test Prism Reference, Bridge, and Waterfall plugins
+ if: runner.os != 'Windows'
+ run: |
+ cmake --build plugin/build --target PrismReferenceTests PrismBridgeTests PrismWaterfallTests --config Release --parallel 4
+ ctest --test-dir plugin/build -C Release --output-on-failure -R "Prism(Reference|BridgeProcessor|Waterfall)Tests"
+
+ - name: Stage plugins for installer
+ run: node scripts/build/stage-plugins.mjs
+
+ - name: Setup Rust for CLAP validation
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: '1.95.0'
+
+ - name: Cache CLAP validator
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ .ci-cache/clap-validator/target
+ key: ${{ runner.os }}-clap-validator-b2f1d9b-rust-1.95.0
+
+ - name: Build pinned CLAP validator
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
+ git clone https://github.com/free-audio/clap-validator.git .ci-cache/clap-validator-source
+ git -C .ci-cache/clap-validator-source checkout b2f1d9b79b1d264a5747f46707d72b1aa40a02ef
+ cargo build --release --locked --package clap-validator --manifest-path .ci-cache/clap-validator-source/Cargo.toml --target-dir .ci-cache/clap-validator/target
+ echo "$GITHUB_WORKSPACE/.ci-cache/clap-validator/target/release" >> "$GITHUB_PATH"
+
+ - name: Validate all CLAP plugins
+ shell: bash
+ run: |
+ if [ "$RUNNER_OS" = "Linux" ]; then
+ xvfb-run -a node scripts/validate-clap.mjs
+ else
+ node scripts/validate-clap.mjs
fi
+ - name: Retain CLAP validation output
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: clap-validation-${{ runner.os }}
+ path: plugin/build/clap-validation/
+ if-no-files-found: ignore
+
+ - name: Test plugin staging and Unix installer hooks
+ run: node --test test/plugin-packaging.test.mjs
+
- name: Stage Prism TUI for Windows installer
if: runner.os == 'Windows'
run: npm run stage:tui
@@ -296,7 +353,7 @@ jobs:
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- - name: Smoke-test Windows installer PATH integration
+ - name: Smoke-test Windows installer plugins and PATH integration
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/test-windows-installer.ps1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8cf3513..674b6c1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,7 +24,8 @@ jobs:
include:
- os: macos-latest
dist_cmd: dist:mac
- - os: ubuntu-latest
+ # Keep the Linux plugin ABI baseline stable for older DAW runtimes.
+ - os: ubuntu-24.04
dist_cmd: dist:linux:packages
# Pinned to windows-2022 because windows-latest now ships VS 2026
# (internal version 18), which the @electron/node-gyp version in our
@@ -99,6 +100,8 @@ jobs:
libxrandr-dev \
libxrender-dev \
libwebkit2gtk-4.1-dev \
+ xvfb \
+ xauth \
libglu1-mesa-dev \
mesa-common-dev \
rpm
@@ -121,6 +124,9 @@ jobs:
- name: Test native spectrum and capture exports
run: npm run test:spectrum-native
+ - name: Test native channel routing and PCM formats
+ run: npm run test:capture-channel-selection-native
+
- name: Test native spectrogram accuracy
run: npm run test:spectrogram-native
@@ -153,10 +159,11 @@ jobs:
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_PLUGIN_FORMATS="AU;VST3;CLAP" \
-DPRISM_COPY_PLUGIN_AFTER_BUILD=OFF \
-DCMAKE_C_COMPILER_LAUNCHER=sccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
+ node scripts/check-plugin-formats.mjs
- name: Configure JUCE plugins (Linux)
if: runner.os == 'Linux'
@@ -164,8 +171,9 @@ jobs:
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_PLUGIN_FORMATS="VST3;CLAP" \
-DPRISM_COPY_PLUGIN_AFTER_BUILD=OFF
+ node scripts/check-plugin-formats.mjs
# JUCE's NEEDS_WEBVIEW2 expects the Microsoft.Web.WebView2 NuGet package
# to already be installed locally; it doesn't fetch it itself. Install it
@@ -197,7 +205,8 @@ jobs:
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
+ 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;CLAP" -DPRISM_COPY_PLUGIN_AFTER_BUILD=OFF -DJUCE_WEBVIEW2_PACKAGE_LOCATION="%JUCE_WEBVIEW2_PACKAGE_LOCATION%" -DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache
+ node scripts/check-plugin-formats.mjs
- name: Build JUCE plugins (macOS)
if: runner.os == 'macOS'
@@ -207,6 +216,12 @@ jobs:
if: runner.os == 'Linux'
run: cmake --build plugin/build --target PrismInstallerPlugins --parallel 4
+ - name: Test Linux browser helper isolation
+ if: runner.os == 'Linux'
+ run: |
+ cmake --build plugin/build --target PrismLinuxWebViewHelperProbe --parallel 4
+ ctest --test-dir plugin/build --output-on-failure -R '^PrismLinuxWebViewHelperTests$'
+
- name: Build JUCE plugins (Windows)
if: runner.os == 'Windows'
shell: cmd
@@ -214,53 +229,64 @@ jobs:
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
+ cmake --build plugin/build --target PrismReferenceTests PrismBridgeTests PrismWaterfallTests --config Release --parallel 4
+ ctest --test-dir plugin/build -C Release --output-on-failure -R "Prism(Reference|BridgeProcessor|Waterfall)Tests"
+
+ - name: Test Prism Reference, Bridge, and Waterfall plugins
+ if: runner.os != 'Windows'
+ run: |
+ cmake --build plugin/build --target PrismReferenceTests PrismBridgeTests PrismWaterfallTests --config Release --parallel 4
+ ctest --test-dir plugin/build -C Release --output-on-failure -R "Prism(Reference|BridgeProcessor|Waterfall)Tests"
- name: Show sccache stats
if: runner.os != 'Linux'
run: sccache --show-stats
- name: Stage plugins for installer
+ run: node scripts/build/stage-plugins.mjs
+
+ - name: Setup Rust for CLAP validation
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: '1.95.0'
+
+ - name: Cache CLAP validator
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ .ci-cache/clap-validator/target
+ key: ${{ runner.os }}-clap-validator-b2f1d9b-rust-1.95.0
+
+ - name: Build pinned CLAP validator
+ shell: bash
+ run: |
+ git clone https://github.com/free-audio/clap-validator.git .ci-cache/clap-validator-source
+ git -C .ci-cache/clap-validator-source checkout b2f1d9b79b1d264a5747f46707d72b1aa40a02ef
+ cargo build --release --locked --package clap-validator --manifest-path .ci-cache/clap-validator-source/Cargo.toml --target-dir .ci-cache/clap-validator/target
+ echo "$GITHUB_WORKSPACE/.ci-cache/clap-validator/target/release" >> "$GITHUB_PATH"
+
+ - name: Validate all CLAP plugins
shell: bash
run: |
- rm -rf plugin/dist-installer
- mkdir -p plugin/dist-installer
- mkdir -p plugin/dist-installer/VST3
- [ "$RUNNER_OS" = "macOS" ] && mkdir -p plugin/dist-installer/AU
- for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform; do
- vst3_dir="plugin/build/${art}_artefacts/Release/VST3"
- [ -d "$vst3_dir" ] && find "$vst3_dir" -maxdepth 1 -name "*.vst3" -exec cp -R {} plugin/dist-installer/VST3/ \;
- if [ "$RUNNER_OS" = "macOS" ]; then
- au_dir="plugin/build/${art}_artefacts/Release/AU"
- [ -d "$au_dir" ] && find "$au_dir" -maxdepth 1 -name "*.component" -exec cp -R {} plugin/dist-installer/AU/ \;
- fi
- done
- echo "--- staged VST3 ---"
- ls -la plugin/dist-installer/VST3
- vst3_count="$(find plugin/dist-installer/VST3 -maxdepth 1 -name "*.vst3" | wc -l | tr -d ' ')"
- if [ "$vst3_count" -ne 7 ]; then
- echo "ERROR: expected 7 VST3 plugins, found $vst3_count"
- exit 1
- fi
if [ "$RUNNER_OS" = "Linux" ]; then
- for vst3 in plugin/dist-installer/VST3/*.vst3; do
- if [ ! -d "$vst3/Contents/x86_64-linux" ]; then
- echo "ERROR: Linux VST3 bundle is missing Contents/x86_64-linux: $vst3"
- exit 1
- fi
- done
- cp resources/installer/linux/install-vst3.sh plugin/dist-installer/install-vst3.sh
- chmod +x plugin/dist-installer/install-vst3.sh
- fi
- if [ "$RUNNER_OS" = "macOS" ]; then
- echo "--- staged AU ---"
- ls -la plugin/dist-installer/AU
- au_count="$(find plugin/dist-installer/AU -maxdepth 1 -name "*.component" | wc -l | tr -d ' ')"
- if [ "$au_count" -ne 7 ]; then
- echo "ERROR: expected 7 AU plugins, found $au_count"
- exit 1
- fi
+ xvfb-run -a node scripts/validate-clap.mjs
+ else
+ node scripts/validate-clap.mjs
fi
+ - name: Retain CLAP validation output
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: clap-validation-${{ runner.os }}
+ path: plugin/build/clap-validation/
+ if-no-files-found: ignore
+
+ - name: Test plugin staging and Unix installer hooks
+ run: node --test test/plugin-packaging.test.mjs
+
- name: Stage Prism TUI for installer
run: npm run stage:tui
@@ -282,20 +308,22 @@ jobs:
if: runner.os != 'Linux'
run: npm run ${{ matrix.dist_cmd }}
env:
+ PRISM_PLUGINS_PREBUILT: '1'
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- - name: Smoke-test Windows installer PATH integration
+ - name: Smoke-test Windows installer plugins and PATH integration
if: runner.os == 'Windows'
shell: pwsh
run: ./scripts/test-windows-installer.ps1
- - name: Build Linux packages with VST3 plugins
+ - name: Build Linux packages with VST3 and CLAP plugins
if: runner.os == 'Linux'
shell: bash
run: |
rm -rf dist/linux-unpacked
npm run ${{ matrix.dist_cmd }}
env:
+ PRISM_PLUGINS_PREBUILT: '1'
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- name: Upload artifacts
diff --git a/.gitignore b/.gitignore
index 0f62405..b30e83c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
node_modules/
# Build outputs
+artifacts/linux-tests/
out/
dist/
native/build/
@@ -36,3 +37,6 @@ yarn-error.log*
# notes
untitled-1
+
+# Local latency benchmark recordings, traces, and isolated settings
+benchmark-results/
diff --git a/README.md b/README.md
index c961a37..6205334 100644
--- a/README.md
+++ b/README.md
@@ -14,17 +14,22 @@
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.
+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/CLAP plugins, and Prism includes a native terminal interface for lightweight monitoring.
+
+### Multichannel input routing
+
+The desktop app's Channel Routing matrix works with system audio and device inputs on macOS, Windows, and Linux. Select any source channel independently for Prism's Left and Right channels, including the same channel for both. Activity fills show the incoming signal on every available channel, before routing and input trim; routes are saved per source.

## Scopes
-Prism includes seven real-time scopes and meters:
+Prism includes eight real-time scopes and meters:
* **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
+* **Waterfall** — Layered spectrum history with 1–30 seconds of audio, adaptive ridge density, theme or heat colors, and frequency/time guides in the desktop app, TUI, and DAW plugin
* **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
@@ -32,9 +37,11 @@ Prism includes seven real-time scopes and meters:
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.
+On Windows, enable Reserve screen space in the rack's Top/Bottom reposition menu or its tray equivalent to dock the main rack across one monitor. Maximized applications use the remaining workspace. Resize the edge facing the workspace, drag the grab handle to return to a floating rack, or disable the option to restore its previous position and size. Settings open in a separate panel beside the dock. Docking is remembered on this computer independently of profiles; minimizing or hiding Prism releases the reserved space.
+

-Spectrum, Spectrogram, Oscilloscope, and Waveform also include interactive measurement overlays for inspecting frequency, level, pitch, amplitude, or time directly from the display.
+Spectrum, Spectrogram, Oscilloscope, and Waveform also include interactive measurement overlays for inspecting frequency, level, pitch, amplitude, or time directly from the display. Optional Linked Analysis mirrors compatible frequency, history-time, and amplitude guides across docked and detached scopes.
Stats for nerds
@@ -51,6 +58,25 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r
* 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
+* One whole-track reference: drop a mono/stereo WAV, AIFF, FLAC, or MP3 onto the spectrum, or use Spectrum settings → Reference → Load track
+* Dashed Overlay and a centered ±24 dB Difference view, with ±24 dB reference trim and one-shot Match level using the latest three seconds of live Mid audio
+* Cancellable background analysis with an evolving preview; analyzed curves are saved with profiles and Spectrum plugin state, so recall does not need the original file
+
+Spectrum settings have General and Reference tabs. Matching requires at least one second of current, non-silent audio. During import, the spectrum temporarily shows Overlay; the previous reference remains available if loading fails or is canceled. Reference analysis uses a shared 48 kHz path with all five FFT sizes cached, while respecting the original file's bandwidth. This affects analysis only.
+
+### Waterfall
+
+* Independent native FFT analyzer with the same calibrated channel-max stereo spectrum as Spectrum
+* Stores 60 spectrum slices per second on the audio clock, independent of terminal or desktop refresh rate
+* Bounded 1–30 second history; defaults to 5 seconds
+* Sparse, Balanced, and Dense ridge settings adapt to panel size while retaining the selected history duration
+* Desktop shows a lightly softened live spectrum followed by its history, retaining distinct peaks and a common frequency axis
+* Frequency scale, FFT size, smoothing, range, and spectral tilt controls
+* Theme-colored ridges fading with age, or level-based heat colors
+* Foreground ridges hide covered sections of older lines
+* Available through the desktop add-scope menu and TUI layout editor (shortcut **8**)
+* Desktop popouts and saved profiles are supported; existing layouts stay unchanged
+* Dedicated Prism Waterfall VST3/AU/CLAP plugin shares the desktop renderer and native history, with settings saved in DAW projects
### Spectrogram
@@ -134,16 +160,17 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r
### 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
+* Buffer retains 32-bit floating-point samples and exports standard RIFF/WAV files
* Mono and stereo capture are supported
-* Exported clips use 16-bit PCM
+* Choose 16-bit PCM (default) or 32-bit float WAV export in Rolling Capture settings; the choice is remembered across launches
* 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
+* All eight Prism analyzers are built from the same native DSP source used by the desktop application
+* Prism Bridge streams one DAW track or bus into the standalone app without changing the track audio
+* VST3 and CLAP 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
@@ -154,7 +181,7 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r
### Terminal UI
-* Native C++ frontend using the same Spectrum, Oscilloscope, Vectorscope, VU, Loudness, Spectrogram, Waveform, and system-capture implementations
+* Native C++ frontend using the same Spectrum, Waterfall, 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
@@ -170,6 +197,7 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r
* 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
+* Linked Analysis maps only compatible semantic axes between scopes and hides guides when an exact value is outside the target's visible range
@@ -180,31 +208,52 @@ Prism can capture system output directly through CoreAudio on macOS, WASAPI on W
No virtual audio cable is required for normal system capture.
+The default rack measured [around 7 ms median capture-to-render latency](docs/latency.md) on an M5 Pro at 120 FPS.
+
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.
+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.
+Choose 16-bit PCM for smaller files or 32-bit float to preserve captured floating-point precision and samples above full scale for DAW editing. Changing the format applies to the next export without clearing buffered audio. Exports retain the source sample rate and do not normalize or dither audio.
+
+The float buffer uses approximately 23 MB for 60 seconds of stereo at 48 kHz. Rolling Capture allocates no recorder buffer while off.
+
## 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.
+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
+* VST3 and CLAP 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.
+Prism also installs Prism Bridge, a lightweight pass-through plugin for sending
+one DAW track or bus to the standalone application. Keep Prism open, insert Bridge
+on the source you want to inspect, then choose it under DAW Bridges in Prism's
+audio selector. Multiple Bridge instances can be available at once; only the one
+you select streams audio. Waveform and Spectrogram can optionally show host-derived
+Bars + Beats or Seconds rulers, including loop, seek, and packet-gap seams.
+
+Bridge is available as VST3 and CLAP on Windows, macOS, and Linux and as AU on macOS. The AU
+is intentionally not marked sandbox-safe because it requires localhost access.
+Protocol details and host-validation notes are in [docs/daw-bridge.md](docs/daw-bridge.md).
+
+Existing VST3/AU versions have been tested in Ableton Live, FL Studio, and Reaper.
+CLAP requires a DAW that supports CLAP; see [plugin build and validation notes](plugin/README.md).
-The plugins install alongside Prism, so there is no separate download.
+The plugins ship alongside Prism. The Windows installer offers separate VST3 and
+CLAP checkboxes, both enabled by default. The macOS package installs VST3, AU, and
+CLAP; Linux deb/rpm packages install VST3 and CLAP. Archives support manual plugin
+installation; the Linux AppImage remains app-only. See [installation instructions](plugin/README.md).
## Terminal UI
@@ -258,7 +307,7 @@ On Windows, the NSIS installer adds `prism-tui` to the machine `PATH`. Open a ne
## Building from Source
-**Prerequisites:** Node.js 18+, npm, CMake 3.22+, and a C++ compiler toolchain.
+**Prerequisites:** Node.js 20.19+ or 22.12+ (Node 22.x is used in CI), npm, CMake 3.22+, and a C++ compiler toolchain.
| Platform | Toolchain |
| -------- | ------------------------------------------------------------------------------------------------------- |
@@ -266,6 +315,10 @@ On Windows, the NSIS installer adds `prism-tui` to the machine `PATH`. Open a ne
| Windows | Visual Studio Build Tools |
| Linux | `build-essential`, `python3`, `libasound2-dev`, `libpulse-dev`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` |
+To build and test Linux from Windows using WSL2 or a Fedora VM over SSH, see
+[Linux testing](docs/linux-testing.md). After setup, run `npm run test:linux`;
+add `-- --target fedora --suite full` for Fedora plugin and package checks.
+
```bash
git clone https://github.com/Boof2015/prism.git
cd prism
@@ -274,12 +327,21 @@ npm install
The `postinstall` script compiles the native C++ module for your platform.
+Use `npm run rebuild:native` to rebuild it for the installed Electron version
+before running the desktop app. `npm run build:native` builds for your current
+Node.js version, for example when running native tests directly with Node.
+These commands select the project's `node-gyp` dependency explicitly to avoid
+conflicts with the older copy bundled by Electron's build tools on Windows.
+
```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 build:plugins # Build bundled UI and native DAW plugins
+npm run test:plugins # Build plugins and run native plugin tests
+npm run install:plugins # Install built plugins (Windows prompts for UAC)
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
@@ -295,7 +357,7 @@ 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.
+`npm run build:plugins` compiles the DAW plugins locally. `npm run install:plugins` copies the built files, requesting Windows UAC only for the protected copy; macOS/Linux default to user plugin folders. `npm run dist` builds and stages the complete plugin set before packaging on the current OS (the Linux AppImage remains app-only). See [`plugin/README.md`](plugin/README.md) for prerequisites and options.
## Astra Integration
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index f27793c..3fae947 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -26,3 +26,124 @@ 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.
+
+## Reference audio decoding and resampling
+
+Prism includes dr_libs (WAV/AIFF, FLAC, MP3 decoding) and r8brain-free-src. Pinned revisions and license texts are provided in `native/vendor/REFERENCE_DEPENDENCIES.md` and each vendored library directory.
+
+Sample rate converter designed by Aleksey Vaneev of Voxengo.
+
+### dr_libs license
+
+This software is available as a choice of the following licenses. Choose
+whichever you prefer.
+
+===============================================================================
+ALTERNATIVE 1 - Public Domain (www.unlicense.org)
+===============================================================================
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
+software, either in source code form or as a compiled binary, for any purpose,
+commercial or non-commercial, and by any means.
+
+In jurisdictions that recognize copyright laws, the author or authors of this
+software dedicate any and all copyright interest in the software to the public
+domain. We make this dedication for the benefit of the public at large and to
+the detriment of our heirs and successors. We intend this dedication to be an
+overt act of relinquishment in perpetuity of all present and future rights to
+this software under copyright law.
+
+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 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.
+
+For more information, please refer to
+
+===============================================================================
+ALTERNATIVE 2 - MIT No Attribution
+===============================================================================
+Copyright 2020 David Reid
+
+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.
+
+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.
+
+
+### r8brain license
+
+MIT License
+
+Copyright (c) 2013-2026 Aleksey Vaneev
+
+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.
+
+## CLAP plugin support
+
+Prism plugins link clap-juce-extensions at `9fbefae3d9c3d130aafb558c1ec15427a4bd24be`,
+CLAP at `29ffcc273be7c7c651f6c9953b99e69700e2387a`, and clap-helpers at
+`a61bcdf0ecc2c8db1e80bfe8bf9cb7e8d9fd2bbc`.
+
+### clap-juce-extensions
+
+Copyright 2019-2020, Paul Walker
+
+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.
+
+### CLAP and clap-helpers
+
+MIT License
+
+Copyright (c) 2021 Alexandre BIQUE
+
+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.
diff --git a/docs/benchmarks/2026-09-16/instrumentation.patch b/docs/benchmarks/2026-09-16/instrumentation.patch
new file mode 100644
index 0000000..0a47322
--- /dev/null
+++ b/docs/benchmarks/2026-09-16/instrumentation.patch
@@ -0,0 +1,1610 @@
+diff --git a/.gitignore b/.gitignore
+index 02869b1..40f1ec5 100644
+--- a/.gitignore
++++ b/.gitignore
+@@ -37,3 +37,6 @@ yarn-error.log*
+
+ # notes
+ untitled-1
++
++# Local latency benchmark artifacts (raw records, traces, isolated settings)
++benchmark-results/
+diff --git a/package.json b/package.json
+index 827d674..20ce6c5 100644
+--- a/package.json
++++ b/package.json
+@@ -4,6 +4,9 @@
+ "description": "Open-source audio metering and visualization tool",
+ "main": "./out/main/index.js",
+ "scripts": {
++ "bench:latency": "node scripts/benchmark/run.mjs",
++ "bench:latency:report": "node scripts/benchmark/report.mjs",
++ "test:latency": "node scripts/benchmark/test.mjs",
+ "dev": "node ./scripts/build/writeAppBuildMetadata.cjs && electron-vite dev",
+ "dev:mac": "node ./scripts/build/writeAppBuildMetadata.cjs && env -u ELECTRON_RUN_AS_NODE electron-vite dev",
+ "build": "electron-vite build && node ./scripts/build/writeAppBuildMetadata.cjs",
+diff --git a/scripts/benchmark/README.md b/scripts/benchmark/README.md
+new file mode 100644
+index 0000000..81f74ac
+--- /dev/null
++++ b/scripts/benchmark/README.md
+@@ -0,0 +1,155 @@
++# Local capture-to-render submission benchmark
++
++This branch observes Prism's native macOS system-audio capture and its existing
++Spectrum, Oscilloscope, Vectorscope, and VU render paths. It does not change capture
++buffers, polling delays, frame scheduling, DSP settings, or analysis algorithms.
++
++This revision measures upstream `0.3.1` at
++`2a2d01d32da824d03e60440acd505f3d890a4e6f` (vectorscope optimization).
++The original `latency-benchmark` branch and its results measure `166e0f0`.
++The protocol, analysis settings, timing probes, cooldown, and thermal observer are
++unchanged, so the new run can be compared with that baseline on the same setup.
++Each report records its own exact base commit and instrumentation patch; the
++previous result directory is never overwritten. The current upstream revision
++has no frame-scheduler changes relative to the original baseline.
++
++## Run
++
++```sh
++npm run test:latency
++npm run typecheck
++npm run bench:latency -- --quick
++npm run bench:latency
++```
++
++The full run takes approximately 29 minutes. Keep the benchmark window visible
++and avoid changing display settings or audio devices during measurement. A quiet,
++changing stereo tone plays through the current default output using `afplay`.
++Other system audio is captured too; stop unrelated playback for a controlled run.
++No physical measurement equipment or virtual audio driver is needed.
++
++The runner builds production renderer/main/preload bundles and rebuilds the native
++addon for the installed Electron version. It verifies native exports in that
++Electron runtime and refuses to benchmark a missing native capture or DSP module.
++After compilation, the full runner waits at least two minutes, including one
++continuous minute at nominal macOS thermal state (ten-minute timeout). This uses
++a separate Electron process without a rendering window. Quick diagnostics skip
++this wait. The measured app records thermal state and power source at 1 Hz and
++on OS notifications into preallocated main-process memory, plus advertised CPU
++speed-limit changes. A missing speed-limit notification means unknown, not 100%.
++The same observer is active for baselines and probes. Nothing is subtracted from
++latency based on thermals; warm runs remain part of the report.
++The benchmark preload uses the checkout's native addon path; it does not set
++`NODE_ENV=development`. macOS may request audio-capture permission for Electron.
++
++Use `--output benchmark-results/my-run` to choose a new artifact directory. Existing
++run directories are never overwritten. Each run uses its own Electron user-data
++directory, leaving regular Prism preferences untouched. Nothing is pushed, merged,
++or installed. `out/` and the native build output are regenerated as usual.
++
++`--quick` exercises all configurations with 2-second warmups and 3-second samples.
++Quick results are always marked diagnostic and cannot produce publication wording.
++
++The full matrix uses Spectrum alone, Oscilloscope alone, and the default four-scope
++rack. Each configuration runs at display-sync and the 60 FPS target. Each cell has
++one 60-second frame-only baseline and three 60-second probe runs, each preceded by
++10 seconds of warmup. The fixed window is 1200×360 logical pixels. Default scope
++settings are applied without reducing FFT size, smoothing, persistence, or pitch
++locking to obtain a faster result.
++
++## What is measured
++
++Each native chunk retains its existing monotonic capture timestamp and sequence.
++The observer records renderer receipt, scope consumption, and the end of the
++successful draw invocation that consumed it. All chunks in a batch are counted,
++once per scope; multiple chunks can share one render frame. Repainting old state
++without consuming new chunks adds no latency samples.
++
++The endpoint is completion of canvas drawing commands on the renderer thread.
++This is **not** GPU completion, compositor presentation, pixel illumination, or
++the first visible response to a specific sound. The start is inside the native
++capture callback, **not** the original sample's hardware time. Audio buffering
++before that timestamp is excluded. A scope can render selected history or
++smoothed values after consuming newer data; FFT windows, pitch locking, and meter
++ballistics require a separate signal-response experiment.
++
++64 native-clock reads, bracketed by `performance.now()`, are taken before and after
++each run. The narrowest bracket at each endpoint is selected and the union bounds
++the clock offset. Raw clock samples, drift, uncertainty, and each stage's timing
++are retained. The method assumes no unobserved clock excursion between endpoints.
++Renderer timer granularity is measured outside each run. Calibration intervals are
++widened by that quantum (at least 0.1 ms), with another quantum allowed at the draw
++endpoint. Impossible ordering is rejected; receipt durations below resolution are
++reported as unresolved instead of clamped to zero.
++
++Native queue overwrites, sequence gaps, trimmed chunks, router overwrites, missing
++receipt metadata, observer-buffer exhaustion, skipped draws, and session changes
++are reported. Chunks queued before observation began and chunks still queued at
++stop are reported as boundaries, not successful latency samples. Invalid runs
++cannot produce claim wording.
++
++Observation uses preallocated typed arrays. Capture and draw hot paths do not
++write files, log events, or allocate record objects. Export and percentile sorting
++happen after stopping observation. The frame-only baseline retains the same small
++frame recorder but disables chunk probes. Its difference from probe runs is an
++observed workload difference, not an exact correction; nothing is subtracted.
++
++## Artifacts and reporting
++
++Each output directory contains:
++
++- `REPORT.md` and `summary.json`: results, losses, clock bounds, overhead comparison,
++ stage distributions, and supported wording.
++- Per-run JSON: every chunk, chunk/scope/frame association, frame interval, clock
++ calibration sample, router counters, scope settings, and canvas dimensions.
++- `source-manifest.json` and `benchmark.patch`: exact base commit, source hashes,
++ instrumentation patch, and version. New untracked source files are included.
++- `native-build.json`: native addon hash and Electron version.
++- `machine.json`, `stimulus.json`, and `stimulus.wav`: machine/display/runtime and
++ deterministic external test signal.
++- `cooldown.json`, `thermal.json`, and per-run `environment`: cooldown outcome,
++ thermal/power observations, and reported CPU speed limits. `machine.json` also
++ records macOS power settings. Nominal thermal state does not prove an absence
++ of all frequency changes; these records do not establish causal slowdown.
++- `chromium-trace.json` and `trace-measurement.json`: a separate presentation
++ investigation, excluded from latency claims.
++
++Regenerate a report with:
++
++```sh
++npm run bench:latency:report -- benchmark-results/my-run
++```
++
++Rows are compact arrays with explicit `chunkColumns`, `recordColumns`, and
++`frameColumns` in the same JSON. Scope indices refer to `scopes`. Record outcomes
++are 1 (successful draw), 0 (skipped draw), or -1 (missing receipt metadata). JSON
++uses null for unavailable numeric timestamps; the analyzer rejects those records.
++
++A claim names the hardware, capture path, configuration, version/base commit, and
++measurement endpoint. Median/p95 wording uses the largest per-run statistic across
++all three repeats rather than selecting the best run. An under-8-ms p95 statement
++requires every valid repeat's uncertainty-adjusted p95 to be below 8 ms. Observed
++maxima are not universal bounds. An instrumented-build result must not be presented
++as an uninstrumented physical-display measurement.
++
++The separate trace contains `prism.frame...start/end` marks. Do not infer
++presentation from the nearest unrelated compositor event or add a nominal refresh
++interval. If chunk→frame→presentation cannot be established unambiguously,
++presentation remains unavailable. The report attempts a strict association through
++the enclosing RAF task and AnimationFrame interval, exact presentation ID and
++begin-frame source/sequence, and a fully presented compositor frame. Links and
++rejections are exported in `presentation-investigation.json`. Any resulting
++estimate is kept separate from the main measurements and publication wording.
++Chromium also documents estimated presentation
++feedback on some platforms, including macOS:
++https://chromium.googlesource.com/chromium/src.git/+/main/docs/life_of_a_frame.md
++
++## Build isolation
++
++`scripts/benchmark/electron.config.mjs` adds only the benchmark compile-time flag
++to the effective defaults used by the existing build command. The repository's
++`electron-vite.config.ts` is not auto-discovered by Electron Vite, which expects
++`electron.vite.config.*`; the benchmark does not change normal config discovery.
++Normal builds never install the benchmark renderer controller or allocate probe
++buffers. The branch-only main-process runner requires the explicit benchmark
++environment and output directory.
+diff --git a/scripts/benchmark/check-native.cjs b/scripts/benchmark/check-native.cjs
+new file mode 100644
+index 0000000..e18c7f9
+--- /dev/null
++++ b/scripts/benchmark/check-native.cjs
+@@ -0,0 +1,8 @@
++const assert = require('node:assert/strict')
++const addon = require(process.argv[2])
++for (const key of ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter', 'macosCapture']) {
++ assert.ok(addon[key], `Missing native export: ${key}`)
++}
++assert.equal(addon.macosCapture.getSupport().available, true, 'Native system capture unavailable')
++assert.equal(typeof addon.macosCapture.nowMilliseconds(), 'number')
++console.log('[latency] Native addon preflight passed in Electron', process.versions.electron, 'ABI', process.versions.modules)
+diff --git a/scripts/benchmark/cooldown.cjs b/scripts/benchmark/cooldown.cjs
+new file mode 100644
+index 0000000..e07a1f6
+--- /dev/null
++++ b/scripts/benchmark/cooldown.cjs
+@@ -0,0 +1,32 @@
++const { app, powerMonitor, powerSaveBlocker } = require('electron')
++const { writeFile } = require('node:fs/promises')
++const { mkdirSync } = require('node:fs')
++const { join } = require('node:path')
++const { setTimeout: delay } = require('node:timers/promises')
++
++const userData = join(process.env.PRISM_LATENCY_BENCHMARK_DIR, 'cooldown-user-data')
++mkdirSync(userData, { recursive: true })
++app.setPath('userData', userData)
++app.whenReady().then(async () => {
++ app.dock?.hide()
++ const blocker = powerSaveBlocker.start('prevent-display-sleep')
++ const quick = process.env.PRISM_BENCH_DURATION === '3'
++ const minimumSeconds = quick ? 0 : 120
++ const requiredNominalSeconds = quick ? 0 : 60
++ const start = performance.now(), samples = []
++ let nominalSince = null, success = false
++ console.log(`[latency] Post-build cooldown: at least ${minimumSeconds}s, including ${requiredNominalSeconds}s continuously nominal; maximum 10 minutes.`)
++ while (true) {
++ const now = performance.now(), elapsedSeconds = (now - start) / 1000
++ const thermalState = powerMonitor.getCurrentThermalState()
++ samples.push({ elapsedSeconds, thermalState, onBattery: powerMonitor.isOnBatteryPower() })
++ nominalSince = thermalState === 'nominal' ? nominalSince ?? now : null
++ if (quick || (elapsedSeconds >= minimumSeconds && nominalSince !== null && now - nominalSince >= requiredNominalSeconds * 1000)) { success = true; break }
++ if (elapsedSeconds >= 600) break
++ await delay(1000)
++ }
++ await writeFile(join(process.env.PRISM_LATENCY_BENCHMARK_DIR, 'cooldown.json'), JSON.stringify({ success, minimumSeconds, requiredNominalSeconds, samples }, null, 2))
++ powerSaveBlocker.stop(blocker)
++ console.log(success ? '[latency] Cooldown complete.' : '[latency] Nominal thermal state was not sustained; measurement was not started.')
++ app.exit(success ? 0 : 1)
++}).catch(error => { console.error(error); app.exit(1) })
+diff --git a/scripts/benchmark/electron.config.mjs b/scripts/benchmark/electron.config.mjs
+new file mode 100644
+index 0000000..f737207
+--- /dev/null
++++ b/scripts/benchmark/electron.config.mjs
+@@ -0,0 +1,8 @@
++// The existing `npm run build` uses electron-vite's defaults: its auto-discovery
++// expects electron.vite.config.*, whereas this repo has electron-vite.config.ts.
++// Preserve those effective defaults; add only the benchmark compile-time flag.
++export default {
++ main: {},
++ preload: {},
++ renderer: { define: { __PRISM_LATENCY_BENCHMARK__: 'true' } },
++}
+diff --git a/scripts/benchmark/presentation.mjs b/scripts/benchmark/presentation.mjs
+new file mode 100644
+index 0000000..311b927
+--- /dev/null
++++ b/scripts/benchmark/presentation.mjs
+@@ -0,0 +1,64 @@
++/** Conservative association using frame IDs and enclosing RAF tasks, never nearest timestamps. */
++export function linkPresentation(events, raw, clock) {
++ const key = e => `${e.pid}:${e.tid}:${e.id2?.local ?? e.id2?.global ?? e.id}`
++ const intervals = [], stacks = new Map(), presentations = new Map(), reporters = new Map(), marks = new Map()
++ const append = (map, id, value) => map.set(id, [...(map.get(id) ?? []), value])
++ const rafTasks = events.filter(e => e.name === 'FireAnimationFrame' && e.ph === 'X' && e.dur > 0)
++ for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
++ if (e.name === 'AnimationFrame') {
++ if (e.ph === 'b') append(stacks, key(e), e)
++ if (e.ph === 'e') {
++ const start = stacks.get(key(e))?.pop()
++ if (start?.args?.id) intervals.push({ start, end: e })
++ }
++ }
++ if (e.name === 'AnimationFrame::Presentation') append(presentations, `${e.pid}:${e.args?.id}`, e)
++ if (e.name === 'PipelineReporter' && e.ph === 'b') {
++ const info = e.args?.frame_reporter
++ if (info) append(reporters, `${e.pid}:${info.frame_source}:${info.frame_sequence}`, e)
++ }
++ const match = /^prism\.frame\.(\d+)\.([a-z]+)\.(start|end)$/.exec(e.name ?? '')
++ if (match) append(marks, `${match[1]}:${match[2]}:${match[3]}`, e)
++ }
++ const frameLinks = [], rejected = []
++ for (const frame of raw.frames) {
++ const id = frame[0], scope = raw.scopes[frame[1]]
++ if (!frame[4]) continue
++ const starts = marks.get(`${id}:${scope}:start`) ?? [], ends = marks.get(`${id}:${scope}:end`) ?? []
++ const fail = reason => rejected.push({ frame: id, scope, reason })
++ if (starts.length !== 1 || ends.length !== 1) { fail('missing or duplicate scope markers'); continue }
++ const start = starts[0], end = ends[0]
++ const startCall = start.args?.data?.callTime, endCall = end.args?.data?.callTime
++ const endPerformance = end.args?.data?.startTime
++ if (![startCall, endCall, endPerformance].every(Number.isFinite) || start.pid !== end.pid || start.tid !== end.tid) { fail('invalid marker clock/thread'); continue }
++ const parents = intervals.filter(x => x.start.pid === start.pid && x.start.tid === start.tid && x.start.ts <= startCall && x.end.ts >= endCall)
++ const tasks = rafTasks.filter(x => x.pid === start.pid && x.tid === start.tid && x.ts <= startCall && x.ts + x.dur >= endCall)
++ if (parents.length !== 1 || tasks.length !== 1) { fail('ambiguous enclosing animation frame or RAF task'); continue }
++ const parent = parents[0].start, beginId = parent.args?.animation_frame_timing_info?.begin_frame_id
++ const present = presentations.get(`${parent.pid}:${parent.args.id}`) ?? []
++ const pipeline = reporters.get(`${parent.pid}:${beginId?.source_id}:${beginId?.sequence_number}`) ?? []
++ if (present.length !== 1 || pipeline.length !== 1 || pipeline[0].args.frame_reporter.state !== 'STATE_PRESENTED_ALL') { fail('missing/ambiguous presentation or incomplete compositor frame'); continue }
++ const p = present[0], presentId = p.args?.begin_frame_id
++ if (!beginId || beginId.source_id !== presentId?.source_id || beginId.sequence_number !== presentId?.sequence_number
++ || p.ts < endCall || endPerformance + clock.timerQuantumMs < frame[3]) { fail('frame identity or time ordering mismatch'); continue }
++ const rendererPresentationMs = endPerformance + (p.ts - endCall) / 1000
++ frameLinks.push({ frame: id, scope, rendererPid: parent.pid, animationId: parent.args.id,
++ beginFrameSource: beginId.source_id, beginFrameSequence: beginId.sequence_number,
++ presentationTraceUs: p.ts, markerCallTraceUs: endCall, markerPerformanceMs: endPerformance,
++ rendererPresentationMs, drawToPresentationEstimateMs: rendererPresentationMs - frame[3] })
++ }
++ const byFrame = new Map(frameLinks.map(link => [link.frame, link]))
++ const samples = []
++ for (const r of raw.records) {
++ const link = byFrame.get(r[3])
++ if (!link || r[8] !== 1 || !Number.isFinite(r[4])) continue
++ const latencyMs = link.rendererPresentationMs - (r[4] + clock.offset)
++ if (latencyMs >= 0) samples.push({ session: r[0], sequence: r[1], frame: r[3], scope: link.scope, latencyMs })
++ }
++ return { available: samples.length > 0, source: 'Chromium AnimationFrame::Presentation; macOS presentation feedback is an estimate',
++ caveat: 'Separate tracing workload. Software clock bounds do not bound OS presentation-estimation error or physical pixel response.',
++ matchedFrames: frameLinks.length, rejectedFrames: rejected.length, matchedChunkScopeRecords: samples.length,
++ successfulChunkScopeRecords: raw.records.filter(r => r[8] === 1).length,
++ softwareTimingUncertaintyMs: clock.uncertaintyMs + clock.timerQuantumMs,
++ frameLinks, rejected, samples }
++}
+diff --git a/scripts/benchmark/report.mjs b/scripts/benchmark/report.mjs
+new file mode 100644
+index 0000000..8fd4cf5
+--- /dev/null
++++ b/scripts/benchmark/report.mjs
+@@ -0,0 +1,120 @@
++import { build } from 'esbuild'
++import { readdir, readFile, writeFile } from 'node:fs/promises'
++import { resolve, join } from 'node:path'
++import { pathToFileURL } from 'node:url'
++import { linkPresentation } from './presentation.mjs'
++
++const fmt = value => value == null ? '—' : Number(value).toFixed(3)
++export async function generateReport(directory) {
++ const output = resolve(directory)
++ const bundled = join(output, 'analysis.mjs')
++ await build({ entryPoints: [resolve(import.meta.dirname, '../../src/renderer/benchmark/analyze.ts')], outfile: bundled,
++ bundle: true, platform: 'node', format: 'esm', define: { __PRISM_LATENCY_BENCHMARK__: 'false' } })
++ const { analyzeMeasurement, statistics } = await import(`${pathToFileURL(bundled).href}?t=${Date.now()}`)
++ const read = async name => JSON.parse(await readFile(join(output, name), 'utf8'))
++ const manifest = await read('source-manifest.json')
++ const machine = await read('machine.json').catch(() => null)
++ const completed = await read('complete.json').catch(() => null)
++ const names = (await readdir(output)).filter(name => /^\d+-.*\.json$/.test(name)).sort()
++ const results = []
++ for (const name of names) {
++ const raw = await read(name)
++ results.push({ id: raw.id, config: raw.config, target: raw.target, mode: raw.mode, repeat: raw.repeat,
++ metadata: raw.metadata, environment: raw.environment, ...analyzeMeasurement(raw) })
++ }
++ await writeFile(join(output, 'summary.json'), JSON.stringify(results, null, 2))
++ const lines = ['# Prism capture-to-render submission benchmark', '',
++ `Base: \`${manifest.baseCommit}\` (${manifest.version}); branch: \`${manifest.branch}\`.`,
++ `Instrumentation patch SHA-256: \`${manifest.patchSha256}\`.`, '',
++ 'Endpoint: the existing native timestamp inside the CoreAudio capture callback → return from a successful scope draw invocation that consumed that chunk. Every consumed chunk is counted once per scope. This measures processing and draw-command submission, not physical presentation or the time for a signal change to become visible.', '',
++ 'Excluded: audio buffering before the native timestamp, GPU execution/compositing, display scanout/pixel response, and the perceptual response of FFT windows, pitch locking, persistence, and meter ballistics. A scope may display selected history after consuming newer audio.', '',
++ `Status: ${completed ? 'suite completed' : 'incomplete suite'}${manifest.quick ? '; diagnostic quick run — no publication claim' : ''}.`,
++ ...(machine ? [`Machine: ${machine.cpu}, ${machine.model}, macOS ${machine.systemVersion}, ${machine.arch}.`,
++ `Window: ${machine.bounds.width}×${machine.bounds.height}; display scale: ${machine.display.scaleFactor}; reported display frequency: ${machine.display.displayFrequency ?? 'unavailable'} Hz.`,
++ `Electron ${machine.versions.electron}; Chromium ${machine.versions.chrome}. ${machine.warmupSeconds}s warmup; ${machine.durationSeconds}s measurement; ${machine.repeats} probe repeats.`] : []), '',
++ 'Complete scope settings, capture device/rate, canvas dimensions, and start/end metadata are in each raw JSON. See machine.json, stimulus.json, source-manifest.json, and benchmark.patch for reproduction.', '',
++ '## Per-run results', '',
++ '| Run | Scope | Median ms | p95 ms | p99 ms | Max ms | N | Timing ±ms | Observed FPS | Valid |',
++ '|---|---|---:|---:|---:|---:|---:|---:|---:|---|']
++ for (const r of results.filter(r => r.mode === 'probe')) for (const s of r.scopes) {
++ lines.push(`| ${r.id} | ${s.scope} | ${fmt(s.latencyMs.median)} | ${fmt(s.latencyMs.p95)} | ${fmt(s.latencyMs.p99)} | ${fmt(s.latencyMs.max)} | ${s.latencyMs.count} | ${fmt(r.latencyUncertaintyMs)} | ${fmt(s.observedFps)} | ${r.valid ? 'yes' : 'no'} |`)
++ }
++ lines.push('', '## Stage timings', '',
++ '| Run | Scope | Capture → receipt p95 ms | Receipt → consumption p95 ms | Consumption → draw end p95 ms | Unresolved receipt durations |',
++ '|---|---|---:|---:|---:|---:|')
++ for (const r of results.filter(r => r.mode === 'probe')) for (const s of r.scopes) {
++ lines.push(`| ${r.id} | ${s.scope} | ${fmt(s.captureToReceiptMs.p95)} | ${fmt(s.receiptToConsumptionMs.p95)} | ${fmt(s.consumptionToDrawEndMs.p95)} | ${s.unresolvedDelivery} |`)
++ }
++ lines.push('', 'Stage percentiles are separate distributions and must not be added together. Receipt intervals that overlap clock uncertainty are unresolved and excluded only from the receipt-duration distribution; their capture-to-draw measurements remain subject to the full uncertainty bounds.')
++ lines.push('', '## Losses and exclusions', '')
++ for (const r of results) lines.push(`- ${r.id}: ${r.problems.join('; ') || 'no validity failures'}. ${r.mode === 'baseline' ? 'Chunk probes disabled; chunk-loss counters unavailable.' : `Counters: ${JSON.stringify(r.counters)}. Pending at stop: ${r.scopes.map(s => `${s.scope}=${s.unconsumedAtStop}`).join(', ')}.`}`)
++ lines.push('', 'Boundary chunks were queued before observation began and are reported separately; chunks still queued at stop are right-censored. Neither is treated as a successful low-latency sample. Skipped draws, unexplained losses, duplicate consumption, and invalid clock ordering invalidate that run for a claim.', '',
++ '## Probe overhead comparison', '',
++ 'Baseline disables capture/chunk probes but retains the same minimal frame-duration recorder. Differences below are observed workload differences, not a precise causal overhead correction; run order and system load can also affect them. No overhead is subtracted from latency.', '',
++ '| Configuration / target | Scope | Baseline draw p95 ms | Probe draw p95 range ms | Baseline FPS | Probe FPS range |',
++ '|---|---|---:|---|---:|---|')
++ for (const b of results.filter(r => r.mode === 'baseline')) for (const bs of b.scopes) {
++ const probes = results.filter(r => r.mode === 'probe' && r.config === b.config && r.target === b.target).map(r => r.scopes.find(s => s.scope === bs.scope)).filter(Boolean)
++ if (!probes.length) continue
++ const range = values => `${fmt(Math.min(...values))}–${fmt(Math.max(...values))}`
++ lines.push(`| ${b.config} / ${b.target} | ${bs.scope} | ${fmt(bs.frameWorkMs.p95)} | ${range(probes.map(s => s.frameWorkMs.p95))} | ${fmt(bs.observedFps)} | ${range(probes.map(s => s.observedFps))} |`)
++ }
++ lines.push('', '## Thermal and power observations', '',
++ 'The full runner waits at least two minutes after compilation, including one continuous minute at nominal thermal state. Thermal state and AC/battery power are then sampled at 1 Hz in the main process and on OS notifications, using preallocated memory. CPU speed limits are only known after an OS notification; unavailable values do not mean 100%. Both baseline and probe runs include this observer. See cooldown.json, thermal.json, per-run environment records, and machine.json power settings.', '',
++ '| Run | Observed thermal states | Battery observed | Lowest reported CPU speed limit % |',
++ '|---|---|---|---:|')
++ for (const r of results) {
++ const e = r.environment
++ lines.push(`| ${r.id} | ${e?.thermalStates.join(', ') ?? 'unavailable'} | ${e ? e.batteryObserved ? 'yes' : 'no' : 'unavailable'} | ${fmt(e?.minimumReportedCpuSpeedLimitPercent)} |`)
++ }
++ lines.push('', 'These observations can identify reported thermal pressure, but cannot quantify how many milliseconds it caused. Nominal state does not rule out all frequency changes. Run order/configuration and background load confound a causal cool-versus-hot comparison; no thermal correction is subtracted.', '', '## Supported wording', '')
++ let claims = 0
++ if (!manifest.quick && completed) {
++ for (const config of ['spectrum', 'oscilloscope', 'default-rack']) for (const target of ['display-sync', 60]) {
++ const runs = results.filter(r => r.config === config && r.target === target && r.mode === 'probe')
++ if (runs.length !== 3 || runs.some(r => !r.valid || r.durationMs < 59900)) continue
++ for (const scope of runs[0].scopes.map(s => s.scope)) {
++ const samples = runs.map(r => r.scopes.find(s => s.scope === scope))
++ const p95 = Math.max(...samples.map(s => s.latencyMs.p95))
++ const p95Upper = Math.max(...samples.map(s => s.upperLatencyMs.p95))
++ const median = Math.max(...samples.map(s => s.latencyMs.median))
++ const uncertainty = Math.max(...runs.map(r => r.latencyUncertaintyMs))
++ lines.push(`- On ${machine.cpu} / macOS ${machine.systemVersion}, an instrumented build of Prism ${manifest.version} based on ${manifest.baseCommit.slice(0, 7)} measured at most ${fmt(median)} ms median and ${fmt(p95)} ms p95 across three 60-second runs for **${scope}**, using **${config}, ${target} target**, native system capture at ${runs[0].metadata.capture.sampleRate} Hz. Timing uncertainty was at most ±${fmt(uncertainty)} ms. ${p95Upper < 8 ? 'All three observed p95 upper bounds were below 8 ms.' : 'These results do not support an under-8-ms p95 claim.'}`)
++ claims++
++ }
++ }
++ }
++ if (!claims) lines.push('No publication-ready latency claim is supported by this dataset. See validity failures and suite status above.')
++ lines.push('', 'These statements apply only to the tested configuration and instrumentation patch. Observed maxima are not guaranteed bounds. No claim of capture-to-physical-display latency is made.', '',
++ '## Presentation investigation', '',
++ 'Presentation latency: **unavailable unless separately established from an unambiguous chunk/frame/presentation association**. The separate chromium-trace.json and trace-measurement.json contain scope frame markers and Chromium events; their mere proximity is not a valid association. Presentation feedback may itself be estimated on macOS. No nominal refresh interval is added to manufacture an end-to-end result.', '',
++ '## Clock method', '',
++ '64 synchronous native-clock calls are bracketed by performance.now() before and after each run. The narrowest round-trip bracket at each endpoint is selected, then their union bounds the offset used for the run. This assumes no unobserved clock excursion between endpoints. Calibration bounds include the measured renderer timer quantum (at least 0.1 ms), and draw-end uncertainty includes an additional quantum. The midpoint supplies reported estimates; conservative p95 checks use the older possible capture time and later possible draw time. Receipt durations below timer resolution are counted as unresolved, never clamped or presented as zero latency. Complete calibration samples and endpoint drift are exported.', '')
++ try {
++ const traceRaw = await read('trace-measurement.json')
++ const trace = await read('chromium-trace.json')
++ const traceAnalysis = analyzeMeasurement(traceRaw)
++ const presentation = linkPresentation(trace.traceEvents, traceRaw, traceAnalysis.clock)
++ await writeFile(join(output, 'presentation-investigation.json'), JSON.stringify({ ...presentation, traceValidity: traceAnalysis.problems }, null, 2))
++ lines.push('## Separate trace findings', '',
++ `Matched ${presentation.matchedFrames} scope frames and ${presentation.matchedChunkScopeRecords}/${presentation.successfulChunkScopeRecords} successful chunk/scope records; ${presentation.rejectedFrames} frame associations were rejected.`,
++ 'Associations require unique scope markers inside one RAF task and one AnimationFrame interval, exact animation/presentation IDs, matching begin-frame source/sequence IDs, and a STATE_PRESENTED_ALL compositor reporter.', '')
++ if (presentation.available && traceAnalysis.valid) {
++ lines.push('| Scope | Estimated capture → presentation median ms | Estimated p95 ms | Matched records |', '|---|---:|---:|---:|')
++ for (const scope of traceRaw.metadata.visibleScopes) {
++ const stats = statistics(presentation.samples.filter(s => s.scope === scope).map(s => s.latencyMs))
++ lines.push(`| ${scope} | ${fmt(stats.median)} | ${fmt(stats.p95)} | ${stats.count} |`)
++ }
++ lines.push('', `Software timestamp uncertainty is approximately ±${fmt(presentation.softwareTimingUncertaintyMs)} ms, **plus unquantified macOS presentation-estimation error**. These are diagnostic estimates from the separate tracing workload, not physical display measurements or publication claims.`)
++ } else lines.push(`Presentation estimate unavailable: ${traceAnalysis.problems.join('; ') || 'no unambiguous associations'}.`)
++ } catch (error) {
++ lines.push('## Separate trace findings', '', `Presentation estimate unavailable: ${String(error)}.`)
++ }
++ await writeFile(join(output, 'REPORT.md'), lines.join('\n'))
++ console.log(`Report: ${join(output, 'REPORT.md')}`)
++ return results
++}
++if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
++ if (!process.argv[2]) throw new Error('Usage: npm run bench:latency:report -- benchmark-results/')
++ await generateReport(process.argv[2])
++}
+diff --git a/scripts/benchmark/run.mjs b/scripts/benchmark/run.mjs
+new file mode 100644
+index 0000000..8e6a893
+--- /dev/null
++++ b/scripts/benchmark/run.mjs
+@@ -0,0 +1,98 @@
++import { spawn, execFileSync } from 'node:child_process'
++import { mkdir, readFile, writeFile, open } from 'node:fs/promises'
++import { resolve, join } from 'node:path'
++import { createHash } from 'node:crypto'
++import { createRequire } from 'node:module'
++import { generateReport } from './report.mjs'
++
++const root = resolve(import.meta.dirname, '../..')
++const args = process.argv.slice(2)
++const quick = args.includes('--quick')
++const outputIndex = args.indexOf('--output')
++if (outputIndex >= 0 && !args[outputIndex + 1]) throw new Error('--output requires a directory')
++const output = resolve(outputIndex >= 0 ? args[outputIndex + 1] : join(root, 'benchmark-results', new Date().toISOString().replaceAll(':', '-')))
++if (process.platform !== 'darwin') throw new Error('This first benchmark supports native macOS system capture only')
++const run = (command, argv, options = {}) => new Promise((resolveRun, reject) => {
++ const child = spawn(command, argv, { cwd: root, stdio: 'inherit', ...options })
++ child.on('error', reject)
++ child.on('exit', (code, signal) => code === 0 ? resolveRun() : reject(new Error(`${command} exited ${code ?? signal}`)))
++})
++await mkdir(output, { recursive: true })
++// Refuse accidental overwrite of a previous experiment.
++await writeFile(join(output, 'run.lock'), `${process.pid}\n`, { flag: 'wx' })
++await mkdir(join(output, 'user-data'), { recursive: true })
++const git = (...argv) => execFileSync('git', argv, { cwd: root, encoding: 'utf8' })
++const baseCommit = '2a2d01d32da824d03e60440acd505f3d890a4e6f'
++const measurementCommit = git('rev-parse', 'HEAD').trim()
++let patch = git('diff', '--binary', baseCommit)
++const untracked = git('ls-files', '--others', '--exclude-standard', '-z').split('\0').filter(Boolean)
++for (const file of untracked) {
++ try { patch += git('diff', '--no-index', '--binary', '--', '/dev/null', file) }
++ catch (error) { if (error.status !== 1) throw error; patch += error.stdout }
++}
++await writeFile(join(output, 'benchmark.patch'), patch)
++const files = [...new Set([...git('ls-files', '-z').split('\0').filter(Boolean), ...untracked])]
++const hashes = {}
++for (const file of files) {
++ try { hashes[file] = createHash('sha256').update(await readFile(join(root, file))).digest('hex') }
++ catch (error) { if (error.code !== 'ENOENT') throw error; hashes[file] = null }
++}
++const env = { ...process.env, PRISM_LATENCY_BENCHMARK: '1', PRISM_LATENCY_BENCHMARK_DIR: output,
++ PRISM_BENCH_DURATION: quick ? '3' : '60', PRISM_BENCH_WARMUP: quick ? '2' : '10', PRISM_BENCH_REPEATS: quick ? '1' : '3' }
++delete env.ELECTRON_RUN_AS_NODE
++delete env.ELECTRON_RENDERER_URL
++await writeFile(join(output, 'source-manifest.json'), JSON.stringify({
++ baseCommit, measurementCommit, branch: git('branch', '--show-current').trim(), version: JSON.parse(await readFile(join(root, 'package.json'), 'utf8')).version,
++ patchSha256: createHash('sha256').update(patch).digest('hex'), files: hashes,
++ quick, createdAt: new Date().toISOString(), command: process.argv, nodeVersion: process.version,
++}, null, 2))
++
++// A file-backed external player keeps synthesis work off Prism's rendering thread.
++const sampleRate = 48000, seconds = Number(env.PRISM_BENCH_DURATION) + Number(env.PRISM_BENCH_WARMUP) + 15
++const totalFrames = Math.ceil(sampleRate * seconds), dataSize = totalFrames * 4
++const header = Buffer.alloc(44)
++header.write('RIFF'); header.writeUInt32LE(36 + dataSize, 4); header.write('WAVEfmt ', 8)
++header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(2, 22)
++header.writeUInt32LE(sampleRate, 24); header.writeUInt32LE(sampleRate * 4, 28)
++header.writeUInt16LE(4, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(dataSize, 40)
++const wav = await open(join(output, 'stimulus.wav'), 'w')
++await wav.write(header)
++const block = Buffer.alloc(sampleRate * 4)
++let phaseL = 0, phaseR = 0
++for (let first = 0; first < totalFrames; first += sampleRate) {
++ const frames = Math.min(sampleRate, totalFrames - first)
++ for (let i = 0; i < frames; i++) {
++ const t = (first + i) / sampleRate
++ const step = Math.floor(t / 0.73) % 4
++ phaseL += 2 * Math.PI * [220, 440, 880, 330][step] / sampleRate
++ phaseR += 2 * Math.PI * [330, 550, 660, 440][step] / sampleRate
++ const amplitude = 0.025 * (0.7 + 0.3 * Math.sin(2 * Math.PI * 1.37 * t))
++ block.writeInt16LE(Math.round(32767 * amplitude * Math.sin(phaseL)), i * 4)
++ block.writeInt16LE(Math.round(32767 * amplitude * Math.sin(phaseR)), i * 4 + 2)
++ }
++ await wav.write(block.subarray(0, frames * 4))
++}
++await wav.close()
++await writeFile(join(output, 'stimulus.json'), JSON.stringify({ sampleRate, channels: 2, format: 'PCM16', seconds,
++ peakAmplitude: 0.025, amplitudeModulationHz: 1.37, frequencyStepSeconds: 0.73,
++ leftHz: [220, 440, 880, 330], rightHz: [330, 550, 660, 440],
++ sha256: createHash('sha256').update(await readFile(join(output, 'stimulus.wav'))).digest('hex'),
++}, null, 2))
++await run(process.execPath, [join(root, 'node_modules/electron-vite/bin/electron-vite.js'), 'build', '--config', 'scripts/benchmark/electron.config.mjs'], { env })
++await run(process.execPath, ['scripts/build/writeAppBuildMetadata.cjs'], { env })
++const require = createRequire(import.meta.url)
++const electron = require('electron')
++await run('npm', ['run', 'rebuild:native'], { env })
++const nativePath = join(root, 'native/build/Release/visualizer_dsp.node')
++await run(electron, [join(root, 'scripts/benchmark/check-native.cjs'), nativePath], { env: { ...env, ELECTRON_RUN_AS_NODE: '1' } })
++await writeFile(join(output, 'native-build.json'), JSON.stringify({ path: nativePath,
++ sha256: createHash('sha256').update(await readFile(nativePath)).digest('hex'),
++ electronVersion: require('electron/package.json').version, rebuiltFromSource: true,
++}, null, 2))
++console.log(`Benchmark artifacts: ${output}\n${quick ? 'Diagnostic quick run; not suitable for published claims.' : 'Full suite: approximately 29 minutes. Keep Prism visible; a quiet test tone will play.'}`)
++await run(electron, [join(root, 'scripts/benchmark/cooldown.cjs')], { env })
++try {
++ await run(electron, [root], { env })
++} finally {
++ await generateReport(output)
++}
+diff --git a/scripts/benchmark/test.mjs b/scripts/benchmark/test.mjs
+new file mode 100644
+index 0000000..e94bad0
+--- /dev/null
++++ b/scripts/benchmark/test.mjs
+@@ -0,0 +1,24 @@
++import { build } from 'esbuild'
++import { mkdtemp, rm } from 'node:fs/promises'
++import { tmpdir } from 'node:os'
++import { join, resolve } from 'node:path'
++import { spawn } from 'node:child_process'
++const directory = await mkdtemp(join(tmpdir(), 'prism-latency-tests-'))
++try {
++ for (const [name, enabled] of [['latency-benchmark', false], ['latency-benchmark-integration', true]]) {
++ const file = join(directory, name + '.mjs')
++ await build({ entryPoints: [resolve(import.meta.dirname, `../../test/${name}.test.ts`)], outfile: file,
++ bundle: true, platform: 'node', format: 'esm', define: { __PRISM_LATENCY_BENCHMARK__: String(enabled) } })
++ process.exitCode = await new Promise((resolveRun, reject) => {
++ const child = spawn(process.execPath, ['--test', file], { stdio: 'inherit' })
++ child.on('error', reject); child.on('exit', code => resolveRun(code ?? 1))
++ })
++ if (process.exitCode) break
++ }
++ if (!process.exitCode) {
++ process.exitCode = await new Promise((resolveRun, reject) => {
++ const child = spawn(process.execPath, ['--test', resolve(import.meta.dirname, '../../test/presentation-benchmark.test.mjs')], { stdio: 'inherit' })
++ child.on('error', reject); child.on('exit', code => resolveRun(code ?? 1))
++ })
++ }
++} finally { await rm(directory, { recursive: true, force: true }) }
+diff --git a/src/main/benchmarkEnvironment.ts b/src/main/benchmarkEnvironment.ts
+new file mode 100644
+index 0000000..be55c80
+--- /dev/null
++++ b/src/main/benchmarkEnvironment.ts
+@@ -0,0 +1,52 @@
++import { powerMonitor } from 'electron'
++
++/** Main-process-only observer. No per-frame polling, logging, or file writes. */
++export function recordBenchmarkEnvironment() {
++ const states = ['unknown', 'nominal', 'fair', 'serious', 'critical']
++ const columns = ['mainMonotonicMs', 'unixMs', 'thermalState', 'onBattery', 'cpuSpeedLimitPercent', 'reason']
++ const reasons = ['sample', 'thermal-change', 'speed-limit-change', 'power-change', 'boundary']
++ const width = columns.length
++ const data = new Float64Array(8192 * width)
++ let count = 0, overflow = 0, speedLimit = Number.NaN
++ const sample = (reason = 0) => {
++ if (count >= data.length / width) { overflow++; return }
++ const offset = count++ * width
++ data[offset] = performance.now()
++ data[offset + 1] = Date.now()
++ data[offset + 2] = states.indexOf(powerMonitor.getCurrentThermalState())
++ data[offset + 3] = Number(powerMonitor.isOnBatteryPower())
++ data[offset + 4] = speedLimit
++ data[offset + 5] = reason
++ }
++ const thermal = () => sample(1)
++ const speed = ({ limit }: { limit: number }) => { speedLimit = limit; sample(2) }
++ const power = () => sample(3)
++ powerMonitor.on('thermal-state-change', thermal)
++ powerMonitor.on('speed-limit-change', speed)
++ powerMonitor.on('on-ac', power)
++ powerMonitor.on('on-battery', power)
++ const timer = setInterval(sample, 1000)
++ sample()
++ const exportSince = (first = 0) => {
++ const samples = Array.from({ length: count - first }, (_, i) => Array.from(data.subarray((first + i) * width, (first + i + 1) * width)))
++ const limits = samples.map(row => row[4]).filter(Number.isFinite)
++ return { columns, states, reasons, samples, overflow,
++ thermalStates: [...new Set(samples.map(row => states[row[2]] ?? 'unknown'))],
++ batteryObserved: samples.some(row => row[3] === 1),
++ minimumReportedCpuSpeedLimitPercent: limits.length ? Math.min(...limits) : null,
++ note: '1 Hz snapshots plus OS notifications; CPU speed limit is unknown until a notification arrives. Nominal thermal state does not prove an absence of all clock-frequency changes. No temperature or causal latency correction is inferred.' }
++ }
++ return {
++ mark: () => { const first = count; sample(4); return first },
++ finishRun: (first: number) => { sample(4); return exportSince(first) },
++ stop: () => {
++ clearInterval(timer)
++ powerMonitor.removeListener('thermal-state-change', thermal)
++ powerMonitor.removeListener('speed-limit-change', speed)
++ powerMonitor.removeListener('on-ac', power)
++ powerMonitor.removeListener('on-battery', power)
++ sample(4)
++ return exportSince()
++ },
++ }
++}
+diff --git a/src/main/index.ts b/src/main/index.ts
+index 105db5b..311f0b9 100644
+--- a/src/main/index.ts
++++ b/src/main/index.ts
+@@ -92,6 +92,12 @@ import { resolveTrayAssetPath } from './services/trayAssets'
+ import { DawBridgeService } from './services/dawBridgeService'
+ import type { DawBridgeAudioBatch, DawBridgeSnapshot } from '../types/dawBridge'
+
++const latencyBenchmarkDirectory = process.env.PRISM_LATENCY_BENCHMARK === '1'
++ ? process.env.PRISM_LATENCY_BENCHMARK_DIR : undefined
++if (latencyBenchmarkDirectory) {
++ app.setPath('userData', join(resolve(latencyBenchmarkDirectory), 'user-data'))
++}
++let latencyBenchmarkStarted = false
+ let mainWindow: BrowserWindow | null = null
+ let windowDocking: WindowDockingService | null = null
+ const dockingGeometryGuard = new DockingGeometryGuard()
+@@ -2839,6 +2845,11 @@ function setupIPC(): void {
+ if (!isMainRendererWindow(targetWindow)) return
+
+ mainRendererReady = true
++ if (latencyBenchmarkDirectory && !latencyBenchmarkStarted && mainWindow) {
++ latencyBenchmarkStarted = true
++ const benchmarkWindow = mainWindow
++ void import('./latencyBenchmark').then(({ runLatencyBenchmark }) => runLatencyBenchmark(benchmarkWindow))
++ }
+ windowDocking?.ready()
+ void processPendingProfileOpenPaths()
+ })
+diff --git a/src/main/latencyBenchmark.ts b/src/main/latencyBenchmark.ts
+new file mode 100644
+index 0000000..87f2946
+--- /dev/null
++++ b/src/main/latencyBenchmark.ts
+@@ -0,0 +1,111 @@
++import { app, contentTracing, powerSaveBlocker, screen, type BrowserWindow } from 'electron'
++import { spawn, execFileSync, type ChildProcess } from 'node:child_process'
++import { writeFile } from 'node:fs/promises'
++import { join, resolve } from 'node:path'
++import os from 'node:os'
++import { setTimeout as delay } from 'node:timers/promises'
++import { recordBenchmarkEnvironment } from './benchmarkEnvironment'
++
++/** Runs only with the explicit benchmark environment, in an isolated userData directory. */
++export async function runLatencyBenchmark(window: BrowserWindow): Promise {
++ const directory = process.env.PRISM_LATENCY_BENCHMARK_DIR
++ if (!directory || process.platform !== 'darwin') throw new Error('Benchmark requires macOS and an output directory')
++ const output = resolve(directory)
++ const duration = Number(process.env.PRISM_BENCH_DURATION ?? 60)
++ const warmup = Number(process.env.PRISM_BENCH_WARMUP ?? 10)
++ const repeats = Number(process.env.PRISM_BENCH_REPEATS ?? 3)
++ if (![duration, warmup, repeats].every(n => Number.isFinite(n) && n > 0) || repeats > 10 || duration > 120) throw new Error('Invalid benchmark durations')
++ const invoke = async (method: string, ...args: unknown[]) => window.webContents.executeJavaScript(
++ `window.prismLatencyBenchmark.${method}(...${JSON.stringify(args)})`,
++ )
++ let player: ChildProcess | null = null
++ let tracing = false
++ const sleepBlocker = powerSaveBlocker.start('prevent-display-sleep')
++ const environment = recordBenchmarkEnvironment()
++ const stopPlayer = () => { player?.kill('SIGTERM'); player = null }
++ const assertWindow = () => {
++ if (window.isDestroyed() || window.isMinimized() || !window.isVisible()) throw new Error('Benchmark window was closed, minimized, or hidden')
++ }
++ try {
++ for (let i = 0; i < 100; i++) {
++ if (await window.webContents.executeJavaScript('Boolean(window.prismLatencyBenchmark)')) break
++ if (i === 99) throw new Error('Benchmark renderer interface unavailable; rebuild with PRISM_LATENCY_BENCHMARK=1')
++ await delay(100)
++ }
++ window.setBounds({ width: 1200, height: 360 })
++ window.center(); window.show(); window.focus()
++ const display = screen.getDisplayMatching(window.getBounds())
++ await writeFile(join(output, 'machine.json'), JSON.stringify({
++ platform: process.platform, arch: process.arch, os: os.release(), systemVersion: process.getSystemVersion(),
++ model: execFileSync('/usr/sbin/sysctl', ['-n', 'hw.model'], { encoding: 'utf8' }).trim(),
++ cpu: os.cpus()[0]?.model, memoryBytes: os.totalmem(), versions: process.versions,
++ display, bounds: window.getBounds(), durationSeconds: duration, warmupSeconds: warmup, repeats,
++ audioPlayer: '/usr/bin/afplay', signalFile: 'stimulus.wav',
++ powerSettings: execFileSync('/usr/bin/pmset', ['-g', 'custom'], { encoding: 'utf8' }),
++ }, null, 2))
++ const cases = [
++ { name: 'spectrum', scopes: ['spectrum'] },
++ { name: 'oscilloscope', scopes: ['oscilloscope'] },
++ { name: 'default-rack', scopes: ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] },
++ ]
++ const modes = ['baseline', ...Array.from({ length: repeats }, () => 'probe')]
++ let run = 0
++ for (const target of ['display-sync', 60] as const) {
++ for (const config of cases) {
++ await invoke('configure', config.scopes, target)
++ for (let repeat = 0; repeat < modes.length; repeat++) {
++ const mode = modes[repeat]
++ const id = `${String(++run).padStart(2, '0')}-${config.name}-${target}-${mode}-${repeat}`
++ console.log(`[latency] ${id}: warming up ${warmup}s`)
++ let playerError = ''
++ player = spawn('/usr/bin/afplay', [join(output, 'stimulus.wav')], { stdio: ['ignore', 'ignore', 'pipe'] })
++ player.on('error', error => { playerError = error.message })
++ player.stderr?.on('data', data => { playerError += data.toString() })
++ await delay(warmup * 1000)
++ assertWindow()
++ if (playerError || player.exitCode !== null) throw new Error(`Stimulus player failed: ${playerError}`)
++ const environmentStart = environment.mark()
++ const startMetadata = await invoke('start', mode)
++ console.log(`[latency] ${id}: measuring ${duration}s`)
++ await delay(duration * 1000)
++ assertWindow()
++ const result = await invoke('stop')
++ const runEnvironment = environment.finishRun(environmentStart)
++ if (playerError || player.exitCode !== null) throw new Error(`Stimulus ended during measurement: ${playerError}`)
++ stopPlayer()
++ await writeFile(join(output, `${id}.json`), JSON.stringify({ id, config: config.name, target, repeat, startMetadata, environment: runEnvironment, ...result }))
++ console.log(`[latency] ${id}: saved ${result.records.length} chunk/scope records, ${result.frames.length} frames`)
++ }
++ }
++ }
++ // Diagnostic trace is a separate workload and is never pooled into latency claims.
++ await invoke('configure', cases[2].scopes, 'display-sync')
++ player = spawn('/usr/bin/afplay', [join(output, 'stimulus.wav')], { stdio: 'ignore' })
++ await delay(warmup * 1000)
++ const categories = await contentTracing.getCategories()
++ await writeFile(join(output, 'trace-categories.json'), JSON.stringify(categories, null, 2))
++ await contentTracing.startRecording({ included_categories: ['blink.user_timing', 'devtools.timeline', 'cc', 'viz', 'gpu', 'toplevel', 'benchmark'] })
++ tracing = true
++ const traceEnvironmentStart = environment.mark()
++ await invoke('start', 'trace')
++ await delay(Math.min(duration, 10) * 1000)
++ const traceResult = await invoke('stop')
++ await contentTracing.stopRecording(join(output, 'chromium-trace.json'))
++ tracing = false
++ await writeFile(join(output, 'trace-measurement.json'), JSON.stringify({ ...traceResult, environment: environment.finishRun(traceEnvironmentStart) }))
++ stopPlayer()
++ await writeFile(join(output, 'thermal.json'), JSON.stringify(environment.stop()))
++ await writeFile(join(output, 'complete.json'), JSON.stringify({ completedAt: new Date().toISOString(), runs: run }))
++ console.log(`[latency] Complete: ${output}`)
++ app.exit(0)
++ } catch (error) {
++ stopPlayer()
++ await writeFile(join(output, 'thermal.json'), JSON.stringify(environment.stop()))
++ if (tracing) await contentTracing.stopRecording(join(output, 'interrupted-trace.json')).catch(() => {})
++ await writeFile(join(output, 'failure.json'), JSON.stringify({ error: String(error), at: new Date().toISOString() }, null, 2))
++ console.error('[latency]', error)
++ app.exit(1)
++ } finally {
++ if (powerSaveBlocker.isStarted(sleepBlocker)) powerSaveBlocker.stop(sleepBlocker)
++ }
++}
+diff --git a/src/preload/index.ts b/src/preload/index.ts
+index 4d2ee44..72e44f8 100644
+--- a/src/preload/index.ts
++++ b/src/preload/index.ts
+@@ -361,11 +361,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
+ let nativeAddonModule: NativeAddonModule | null = null
+ try {
+ const isDev = process.env.NODE_ENV === 'development'
+- const modulePath = isDev
++ const isLatencyBenchmark = process.env.PRISM_LATENCY_BENCHMARK === '1'
++ const modulePath = isDev || isLatencyBenchmark
+ ? require('path').join(__dirname, '../../native/build/Release/visualizer_dsp.node')
+ : require('path').join(process.resourcesPath!, 'native/visualizer_dsp.node')
+ nativeAddonModule = require(modulePath) as NativeAddonModule
+-} catch {
++} catch (error) {
++ if (process.env.PRISM_LATENCY_BENCHMARK === '1') console.error('Benchmark native addon load failed:', error)
+ console.warn('Native DSP module not available — using JS fallback')
+ }
+
+diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts
+index fc414a2..8f3ece8 100644
+--- a/src/renderer/audio/AudioCapture.ts
++++ b/src/renderer/audio/AudioCapture.ts
+@@ -6,6 +6,7 @@
+ */
+
+ import { audioRouter } from './AudioRouter'
++import { latencyProbe } from '../benchmark/latencyProbe'
+ import { nativeVisualizerTransport } from './NativeVisualizerTransport'
+ import { RollingAudioBuffer } from './RollingAudioBuffer'
+ import { ChannelActivity, type ChannelActivitySnapshot } from './ChannelActivity'
+@@ -596,7 +597,9 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend {
+ result,
+ this.shouldTrimBacklogForLiveCapture(),
+ )
++ latencyProbe?.nativeDrain(result.overwriteCount, result.queueDepth, result.chunks.length - deliveredChunks.length)
+ for (const chunk of deliveredChunks) {
++ latencyProbe?.receipt(chunk.sequence, chunk.capturedAtMilliseconds, chunk.left.length, chunk.sourceChannelPeaks?.[0] ?? 0)
+ const routedChunk: CaptureChunk = {
+ left: chunk.left,
+ right: chunk.right,
+diff --git a/src/renderer/audio/AudioRouter.ts b/src/renderer/audio/AudioRouter.ts
+index 9850e4e..0187a2f 100644
+--- a/src/renderer/audio/AudioRouter.ts
++++ b/src/renderer/audio/AudioRouter.ts
+@@ -5,6 +5,7 @@
+ */
+
+ import { AUDIO_SCOPE_KINDS, type AudioScopeKind } from '../../types/scope'
++import { latencyProbe } from '../benchmark/latencyProbe'
+ import type { CaptureBackendKind } from '../../types/capture'
+ import type { DawTransportSnapshot } from '../../types/dawBridge'
+
+@@ -259,6 +260,7 @@ export class AudioRouter {
+ private demandListeners = new Set<(demand: NormalizedVisualizerConsumerDemand) => void>()
+
+ private emitSessionState(): void {
++ latencyProbe?.sessionChanged(this._sessionId)
+ const state = this.getSessionState()
+ for (const listener of this.sessionListeners) {
+ listener(state)
+@@ -618,6 +620,7 @@ export class AudioRouter {
+ const tracker = this.scopeLatency[scope]
+ const now = performance.now()
+ for (const record of records) {
++ latencyProbe?.consume(scope, record.sequence)
+ const captureToScopeMs = Math.max(0, now - record.capturedAt)
+ tracker.lastCaptureToScopeMs = captureToScopeMs
+ tracker.latencyWindow.push(captureToScopeMs)
+diff --git a/src/renderer/benchmark/analyze.ts b/src/renderer/benchmark/analyze.ts
+new file mode 100644
+index 0000000..0469ecd
+--- /dev/null
++++ b/src/renderer/benchmark/analyze.ts
+@@ -0,0 +1,71 @@
++import { clockBounds, percentile, type ClockSample, type LatencyProbe } from './latencyProbe'
++import type { AudioRouterDiagnostics } from '../audio/AudioRouter'
++
++export type RawMeasurement = ReturnType & {
++ clockStart: ClockSample[]; clockEnd: ClockSample[]
++ timerQuantumMs?: number
++ routerStart: AudioRouterDiagnostics; routerEnd: AudioRouterDiagnostics
++ hiddenEvents: number
++ startMetadata?: unknown
++ metadata: { hidden: boolean; visibleScopes: string[]; capture: { activeBackendKind: string | null; isCapturing: boolean } }
++}
++export function statistics(values: number[]) {
++ return { count: values.length, median: percentile(values, 0.5), p95: percentile(values, 0.95), p99: percentile(values, 0.99), max: percentile(values, 1) }
++}
++
++export function analyzeMeasurement(raw: RawMeasurement) {
++ const timerQuantumMs = raw.timerQuantumMs ?? 0
++ const clock = { ...clockBounds(raw.clockStart, raw.clockEnd, timerQuantumMs), timerQuantumMs }
++ const latencyUncertaintyMs = clock.uncertaintyMs + timerQuantumMs
++ const problems: string[] = []
++ if (raw.startMetadata && JSON.stringify(raw.startMetadata) !== JSON.stringify(raw.metadata)) problems.push('Capture, display geometry, or scope settings changed during measurement')
++ if (raw.hiddenEvents || raw.metadata.hidden) problems.push('Window visibility changed or window was hidden')
++ if (!raw.metadata.capture.isCapturing || raw.metadata.capture.activeBackendKind !== 'native-macos') problems.push('Native macOS system capture was not active')
++ for (const key of ['nativeOverwrites', 'sequenceGaps', 'sessionChanges', 'bufferOverflow', 'missingReceipts', 'trimmedChunks', 'consumedOutsideFrame'] as const) {
++ if (raw.counters[key]) problems.push(`${key}: ${raw.counters[key]}`)
++ }
++ if (raw.routerEnd.totalOverwriteCount !== raw.routerStart.totalOverwriteCount) problems.push('Router overwrites occurred')
++ if (raw.mode !== 'baseline' && !raw.chunks.some(c => c[5] > 0.00001)) problems.push('No non-silent captured signal verified')
++ if (raw.mode !== 'baseline' && !raw.chunks.length) problems.push('No captured chunks')
++ const scopes = raw.scopes.filter(s => raw.metadata.visibleScopes.includes(s)).map(scope => {
++ const id = raw.scopes.indexOf(scope)
++ const rows = raw.records.filter(r => r[2] === id)
++ const frames = raw.frames.filter(f => f[1] === id)
++ const frameIntervals = frames.slice(1).map((f, i) => f[2] - frames[i][2])
++ const drawWork = frames.map(f => f[3] - f[2])
++ const total: number[] = [], upper: number[] = [], delivery: number[] = [], queue: number[] = [], render: number[] = []
++ const seen = new Set()
++ let invalid = 0, skipped = 0, duplicate = 0, unresolvedDelivery = 0
++ for (const r of rows) {
++ const key = `${r[0]}:${r[1]}`
++ if (seen.has(key)) { duplicate++; continue }
++ seen.add(key)
++ if (r[8] === 0) { skipped++; continue }
++ if (r[8] !== 1 || !r.slice(4, 8).every(Number.isFinite)) { invalid++; continue }
++ const capture = r[4] + clock.offset
++ // Reject impossible ordering. Overlapping receipt/capture uncertainty intervals
++ // leave delivery duration unresolved, but need not invalidate a later draw endpoint.
++ if (r[5] + timerQuantumMs < r[4] + clock.low || r[6] < r[5] || r[7] < r[6]
++ || r[7] - capture < 0) { invalid++; continue }
++ total.push(r[7] - capture)
++ upper.push(r[7] + timerQuantumMs - (r[4] + clock.low))
++ if (r[5] - timerQuantumMs >= r[4] + clock.high) delivery.push(r[5] - capture)
++ else unresolvedDelivery++
++ queue.push(r[6] - r[5]); render.push(r[7] - r[6])
++ }
++ if (raw.mode !== 'baseline' && (!total.length || invalid || skipped || duplicate)) problems.push(`${scope}: ${invalid} invalid, ${skipped} skipped, ${duplicate} duplicate, ${total.length} valid`)
++ // End-of-window arrivals still in the normal scope queue are right-censored, not successful draws.
++ const unconsumedAtStop = Math.max(0, raw.chunks.length - seen.size)
++ const pendingAtStop = raw.routerEnd.scopes[scope].queuedChunks
++ if (raw.mode !== 'baseline' && unconsumedAtStop > pendingAtStop) problems.push(`${scope}: unexplained unconsumed chunks`)
++ return { scope, latencyMs: statistics(total), upperLatencyMs: statistics(upper), captureToReceiptMs: statistics(delivery),
++ receiptToConsumptionMs: statistics(queue), consumptionToDrawEndMs: statistics(render),
++ frameWorkMs: statistics(drawWork), frameIntervalMs: statistics(frameIntervals),
++ observedFps: frames.length > 1 ? (frames.length - 1) * 1000 / (frames[frames.length - 1][2] - frames[0][2]) : 0,
++ frameCount: frames.length, skippedDrawFrames: frames.filter(f => !f[4]).length,
++ invalid, skippedChunks: skipped, duplicate, unresolvedDelivery, unconsumedAtStop, pendingAtStop }
++ })
++ if (!scopes.length || scopes.some(s => s.frameCount < 2)) problems.push('Insufficient scope frames')
++ return { durationMs: raw.stoppedAt - raw.startedAt, clock, latencyUncertaintyMs, problems, valid: problems.length === 0,
++ counters: raw.counters, chunkSizes: [...new Set(raw.chunks.map(c => c[4]))], scopes }
++}
+diff --git a/src/renderer/benchmark/controller.ts b/src/renderer/benchmark/controller.ts
+new file mode 100644
+index 0000000..754c7e4
+--- /dev/null
++++ b/src/renderer/benchmark/controller.ts
+@@ -0,0 +1,85 @@
++import { audioCapture } from '../audio/AudioCapture'
++import { audioRouter } from '../audio/AudioRouter'
++import { isNativeAvailable } from '../audio/native'
++import { useAudioStore } from '../stores/audioStore'
++import { useSettingsStore } from '../stores/settingsStore'
++import { usePerformanceStore } from '../stores/performanceStore'
++import { createDefaultProfile } from '../../shared/profileState'
++import { SCOPE_KINDS } from '../../types/scope'
++import { BENCHMARK_BUILD, calibrateClock, measureTimerQuantum, latencyProbe, type BenchScope, type ClockSample, type ProbeMode } from './latencyProbe'
++
++export function installLatencyBenchmark(): void {
++ if (!BENCHMARK_BUILD || !latencyProbe) return
++ const probe = latencyProbe
++ let clockStart: ClockSample[] = []
++ let timerQuantumStart = 0
++ let routerStart = audioRouter.getDiagnosticsSnapshot()
++ let hiddenEvents = 0
++ document.addEventListener('visibilitychange', () => { if (probe.active) hiddenEvents++ })
++ const metadata = () => ({
++ capture: audioCapture.getStatus(), session: audioRouter.getSessionState(),
++ frameTarget: usePerformanceStore.getState().frameTarget,
++ scopeSettings: useSettingsStore.getState().scopeSettings,
++ visibleScopes: useSettingsStore.getState().visibleScopes(),
++ pixelRatio: devicePixelRatio, innerWidth, innerHeight, hidden: document.hidden,
++ canvases: [...document.querySelectorAll('canvas')].map(canvas => ({
++ width: canvas.width, height: canvas.height, cssWidth: canvas.clientWidth, cssHeight: canvas.clientHeight,
++ })),
++ })
++ const readNative = () => {
++ const api = window.nativeCaptureAPI?.macosCapture
++ if (!api) throw new Error('Native macOS capture API unavailable')
++ return api.nowMilliseconds()
++ }
++ window.prismLatencyBenchmark = {
++ async configure(scopes: BenchScope[], frameTarget: 60 | 'display-sync') {
++ if (probe.active) throw new Error('Cannot configure during a measurement')
++ if (!isNativeAvailable() || !window.nativeCaptureAPI?.macosCapture?.getSupport().available
++ || scopes.some(scope => !window.visualizerAPI?.[scope])) {
++ throw new Error('Benchmark requires the native capture and every selected native DSP module; fallback measurements are forbidden')
++ }
++ const profile = createDefaultProfile()
++ useSettingsStore.setState({ ...profile, hiddenScopes: new Set(SCOPE_KINDS.filter(s => !scopes.includes(s as BenchScope))) })
++ usePerformanceStore.getState().setFrameTarget(frameTarget)
++ useAudioStore.getState().setRollingCaptureSeconds(null)
++ useAudioStore.getState().setInputGain(0)
++ if (!audioCapture.getStatus().isCapturing) {
++ useAudioStore.getState().setCaptureMode('system')
++ await useAudioStore.getState().startCapture()
++ }
++ const status = audioCapture.getStatus()
++ if (!status.isCapturing || status.activeBackendKind !== 'native-macos') {
++ throw new Error(`Benchmark requires native macOS system capture: ${JSON.stringify(status)}`)
++ }
++ return metadata()
++ },
++ start(mode: ProbeMode) {
++ if (document.hidden) throw new Error('Benchmark window must be visible')
++ timerQuantumStart = measureTimerQuantum()
++ clockStart = calibrateClock(readNative)
++ routerStart = audioRouter.getDiagnosticsSnapshot()
++ hiddenEvents = 0
++ probe.start(mode, audioRouter.getSessionState().sessionId)
++ return metadata()
++ },
++ stop() {
++ probe.stop()
++ const clockEnd = calibrateClock(readNative)
++ const timerQuantumMs = Math.max(timerQuantumStart, measureTimerQuantum())
++ const routerEnd = audioRouter.getDiagnosticsSnapshot()
++ return { ...probe.export(), clockStart, clockEnd, timerQuantumMs, routerStart, routerEnd, hiddenEvents, metadata: metadata() }
++ },
++ metadata,
++ }
++}
++
++declare global {
++ interface Window {
++ prismLatencyBenchmark?: {
++ configure: (scopes: BenchScope[], frameTarget: 60 | 'display-sync') => Promise
++ start: (mode: ProbeMode) => unknown
++ stop: () => unknown
++ metadata: () => unknown
++ }
++ }
++}
+diff --git a/src/renderer/benchmark/latencyProbe.ts b/src/renderer/benchmark/latencyProbe.ts
+new file mode 100644
+index 0000000..47f3dfe
+--- /dev/null
++++ b/src/renderer/benchmark/latencyProbe.ts
+@@ -0,0 +1,184 @@
++/** Benchmark-only observation. This module never changes capture or DSP state. */
++export const BENCHMARK_BUILD = typeof __PRISM_LATENCY_BENCHMARK__ !== 'undefined' && __PRISM_LATENCY_BENCHMARK__
++export const BENCH_SCOPES = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] as const
++export type BenchScope = typeof BENCH_SCOPES[number]
++export type ProbeMode = 'baseline' | 'probe' | 'trace'
++export const CHUNK_COLUMNS = ['session', 'sequence', 'nativeAt', 'receivedAt', 'sampleFrames', 'peak'] as const
++export const RECORD_COLUMNS = ['session', 'sequence', 'scope', 'frame', 'nativeAt', 'receivedAt', 'consumedAt', 'drawEnd', 'outcome', 'sampleFrames'] as const
++export const FRAME_COLUMNS = ['frame', 'scope', 'start', 'end', 'drew'] as const
++// outcome: 1 = successful draw invocation, 0 = skipped; -1 = missing receipt metadata.
++const C = CHUNK_COLUMNS.length, R = RECORD_COLUMNS.length, F = FRAME_COLUMNS.length
++
++export interface ClockSample { before: number; native: number; after: number }
++export function measureTimerQuantum(now = () => performance.now(), minimumMs = 0.1): number {
++ const deltas: number[] = []
++ let previous = now()
++ for (let i = 0; i < 1000000 && deltas.length < 32; i++) {
++ const current = now()
++ if (current > previous) { deltas.push(current - previous); previous = current }
++ }
++ if (!deltas.length) throw new Error('Renderer clock did not advance')
++ return Math.max(minimumMs, Math.min(...deltas) * 1.05)
++}
++export function calibrateClock(readNative: () => number, now = () => performance.now(), count = 64): ClockSample[] {
++ return Array.from({ length: count }, () => {
++ const before = now(), native = readNative(), after = now()
++ return { before, native, after }
++ })
++}
++
++/** Lowest round-trip bracket, widened to cover both endpoint calibrations. */
++export function clockBounds(start: ClockSample[], end: ClockSample[], timerQuantumMs = 0) {
++ const best = (samples: ClockSample[]) => samples.filter(s =>
++ Number.isFinite(s.native) && Number.isFinite(s.before) && Number.isFinite(s.after) && s.after >= s.before,
++ ).sort((a, b) => (a.after - a.before) - (b.after - b.before))[0]
++ const a = best(start), b = best(end)
++ if (!a || !b) throw new Error('Missing valid clock calibration')
++ const low = Math.min(a.before - a.native, b.before - b.native) - timerQuantumMs
++ const high = Math.max(a.after - a.native, b.after - b.native) + timerQuantumMs
++ return { low, high, offset: (low + high) / 2, uncertaintyMs: (high - low) / 2,
++ endpointDriftMs: Math.abs((a.before + a.after) / 2 - a.native - ((b.before + b.after) / 2 - b.native)) }
++}
++
++export function percentile(values: number[], fraction: number): number | null {
++ if (!values.length) return null
++ const sorted = [...values].sort((a, b) => a - b)
++ return sorted[Math.max(0, Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1))]
++}
++
++export class LatencyProbe {
++ active = false
++ mode: ProbeMode = 'probe'
++ private chunks: Float64Array
++ private records: Float64Array
++ private frames: Float64Array
++ private receiptSlots: Int32Array
++ private boundaryChunks = 0
++ private latestNativeOverwrites = 0
++ private chunkCount = 0
++ private recordCount = 0
++ private frameCount = 0
++ private frameRecordStart = 0
++ private currentFrame = -1
++ private currentScope = -1
++ private didDraw = false
++ private session = 0
++ private lastSequence = 0
++ private nativeOverwrites = 0
++ private sequenceGaps = 0
++ private sessionChanges = 0
++ private overflow = 0
++ private missingReceipts = 0
++ private trimmedChunks = 0
++ private outsideFrame = 0
++ private nativeQueueMax = 0
++ private lastNativeOverwrites: number | null = null
++ private startedAt = 0
++ private stoppedAt = 0
++ constructor(private capacity = 262144, private now = () => performance.now()) {
++ this.chunks = new Float64Array(capacity * C)
++ this.records = new Float64Array(capacity * R)
++ this.frames = new Float64Array(65536 * F)
++ this.receiptSlots = new Int32Array(8192).fill(-1)
++ }
++ start(mode: ProbeMode, session: number): void {
++ if (this.active) throw new Error('Benchmark already running')
++ this.mode = mode
++ this.session = session
++ this.chunkCount = this.recordCount = this.frameCount = this.lastSequence = 0
++ this.nativeOverwrites = this.sequenceGaps = this.sessionChanges = this.overflow = 0
++ this.missingReceipts = this.trimmedChunks = this.outsideFrame = this.nativeQueueMax = 0
++ this.currentFrame = -1
++ this.lastNativeOverwrites = this.latestNativeOverwrites
++ this.boundaryChunks = 0
++ this.receiptSlots.fill(-1)
++ this.startedAt = this.now()
++ this.active = true
++ }
++ sessionChanged(session: number): void {
++ if (!this.active || session === this.session) return
++ this.sessionChanges++
++ this.session = session
++ this.lastSequence = 0
++ this.lastNativeOverwrites = null
++ this.receiptSlots.fill(-1)
++ }
++ nativeDrain(overwrites: number, queueDepth: number, trimmed: number): void {
++ this.latestNativeOverwrites = overwrites
++ if (!this.active || this.mode === 'baseline') return
++ if (this.lastNativeOverwrites !== null) this.nativeOverwrites += Math.max(0, overwrites - this.lastNativeOverwrites)
++ this.lastNativeOverwrites = overwrites
++ this.nativeQueueMax = Math.max(this.nativeQueueMax, queueDepth)
++ this.trimmedChunks += trimmed
++ }
++ receipt(sequence: number, nativeAt: number, sampleFrames: number, peak: number): void {
++ if (!this.active || this.mode === 'baseline') return
++ const receivedAt = this.now()
++ if (this.lastSequence && sequence !== this.lastSequence + 1) this.sequenceGaps += Math.max(1, sequence - this.lastSequence - 1)
++ this.lastSequence = sequence
++ if (this.chunkCount >= this.capacity) { this.overflow++; return }
++ const row = this.chunkCount++, i = row * C
++ this.chunks[i] = this.session; this.chunks[i + 1] = sequence
++ this.chunks[i + 2] = nativeAt; this.chunks[i + 3] = receivedAt
++ this.chunks[i + 4] = sampleFrames; this.chunks[i + 5] = peak
++ this.receiptSlots[sequence % this.receiptSlots.length] = row
++ }
++ beginFrame(scope: BenchScope): void {
++ if (!this.active) return
++ if (this.currentFrame !== -1) throw new Error('Nested benchmark frames')
++ if (this.frameCount >= this.frames.length / F) { this.overflow++; return }
++ this.currentScope = BENCH_SCOPES.indexOf(scope)
++ this.currentFrame = this.frameCount++
++ this.frameRecordStart = this.recordCount
++ this.didDraw = false
++ const i = this.currentFrame * F
++ this.frames[i] = this.currentFrame; this.frames[i + 1] = this.currentScope
++ this.frames[i + 2] = this.now()
++ if (this.mode === 'trace') performance.mark(`prism.frame.${this.currentFrame}.${scope}.start`)
++ }
++ consume(scope: string, sequence: number): void {
++ if (!this.active || this.mode === 'baseline' || !BENCH_SCOPES.includes(scope as BenchScope)) return
++ if (this.currentFrame < 0 || BENCH_SCOPES[this.currentScope] !== scope) { this.outsideFrame++; return }
++ if (this.chunkCount === 0 || (this.chunks[0] === this.session && sequence < this.chunks[1])) { this.boundaryChunks++; return }
++ if (this.recordCount >= this.capacity) { this.overflow++; return }
++ const i = this.recordCount++ * R
++ const slot = this.receiptSlots[sequence % this.receiptSlots.length], c = slot * C
++ const found = slot >= 0 && this.chunks[c] === this.session && this.chunks[c + 1] === sequence
++ if (!found) this.missingReceipts++
++ this.records[i] = this.session; this.records[i + 1] = sequence
++ this.records[i + 2] = this.currentScope; this.records[i + 3] = this.currentFrame
++ this.records[i + 4] = found ? this.chunks[c + 2] : NaN
++ this.records[i + 5] = found ? this.chunks[c + 3] : NaN
++ this.records[i + 6] = this.now(); this.records[i + 7] = NaN
++ this.records[i + 8] = found ? 0 : -1
++ this.records[i + 9] = found ? this.chunks[c + 4] : 0
++ }
++ drawn(): void { if (this.active) this.didDraw = true }
++ endFrame(): void {
++ if (!this.active || this.currentFrame < 0) return
++ // Capture endpoint before observer bookkeeping and trace emission.
++ const end = this.now(), i = this.currentFrame * F
++ this.frames[i + 3] = end; this.frames[i + 4] = Number(this.didDraw)
++ for (let row = this.frameRecordStart; row < this.recordCount; row++) {
++ const r = row * R
++ this.records[r + 7] = end
++ if (this.records[r + 8] !== -1) this.records[r + 8] = Number(this.didDraw)
++ }
++ if (this.mode === 'trace') performance.mark(`prism.frame.${this.currentFrame}.${BENCH_SCOPES[this.currentScope]}.end`)
++ this.currentFrame = -1
++ }
++ stop(): void { this.stoppedAt = this.now(); this.active = false }
++ export() {
++ if (this.active) throw new Error('Stop before export')
++ const rows = (data: Float64Array, count: number, width: number) => Array.from({ length: count }, (_, i) => Array.from(data.subarray(i * width, (i + 1) * width)))
++ return { mode: this.mode, startedAt: this.startedAt, stoppedAt: this.stoppedAt,
++ scopes: BENCH_SCOPES, chunkColumns: CHUNK_COLUMNS, recordColumns: RECORD_COLUMNS, frameColumns: FRAME_COLUMNS,
++ chunks: rows(this.chunks, this.chunkCount, C), records: rows(this.records, this.recordCount, R), frames: rows(this.frames, this.frameCount, F),
++ counters: { boundaryChunks: this.boundaryChunks, nativeOverwrites: this.nativeOverwrites, sequenceGaps: this.sequenceGaps, sessionChanges: this.sessionChanges,
++ bufferOverflow: this.overflow, missingReceipts: this.missingReceipts, trimmedChunks: this.trimmedChunks,
++ consumedOutsideFrame: this.outsideFrame, nativeQueueMax: this.nativeQueueMax } }
++ }
++}
++
++// Allocate only in the explicit benchmark build, never in a normal build.
++export const latencyProbe = BENCHMARK_BUILD ? new LatencyProbe() : null
+diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx
+index 74d8f82..25a7bf6 100644
+--- a/src/renderer/main.tsx
++++ b/src/renderer/main.tsx
+@@ -24,6 +24,10 @@ const windowMode = params.get('mode')
+ const windowRole = params.get('window')
+ const scopeKind = params.get('scope')
+
++if (typeof __PRISM_LATENCY_BENCHMARK__ !== 'undefined' && __PRISM_LATENCY_BENCHMARK__ && windowRole === 'main') {
++ void import('./benchmark/controller').then(({ installLatencyBenchmark }) => installLatencyBenchmark())
++}
++
+ if (windowMode !== 'dialog' && windowRole !== 'now-playing-config') {
+ bootstrapWindowBackgroundFromQuery()
+ }
+diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts
+index eb0bea1..afcd685 100644
+--- a/src/renderer/visualizers/Oscilloscope.ts
++++ b/src/renderer/visualizers/Oscilloscope.ts
+@@ -1,3 +1,4 @@
++import { latencyProbe } from '../benchmark/latencyProbe'
+ import { audioRouter } from '../audio/AudioRouter'
+ import {
+ oscilloscope as defaultNativeOscilloscope,
+@@ -109,6 +110,7 @@ export class Oscilloscope {
+ this.dataSource = dataSource ?? defaultOscilloscopeDataSource
+ this.nativeAnalyzer = nativeAnalyzer === undefined ? defaultNativeOscilloscope : (nativeAnalyzer ?? defaultNativeOscilloscope)
+ this.frameLoop = new VisualizerFrameLoop({
++ benchmarkScope: 'oscilloscope',
+ frameScheduler,
+ shouldRun: () => this.dataSource.isPlaying(),
+ onFrame: this.drawFrame,
+@@ -335,6 +337,7 @@ export class Oscilloscope {
+ ctx.lineTo(x, y)
+ }
+ ctx.stroke()
++ latencyProbe?.drawn()
+ }
+
+ private renderStaticLayer(): void {
+diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts
+index 5ca6e88..9f64ec2 100644
+--- a/src/renderer/visualizers/SpectrumAnalyzer.ts
++++ b/src/renderer/visualizers/SpectrumAnalyzer.ts
+@@ -1,3 +1,4 @@
++import { latencyProbe } from '../benchmark/latencyProbe'
+ import { REFERENCE_SAMPLE_RATE, type SpectrumReferenceAsset, type SpectrumReferenceSettings, type SpectrumReferenceLevel } from '../../types/spectrumReference'
+ import { referenceBins, referenceDbAt, differenceReferenceBins, ReferenceCurveTransition } from './referenceCurve'
+ import { audioRouter } from '../audio/AudioRouter'
+@@ -346,6 +347,7 @@ export class SpectrumAnalyzer {
+ this.nativeAnalyzer = nativeAnalyzer === undefined ? defaultNativeSpectrum : nativeAnalyzer
+ this.heatLut = buildHeatLUT(this.options.heatColors)
+ this.frameLoop = new VisualizerFrameLoop({
++ benchmarkScope: 'spectrum',
+ frameScheduler,
+ shouldRun: () => this.dataSource.isPlaying() || this.options.referenceImporting || performance.now() < this.referenceSettleUntil,
+ onFrame: this.drawFrame,
+@@ -1337,6 +1339,7 @@ export class SpectrumAnalyzer {
+ }
+
+ this.emitPeakInfo(primaryRender.peakInfo)
++ latencyProbe?.drawn()
+ }
+
+ private renderStaticLayer(minFrequency: number, maxFrequency: number): void {
+diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts
+index 70ab830..0bb23a6 100644
+--- a/src/renderer/visualizers/VUMeter.ts
++++ b/src/renderer/visualizers/VUMeter.ts
+@@ -1,3 +1,4 @@
++import { latencyProbe } from '../benchmark/latencyProbe'
+ import { audioRouter } from '../audio/AudioRouter'
+ import {
+ vumeter as nativeVUMeter,
+@@ -334,6 +335,7 @@ export class VUMeter {
+ this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeVUMeter : nativeAnalyzer
+ this.meterBallistics = new VUMeterBallistics(this.dataSource.getSampleRate())
+ this.frameLoop = new VisualizerFrameLoop({
++ benchmarkScope: 'vumeter',
+ frameScheduler,
+ shouldRun: () => this.dataSource.isPlaying(),
+ onFrame: this.drawFrame,
+@@ -1385,6 +1387,7 @@ export class VUMeter {
+ } else {
+ this.drawBarMode(width, height)
+ }
++ latencyProbe?.drawn()
+ }
+
+ dispose(): void {
+diff --git a/src/renderer/visualizers/Vectorscope.ts b/src/renderer/visualizers/Vectorscope.ts
+index 67b27de..f0583f6 100644
+--- a/src/renderer/visualizers/Vectorscope.ts
++++ b/src/renderer/visualizers/Vectorscope.ts
+@@ -1,3 +1,4 @@
++import { latencyProbe } from '../benchmark/latencyProbe'
+ import { audioRouter } from '../audio/AudioRouter'
+ import { vectorscope as nativeVectorscope, type VectorscopeNativeAnalyzer } from '../audio/native'
+ import {
+@@ -119,6 +120,7 @@ export class Vectorscope {
+ this.dataSource = dataSource ?? defaultVectorscopeDataSource
+ this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeVectorscope : nativeAnalyzer
+ this.frameLoop = new VisualizerFrameLoop({
++ benchmarkScope: 'vectorscope',
+ frameScheduler,
+ shouldRun: () => this.dataSource.isPlaying(),
+ onFrame: this.drawFrame,
+@@ -288,6 +290,7 @@ export class Vectorscope {
+
+ this.renderStaticLayer()
+ ctx.drawImage(offscreenCanvas, 0, 0)
++ latencyProbe?.drawn()
+ }
+
+ private isNativeAvailable(): boolean {
+diff --git a/src/renderer/visualizers/visualizerFrameLoop.ts b/src/renderer/visualizers/visualizerFrameLoop.ts
+index aa7f1a1..5433f9f 100644
+--- a/src/renderer/visualizers/visualizerFrameLoop.ts
++++ b/src/renderer/visualizers/visualizerFrameLoop.ts
+@@ -1,12 +1,15 @@
+ import { FrameScheduler } from './frameScheduler'
++import { latencyProbe, type BenchScope } from '../benchmark/latencyProbe'
+
+ interface VisualizerFrameLoopOptions {
++ benchmarkScope?: BenchScope
+ frameScheduler?: FrameScheduler
+ shouldRun: () => boolean
+ onFrame: () => void
+ }
+
+ export class VisualizerFrameLoop {
++ private readonly benchmarkScope?: BenchScope
+ private readonly frameScheduler: FrameScheduler
+ private readonly shouldRun: () => boolean
+ private readonly onFrame: () => void
+@@ -14,7 +17,8 @@ export class VisualizerFrameLoop {
+ private isStarted = false
+ private isInvalidated = false
+
+- constructor({ frameScheduler, shouldRun, onFrame }: VisualizerFrameLoopOptions) {
++ constructor({ frameScheduler, shouldRun, onFrame, benchmarkScope }: VisualizerFrameLoopOptions) {
++ this.benchmarkScope = benchmarkScope
+ this.frameScheduler = frameScheduler ?? new FrameScheduler()
+ this.shouldRun = shouldRun
+ this.onFrame = onFrame
+@@ -67,7 +71,12 @@ export class VisualizerFrameLoop {
+ private tick = (): void => {
+ if (!this.isStarted) return
+ this.isInvalidated = false
+- this.onFrame()
++ if (latencyProbe?.active && this.benchmarkScope) {
++ latencyProbe.beginFrame(this.benchmarkScope)
++ try { this.onFrame() } finally { latencyProbe.endFrame() }
++ } else {
++ this.onFrame()
++ }
+ this.sync()
+ }
+ }
+diff --git a/src/types/latencyBenchmark.d.ts b/src/types/latencyBenchmark.d.ts
+new file mode 100644
+index 0000000..4743f7d
+--- /dev/null
++++ b/src/types/latencyBenchmark.d.ts
+@@ -0,0 +1 @@
++declare const __PRISM_LATENCY_BENCHMARK__: boolean
+diff --git a/test/latency-benchmark-integration.test.ts b/test/latency-benchmark-integration.test.ts
+new file mode 100644
+index 0000000..60a86b1
+--- /dev/null
++++ b/test/latency-benchmark-integration.test.ts
+@@ -0,0 +1,34 @@
++import test from 'node:test'
++import assert from 'node:assert/strict'
++import { AudioRouter } from '../src/renderer/audio/AudioRouter'
++import { VisualizerFrameLoop } from '../src/renderer/visualizers/visualizerFrameLoop'
++import type { FrameScheduler } from '../src/renderer/visualizers/frameScheduler'
++import { latencyProbe } from '../src/renderer/benchmark/latencyProbe'
++
++test('real router drains bind to the active visualizer frame without modifying samples', () => {
++ const probe = latencyProbe!
++ const router = new AudioRouter()
++ const session = router.beginSession(48000, 2, 'native-macos')
++ router.setVisualizerConsumerDemand('bench-test', { spectrum: true })
++ const callbacks = new Set<() => void>()
++ const scheduler = { subscribe(callback: () => void) { callbacks.add(callback); return () => callbacks.delete(callback) } } as FrameScheduler
++ const left = Float32Array.of(0.5, 0.25), right = Float32Array.of(-0.5, -0.25)
++ let observed: ReturnType = []
++ const loop = new VisualizerFrameLoop({ frameScheduler: scheduler, benchmarkScope: 'spectrum', shouldRun: () => true,
++ onFrame: () => { observed = router.flushPendingSpectrumStereoSamples(); probe.drawn() } })
++ probe.start('probe', session)
++ probe.receipt(4, 100, 2, 0.5)
++ router.ingestChunk(left, right, { sessionId: session, sequence: 4, capturedAt: 100 })
++ loop.start()
++ for (const cb of callbacks) cb()
++ assert.equal(observed[0].left, left)
++ assert.equal(observed[0].right, right)
++ for (const cb of callbacks) cb()
++ loop.dispose(); probe.stop()
++ const raw = probe.export()
++ assert.equal(raw.records.length, 1)
++ assert.equal(raw.records[0][1], 4)
++ assert.equal(raw.records[0][8], 1)
++ assert.equal(raw.frames.length, 2)
++ assert.equal(callbacks.size, 0)
++})
+diff --git a/test/latency-benchmark.test.ts b/test/latency-benchmark.test.ts
+new file mode 100644
+index 0000000..5d75f48
+--- /dev/null
++++ b/test/latency-benchmark.test.ts
+@@ -0,0 +1,139 @@
++import test from 'node:test'
++import assert from 'node:assert/strict'
++import { LatencyProbe, clockBounds, calibrateClock, percentile } from '../src/renderer/benchmark/latencyProbe'
++import { analyzeMeasurement, type RawMeasurement } from '../src/renderer/benchmark/analyze'
++
++const calibration = [{ before: 0, native: 1000, after: 0.2 }]
++function fixture(capacity = 100) {
++ let time = 0
++ const probe = new LatencyProbe(capacity, () => time)
++ probe.start('probe', 7)
++ return { probe, at: (t: number) => { time = t } }
++}
++function raw(probe: LatencyProbe): RawMeasurement {
++ const router = { totalOverwriteCount: 0, scopes: { spectrum: { queuedChunks: 0 } } } as RawMeasurement['routerStart']
++ return { ...probe.export(), clockStart: calibration, clockEnd: calibration, hiddenEvents: 0,
++ routerStart: router, routerEnd: router, metadata: { hidden: false, visibleScopes: ['spectrum'],
++ capture: { activeBackendKind: 'native-macos', isCapturing: true } } }
++}
++test('clock calibration brackets calls and retains drift as uncertainty', () => {
++ let clock = 0
++ const samples = calibrateClock(() => 1000, () => clock++, 2)
++ assert.deepEqual(samples, [{ before: 0, native: 1000, after: 1 }, { before: 2, native: 1000, after: 3 }])
++ const bounds = clockBounds([{ before: 1, native: 1000, after: 9 }, ...calibration], [{ before: 100, native: 1099, after: 100.2 }])
++ assert.equal(bounds.low, -1000)
++ assert.ok(Math.abs(bounds.high + 998.8) < 1e-9)
++ assert.ok(Math.abs(bounds.uncertaintyMs - 0.6) < 1e-9)
++ assert.throws(() => clockBounds([], calibration))
++})
++test('multiple chunks retain their own capture ages and exact frame association', () => {
++ const { probe, at } = fixture()
++ at(3); probe.receipt(1, 1001, 128, 0.02)
++ at(4); probe.receipt(2, 1002, 256, 0.03)
++ at(8); probe.beginFrame('spectrum'); probe.consume('spectrum', 1); probe.consume('spectrum', 2)
++ probe.drawn(); at(10); probe.endFrame()
++ at(20); probe.beginFrame('spectrum'); probe.drawn(); at(21); probe.endFrame()
++ probe.stop()
++ const data = raw(probe), summary = analyzeMeasurement(data)
++ assert.equal(data.records.length, 2, 'redrawing without new audio must not add samples')
++ assert.equal(data.records[0][3], data.records[1][3])
++ assert.equal(data.records[0][9], 128)
++ assert.equal(summary.scopes[0].latencyMs.count, 2)
++ assert.ok(Math.abs(summary.scopes[0].latencyMs.p95! - 8.9) < 1e-8)
++ assert.equal(summary.scopes[0].upperLatencyMs.p95, 9)
++ assert.equal(summary.valid, true)
++})
++test('skipped frames are not reported as successful low latency', () => {
++ const { probe, at } = fixture()
++ at(3); probe.receipt(1, 1001, 128, 0.02)
++ at(4); probe.beginFrame('spectrum'); probe.consume('spectrum', 1); probe.endFrame(); probe.stop()
++ const result = analyzeMeasurement(raw(probe))
++ assert.equal(result.scopes[0].skippedChunks, 1)
++ assert.equal(result.scopes[0].latencyMs.count, 0)
++ assert.equal(result.valid, false)
++})
++test('negative clock ordering is invalid, never clamped into a fast result', () => {
++ const { probe, at } = fixture()
++ at(3); probe.receipt(1, 1100, 128, 0.02)
++ at(4); probe.beginFrame('spectrum'); probe.consume('spectrum', 1); probe.drawn(); probe.endFrame(); probe.stop()
++ const result = analyzeMeasurement(raw(probe))
++ assert.equal(result.scopes[0].invalid, 1)
++ assert.equal(result.scopes[0].latencyMs.count, 0)
++})
++test('same sequence from a new session cannot use old receipt metadata', () => {
++ const { probe, at } = fixture()
++ at(3); probe.receipt(1, 1001, 128, 0.02)
++ probe.sessionChanged(8)
++ at(4); probe.beginFrame('spectrum'); probe.consume('spectrum', 1); probe.drawn(); probe.endFrame(); probe.stop()
++ assert.equal(probe.export().counters.sessionChanges, 1)
++ assert.equal(probe.export().records[0][8], -1)
++ assert.equal(analyzeMeasurement(raw(probe)).valid, false)
++})
++test('native drops, trimming, sequence gaps and metadata loss are explicit', () => {
++ const { probe, at } = fixture()
++ probe.nativeDrain(2, 3, 1)
++ at(3); probe.receipt(1, 1001, 128, 0.02); probe.receipt(4, 1002, 128, 0.02)
++ probe.beginFrame('spectrum'); probe.consume('spectrum', 3); probe.endFrame(); probe.stop()
++ const counters = probe.export().counters
++ assert.equal(counters.nativeOverwrites, 2)
++ assert.equal(counters.sequenceGaps, 2)
++ assert.equal(counters.trimmedChunks, 1)
++ assert.equal(counters.missingReceipts, 1)
++})
++test('pre-existing boundary chunks are excluded explicitly, pending tail remains censored', () => {
++ const { probe, at } = fixture()
++ at(3); probe.receipt(10, 1001, 128, 0.02)
++ probe.beginFrame('spectrum'); probe.consume('spectrum', 9); probe.consume('spectrum', 10); probe.drawn(); probe.endFrame()
++ at(5); probe.receipt(11, 1004, 128, 0.02)
++ at(6); probe.beginFrame('spectrum'); probe.drawn(); probe.endFrame(); probe.stop()
++ const data = raw(probe)
++ data.routerEnd = { ...data.routerEnd, scopes: { ...data.routerEnd.scopes, spectrum: { ...data.routerEnd.scopes.spectrum, queuedChunks: 1 } } }
++ const result = analyzeMeasurement(data)
++ assert.equal(result.counters.boundaryChunks, 1)
++ assert.equal(result.scopes[0].latencyMs.count, 1)
++ assert.equal(result.scopes[0].unconsumedAtStop, 1)
++ assert.equal(result.valid, true)
++})
++test('baseline observes frames but records no chunk probes; overflow never silently overwrites', () => {
++ const { probe, at } = fixture(1)
++ at(3); probe.receipt(1, 1001, 128, 0.02); probe.receipt(2, 1002, 128, 0.02)
++ probe.stop()
++ assert.equal(probe.export().chunks.length, 1)
++ assert.equal(probe.export().counters.bufferOverflow, 1)
++ probe.start('baseline', 7); probe.receipt(1, 1001, 128, 0.02)
++ probe.beginFrame('spectrum'); probe.consume('spectrum', 1); probe.drawn(); probe.endFrame(); probe.stop()
++ assert.equal(probe.export().records.length, 0)
++ assert.equal(probe.export().frames.length, 1)
++})
++test('quantiles use nearest rank and preserve worst-case tails', () => {
++ assert.equal(percentile([], 0.95), null)
++ assert.equal(percentile([2, 100, 1, 3], 0.5), 2)
++ assert.equal(percentile([2, 100, 1, 3], 0.95), 100)
++})
++
++test('timer-quantized delivery is unresolved while later draw latency remains measurable', () => {
++ const { probe, at } = fixture()
++ at(1); probe.receipt(1, 1001.05, 128, 0.02)
++ at(4); probe.beginFrame('spectrum'); probe.consume('spectrum', 1); probe.drawn(); at(5); probe.endFrame()
++ at(10); probe.beginFrame('spectrum'); probe.drawn(); probe.endFrame(); probe.stop()
++ const data = raw(probe); data.timerQuantumMs = 0.1
++ const result = analyzeMeasurement(data)
++ assert.equal(result.valid, true)
++ assert.equal(result.scopes[0].unresolvedDelivery, 1)
++ assert.equal(result.scopes[0].captureToReceiptMs.count, 0)
++ assert.equal(result.scopes[0].latencyMs.count, 1)
++ assert.ok(Math.abs(result.latencyUncertaintyMs - 0.3) < 1e-8)
++ assert.ok(result.scopes[0].upperLatencyMs.p95! > result.scopes[0].latencyMs.p95!)
++})
++
++test('duplicate consumption and changed configuration cannot support a claim', () => {
++ const { probe, at } = fixture()
++ at(3); probe.receipt(1, 1001, 128, 0.02)
++ at(4); probe.beginFrame('spectrum'); probe.consume('spectrum', 1); probe.drawn(); probe.endFrame()
++ at(8); probe.beginFrame('spectrum'); probe.consume('spectrum', 1); probe.drawn(); probe.endFrame(); probe.stop()
++ const data = raw(probe); data.startMetadata = { changed: true }
++ const result = analyzeMeasurement(data)
++ assert.equal(result.scopes[0].duplicate, 1)
++ assert.equal(result.valid, false)
++ assert.ok(result.problems.some(p => p.includes('settings changed')))
++})
+diff --git a/test/presentation-benchmark.test.mjs b/test/presentation-benchmark.test.mjs
+new file mode 100644
+index 0000000..13313cc
+--- /dev/null
++++ b/test/presentation-benchmark.test.mjs
+@@ -0,0 +1,39 @@
++import test from 'node:test'
++import assert from 'node:assert/strict'
++import { linkPresentation } from '../scripts/benchmark/presentation.mjs'
++const fixture = () => {
++ const identity = { sequence_number: 2, source_id: 1 }
++ const event = (name, ph, ts, args = {}, extra = {}) => ({ name, ph, ts, args, pid: 10, tid: 20, id2: { local: '0x3' }, ...extra })
++ const events = [
++ event('AnimationFrame', 'b', 100000, { id: 'exact-id', animation_frame_timing_info: { begin_frame_id: identity } }),
++ event('AnimationFrame', 'e', 104000),
++ event('FireAnimationFrame', 'X', 101000, {}, { dur: 2000 }),
++ event('prism.frame.0.spectrum.start', 'I', 101100, { data: { callTime: 101100, startTime: 1.1 } }),
++ event('prism.frame.0.spectrum.end', 'I', 102100, { data: { callTime: 102100, startTime: 2.1 } }),
++ event('AnimationFrame::Presentation', 'n', 110000, { id: 'exact-id', begin_frame_id: identity }),
++ event('PipelineReporter', 'b', 99000, { frame_reporter: { frame_source: 1, frame_sequence: 2, state: 'STATE_PRESENTED_ALL' } }, { tid: 30 }),
++ ]
++ const raw = { scopes: ['spectrum'], frames: [[0, 0, 1, 2, 1]], records: [[1, 4, 0, 0, 1001, 1, 1.5, 2, 1, 128]] }
++ const clock = { offset: -1000, uncertaintyMs: 0.1, timerQuantumMs: 0.1 }
++ return { events, raw, clock }
++}
++test('presentation estimate requires complete identity chain and calibrated clock bridge', () => {
++ const { events, raw, clock } = fixture(), result = linkPresentation(events, raw, clock)
++ assert.equal(result.available, true)
++ assert.equal(result.samples[0].latencyMs, 9)
++ assert.equal(result.frameLinks[0].drawToPresentationEstimateMs, 8)
++})
++test('duplicate or wrong-frame presentation is rejected, regardless of proximity', () => {
++ const { events, raw, clock } = fixture()
++ assert.equal(linkPresentation([...events, events[5]], raw, clock).available, false)
++ events[5].args.begin_frame_id = { source_id: 1, sequence_number: 3 }
++ assert.equal(linkPresentation(events, raw, clock).available, false)
++})
++test('a partial compositor frame or marker outside its RAF task cannot establish presentation', () => {
++ const { events, raw, clock } = fixture()
++ events[6].args.frame_reporter.state = 'STATE_PRESENTED_PARTIAL'
++ assert.equal(linkPresentation(events, raw, clock).available, false)
++ events[6].args.frame_reporter.state = 'STATE_PRESENTED_ALL'
++ events[2].dur = 10
++ assert.equal(linkPresentation(events, raw, clock).available, false)
++})
diff --git a/docs/benchmarks/2026-09-16/results.json b/docs/benchmarks/2026-09-16/results.json
new file mode 100644
index 0000000..749d5a1
--- /dev/null
+++ b/docs/benchmarks/2026-09-16/results.json
@@ -0,0 +1,7783 @@
+{
+ "schemaVersion": 1,
+ "description": "Per-run summary statistics and recorded settings; raw per-chunk timestamps are not included.",
+ "source": {
+ "baseCommit": "2a2d01d32da824d03e60440acd505f3d890a4e6f",
+ "measurementCommit": "ce54ece6deceb9771d2d277d2be089a9f4b68205",
+ "packageVersion": "0.3.0-beta",
+ "patchSha256": "f4a582b71c39165193bcd2d5ea50561aea2aa9eed74d1fb2b68c5531184ea0ef",
+ "startedAt": "2026-09-16T23:48:56.144Z",
+ "completedAt": "2026-09-17T00:19:36.722Z",
+ "runs": 24,
+ "quick": false
+ },
+ "machine": {
+ "platform": "darwin",
+ "arch": "arm64",
+ "os": "25.5.0",
+ "systemVersion": "26.5.1",
+ "model": "Mac17,9",
+ "cpu": "Apple M5 Pro",
+ "memoryBytes": 51539607552,
+ "versions": {
+ "node": "24.14.0",
+ "acorn": "8.15.0",
+ "ada": "3.4.2",
+ "amaro": "1.1.7",
+ "ares": "1.34.6",
+ "brotli": "1.0.9",
+ "cldr": "47.0",
+ "icu": "77.1",
+ "llhttp": "9.3.0",
+ "merve": "1.0.0",
+ "modules": "143",
+ "napi": "10",
+ "nbytes": "0.1.1",
+ "ncrypto": "0.0.1",
+ "nghttp2": "1.68.0",
+ "openssl": "0.0.0",
+ "simdjson": "4.2.4",
+ "simdutf": "7.7.0",
+ "sqlite": "3.51.2",
+ "tz": "2025b",
+ "undici": "7.21.0",
+ "unicode": "16.0",
+ "uv": "1.51.0",
+ "uvwasi": "0.0.23",
+ "v8": "14.4.258.32-electron.0",
+ "zlib": "1.3.1",
+ "zstd": "1.5.8",
+ "electron": "40.8.3",
+ "chrome": "144.0.7559.236"
+ },
+ "display": {
+ "accelerometerSupport": "unknown",
+ "bounds": {
+ "x": 0,
+ "y": 0,
+ "width": 1512,
+ "height": 982
+ },
+ "colorDepth": 30,
+ "colorSpace": "{primaries:P3, transfer:{0.0777*x + 0.0000 if abs(x) < 0.0450 else sign(x)*((0.9495*abs(x) + 0.0495)**2.3955 + 0.0003)}, matrix:RGB, range:FULL}",
+ "depthPerComponent": 10,
+ "detected": true,
+ "displayFrequency": 120.0006103515625,
+ "id": 1,
+ "internal": true,
+ "label": "Built-in Retina Display",
+ "maximumCursorSize": {
+ "width": 0,
+ "height": 0
+ },
+ "monochrome": false,
+ "nativeOrigin": {
+ "x": 0,
+ "y": 0
+ },
+ "rotation": 0,
+ "scaleFactor": 2,
+ "size": {
+ "width": 1512,
+ "height": 982
+ },
+ "workArea": {
+ "x": 0,
+ "y": 33,
+ "width": 1512,
+ "height": 949
+ },
+ "workAreaSize": {
+ "width": 1512,
+ "height": 949
+ },
+ "touchSupport": "unknown"
+ },
+ "bounds": {
+ "x": 156,
+ "y": 181,
+ "width": 1200,
+ "height": 360
+ },
+ "durationSeconds": 60,
+ "warmupSeconds": 10,
+ "repeats": 3,
+ "audioPlayer": "/usr/bin/afplay",
+ "signalFile": "stimulus.wav",
+ "powerSettings": "Battery Power:\n Sleep On Power Button 1\n powermode 0\n standby 1\n ttyskeepawake 1\n hibernatemode 3\n powernap 1\n hibernatefile /var/vm/sleepimage\n displaysleep 2\n womp 0\n networkoversleep 0\n sleep 1\n lessbright 0\n tcpkeepalive 1\n disksleep 10\nAC Power:\n Sleep On Power Button 1\n powermode 0\n standby 1\n ttyskeepawake 1\n hibernatemode 3\n powernap 1\n hibernatefile /var/vm/sleepimage\n displaysleep 10\n womp 1\n networkoversleep 0\n sleep 1\n tcpkeepalive 1\n disksleep 10\n"
+ },
+ "stimulus": {
+ "sampleRate": 48000,
+ "channels": 2,
+ "format": "PCM16",
+ "seconds": 85,
+ "peakAmplitude": 0.025,
+ "amplitudeModulationHz": 1.37,
+ "frequencyStepSeconds": 0.73,
+ "leftHz": [
+ 220,
+ 440,
+ 880,
+ 330
+ ],
+ "rightHz": [
+ 330,
+ 550,
+ 660,
+ 440
+ ],
+ "sha256": "dc1679f2aa2c49758aab60bcbcb509bfbcf691667c38293f7a4ea9af7b7e3322"
+ },
+ "nativeBuild": {
+ "sha256": "fcbe0711e820082797ffbdf36e13950381d1c057aa58fffb585f06853f84549d",
+ "electronVersion": "40.8.3",
+ "rebuiltFromSource": true
+ },
+ "cooldown": {
+ "success": true,
+ "minimumSeconds": 120,
+ "requiredNominalSeconds": 60,
+ "elapsedSeconds": 120.46282191700001,
+ "thermalStates": [
+ "nominal"
+ ]
+ },
+ "thermal": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "sampleCount": 1744,
+ "overflow": 0,
+ "note": "1 Hz snapshots plus OS notifications; CPU speed limit is unknown until a notification arrives. Nominal thermal state does not prove an absence of all clock-frequency changes. No temperature or causal latency correction is inferred."
+ },
+ "totalCpuUtilizationMeasured": false,
+ "runs": [
+ {
+ "id": "01-spectrum-display-sync-baseline-0",
+ "config": "spectrum",
+ "target": "display-sync",
+ "mode": "baseline",
+ "repeat": 0,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60002.5,
+ "latencyUncertaintyMs": 0.229394793510437,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 7201,
+ "median": 1.1999998092651367,
+ "p95": 1.6999998092651367,
+ "p99": 1.9000000953674316,
+ "max": 2
+ },
+ "frameIntervalMs": {
+ "count": 7200,
+ "median": 8.300000190734863,
+ "p95": 9.300000190734863,
+ "p99": 9.400000095367432,
+ "max": 9.699999809265137
+ },
+ "observedFps": 120.00120001180939,
+ "frameCount": 7201,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.12439489364624023,
+ "endpointDriftMs": 0.03879070281982422,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "02-spectrum-display-sync-probe-1",
+ "config": "spectrum",
+ "target": "display-sync",
+ "mode": "probe",
+ "repeat": 1,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60005.40000009537,
+ "latencyUncertaintyMs": 0.21310365200042725,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5169,
+ "median": 7.477687358856201,
+ "p95": 12.2352294921875,
+ "p99": 13.566480159759521,
+ "max": 15.187979698181152
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 7.690791010856628,
+ "p95": 12.448333144187927,
+ "p99": 13.779583811759949,
+ "max": 15.40108335018158
+ },
+ "captureToReceiptMs": {
+ "count": 5065,
+ "median": 2.6026878356933594,
+ "p95": 4.7777299880981445,
+ "p99": 5.594770431518555,
+ "max": 6.610645294189453
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 4,
+ "p95": 7.100000381469727,
+ "p99": 7.599999904632568,
+ "max": 9
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 1.299999713897705,
+ "p95": 1.8000001907348633,
+ "p99": 2,
+ "max": 2.3000001907348633
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 1.200000286102295,
+ "p95": 1.799999713897705,
+ "p99": 2,
+ "max": 2.3000001907348633
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.300000190734863,
+ "p95": 9.300000190734863,
+ "p99": 9.400000095367432,
+ "max": 11.5
+ },
+ "observedFps": 119.9881328588384,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 104,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.10810375213623047,
+ "endpointDriftMs": 0.0062084197998046875,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "03-spectrum-display-sync-probe-2",
+ "config": "spectrum",
+ "target": "display-sync",
+ "mode": "probe",
+ "repeat": 2,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60007.800000190735,
+ "latencyUncertaintyMs": 0.25085341930389404,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5169,
+ "median": 7.749477863311768,
+ "p95": 12.831936359405518,
+ "p99": 14.215853691101074,
+ "max": 16.641478061676025
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 8.00033175945282,
+ "p95": 13.08279025554657,
+ "p99": 14.466707587242126,
+ "max": 16.892331957817078
+ },
+ "captureToReceiptMs": {
+ "count": 4981,
+ "median": 2.690436363220215,
+ "p95": 5.056145191192627,
+ "p99": 5.757186412811279,
+ "max": 8.120478630065918
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 4.099999904632568,
+ "p95": 7.5,
+ "p99": 8.5,
+ "max": 9.599999904632568
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 1.299999713897705,
+ "p95": 1.8999996185302734,
+ "p99": 2,
+ "max": 2.1000003814697266
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 1.200000286102295,
+ "p95": 1.8000001907348633,
+ "p99": 2,
+ "max": 2.1000003814697266
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.300000190734863,
+ "p95": 10.199999809265137,
+ "p99": 10.399999618530273,
+ "max": 11.300000190734863
+ },
+ "observedFps": 120.00626612398408,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 188,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.14585351943969727,
+ "endpointDriftMs": 0.08170795440673828,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "04-spectrum-display-sync-probe-3",
+ "config": "spectrum",
+ "target": "display-sync",
+ "mode": "probe",
+ "repeat": 3,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60009.40000009537,
+ "latencyUncertaintyMs": 0.21766650676727295,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 1,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5169,
+ "median": 8.039499759674072,
+ "p95": 13.061541557312012,
+ "p99": 14.383831977844238,
+ "max": 16.698166847229004
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 8.257166743278503,
+ "p95": 13.279208540916443,
+ "p99": 14.60149896144867,
+ "max": 16.915833830833435
+ },
+ "captureToReceiptMs": {
+ "count": 5101,
+ "median": 2.801166534423828,
+ "p95": 5.261499881744385,
+ "p99": 6.096499443054199,
+ "max": 7.179290771484375
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 4.299999713897705,
+ "p95": 7.900000095367432,
+ "p99": 8.5,
+ "max": 9.200000286102295
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 1.200000286102295,
+ "p95": 1.9000000953674316,
+ "p99": 2,
+ "max": 2.1000003814697266
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 1.200000286102295,
+ "p95": 1.8000001907348633,
+ "p99": 2,
+ "max": 2.1000003814697266
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.300000190734863,
+ "p95": 10.200000286102295,
+ "p99": 10.400000095367432,
+ "max": 11.600000381469727
+ },
+ "observedFps": 120.0076660917098,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 68,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.11266660690307617,
+ "endpointDriftMs": 0.015334129333496094,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "05-oscilloscope-display-sync-baseline-0",
+ "config": "oscilloscope",
+ "target": "display-sync",
+ "mode": "baseline",
+ "repeat": 0,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "oscilloscope"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60011.90000009537,
+ "latencyUncertaintyMs": 0.22754085063934326,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [],
+ "scopes": [
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0.5999999046325684,
+ "p95": 0.9000000953674316,
+ "p99": 0.9000000953674316,
+ "max": 1.200000286102295
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.300000190734863,
+ "p95": 10.200000286102295,
+ "p99": 10.400000095367432,
+ "max": 11.399999618530273
+ },
+ "observedFps": 119.99766703605262,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.12254095077514648,
+ "endpointDriftMs": 0.03508281707763672,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "06-oscilloscope-display-sync-probe-1",
+ "config": "oscilloscope",
+ "target": "display-sync",
+ "mode": "probe",
+ "repeat": 1,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "oscilloscope"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60007.2999997139,
+ "latencyUncertaintyMs": 0.238791823387146,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 1,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5169,
+ "median": 7.345249652862549,
+ "p95": 12.382124900817871,
+ "p99": 13.621125221252441,
+ "max": 15.643291473388672
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 7.584041476249695,
+ "p95": 12.620916724205017,
+ "p99": 13.859917044639587,
+ "max": 15.882083296775818
+ },
+ "captureToReceiptMs": {
+ "count": 5049,
+ "median": 2.8019165992736816,
+ "p95": 5.054374694824219,
+ "p99": 5.49970817565918,
+ "max": 6.5341668128967285
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 4,
+ "p95": 8,
+ "p99": 9.099999904632568,
+ "max": 9.800000190734863
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.6999998092651367,
+ "p95": 0.8000001907348633,
+ "p99": 0.9000000953674316,
+ "max": 1.200000286102295
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0.5999999046325684,
+ "p95": 0.9000000953674316,
+ "p99": 0.9000000953674316,
+ "max": 1.4000000953674316
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.300000190734863,
+ "p95": 10.200000286102295,
+ "p99": 10.400000095367432,
+ "max": 11.899999618530273
+ },
+ "observedFps": 120.0066661111574,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 120,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.13379192352294922,
+ "endpointDriftMs": 0.05758476257324219,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "07-oscilloscope-display-sync-probe-2",
+ "config": "oscilloscope",
+ "target": "display-sync",
+ "mode": "probe",
+ "repeat": 2,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "oscilloscope"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60002.09999990463,
+ "latencyUncertaintyMs": 0.21385324001312256,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5168,
+ "median": 7.3501458168029785,
+ "p95": 12.303604125976562,
+ "p99": 13.47222900390625,
+ "max": 15.835021495819092
+ },
+ "upperLatencyMs": {
+ "count": 5168,
+ "median": 7.563999056816101,
+ "p95": 12.517457365989685,
+ "p99": 13.686082243919373,
+ "max": 16.048874735832214
+ },
+ "captureToReceiptMs": {
+ "count": 5038,
+ "median": 2.7769365310668945,
+ "p95": 5.029728889465332,
+ "p99": 5.519270896911621,
+ "max": 6.290228843688965
+ },
+ "receiptToConsumptionMs": {
+ "count": 5168,
+ "median": 4.099999904632568,
+ "p95": 8,
+ "p99": 9.100000381469727,
+ "max": 9.900000095367432
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5168,
+ "median": 0.6999998092651367,
+ "p95": 0.8000001907348633,
+ "p99": 0.9000000953674316,
+ "max": 1.299999713897705
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 0.5999999046325684,
+ "p95": 0.9000000953674316,
+ "p99": 1,
+ "max": 1.3999996185302734
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.300000190734863,
+ "p95": 10.199999809265137,
+ "p99": 10.400000095367432,
+ "max": 12.800000190734863
+ },
+ "observedFps": 120.0043340917994,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 130,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.10885334014892578,
+ "endpointDriftMs": 0.0077075958251953125,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "08-oscilloscope-display-sync-probe-3",
+ "config": "oscilloscope",
+ "target": "display-sync",
+ "mode": "probe",
+ "repeat": 3,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "oscilloscope"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60011.300000190735,
+ "latencyUncertaintyMs": 0.24416673183441162,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5170,
+ "median": 7.3863749504089355,
+ "p95": 12.353624820709229,
+ "p99": 13.42812442779541,
+ "max": 16.133207321166992
+ },
+ "upperLatencyMs": {
+ "count": 5170,
+ "median": 7.630541682243347,
+ "p95": 12.59779155254364,
+ "p99": 13.672291159629822,
+ "max": 16.377374053001404
+ },
+ "captureToReceiptMs": {
+ "count": 5020,
+ "median": 2.766416549682617,
+ "p95": 5.0372490882873535,
+ "p99": 5.4561662673950195,
+ "max": 8.925291061401367
+ },
+ "receiptToConsumptionMs": {
+ "count": 5170,
+ "median": 4.199999809265137,
+ "p95": 8,
+ "p99": 9.199999809265137,
+ "max": 9.900000095367432
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5170,
+ "median": 0.6999998092651367,
+ "p95": 0.8000001907348633,
+ "p99": 0.9000000953674316,
+ "max": 1.200000286102295
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0.5999999046325684,
+ "p95": 0.9000000953674316,
+ "p99": 0.9000000953674316,
+ "max": 1.3000001907348633
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.300000190734863,
+ "p95": 10.200000286102295,
+ "p99": 10.400000095367432,
+ "max": 11.299999713897705
+ },
+ "observedFps": 120.00426622620478,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 150,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.13916683197021484,
+ "endpointDriftMs": 0.06833457946777344,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "09-default-rack-display-sync-baseline-0",
+ "config": "default-rack",
+ "target": "display-sync",
+ "mode": "baseline",
+ "repeat": 0,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum",
+ "oscilloscope",
+ "vectorscope",
+ "vumeter"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 342,
+ "height": 718,
+ "cssWidth": 171,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60000.59999990463,
+ "latencyUncertaintyMs": 0.2610825300216675,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.300000190734863,
+ "p95": 10.199999809265137,
+ "p99": 10.5,
+ "max": 10.700000286102295
+ },
+ "observedFps": 120.00133353336334,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 1
+ },
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 0.3000001907348633,
+ "p95": 0.40000009536743164,
+ "p99": 0.5,
+ "max": 0.6000003814697266
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.300000190734863,
+ "p95": 10.199999809265137,
+ "p99": 10.300000190734863,
+ "max": 10.600000381469727
+ },
+ "observedFps": 120.00093347062383,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 1
+ },
+ {
+ "scope": "vectorscope",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 0.5,
+ "p95": 0.6000003814697266,
+ "p99": 0.7000002861022949,
+ "max": 0.8000001907348633
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.300000190734863,
+ "p95": 10.199999809265137,
+ "p99": 10.599999904632568,
+ "max": 10.900000095367432
+ },
+ "observedFps": 120.00093346967,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 1
+ },
+ {
+ "scope": "vumeter",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 0,
+ "p95": 0.10000038146972656,
+ "p99": 0.20000028610229492,
+ "max": 0.3000001907348633
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.300000190734863,
+ "p95": 10.199999809265137,
+ "p99": 10.599999904632568,
+ "max": 11.099999904632568
+ },
+ "observedFps": 120.00093346967,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 1
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.1560826301574707,
+ "endpointDriftMs": 0.10216617584228516,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "10-default-rack-display-sync-probe-1",
+ "config": "default-rack",
+ "target": "display-sync",
+ "mode": "probe",
+ "repeat": 1,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum",
+ "oscilloscope",
+ "vectorscope",
+ "vumeter"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 342,
+ "height": 718,
+ "cssWidth": 171,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60000.800000190735,
+ "latencyUncertaintyMs": 0.26506173610687256,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5168,
+ "median": 6.313645362854004,
+ "p95": 11.039021492004395,
+ "p99": 12.349645614624023,
+ "max": 15.220146179199219
+ },
+ "upperLatencyMs": {
+ "count": 5168,
+ "median": 6.5787070989608765,
+ "p95": 11.304083228111267,
+ "p99": 12.614707350730896,
+ "max": 15.485207915306091
+ },
+ "captureToReceiptMs": {
+ "count": 4892,
+ "median": 2.584770679473877,
+ "p95": 4.885395526885986,
+ "p99": 5.463270664215088,
+ "max": 5.981645584106445
+ },
+ "receiptToConsumptionMs": {
+ "count": 5168,
+ "median": 3.6999998092651367,
+ "p95": 7.799999713897705,
+ "p99": 8.599999904632568,
+ "max": 10.899999618530273
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5168,
+ "median": 0.19999980926513672,
+ "p95": 0.20000028610229492,
+ "p99": 0.3000001907348633,
+ "max": 0.39999961853027344
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 0.19999980926513672,
+ "p95": 0.20000028610229492,
+ "p99": 0.3000001907348633,
+ "max": 0.39999961853027344
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.299999713897705,
+ "p95": 10,
+ "p99": 10.400000095367432,
+ "max": 11.5
+ },
+ "observedFps": 120.00093347062383,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 276,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ },
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5168,
+ "median": 6.150813102722168,
+ "p95": 10.863229751586914,
+ "p99": 12.186604499816895,
+ "max": 15.020146369934082
+ },
+ "upperLatencyMs": {
+ "count": 5168,
+ "median": 6.4158748388290405,
+ "p95": 11.128291487693787,
+ "p99": 12.451666235923767,
+ "max": 15.285208106040955
+ },
+ "captureToReceiptMs": {
+ "count": 4892,
+ "median": 2.584770679473877,
+ "p95": 4.885395526885986,
+ "p99": 5.463270664215088,
+ "max": 5.981645584106445
+ },
+ "receiptToConsumptionMs": {
+ "count": 5168,
+ "median": 3.5,
+ "p95": 7.599999904632568,
+ "p99": 8.5,
+ "max": 10.599999904632568
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5168,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.300000190734863,
+ "p95": 10,
+ "p99": 10.300000190734863,
+ "max": 11.300000190734863
+ },
+ "observedFps": 120.00093347062383,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 276,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ },
+ {
+ "scope": "vectorscope",
+ "latencyMs": {
+ "count": 5168,
+ "median": 6.572645664215088,
+ "p95": 11.316104412078857,
+ "p99": 12.629895687103271,
+ "max": 15.520146369934082
+ },
+ "upperLatencyMs": {
+ "count": 5168,
+ "median": 6.8377074003219604,
+ "p95": 11.58116614818573,
+ "p99": 12.894957423210144,
+ "max": 15.785208106040955
+ },
+ "captureToReceiptMs": {
+ "count": 4892,
+ "median": 2.584770679473877,
+ "p95": 4.885395526885986,
+ "p99": 5.463270664215088,
+ "max": 5.981645584106445
+ },
+ "receiptToConsumptionMs": {
+ "count": 5168,
+ "median": 3.8999996185302734,
+ "p95": 8,
+ "p99": 8.800000190734863,
+ "max": 11.099999904632568
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5168,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.299999713897705,
+ "p95": 10,
+ "p99": 10.400000095367432,
+ "max": 11.700000286102295
+ },
+ "observedFps": 120.00093346967,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 276,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ },
+ {
+ "scope": "vumeter",
+ "latencyMs": {
+ "count": 5168,
+ "median": 6.6382293701171875,
+ "p95": 11.354687690734863,
+ "p99": 12.685896396636963,
+ "max": 15.62014627456665
+ },
+ "upperLatencyMs": {
+ "count": 5168,
+ "median": 6.90329110622406,
+ "p95": 11.619749426841736,
+ "p99": 12.950958132743835,
+ "max": 15.885208010673523
+ },
+ "captureToReceiptMs": {
+ "count": 4892,
+ "median": 2.584770679473877,
+ "p95": 4.885395526885986,
+ "p99": 5.463270664215088,
+ "max": 5.981645584106445
+ },
+ "receiptToConsumptionMs": {
+ "count": 5168,
+ "median": 4.199999809265137,
+ "p95": 8.200000286102295,
+ "p99": 9.099999904632568,
+ "max": 11.399999618530273
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5168,
+ "median": 0,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameWorkMs": {
+ "count": 7200,
+ "median": 0,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameIntervalMs": {
+ "count": 7199,
+ "median": 8.299999713897705,
+ "p95": 10,
+ "p99": 10.400000095367432,
+ "max": 11.599999904632568
+ },
+ "observedFps": 120.00093347062383,
+ "frameCount": 7200,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 276,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.16006183624267578,
+ "endpointDriftMs": 0.11012458801269531,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "11-default-rack-display-sync-probe-2",
+ "config": "default-rack",
+ "target": "display-sync",
+ "mode": "probe",
+ "repeat": 2,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum",
+ "oscilloscope",
+ "vectorscope",
+ "vumeter"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 342,
+ "height": 718,
+ "cssWidth": 171,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60009.5,
+ "latencyUncertaintyMs": 0.23379123210906982,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 4,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5169,
+ "median": 6.390501022338867,
+ "p95": 11.09675121307373,
+ "p99": 12.603333950042725,
+ "max": 15.534208297729492
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 6.624291777610779,
+ "p95": 11.330541968345642,
+ "p99": 12.837124705314636,
+ "max": 15.767999053001404
+ },
+ "captureToReceiptMs": {
+ "count": 4961,
+ "median": 2.6115832328796387,
+ "p95": 4.93583345413208,
+ "p99": 5.509792327880859,
+ "max": 6.00604248046875
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 3.8999996185302734,
+ "p95": 7.800000190734863,
+ "p99": 9.099999904632568,
+ "max": 9.900000095367432
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.19999980926513672,
+ "p95": 0.20000028610229492,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0.10000038146972656,
+ "p95": 0.20000028610229492,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.300000190734863,
+ "p95": 10.099999904632568,
+ "p99": 10.400000095367432,
+ "max": 10.599999904632568
+ },
+ "observedFps": 120.00186643628211,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 208,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5169,
+ "median": 6.233917713165283,
+ "p95": 10.919708251953125,
+ "p99": 12.410542488098145,
+ "max": 15.334208488464355
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 6.467708468437195,
+ "p95": 11.153499007225037,
+ "p99": 12.644333243370056,
+ "max": 15.567999243736267
+ },
+ "captureToReceiptMs": {
+ "count": 4961,
+ "median": 2.6115832328796387,
+ "p95": 4.93583345413208,
+ "p99": 5.509792327880859,
+ "max": 6.00604248046875
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 3.6999998092651367,
+ "p95": 7.599999904632568,
+ "p99": 8.900000095367432,
+ "max": 9.700000286102295
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.300000190734863,
+ "p95": 10,
+ "p99": 10.300000190734863,
+ "max": 10.400000095367432
+ },
+ "observedFps": 120.00166645835937,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 208,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "vectorscope",
+ "latencyMs": {
+ "count": 5169,
+ "median": 6.678333282470703,
+ "p95": 11.37604284286499,
+ "p99": 12.891375541687012,
+ "max": 15.834208488464355
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 6.912124037742615,
+ "p95": 11.609833598136902,
+ "p99": 13.125166296958923,
+ "max": 16.067999243736267
+ },
+ "captureToReceiptMs": {
+ "count": 4961,
+ "median": 2.6115832328796387,
+ "p95": 4.93583345413208,
+ "p99": 5.509792327880859,
+ "max": 6.00604248046875
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 4,
+ "p95": 8,
+ "p99": 9.299999713897705,
+ "max": 10.099999904632568
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.299999713897705,
+ "p95": 10.099999904632568,
+ "p99": 10.400000095367432,
+ "max": 10.600000381469727
+ },
+ "observedFps": 120.00166645835937,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 208,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "vumeter",
+ "latencyMs": {
+ "count": 5169,
+ "median": 6.727917671203613,
+ "p95": 11.419167041778564,
+ "p99": 12.944208145141602,
+ "max": 15.834208488464355
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 6.961708426475525,
+ "p95": 11.652957797050476,
+ "p99": 13.177998900413513,
+ "max": 16.067999243736267
+ },
+ "captureToReceiptMs": {
+ "count": 4961,
+ "median": 2.6115832328796387,
+ "p95": 4.93583345413208,
+ "p99": 5.509792327880859,
+ "max": 6.00604248046875
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 4.299999713897705,
+ "p95": 8.299999713897705,
+ "p99": 9.599999904632568,
+ "max": 10.400000095367432
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.299999713897705,
+ "p95": 10.099999904632568,
+ "p99": 10.400000095367432,
+ "max": 10.600000381469727
+ },
+ "observedFps": 120.00186643628211,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 208,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.12879133224487305,
+ "endpointDriftMs": 0.047583580017089844,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "12-default-rack-display-sync-probe-3",
+ "config": "default-rack",
+ "target": "display-sync",
+ "mode": "probe",
+ "repeat": 3,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": "display-sync",
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum",
+ "oscilloscope",
+ "vectorscope",
+ "vumeter"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 342,
+ "height": 718,
+ "cssWidth": 171,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60010.40000009537,
+ "latencyUncertaintyMs": 0.21001994609832764,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5170,
+ "median": 6.381020545959473,
+ "p95": 10.987812995910645,
+ "p99": 12.41881275177002,
+ "max": 15.535937786102295
+ },
+ "upperLatencyMs": {
+ "count": 5170,
+ "median": 6.591040015220642,
+ "p95": 11.197832465171814,
+ "p99": 12.628832221031189,
+ "max": 15.745957255363464
+ },
+ "captureToReceiptMs": {
+ "count": 4990,
+ "median": 2.6012706756591797,
+ "p95": 4.905938148498535,
+ "p99": 5.4233551025390625,
+ "max": 6.014895915985107
+ },
+ "receiptToConsumptionMs": {
+ "count": 5170,
+ "median": 3.700000286102295,
+ "p95": 7.799999713897705,
+ "p99": 8.699999809265137,
+ "max": 10
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5170,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0.19999980926513672,
+ "p95": 0.20000028610229492,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.300000190734863,
+ "p95": 10,
+ "p99": 10.400000095367432,
+ "max": 10.699999809265137
+ },
+ "observedFps": 120.0012665045134,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 180,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5170,
+ "median": 6.19868803024292,
+ "p95": 10.856687545776367,
+ "p99": 12.257229804992676,
+ "max": 15.3359375
+ },
+ "upperLatencyMs": {
+ "count": 5170,
+ "median": 6.408707499504089,
+ "p95": 11.066707015037537,
+ "p99": 12.467249274253845,
+ "max": 15.54595696926117
+ },
+ "captureToReceiptMs": {
+ "count": 4990,
+ "median": 2.6012706756591797,
+ "p95": 4.905938148498535,
+ "p99": 5.4233551025390625,
+ "max": 6.014895915985107
+ },
+ "receiptToConsumptionMs": {
+ "count": 5170,
+ "median": 3.5999999046325684,
+ "p95": 7.599999904632568,
+ "p99": 8.5,
+ "max": 9.799999713897705
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5170,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.300000190734863,
+ "p95": 10,
+ "p99": 10.300000190734863,
+ "max": 10.600000381469727
+ },
+ "observedFps": 120.0010665276366,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 180,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "vectorscope",
+ "latencyMs": {
+ "count": 5170,
+ "median": 6.669188022613525,
+ "p95": 11.278979778289795,
+ "p99": 12.721396446228027,
+ "max": 15.8359375
+ },
+ "upperLatencyMs": {
+ "count": 5170,
+ "median": 6.879207491874695,
+ "p95": 11.488999247550964,
+ "p99": 12.931415915489197,
+ "max": 16.04595696926117
+ },
+ "captureToReceiptMs": {
+ "count": 4990,
+ "median": 2.6012706756591797,
+ "p95": 4.905938148498535,
+ "p99": 5.4233551025390625,
+ "max": 6.014895915985107
+ },
+ "receiptToConsumptionMs": {
+ "count": 5170,
+ "median": 3.9000000953674316,
+ "p95": 8,
+ "p99": 8.799999713897705,
+ "max": 10.199999809265137
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5170,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5999999046325684
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5999999046325684
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.299999713897705,
+ "p95": 10,
+ "p99": 10.400000095367432,
+ "max": 10.699999809265137
+ },
+ "observedFps": 120.0012665045134,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 180,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "vumeter",
+ "latencyMs": {
+ "count": 5170,
+ "median": 6.700145244598389,
+ "p95": 11.337437629699707,
+ "p99": 12.721396446228027,
+ "max": 15.935937881469727
+ },
+ "upperLatencyMs": {
+ "count": 5170,
+ "median": 6.910164713859558,
+ "p95": 11.547457098960876,
+ "p99": 12.931415915489197,
+ "max": 16.145957350730896
+ },
+ "captureToReceiptMs": {
+ "count": 4990,
+ "median": 2.6012706756591797,
+ "p95": 4.905938148498535,
+ "p99": 5.4233551025390625,
+ "max": 6.014895915985107
+ },
+ "receiptToConsumptionMs": {
+ "count": 5170,
+ "median": 4.199999809265137,
+ "p95": 8.200000286102295,
+ "p99": 9.099999904632568,
+ "max": 10.5
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5170,
+ "median": 0,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameWorkMs": {
+ "count": 7202,
+ "median": 0,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameIntervalMs": {
+ "count": 7201,
+ "median": 8.299999713897705,
+ "p95": 10,
+ "p99": 10.400000095367432,
+ "max": 10.699999809265137
+ },
+ "observedFps": 120.0012665045134,
+ "frameCount": 7202,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 180,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.10502004623413086,
+ "endpointDriftMs": 0.00004100799560546875,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "13-spectrum-60-baseline-0",
+ "config": "spectrum",
+ "target": 60,
+ "mode": "baseline",
+ "repeat": 0,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60000.59999990463,
+ "latencyUncertaintyMs": 0.2347491979598999,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 3228,
+ "median": 0.40000009536743164,
+ "p95": 0.5,
+ "p99": 0.5,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3227,
+ "median": 16.699999809265137,
+ "p95": 25.200000286102295,
+ "p99": 25.40000009536743,
+ "max": 26.399999618530273
+ },
+ "observedFps": 53.79059506358142,
+ "frameCount": 3228,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 1
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.12974929809570312,
+ "endpointDriftMs": 0.04949951171875,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "14-spectrum-60-probe-1",
+ "config": "spectrum",
+ "target": 60,
+ "mode": "probe",
+ "repeat": 1,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60002.300000190735,
+ "latencyUncertaintyMs": 0.21668708324432373,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 1,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5168,
+ "median": 11.899187088012695,
+ "p95": 24.29002094268799,
+ "p99": 27.971854209899902,
+ "max": 29.992562294006348
+ },
+ "upperLatencyMs": {
+ "count": 5168,
+ "median": 12.115874648094177,
+ "p95": 24.50670850276947,
+ "p99": 28.188541769981384,
+ "max": 30.20924985408783
+ },
+ "captureToReceiptMs": {
+ "count": 4919,
+ "median": 2.587228775024414,
+ "p95": 4.805229187011719,
+ "p99": 5.159811496734619,
+ "max": 5.92172908782959
+ },
+ "receiptToConsumptionMs": {
+ "count": 5168,
+ "median": 9.200000286102295,
+ "p95": 19.700000286102295,
+ "p99": 24.5,
+ "max": 25.200000286102295
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5168,
+ "median": 0.40000009536743164,
+ "p95": 0.5,
+ "p99": 0.5,
+ "max": 0.6000003814697266
+ },
+ "frameWorkMs": {
+ "count": 3211,
+ "median": 0.40000009536743164,
+ "p95": 0.5,
+ "p99": 0.5,
+ "max": 0.6000003814697266
+ },
+ "frameIntervalMs": {
+ "count": 3210,
+ "median": 16.699999809265137,
+ "p95": 25.200000286102295,
+ "p99": 25.40000009536743,
+ "max": 26
+ },
+ "observedFps": 53.51489497893235,
+ "frameCount": 3211,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 249,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.11168718338012695,
+ "endpointDriftMs": 0.013375282287597656,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "15-spectrum-60-probe-2",
+ "config": "spectrum",
+ "target": 60,
+ "mode": "probe",
+ "repeat": 2,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60006.5,
+ "latencyUncertaintyMs": 0.2282494306564331,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 2,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5168,
+ "median": 11.81491756439209,
+ "p95": 23.561209678649902,
+ "p99": 28.00254249572754,
+ "max": 29.594709396362305
+ },
+ "upperLatencyMs": {
+ "count": 5168,
+ "median": 12.043166518211365,
+ "p95": 23.789458632469177,
+ "p99": 28.230791449546814,
+ "max": 29.82295835018158
+ },
+ "captureToReceiptMs": {
+ "count": 4948,
+ "median": 2.5679593086242676,
+ "p95": 4.828917980194092,
+ "p99": 5.1947503089904785,
+ "max": 5.744501113891602
+ },
+ "receiptToConsumptionMs": {
+ "count": 5168,
+ "median": 9.099999904632568,
+ "p95": 19.5,
+ "p99": 24.5,
+ "max": 25.299999713897705
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5168,
+ "median": 0.40000009536743164,
+ "p95": 0.5,
+ "p99": 0.5,
+ "max": 0.6000003814697266
+ },
+ "frameWorkMs": {
+ "count": 3241,
+ "median": 0.40000009536743164,
+ "p95": 0.5,
+ "p99": 0.5,
+ "max": 0.6000003814697266
+ },
+ "frameIntervalMs": {
+ "count": 3240,
+ "median": 16.699999809265137,
+ "p95": 25.200000286102295,
+ "p99": 25.40000009536743,
+ "max": 26.09999990463257
+ },
+ "observedFps": 54.001800060002,
+ "frameCount": 3241,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 220,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.12324953079223633,
+ "endpointDriftMs": 0.036499977111816406,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "16-spectrum-60-probe-3",
+ "config": "spectrum",
+ "target": 60,
+ "mode": "probe",
+ "repeat": 3,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60010.89999961853,
+ "latencyUncertaintyMs": 0.22658288478851318,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5168,
+ "median": 12.02066707611084,
+ "p95": 24.281042098999023,
+ "p99": 28.084333419799805,
+ "max": 29.948291778564453
+ },
+ "upperLatencyMs": {
+ "count": 5168,
+ "median": 12.247249960899353,
+ "p95": 24.507624983787537,
+ "p99": 28.310916304588318,
+ "max": 30.174874663352966
+ },
+ "captureToReceiptMs": {
+ "count": 4910,
+ "median": 2.565751075744629,
+ "p95": 4.781625747680664,
+ "p99": 5.162417411804199,
+ "max": 5.7882914543151855
+ },
+ "receiptToConsumptionMs": {
+ "count": 5168,
+ "median": 9.200000286102295,
+ "p95": 19.90000009536743,
+ "p99": 24.59999990463257,
+ "max": 25.800000190734863
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5168,
+ "median": 0.40000009536743164,
+ "p95": 0.5,
+ "p99": 0.5,
+ "max": 0.5999999046325684
+ },
+ "frameWorkMs": {
+ "count": 3218,
+ "median": 0.40000009536743164,
+ "p95": 0.5,
+ "p99": 0.5,
+ "max": 0.5999999046325684
+ },
+ "frameIntervalMs": {
+ "count": 3217,
+ "median": 16.699999809265137,
+ "p95": 25.200000286102295,
+ "p99": 25.5,
+ "max": 26.40000009536743
+ },
+ "observedFps": 53.63230942358188,
+ "frameCount": 3218,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 258,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.1215829849243164,
+ "endpointDriftMs": 0.03316688537597656,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "17-oscilloscope-60-baseline-0",
+ "config": "oscilloscope",
+ "target": 60,
+ "mode": "baseline",
+ "repeat": 0,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "oscilloscope"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60004.60000038147,
+ "latencyUncertaintyMs": 0.2321246862411499,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [],
+ "scopes": [
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 3258,
+ "median": 0.20000028610229492,
+ "p95": 0.39999961853027344,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3257,
+ "median": 16.699999809265137,
+ "p95": 25.199999809265137,
+ "p99": 25.300000190734863,
+ "max": 26.100000381469727
+ },
+ "observedFps": 54.27953376614225,
+ "frameCount": 3258,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.12712478637695312,
+ "endpointDriftMs": 0.04425048828125,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "18-oscilloscope-60-probe-1",
+ "config": "oscilloscope",
+ "target": 60,
+ "mode": "probe",
+ "repeat": 1,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "oscilloscope"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60003.90000009537,
+ "latencyUncertaintyMs": 0.21399915218353271,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5169,
+ "median": 11.792458057403564,
+ "p95": 23.46583366394043,
+ "p99": 27.893625736236572,
+ "max": 29.731791973114014
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 12.006457209587097,
+ "p95": 23.679832816123962,
+ "p99": 28.107624888420105,
+ "max": 29.945791125297546
+ },
+ "captureToReceiptMs": {
+ "count": 4970,
+ "median": 2.6244163513183594,
+ "p95": 4.864790916442871,
+ "p99": 5.2530412673950195,
+ "max": 5.891958236694336
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 9.200000286102295,
+ "p95": 19.59999990463257,
+ "p99": 24.59999990463257,
+ "max": 25.699999809265137
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 3246,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3245,
+ "median": 16.699999809265137,
+ "p95": 25.199999809265137,
+ "p99": 25.40000009536743,
+ "max": 26.59999990463257
+ },
+ "observedFps": 54.09162738312334,
+ "frameCount": 3246,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 199,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.10899925231933594,
+ "endpointDriftMs": 0.007999420166015625,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "19-oscilloscope-60-probe-2",
+ "config": "oscilloscope",
+ "target": 60,
+ "mode": "probe",
+ "repeat": 2,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "oscilloscope"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60002.7000002861,
+ "latencyUncertaintyMs": 0.21645820140838623,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 1,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5169,
+ "median": 11.664832592010498,
+ "p95": 23.237083435058594,
+ "p99": 27.62833309173584,
+ "max": 29.96595811843872
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 11.881291270256042,
+ "p95": 23.453542113304138,
+ "p99": 27.844791769981384,
+ "max": 30.182416796684265
+ },
+ "captureToReceiptMs": {
+ "count": 4942,
+ "median": 2.5999159812927246,
+ "p95": 4.871999740600586,
+ "p99": 5.280165672302246,
+ "max": 5.994291305541992
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 9.199999809265137,
+ "p95": 19.5,
+ "p99": 24.699999809265137,
+ "max": 25.40000009536743
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 3255,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3254,
+ "median": 16.699999809265137,
+ "p95": 25.199999809265137,
+ "p99": 25.40000009536743,
+ "max": 26.199999809265137
+ },
+ "observedFps": 54.23387567243487,
+ "frameCount": 3255,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 227,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.11145830154418945,
+ "endpointDriftMs": 0.012917518615722656,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "20-oscilloscope-60-probe-3",
+ "config": "oscilloscope",
+ "target": 60,
+ "mode": "probe",
+ "repeat": 3,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "oscilloscope"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 2400,
+ "height": 718,
+ "cssWidth": 1200,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60000.2999997139,
+ "latencyUncertaintyMs": 0.2264789342880249,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5168,
+ "median": 11.733896255493164,
+ "p95": 23.7511043548584,
+ "p99": 28.14960527420044,
+ "max": 30.1286883354187
+ },
+ "upperLatencyMs": {
+ "count": 5168,
+ "median": 11.960375189781189,
+ "p95": 23.977583289146423,
+ "p99": 28.376084208488464,
+ "max": 30.355167269706726
+ },
+ "captureToReceiptMs": {
+ "count": 4915,
+ "median": 2.5867300033569336,
+ "p95": 4.7827301025390625,
+ "p99": 5.155062675476074,
+ "max": 5.74360466003418
+ },
+ "receiptToConsumptionMs": {
+ "count": 5168,
+ "median": 9.299999713897705,
+ "p95": 19.600000381469727,
+ "p99": 24.700000286102295,
+ "max": 25.59999990463257
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5168,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.40000009536743164
+ },
+ "frameWorkMs": {
+ "count": 3216,
+ "median": 0.20000028610229492,
+ "p95": 0.39999961853027344,
+ "p99": 0.40000009536743164,
+ "max": 0.40000009536743164
+ },
+ "frameIntervalMs": {
+ "count": 3215,
+ "median": 16.699999809265137,
+ "p95": 25.200000286102295,
+ "p99": 25.40000009536743,
+ "max": 26.40000009536743
+ },
+ "observedFps": 53.59905572292686,
+ "frameCount": 3216,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 253,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.12147903442382812,
+ "endpointDriftMs": 0.032958984375,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "21-default-rack-60-baseline-0",
+ "config": "default-rack",
+ "target": 60,
+ "mode": "baseline",
+ "repeat": 0,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum",
+ "oscilloscope",
+ "vectorscope",
+ "vumeter"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 342,
+ "height": 718,
+ "cssWidth": 171,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60007.09999990463,
+ "latencyUncertaintyMs": 0.2488325834274292,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 3256,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3255,
+ "median": 16.700000286102295,
+ "p95": 25.299999713897705,
+ "p99": 25.5,
+ "max": 26.5
+ },
+ "observedFps": 54.258410053299535,
+ "frameCount": 3256,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 3256,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3255,
+ "median": 16.699999809265137,
+ "p95": 25.200000286102295,
+ "p99": 25.40000009536743,
+ "max": 26.59999990463257
+ },
+ "observedFps": 54.25831960926548,
+ "frameCount": 3256,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "vectorscope",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 3256,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.40000009536743164
+ },
+ "frameIntervalMs": {
+ "count": 3255,
+ "median": 16.700000286102295,
+ "p95": 25.299999713897705,
+ "p99": 25.5,
+ "max": 26.59999990463257
+ },
+ "observedFps": 54.25841005373081,
+ "frameCount": 3256,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "vumeter",
+ "latencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "upperLatencyMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "captureToReceiptMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "receiptToConsumptionMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "consumptionToDrawEndMs": {
+ "count": 0,
+ "median": null,
+ "p95": null,
+ "p99": null,
+ "max": null
+ },
+ "frameWorkMs": {
+ "count": 3256,
+ "median": 0.09999990463256836,
+ "p95": 0.10000038146972656,
+ "p99": 0.20000028610229492,
+ "max": 0.20000028610229492
+ },
+ "frameIntervalMs": {
+ "count": 3255,
+ "median": 16.700000286102295,
+ "p95": 25.299999713897705,
+ "p99": 25.5,
+ "max": 26.40000009536743
+ },
+ "observedFps": 54.258410053299535,
+ "frameCount": 3256,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 0,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.14383268356323242,
+ "endpointDriftMs": 0.0776662826538086,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "22-default-rack-60-probe-1",
+ "config": "default-rack",
+ "target": 60,
+ "mode": "probe",
+ "repeat": 1,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum",
+ "oscilloscope",
+ "vectorscope",
+ "vumeter"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 342,
+ "height": 718,
+ "cssWidth": 171,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60006.300000190735,
+ "latencyUncertaintyMs": 0.27337491512298584,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 4,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5167,
+ "median": 11.838956832885742,
+ "p95": 23.937374591827393,
+ "p99": 28.06879186630249,
+ "max": 30.498416900634766
+ },
+ "upperLatencyMs": {
+ "count": 5167,
+ "median": 12.112331748008728,
+ "p95": 24.21074950695038,
+ "p99": 28.342166781425476,
+ "max": 30.77179181575775
+ },
+ "captureToReceiptMs": {
+ "count": 4906,
+ "median": 2.6354165077209473,
+ "p95": 4.831916809082031,
+ "p99": 5.317707061767578,
+ "max": 6.037332534790039
+ },
+ "receiptToConsumptionMs": {
+ "count": 5167,
+ "median": 9.5,
+ "p95": 19.90000009536743,
+ "p99": 24.59999990463257,
+ "max": 25.799999713897705
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5167,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.3000001907348633
+ },
+ "frameWorkMs": {
+ "count": 3249,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.3000001907348633
+ },
+ "frameIntervalMs": {
+ "count": 3248,
+ "median": 16.699999809265137,
+ "p95": 25.300000190734863,
+ "p99": 25.5,
+ "max": 26.5
+ },
+ "observedFps": 54.14930737926842,
+ "frameCount": 3249,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 261,
+ "unconsumedAtStop": 2,
+ "pendingAtStop": 2
+ },
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5167,
+ "median": 11.678291320800781,
+ "p95": 23.748541355133057,
+ "p99": 27.93358278274536,
+ "max": 30.29841709136963
+ },
+ "upperLatencyMs": {
+ "count": 5167,
+ "median": 11.951666235923767,
+ "p95": 24.021916270256042,
+ "p99": 28.206957697868347,
+ "max": 30.571792006492615
+ },
+ "captureToReceiptMs": {
+ "count": 4906,
+ "median": 2.6354165077209473,
+ "p95": 4.831916809082031,
+ "p99": 5.317707061767578,
+ "max": 6.037332534790039
+ },
+ "receiptToConsumptionMs": {
+ "count": 5167,
+ "median": 9.299999713897705,
+ "p95": 19.59999990463257,
+ "p99": 24.300000190734863,
+ "max": 25.399999618530273
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5167,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 3249,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3248,
+ "median": 16.699999809265137,
+ "p95": 25.200000286102295,
+ "p99": 25.40000009536743,
+ "max": 26.199999809265137
+ },
+ "observedFps": 54.14912682865836,
+ "frameCount": 3249,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 261,
+ "unconsumedAtStop": 2,
+ "pendingAtStop": 2
+ },
+ {
+ "scope": "vectorscope",
+ "latencyMs": {
+ "count": 5167,
+ "median": 12.13249921798706,
+ "p95": 24.237374305725098,
+ "p99": 28.378957271575928,
+ "max": 30.79841709136963
+ },
+ "upperLatencyMs": {
+ "count": 5167,
+ "median": 12.405874133110046,
+ "p95": 24.510749220848083,
+ "p99": 28.652332186698914,
+ "max": 31.071792006492615
+ },
+ "captureToReceiptMs": {
+ "count": 4906,
+ "median": 2.6354165077209473,
+ "p95": 4.831916809082031,
+ "p99": 5.317707061767578,
+ "max": 6.037332534790039
+ },
+ "receiptToConsumptionMs": {
+ "count": 5167,
+ "median": 9.699999809265137,
+ "p95": 20,
+ "p99": 24.799999713897705,
+ "max": 25.899999618530273
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5167,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 3249,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3248,
+ "median": 16.699999809265137,
+ "p95": 25.300000190734863,
+ "p99": 25.5,
+ "max": 26.399999618530273
+ },
+ "observedFps": 54.14930737883795,
+ "frameCount": 3249,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 261,
+ "unconsumedAtStop": 2,
+ "pendingAtStop": 2
+ },
+ {
+ "scope": "vumeter",
+ "latencyMs": {
+ "count": 5167,
+ "median": 12.1589994430542,
+ "p95": 24.267791748046875,
+ "p99": 28.4442081451416,
+ "max": 30.898416996002197
+ },
+ "upperLatencyMs": {
+ "count": 5167,
+ "median": 12.432374358177185,
+ "p95": 24.54116666316986,
+ "p99": 28.717583060264587,
+ "max": 31.171791911125183
+ },
+ "captureToReceiptMs": {
+ "count": 4906,
+ "median": 2.6354165077209473,
+ "p95": 4.831916809082031,
+ "p99": 5.317707061767578,
+ "max": 6.037332534790039
+ },
+ "receiptToConsumptionMs": {
+ "count": 5167,
+ "median": 10,
+ "p95": 20.300000190734863,
+ "p99": 25.09999990463257,
+ "max": 26.199999809265137
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5167,
+ "median": 0.09999990463256836,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameWorkMs": {
+ "count": 3249,
+ "median": 0.09999990463256836,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameIntervalMs": {
+ "count": 3248,
+ "median": 16.699999809265137,
+ "p95": 25.300000190734863,
+ "p99": 25.5,
+ "max": 26.40000009536743
+ },
+ "observedFps": 54.14930737926842,
+ "frameCount": 3249,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 261,
+ "unconsumedAtStop": 2,
+ "pendingAtStop": 2
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.16837501525878906,
+ "endpointDriftMs": 0.12675094604492188,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "23-default-rack-60-probe-2",
+ "config": "default-rack",
+ "target": 60,
+ "mode": "probe",
+ "repeat": 2,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum",
+ "oscilloscope",
+ "vectorscope",
+ "vumeter"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 342,
+ "height": 718,
+ "cssWidth": 171,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60006,
+ "latencyUncertaintyMs": 0.25222909450531006,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 4,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5167,
+ "median": 11.70135498046875,
+ "p95": 23.293687343597412,
+ "p99": 27.861562728881836,
+ "max": 30.002562522888184
+ },
+ "upperLatencyMs": {
+ "count": 5167,
+ "median": 11.95358407497406,
+ "p95": 23.545916438102722,
+ "p99": 28.113791823387146,
+ "max": 30.254791617393494
+ },
+ "captureToReceiptMs": {
+ "count": 4894,
+ "median": 2.589229106903076,
+ "p95": 4.787895202636719,
+ "p99": 5.201145172119141,
+ "max": 5.875354290008545
+ },
+ "receiptToConsumptionMs": {
+ "count": 5167,
+ "median": 9.5,
+ "p95": 19.799999713897705,
+ "p99": 24.59999990463257,
+ "max": 25.700000286102295
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5167,
+ "median": 0.19999980926513672,
+ "p95": 0.20000028610229492,
+ "p99": 0.3000001907348633,
+ "max": 0.3000001907348633
+ },
+ "frameWorkMs": {
+ "count": 3270,
+ "median": 0.19999980926513672,
+ "p95": 0.20000028610229492,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameIntervalMs": {
+ "count": 3269,
+ "median": 16.699999809265137,
+ "p95": 25.299999713897705,
+ "p99": 25.5,
+ "max": 26.600000381469727
+ },
+ "observedFps": 54.491688725431345,
+ "frameCount": 3270,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 273,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ },
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5167,
+ "median": 11.537562370300293,
+ "p95": 23.107354164123535,
+ "p99": 27.626479625701904,
+ "max": 29.902562618255615
+ },
+ "upperLatencyMs": {
+ "count": 5167,
+ "median": 11.789791464805603,
+ "p95": 23.359583258628845,
+ "p99": 27.878708720207214,
+ "max": 30.154791712760925
+ },
+ "captureToReceiptMs": {
+ "count": 4894,
+ "median": 2.589229106903076,
+ "p95": 4.787895202636719,
+ "p99": 5.201145172119141,
+ "max": 5.875354290008545
+ },
+ "receiptToConsumptionMs": {
+ "count": 5167,
+ "median": 9.199999809265137,
+ "p95": 19.5,
+ "p99": 24.299999713897705,
+ "max": 25.40000009536743
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5167,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 3270,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3269,
+ "median": 16.699999809265137,
+ "p95": 25.199999809265137,
+ "p99": 25.300000190734863,
+ "max": 26.40000009536743
+ },
+ "observedFps": 54.49168872586447,
+ "frameCount": 3270,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 273,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ },
+ {
+ "scope": "vectorscope",
+ "latencyMs": {
+ "count": 5167,
+ "median": 11.984437942504883,
+ "p95": 23.607354164123535,
+ "p99": 28.126479625701904,
+ "max": 30.302562713623047
+ },
+ "upperLatencyMs": {
+ "count": 5167,
+ "median": 12.236667037010193,
+ "p95": 23.859583258628845,
+ "p99": 28.378708720207214,
+ "max": 30.554791808128357
+ },
+ "captureToReceiptMs": {
+ "count": 4894,
+ "median": 2.589229106903076,
+ "p95": 4.787895202636719,
+ "p99": 5.201145172119141,
+ "max": 5.875354290008545
+ },
+ "receiptToConsumptionMs": {
+ "count": 5167,
+ "median": 9.599999904632568,
+ "p95": 19.90000009536743,
+ "p99": 24.700000286102295,
+ "max": 25.90000009536743
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5167,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 3270,
+ "median": 0.3000001907348633,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3269,
+ "median": 16.699999809265137,
+ "p95": 25.299999713897705,
+ "p99": 25.5,
+ "max": 26.59999990463257
+ },
+ "observedFps": 54.49168872586447,
+ "frameCount": 3270,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 273,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ },
+ {
+ "scope": "vumeter",
+ "latencyMs": {
+ "count": 5167,
+ "median": 12.047228813171387,
+ "p95": 23.641811847686768,
+ "p99": 28.16156244277954,
+ "max": 30.402562618255615
+ },
+ "upperLatencyMs": {
+ "count": 5167,
+ "median": 12.299457907676697,
+ "p95": 23.894040942192078,
+ "p99": 28.41379153728485,
+ "max": 30.654791712760925
+ },
+ "captureToReceiptMs": {
+ "count": 4894,
+ "median": 2.589229106903076,
+ "p95": 4.787895202636719,
+ "p99": 5.201145172119141,
+ "max": 5.875354290008545
+ },
+ "receiptToConsumptionMs": {
+ "count": 5167,
+ "median": 9.900000095367432,
+ "p95": 20.199999809265137,
+ "p99": 25,
+ "max": 26.200000286102295
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5167,
+ "median": 0.09999990463256836,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameWorkMs": {
+ "count": 3270,
+ "median": 0.09999990463256836,
+ "p95": 0.10000038146972656,
+ "p99": 0.19999980926513672,
+ "max": 0.20000028610229492
+ },
+ "frameIntervalMs": {
+ "count": 3269,
+ "median": 16.700000286102295,
+ "p95": 25.299999713897705,
+ "p99": 25.5,
+ "max": 26.5
+ },
+ "observedFps": 54.491688725431345,
+ "frameCount": 3270,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 273,
+ "unconsumedAtStop": 1,
+ "pendingAtStop": 1
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.14722919464111328,
+ "endpointDriftMs": 0.08445930480957031,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ },
+ {
+ "id": "24-default-rack-60-probe-3",
+ "config": "default-rack",
+ "target": 60,
+ "mode": "probe",
+ "repeat": 3,
+ "metadata": {
+ "capture": {
+ "captureMode": "system",
+ "activeBackendKind": "native-macos",
+ "backendSupport": {
+ "nativeBackend": {
+ "kind": "native-macos",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "deviceInput": {
+ "kind": "device-input",
+ "available": true,
+ "reason": null,
+ "channelRoutingAvailable": true
+ },
+ "dawBridge": {
+ "kind": "daw-bridge",
+ "available": true,
+ "reason": null
+ }
+ },
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "sourceChannelCount": 2,
+ "channelRoutingAvailable": true,
+ "channelRouting": {
+ "left": 0,
+ "right": 1
+ },
+ "isCapturing": true,
+ "activeSourceId": "BuiltInSpeakerDevice",
+ "activeSourceLabel": "MacBook Pro Speakers",
+ "waiting": false
+ },
+ "session": {
+ "sessionId": 1,
+ "sampleRate": 44100,
+ "channelCount": 2,
+ "capturing": true,
+ "suspended": false,
+ "backendKind": "native-macos"
+ },
+ "frameTarget": 60,
+ "scopeSettings": {
+ "waterfall": {
+ "historySeconds": 5,
+ "density": "balanced",
+ "colorMode": "theme",
+ "showGrid": true,
+ "fftSize": 2048,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "smoothing": 0.9,
+ "tiltDbPerOctave": 2
+ },
+ "spectrum": {
+ "reference": null,
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "fftSize": 2048,
+ "tiltDbPerOctave": 2,
+ "heatmap": false,
+ "heatmapTiltDbPerOctave": 2,
+ "heatmapSmoothing": 0.5,
+ "showGrid": true,
+ "smoothing": 0.9,
+ "fillGradient": true,
+ "showSideLine": false,
+ "peakInfoMode": "off"
+ },
+ "oscilloscope": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "pitchLock": true,
+ "underfillEnabled": false,
+ "showGrid": true,
+ "lineWidth": 2
+ },
+ "vectorscope": {
+ "mode": "lissajous",
+ "zoomDb": 0,
+ "multiband": false,
+ "showGrid": true,
+ "persistence": 0.1,
+ "lineWidth": 1.5
+ },
+ "spectrogram": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "fftSize": 4096,
+ "tiltDbPerOctave": 4,
+ "scrollSpeed": 2,
+ "contrast": 1,
+ "clarityMode": "sharper",
+ "scaleMode": "log",
+ "frequencyRangeMode": "extended",
+ "showGrid": true,
+ "colorScheme": "heat",
+ "timelineUnit": "bars-beats"
+ },
+ "vumeter": {
+ "mode": "bar",
+ "orientation": "horizontal",
+ "needleChannels": "stereo",
+ "referenceDb": -14
+ },
+ "lufsmeter": {
+ "mode": "bar",
+ "readout": "shortTerm"
+ },
+ "waveform": {
+ "rotation": 0,
+ "mirrorHorizontal": false,
+ "mode": "mono",
+ "scrollSpeed": 1,
+ "multiband": false,
+ "timelineUnit": "bars-beats"
+ },
+ "nowPlaying": {
+ "showCoverArt": true,
+ "showTitle": true,
+ "showArtist": true,
+ "showProgress": true,
+ "showTime": true,
+ "showControls": true
+ }
+ },
+ "visibleScopes": [
+ "spectrum",
+ "oscilloscope",
+ "vectorscope",
+ "vumeter"
+ ],
+ "pixelRatio": 2,
+ "innerWidth": 1200,
+ "innerHeight": 360,
+ "hidden": false,
+ "canvases": [
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 684,
+ "height": 718,
+ "cssWidth": 342,
+ "cssHeight": 359
+ },
+ {
+ "width": 342,
+ "height": 718,
+ "cssWidth": 171,
+ "cssHeight": 359
+ }
+ ]
+ },
+ "durationMs": 60009.5,
+ "latencyUncertaintyMs": 0.21195733547210693,
+ "problems": [],
+ "valid": true,
+ "counters": {
+ "boundaryChunks": 0,
+ "nativeOverwrites": 0,
+ "sequenceGaps": 0,
+ "sessionChanges": 0,
+ "bufferOverflow": 0,
+ "missingReceipts": 0,
+ "trimmedChunks": 0,
+ "consumedOutsideFrame": 0,
+ "nativeQueueMax": 0
+ },
+ "chunkSizes": [
+ 512
+ ],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "latencyMs": {
+ "count": 5169,
+ "median": 11.647250175476074,
+ "p95": 23.729416370391846,
+ "p99": 27.86920738220215,
+ "max": 30.419166564941406
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 11.859207510948181,
+ "p95": 23.941373705863953,
+ "p99": 28.081164717674255,
+ "max": 30.631123900413513
+ },
+ "captureToReceiptMs": {
+ "count": 4951,
+ "median": 2.5874576568603516,
+ "p95": 4.795250415802002,
+ "p99": 5.260249614715576,
+ "max": 5.884374618530273
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 9.400000095367432,
+ "p95": 19.800000190734863,
+ "p99": 24.59999990463257,
+ "max": 25.5
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameWorkMs": {
+ "count": 3268,
+ "median": 0.19999980926513672,
+ "p95": 0.2999997138977051,
+ "p99": 0.3000001907348633,
+ "max": 0.40000009536743164
+ },
+ "frameIntervalMs": {
+ "count": 3267,
+ "median": 16.699999809265137,
+ "p95": 25.299999713897705,
+ "p99": 25.5,
+ "max": 26.5
+ },
+ "observedFps": 54.45835028020318,
+ "frameCount": 3268,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 218,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "oscilloscope",
+ "latencyMs": {
+ "count": 5169,
+ "median": 11.487542152404785,
+ "p95": 23.57479190826416,
+ "p99": 27.67212438583374,
+ "max": 30.1191668510437
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 11.699499487876892,
+ "p95": 23.786749243736267,
+ "p99": 27.884081721305847,
+ "max": 30.331124186515808
+ },
+ "captureToReceiptMs": {
+ "count": 4951,
+ "median": 2.5874576568603516,
+ "p95": 4.795250415802002,
+ "p99": 5.260249614715576,
+ "max": 5.884374618530273
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 9.199999809265137,
+ "p95": 19.59999990463257,
+ "p99": 24.200000286102295,
+ "max": 25.199999809265137
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.20000028610229492,
+ "p95": 0.39999961853027344,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 3268,
+ "median": 0.20000028610229492,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3267,
+ "median": 16.699999809265137,
+ "p95": 25.200000286102295,
+ "p99": 25.40000009536743,
+ "max": 26.40000009536743
+ },
+ "observedFps": 54.45835028020318,
+ "frameCount": 3268,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 218,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "vectorscope",
+ "latencyMs": {
+ "count": 5169,
+ "median": 11.947832584381104,
+ "p95": 23.99345827102661,
+ "p99": 28.12649965286255,
+ "max": 30.6191668510437
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 12.15978991985321,
+ "p95": 24.20541560649872,
+ "p99": 28.338456988334656,
+ "max": 30.831124186515808
+ },
+ "captureToReceiptMs": {
+ "count": 4951,
+ "median": 2.5874576568603516,
+ "p95": 4.795250415802002,
+ "p99": 5.260249614715576,
+ "max": 5.884374618530273
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 9.599999904632568,
+ "p95": 20,
+ "p99": 24.700000286102295,
+ "max": 25.699999809265137
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameWorkMs": {
+ "count": 3268,
+ "median": 0.2999997138977051,
+ "p95": 0.40000009536743164,
+ "p99": 0.40000009536743164,
+ "max": 0.5
+ },
+ "frameIntervalMs": {
+ "count": 3267,
+ "median": 16.699999809265137,
+ "p95": 25.299999713897705,
+ "p99": 25.5,
+ "max": 26.5
+ },
+ "observedFps": 54.45844105853719,
+ "frameCount": 3268,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 218,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ },
+ {
+ "scope": "vumeter",
+ "latencyMs": {
+ "count": 5169,
+ "median": 11.989542007446289,
+ "p95": 24.041874885559082,
+ "p99": 28.16920757293701,
+ "max": 30.71916675567627
+ },
+ "upperLatencyMs": {
+ "count": 5169,
+ "median": 12.201499342918396,
+ "p95": 24.25383222103119,
+ "p99": 28.38116490840912,
+ "max": 30.931124091148376
+ },
+ "captureToReceiptMs": {
+ "count": 4951,
+ "median": 2.5874576568603516,
+ "p95": 4.795250415802002,
+ "p99": 5.260249614715576,
+ "max": 5.884374618530273
+ },
+ "receiptToConsumptionMs": {
+ "count": 5169,
+ "median": 9.900000095367432,
+ "p95": 20.299999713897705,
+ "p99": 25,
+ "max": 25.90000009536743
+ },
+ "consumptionToDrawEndMs": {
+ "count": 5169,
+ "median": 0.09999990463256836,
+ "p95": 0.10000038146972656,
+ "p99": 0.20000028610229492,
+ "max": 0.20000028610229492
+ },
+ "frameWorkMs": {
+ "count": 3268,
+ "median": 0.09999990463256836,
+ "p95": 0.10000038146972656,
+ "p99": 0.20000028610229492,
+ "max": 0.20000028610229492
+ },
+ "frameIntervalMs": {
+ "count": 3267,
+ "median": 16.699999809265137,
+ "p95": 25.299999713897705,
+ "p99": 25.5,
+ "max": 26.5
+ },
+ "observedFps": 54.45844105853719,
+ "frameCount": 3268,
+ "skippedDrawFrames": 0,
+ "invalid": 0,
+ "skippedChunks": 0,
+ "duplicate": 0,
+ "unresolvedDelivery": 218,
+ "unconsumedAtStop": 0,
+ "pendingAtStop": 0
+ }
+ ],
+ "clock": {
+ "uncertaintyMs": 0.10695743560791016,
+ "endpointDriftMs": 0.0039157867431640625,
+ "timerQuantumMs": 0.10499989986419678
+ },
+ "environment": {
+ "thermalStates": [
+ "nominal"
+ ],
+ "batteryObserved": false,
+ "minimumReportedCpuSpeedLimitPercent": null,
+ "overflow": 0
+ }
+ }
+ ],
+ "presentation": {
+ "available": true,
+ "source": "Chromium AnimationFrame::Presentation; macOS presentation feedback is an estimate",
+ "caveat": "Separate tracing workload. Software clock bounds do not bound OS presentation-estimation error or physical pixel response.",
+ "matchedFrames": 4804,
+ "rejectedFrames": 0,
+ "matchedChunkScopeRecords": 3448,
+ "successfulChunkScopeRecords": 3448,
+ "softwareTimingUncertaintyMs": 0.24504077434539795,
+ "traceValidity": [],
+ "scopes": [
+ {
+ "scope": "spectrum",
+ "estimatedLatencyMs": {
+ "count": 862,
+ "median": 25.212917305063456,
+ "p95": 30.112042407970876,
+ "p99": 31.347167072352022,
+ "max": 32.14487523259595
+ }
+ },
+ {
+ "scope": "oscilloscope",
+ "estimatedLatencyMs": {
+ "count": 862,
+ "median": 25.223374881781638,
+ "p95": 30.15829247282818,
+ "p99": 31.303667060798034,
+ "max": 32.12187494663522
+ }
+ },
+ {
+ "scope": "vectorscope",
+ "estimatedLatencyMs": {
+ "count": 862,
+ "median": 25.202917449874803,
+ "p95": 30.134834590833634,
+ "p99": 31.273625614121556,
+ "max": 32.145874946611
+ }
+ },
+ {
+ "scope": "vumeter",
+ "estimatedLatencyMs": {
+ "count": 862,
+ "median": 25.2163344649598,
+ "p95": 30.103792789392173,
+ "p99": 31.29616726306267,
+ "max": 32.18487532809377
+ }
+ }
+ ]
+ }
+}
diff --git a/docs/channel-routing-testing.md b/docs/channel-routing-testing.md
new file mode 100644
index 0000000..473c9ea
--- /dev/null
+++ b/docs/channel-routing-testing.md
@@ -0,0 +1,81 @@
+# Multichannel routing validation
+
+Routing supports the OS-exposed channels of one selected source. Windows uses
+WASAPI shared-mode capture; Linux uses libpulse with PulseAudio or pipewire-pulse.
+The source's full channel count and activity are separate from the mono/stereo
+audio delivered to analyzers and rolling clips.
+
+## Automated checks
+
+Build the native addon before running native tests:
+
+```sh
+npm run build:native
+npm run typecheck
+npm run test:capture-support
+npm run test:capture-channel-selection-native
+npm run test:spectrum-native
+npm run test:audio-store
+npm run test:audio-router
+npm run test:renderer-helpers
+npm run test:tui
+npm run build
+```
+
+For an Electron development build, use `npm run rebuild:native` afterward to
+restore the addon build against Electron's headers.
+
+On Linux, install the PulseAudio server and command-line tools, then run:
+
+```sh
+npm run test:linux-capture
+```
+
+The test launches a private server/socket, creates a six-channel null sink and
+non-monitor input, and feeds independent tones into each channel. It verifies
+enumeration, defaults, full channel metadata, startup/live/duplicate routing,
+independent input/output state, activity on unselected channels, silence,
+restart, removal, mono reconnect, and server failure. It does not connect to
+the desktop's audio server. Temporary processes and files are removed afterward.
+CI's Linux quality gate runs this test separately from `npm test`.
+
+To run the same integration test against PipeWire, install `pipewire`,
+`pipewire-pulse`, WirePlumber 0.5+, `dbus-daemon`, and the PulseAudio CLI tools:
+
+```sh
+PRISM_CAPTURE_TEST_SERVER=pipewire npm run test:linux-capture
+```
+
+This starts a private PipeWire server and a policy-only WirePlumber instance;
+physical-device discovery is disabled and configuration/state paths are private.
+
+## Hardware acceptance
+
+For Windows and Linux, use a multichannel output endpoint plus an input interface
+whose current OS configuration exposes more than two channels:
+
+1. Confirm every exposed channel appears with a driver/channel-map label or a
+ numbered fallback. Route a channel above 2 to each analyzer side, then route
+ one channel to both sides. Confirm signal identity and activity on unselected
+ channels; changing input trim must not change the activity fills.
+2. Change routes during capture. Confirm no restart, dropout caused by reopening
+ the device, or changes to the other capture mode's saved route.
+3. Switch between mono, stereo, and multichannel devices, including different
+ sample rates. Change the selected device's OS channel layout while retaining
+ its ID; confirm capture reopens and invalid routes become the default pair.
+4. Stop playback, unplug/reconnect the device, change the default device, and
+ restart Prism. Check silence, existing retry/default notices, and saved routes.
+5. Upgrade from a saved browser input ID. Confirm Default Input is selected with
+ a notice and unrelated saved routes are retained.
+
+## Validation recorded for this change
+
+- macOS arm64: native addon, desktop, and TUI builds; native PCM/export tests,
+ capability tests, renderer/store/router tests, and typecheck passed.
+- Linux arm64 container: full native addon and TUI builds/tests; six-channel
+ integration passed with PulseAudio and PipeWire compatibility.
+- Windows x64: capture core and complete TUI cross-built with MinGW, including
+ audio GUID linkage. The full Electron addon still needs a Windows/MSVC build.
+- Physical multichannel hardware, Windows runtime audio, and live desktop
+ hardware acceptance above were unavailable in this environment and remain
+ unverified. Virtual audio testing does not establish driver-specific behavior.
diff --git a/docs/daw-bridge.md b/docs/daw-bridge.md
new file mode 100644
index 0000000..1bc07f7
--- /dev/null
+++ b/docs/daw-bridge.md
@@ -0,0 +1,92 @@
+# Prism Bridge protocol and host notes
+
+Prism Bridge is a mono/stereo pass-through effect that sends one selected DAW
+track or bus to the standalone Prism application. Prism owns the server and
+binds only `127.0.0.1:51789`; Bridge instances reconnect with exponential
+backoff from 250 ms to 5 seconds.
+
+## Using Bridge
+
+1. Open the standalone Prism application.
+2. Insert **Prism Bridge** on each track or bus you may want to inspect.
+3. Bridge uses the DAW track name automatically when supplied. To override it,
+ open its compact nameplate and click the name. Enter or clicking away saves;
+ Escape cancels. Clearing the name restores automatic naming.
+4. In Prism's audio selector, choose an instance under **DAW Bridges**.
+
+Only the selected instance transmits audio. If it disconnects, Prism stays in
+DAW mode and displays **Waiting** without falling back to another input. A
+same-format reconnect preserves scope history; a sample-rate or channel-layout
+change begins a new analysis session.
+
+The nameplate is optional: Bridge runs with its editor closed. Its status dot is
+gray while waiting for Prism, blue when available, and green when selected;
+hover for the status text. Custom names save with the DAW project. Unnamed
+sources use `Bridge` plus the nameplate's eight-character live instance tag.
+Prism includes the host in source labels and adds the matching tag when names
+would otherwise be identical. Tags identify live instances and can change when
+a project is reopened; saved source UUIDs and custom names remain unchanged.
+Renaming or opening the nameplate does not select a source in Prism.
+
+Waveform and Spectrogram have a **Timeline** setting with Off, Bars + Beats, and
+Seconds choices. The ruler is drawn only for DAW Bridge input. Bars + Beats uses
+the host PPQ, tempo, meter, and bar anchor; when beat metadata is missing it
+shows a labeled seconds fallback. Loop, seek, and packet discontinuities are
+kept in history as dashed Loop, Jump, and Gap seams.
+
+## Wire format (version 1)
+
+Every frame starts with a 12-byte little-endian header:
+
+| Offset | Type | Meaning |
+| ---: | --- | --- |
+| 0 | `uint32` | Magic `0x4d535250` |
+| 4 | `uint16` | Protocol version (`1`) |
+| 6 | `uint16` | Message type |
+| 8 | `uint32` | Payload byte length |
+
+Hello (`1`) and source update (`3`) payloads are UTF-8 JSON. Heartbeat (`2`)
+has no payload. Prism sends a JSON subscription (`10`) containing `selected`.
+Audio (`20`) uses a 76-byte binary metadata header followed by little-endian
+planar Float32 samples (all left samples, then all right samples). Metadata
+includes sequence, frame count, channels, sample rate, sample/seconds/PPQ
+position, tempo, last-bar PPQ, loop points, time signature, and play, record,
+and loop flags.
+
+Payloads larger than 256 KiB and unknown or mismatched protocol versions are
+rejected. Prism caps live connections, batches renderer IPC, and expires clients
+after three seconds without traffic. Bridge packetizes into preallocated
+512-frame records on the audio thread and keeps only the newest approximately
+50 ms before transmission. Socket work, JSON, allocation, and locking stay on
+the background or UI threads.
+
+The stable source UUID and custom name are stored in plug-in state. If a DAW
+duplicates that state, Prism assigns distinct live keys to all duplicates and
+requires an explicit choice instead of guessing which instance was intended.
+
+## Formats and validation
+
+Run `npm run test:daw-bridge` for source naming and connection tests. Build
+`PrismBridgeTests`, then run `ctest --test-dir plugin/build -R PrismBridgeProcessorTests
+--output-on-failure` for state and pass-through checks. `PrismBridgeTests --ui-test`
+exercises the native name editor and writes PNGs to the temporary
+`prism-bridge-nameplate-qa` directory; `--ui` leaves the nameplate open for manual
+inspection.
+
+Bridge ships as VST3 and CLAP on macOS, Windows, and Linux, and as AU on macOS. It has no
+Standalone target. `AU_SANDBOX_SAFE FALSE` is intentional because the AU needs
+loopback networking. Logic may load Audio Units in `AUHostingServiceXPC`, so a
+successful build or `auval` scan is not enough: live Logic-to-Prism loopback is
+a release blocker.
+
+See Apple's [out-of-process Audio Unit debugging documentation](https://developer.apple.com/documentation/audiotoolbox/debugging-out-of-process-audio-units-on-apple-silicon?language=objc)
+and [Audio Unit sandboxing guide](https://developer.apple.com/library/archive/technotes/tn2312/_index.html).
+
+### CLAP validation
+
+CLAP uses the same pass-through processor, state, and Bridge protocol as VST3/AU.
+Its stable ID is `com.astra.prism.PrismBridge`. Verify discovery, mono/stereo audio,
+custom-name recall, duplicate-instance selection, transport/loop/seek updates,
+and reconnecting after the Prism app restarts. Host track names are provided when
+the DAW supports CLAP track-info; custom names and the instance tag remain available.
+See [plugin build and validation instructions](../plugin/README.md).
diff --git a/docs/latency.md b/docs/latency.md
new file mode 100644
index 0000000..5e12768
--- /dev/null
+++ b/docs/latency.md
@@ -0,0 +1,229 @@
+# Capture-to-render latency
+
+The default Spectrum/Oscilloscope/Vectorscope/VU rack measured about **7 ms median
+capture-to-render latency** on an M5 Pro at an observed 120 FPS. Each scope's p95
+was below **12 ms** in all three measured repeats, including the software timing
+uncertainty. This is a result for the tested macOS desktop setup, not a guarantee
+for every machine, capture mode, scope configuration, or platform.
+
+Here, **capture-to-render** means the time from receipt inside Prism's native
+CoreAudio capture callback to completion of the scope's canvas drawing commands.
+It does not include audio buffering before that callback, GPU/compositor work,
+display scanout, or pixel response. It is not a capture-to-physical-display or
+sound-to-visible-response measurement.
+
+## Default rack at display-sync
+
+These are the largest per-scope, per-run statistics across three 60-second runs,
+not pooled percentiles or a selection of the fastest run.
+
+| Statistic | Time |
+| --- | ---: |
+| Median | 6.73 ms |
+| p95 | 11.42 ms |
+| p99 | 12.94 ms |
+| Largest individual observation | 15.94 ms |
+| Highest p95 after allowing for timing uncertainty | 11.65 ms |
+
+Observed frame cadence was approximately 120 FPS. The largest software timing
+uncertainty in these rack runs was ±0.265 ms. The observed maximum is not a
+guaranteed upper bound. These measurements do not support an under-8-ms p95 claim.
+
+## Tested setup
+
+The suite ran on September 16, 2026 (America/New_York). Its instrumented source was
+based on [`2a2d01d`](https://github.com/Boof2015/prism/commit/2a2d01d32da824d03e60440acd505f3d890a4e6f)
+from the `0.3.1` branch. The package version at that commit was `0.3.0-beta`.
+
+| Setting | Value |
+| --- | --- |
+| Computer | Apple M5 Pro, Mac17,9, 48 GiB memory |
+| OS and power | macOS 26.5.1, AC power |
+| Runtime | Electron 40.8.3, Chromium 144.0.7559.236 |
+| Display | Built-in Retina Display, reported 120.0006 Hz, scale factor 2 |
+| Window | 1200 × 360 logical pixels, visible during measurement |
+| Capture | Native CoreAudio system-output capture, MacBook Pro Speakers |
+| Audio format | 44.1 kHz stereo, 512 sample frames per captured chunk |
+| Spectrum | 2048-point FFT, smoothing 0.9, logarithmic scale, default extended range |
+| Oscilloscope | Pitch lock enabled |
+| Vectorscope | XY/Lissajous, 0 dB zoom, persistence 0.1, multiband off |
+| VU | Horizontal bar display, −14 dBFS reference |
+| Rolling capture / input gain | Off / 0 dB |
+
+The [result data](benchmarks/2026-09-16/results.json) records the scope settings,
+canvas dimensions, runtime versions, stage timings, and per-run statistics. Solo
+scopes occupy a larger canvas than each individual scope in the four-scope rack;
+these configurations are not identical drawing workloads.
+
+## Procedure and timing
+
+The runner built production bundles and the native addon, verified that native
+capture and the selected DSP modules were available, and used an isolated
+settings directory. It waited at least two minutes after compilation, including
+one continuous minute of nominal macOS thermal state, before launching Prism.
+
+A deterministic, changing stereo signal played through the normal system output
+using `afplay`, outside the renderer. The source WAV was 48 kHz PCM16 with a peak
+amplitude of 0.025; the capture device delivered 44.1 kHz audio. Frequency steps
+and amplitude modulation kept the signal changing throughout each run.
+
+Spectrum alone, Oscilloscope alone, and the default four-scope rack were each
+tested at display-sync and the 60 FPS target. Every configuration/target pair had
+one frame-only baseline and three instrumented repeats. Each run used 10 seconds
+of warmup followed by 60 seconds of measurement: 24 runs in total. A separate
+10-second Chromium trace followed the suite.
+
+The instrumentation retained the existing capture, buffering, polling,
+scheduling, and analysis behavior. Preallocated buffers recorded capture receipt,
+scope consumption, and draw completion, associated by capture session, chunk
+sequence, scope, and render frame. Every consumed chunk was counted for each
+scope, including multiple chunks consumed in one frame. Frames repainting old
+audio added no new latency samples. Recording did not log or write files per
+frame; export happened after each measurement.
+
+Before and after each run, 64 native clock reads were bracketed by renderer clock
+reads. The narrowest bracket from each endpoint and the measured renderer timer
+granularity supplied an offset interval and timing uncertainty. This assumes no
+unobserved clock excursion between the endpoints. Impossible timestamp ordering
+was rejected, not clamped to zero. Capture-to-receipt durations below resolution
+were marked unresolved; those chunks could still have valid capture-to-draw
+measurements. Percentiles use the nearest-rank method.
+
+The initial FFT window, pitch-lock acquisition, vectorscope persistence, and meter
+smoothing have their own response characteristics. A scope can consume a new
+chunk while drawing selected history or smoothed values, so this benchmark does
+not establish when a particular sound change becomes visible.
+
+## Results for every repeat
+
+The 60 FPS setting is a requested target. Its observed cadence in this suite was
+approximately 53.5–54.5 FPS; display-sync achieved approximately 120 FPS.
+In the tables, milliseconds refer to capture-to-draw-command completion. Timing
+uncertainty is shown separately; N counts successful chunk/scope associations.
+
+| Configuration | Target | Repeat | Scope | Median ms | p95 ms | p99 ms | Max ms | N | Timing ±ms | Observed FPS |
+| --- | --- | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| spectrum | display-sync | 1 | spectrum | 7.478 | 12.235 | 13.566 | 15.188 | 5169 | 0.213 | 119.988 |
+| spectrum | display-sync | 2 | spectrum | 7.749 | 12.832 | 14.216 | 16.641 | 5169 | 0.251 | 120.006 |
+| spectrum | display-sync | 3 | spectrum | 8.039 | 13.062 | 14.384 | 16.698 | 5169 | 0.218 | 120.008 |
+| oscilloscope | display-sync | 1 | oscilloscope | 7.345 | 12.382 | 13.621 | 15.643 | 5169 | 0.239 | 120.007 |
+| oscilloscope | display-sync | 2 | oscilloscope | 7.350 | 12.304 | 13.472 | 15.835 | 5168 | 0.214 | 120.004 |
+| oscilloscope | display-sync | 3 | oscilloscope | 7.386 | 12.354 | 13.428 | 16.133 | 5170 | 0.244 | 120.004 |
+| default-rack | display-sync | 1 | spectrum | 6.314 | 11.039 | 12.350 | 15.220 | 5168 | 0.265 | 120.001 |
+| default-rack | display-sync | 1 | oscilloscope | 6.151 | 10.863 | 12.187 | 15.020 | 5168 | 0.265 | 120.001 |
+| default-rack | display-sync | 1 | vectorscope | 6.573 | 11.316 | 12.630 | 15.520 | 5168 | 0.265 | 120.001 |
+| default-rack | display-sync | 1 | vumeter | 6.638 | 11.355 | 12.686 | 15.620 | 5168 | 0.265 | 120.001 |
+| default-rack | display-sync | 2 | spectrum | 6.391 | 11.097 | 12.603 | 15.534 | 5169 | 0.234 | 120.002 |
+| default-rack | display-sync | 2 | oscilloscope | 6.234 | 10.920 | 12.411 | 15.334 | 5169 | 0.234 | 120.002 |
+| default-rack | display-sync | 2 | vectorscope | 6.678 | 11.376 | 12.891 | 15.834 | 5169 | 0.234 | 120.002 |
+| default-rack | display-sync | 2 | vumeter | 6.728 | 11.419 | 12.944 | 15.834 | 5169 | 0.234 | 120.002 |
+| default-rack | display-sync | 3 | spectrum | 6.381 | 10.988 | 12.419 | 15.536 | 5170 | 0.210 | 120.001 |
+| default-rack | display-sync | 3 | oscilloscope | 6.199 | 10.857 | 12.257 | 15.336 | 5170 | 0.210 | 120.001 |
+| default-rack | display-sync | 3 | vectorscope | 6.669 | 11.279 | 12.721 | 15.836 | 5170 | 0.210 | 120.001 |
+| default-rack | display-sync | 3 | vumeter | 6.700 | 11.337 | 12.721 | 15.936 | 5170 | 0.210 | 120.001 |
+| spectrum | 60 | 1 | spectrum | 11.899 | 24.290 | 27.972 | 29.993 | 5168 | 0.217 | 53.515 |
+| spectrum | 60 | 2 | spectrum | 11.815 | 23.561 | 28.003 | 29.595 | 5168 | 0.228 | 54.002 |
+| spectrum | 60 | 3 | spectrum | 12.021 | 24.281 | 28.084 | 29.948 | 5168 | 0.227 | 53.632 |
+| oscilloscope | 60 | 1 | oscilloscope | 11.792 | 23.466 | 27.894 | 29.732 | 5169 | 0.214 | 54.092 |
+| oscilloscope | 60 | 2 | oscilloscope | 11.665 | 23.237 | 27.628 | 29.966 | 5169 | 0.216 | 54.234 |
+| oscilloscope | 60 | 3 | oscilloscope | 11.734 | 23.751 | 28.150 | 30.129 | 5168 | 0.226 | 53.599 |
+| default-rack | 60 | 1 | spectrum | 11.839 | 23.937 | 28.069 | 30.498 | 5167 | 0.273 | 54.149 |
+| default-rack | 60 | 1 | oscilloscope | 11.678 | 23.749 | 27.934 | 30.298 | 5167 | 0.273 | 54.149 |
+| default-rack | 60 | 1 | vectorscope | 12.132 | 24.237 | 28.379 | 30.798 | 5167 | 0.273 | 54.149 |
+| default-rack | 60 | 1 | vumeter | 12.159 | 24.268 | 28.444 | 30.898 | 5167 | 0.273 | 54.149 |
+| default-rack | 60 | 2 | spectrum | 11.701 | 23.294 | 27.862 | 30.003 | 5167 | 0.252 | 54.492 |
+| default-rack | 60 | 2 | oscilloscope | 11.538 | 23.107 | 27.626 | 29.903 | 5167 | 0.252 | 54.492 |
+| default-rack | 60 | 2 | vectorscope | 11.984 | 23.607 | 28.126 | 30.303 | 5167 | 0.252 | 54.492 |
+| default-rack | 60 | 2 | vumeter | 12.047 | 23.642 | 28.162 | 30.403 | 5167 | 0.252 | 54.492 |
+| default-rack | 60 | 3 | spectrum | 11.647 | 23.729 | 27.869 | 30.419 | 5169 | 0.212 | 54.458 |
+| default-rack | 60 | 3 | oscilloscope | 11.488 | 23.575 | 27.672 | 30.119 | 5169 | 0.212 | 54.458 |
+| default-rack | 60 | 3 | vectorscope | 11.948 | 23.993 | 28.126 | 30.619 | 5169 | 0.212 | 54.458 |
+| default-rack | 60 | 3 | vumeter | 11.990 | 24.042 | 28.169 | 30.719 | 5169 | 0.212 | 54.458 |
+
+All 24 runs passed the benchmark's validity checks. The 18 instrumented runs
+recorded 93,043 captured chunks and 186,064 successful chunk/scope associations.
+There were no recorded native queue overflows, router overwrites, sequence gaps,
+missing receipts, invalid timing records, or skipped consumed-chunk draws.
+
+Chunks already queued when observation began and chunks still pending at its end
+were reported separately. They were not silently discarded into the successful
+sample population. Their counts are included in the result data.
+
+## Probe overhead and thermals
+
+The frame-only baselines disabled chunk probes but retained the frame-duration
+recorder and thermal observer. Frame work below is the scope callback duration,
+not total process CPU utilization. Differences between sequential runs include
+system-load and run-order effects; they are not an exact causal probe cost, and
+no correction was subtracted from latency.
+
+| Configuration / target | Scope | Baseline work p95 ms | Probe work p95 range ms | Baseline FPS | Probe FPS range |
+| --- | --- | ---: | ---: | ---: | ---: |
+| spectrum / display-sync | spectrum | 1.700 | 1.800–1.800 | 120.001 | 119.988–120.008 |
+| oscilloscope / display-sync | oscilloscope | 0.900 | 0.900–0.900 | 119.998 | 120.004–120.007 |
+| default-rack / display-sync | spectrum | 0.400 | 0.200–0.200 | 120.001 | 120.001–120.002 |
+| default-rack / display-sync | oscilloscope | 0.400 | 0.300–0.300 | 120.001 | 120.001–120.002 |
+| default-rack / display-sync | vectorscope | 0.600 | 0.400–0.400 | 120.001 | 120.001–120.002 |
+| default-rack / display-sync | vumeter | 0.100 | 0.100–0.100 | 120.001 | 120.001–120.002 |
+| spectrum / 60 | spectrum | 0.500 | 0.500–0.500 | 53.791 | 53.515–54.002 |
+| oscilloscope / 60 | oscilloscope | 0.400 | 0.400–0.400 | 54.280 | 53.599–54.234 |
+| default-rack / 60 | spectrum | 0.300 | 0.200–0.300 | 54.258 | 54.149–54.492 |
+| default-rack / 60 | oscilloscope | 0.400 | 0.400–0.400 | 54.258 | 54.149–54.492 |
+| default-rack / 60 | vectorscope | 0.400 | 0.400–0.400 | 54.258 | 54.149–54.492 |
+| default-rack / 60 | vumeter | 0.100 | 0.100–0.100 | 54.258 | 54.149–54.492 |
+
+macOS thermal state remained nominal across all 1,744 recorded observations, and
+the machine stayed on AC power. The observer sampled at 1 Hz and recorded OS
+notifications into preallocated main-process memory. No CPU speed-limit
+notification arrived, so the speed limit was unknown rather than assumed to be
+100%. Nominal thermal state does not establish an absence of every frequency
+change. Total CPU utilization was not measured.
+
+## Separate presentation estimate
+
+The separate Chromium trace estimated approximately **25 ms median / 30 ms p95**
+from native capture receipt to reported presentation for the default rack at
+display-sync. Across scopes, medians were 25.20–25.23 ms and p95 values were
+30.10–30.16 ms.
+
+All 3,448 successful chunk/scope records were associated with presentation events,
+covering 4,804 marked scope frames with no rejected frame associations. Matching
+required unique scope markers inside one animation callback and AnimationFrame
+interval, exact presentation IDs, matching begin-frame source/sequence IDs, and
+a fully presented compositor frame. Temporal proximity alone was not accepted.
+
+These diagnostic estimates include approximately ±0.245 ms of software timing
+uncertainty **plus unquantified macOS presentation-estimation error** and the
+separate tracing workload. They are not physical display measurements and were
+not pooled into the main latency results. Chromium documents the platform limits
+of presentation timestamps in [Life of a frame](https://chromium.googlesource.com/chromium/src.git/+/main/docs/life_of_a_frame.md).
+
+## Data and reproduction
+
+- [Per-run statistics and recorded settings](benchmarks/2026-09-16/results.json)
+- [Exact benchmark instrumentation patch](benchmarks/2026-09-16/instrumentation.patch)
+
+The statistics file is a summary, not the raw per-chunk timestamps. Raw records,
+isolated settings, audio stimulus, and the large Chromium trace are retained
+locally and are not included in this repository. The patch contains the runner,
+probes, analysis, tests, and methodology needed to make a new measurement.
+
+To reproduce from a checkout containing these documents, create a separate
+worktree at the tested source revision and apply the archived patch:
+
+```sh
+git worktree add --detach ../prism-latency-repro 2a2d01d32da824d03e60440acd505f3d890a4e6f
+git -C ../prism-latency-repro apply "$PWD/docs/benchmarks/2026-09-16/instrumentation.patch"
+cd ../prism-latency-repro
+npm ci
+npm run test:latency
+npm run bench:latency
+```
+
+Use a Mac with native system-capture support, allow audio-capture permission if
+requested, keep Prism visible and connected to AC power, and stop unrelated
+playback. The full run takes about 32 minutes including build and cooldown. It
+creates a fresh `benchmark-results/` directory with the report and raw records.
+`npm run bench:latency -- --quick` is a short diagnostic check, not a replacement
+for the full suite. Comparisons need the same machine, display, audio device,
+window dimensions, and scope settings.
diff --git a/docs/linux-testing.md b/docs/linux-testing.md
new file mode 100644
index 0000000..912f464
--- /dev/null
+++ b/docs/linux-testing.md
@@ -0,0 +1,167 @@
+# Linux testing from Windows
+
+Run `npm run test:linux` from the Windows checkout to test in Ubuntu WSL2. Use
+`npm run test:linux -- --target fedora` for an SSH-connected Fedora VM. The
+Windows checkout remains the source of truth; each invocation creates a fresh
+Linux copy under `~/.cache/prism-linux-tests/runs//source`.
+If PowerShell blocks `npm.ps1`, use `npm.cmd` in these commands.
+
+## One-time setup
+
+### Ubuntu WSL2
+
+From PowerShell in the repository (substitute your Windows repository path):
+
+```powershell
+wsl -d Ubuntu -u root --exec bash /mnt/c/Users/boofi/Documents/astra/prism/scripts/linux/setup.sh system
+wsl -d Ubuntu --exec bash /mnt/c/Users/boofi/Documents/astra/prism/scripts/linux/setup.sh node
+```
+
+The system step installs the Ubuntu CI build dependencies, Electron runtime
+libraries, Xvfb, and RPM packaging tools. The user step downloads official Node
+22, verifies its SHA-256 checksum, and installs it in
+`~/.local/share/prism-linux/node`. It neither replaces Windows Node nor changes
+your shell configuration. The runner sets a Linux-only PATH and checks Node's
+platform and version. Builds live on the Linux filesystem; Windows
+`node_modules`, native binaries, and build directories are never reused.
+
+### Fedora VM
+
+Start the VM and log into its desktop. In a Fedora terminal:
+
+```bash
+sudo dnf install -y openssh-server
+sudo systemctl enable --now sshd
+sudo firewall-cmd --permanent --add-service=ssh
+sudo firewall-cmd --reload
+whoami
+hostname -I
+```
+
+Add the Windows user's SSH **public** key to Fedora's `~/.ssh/authorized_keys`;
+keep `~/.ssh` mode 700 and `authorized_keys` mode 600, then run
+`restorecon -RF ~/.ssh`. Keep private keys on Windows. Establish a normal SSH
+connection once to verify the host and authentication. The launcher uses batch
+mode and fails rather than prompting for a password.
+
+Add an alias to Windows `%USERPROFILE%\.ssh\config`:
+
+```sshconfig
+Host prism-fedora
+ HostName
+ User
+ IdentityFile ~/.ssh/id_ed25519
+ IdentitiesOnly yes
+```
+
+`ssh prism-fedora id` should work. Update `HostName` if the VM's address changes.
+Alternatively pass `--host user@IP` to the launcher. The VM must be running;
+the launcher does not change VMware power or network settings.
+
+Before installing packages or changing the original checkout, record its Git
+revision, Node/Electron versions, and desktop session and reproduce any existing
+failure. Copy `scripts/linux/setup.sh` to the VM, then run `sudo bash setup.sh
+system` and `bash setup.sh node` when ready to prepare the separate test build.
+An existing Linux Node 22.12+ on the system PATH also works. Do not run npm or
+the application as root.
+
+## Commands and results
+
+```powershell
+npm run test:linux
+npm run test:linux -- --target wsl --distro Ubuntu --suite full
+npm run test:linux -- --target fedora
+npm run test:linux -- --target fedora --host user@IP --suite full
+npm run test:linux -- --dry-run
+```
+
+- `routine`: install locked dependencies, explicitly rebuild/load the Linux
+ native addon, typecheck, run `npm test`, build/test the TUI, and build the
+ desktop and plugin UI.
+- `full`: routine checks, then VST3/CLAP build/tests under Xvfb and
+ `npm run dist:linux` (AppImage, deb, rpm, and tar.gz). Packages remain in the
+ Linux source copy's `dist/`; they are not installed or published.
+- `--dry-run`: list selected files without creating or transferring a run.
+
+Logs are saved locally in `artifacts/linux-tests//`, which Git ignores:
+
+- `source-manifest.json`: revision, dirty state, selected files, executable modes, and
+ SHA-256 hashes. Selection includes staged/unstaged edits and untracked,
+ non-ignored files; tracked deletions stay deleted. Inspect the dry run if you
+ have local files you do not want transferred.
+- `runner.log` and `result.json`: transport output, overall status, exit codes,
+ source fingerprint, and Linux run directory.
+- `logs/`: Linux environment, command output, individual step statuses, and
+ shell-ending conversions. Partial logs are collected on failure too.
+
+The transfer verifies every source hash before running tests. It restores Git
+executable modes and converts CRLF to LF in shell scripts in the Linux copy,
+including extensionless installer hooks. It records every conversion. Source
+symlinks are rejected explicitly. A file changed during archiving causes a
+failure; rerun once editing has stopped.
+
+Native build failure is fatal even though Prism's normal postinstall allows a
+JavaScript fallback. The runner stops at the first failed step and returns its
+exit code. Logs and source copies remain available for investigation; old runs
+are not automatically deleted. npm/Electron download caches are reused, but
+compiled outputs are isolated per run. WSL/SSH commands may need approval to
+cross the Windows agent sandbox boundary.
+
+For a VM with unreliable GitHub DNS, an existing clean FTXUI checkout can seed
+`~/.cache/prism-linux-tests/dependencies/ftxui-` using `git clone
+--no-hardlinks /path/to/existing/ftxui-src ` followed by
+`git -C checkout --detach `. Use the immutable `GIT_TAG`
+from `tui/CMakeLists.txt`. The runner verifies the cache's revision and clean
+status before configuring CMake to use it. It never copies dependencies from
+Windows or changes the VM's DNS settings.
+
+## Fedora desktop and profile diagnosis
+
+Build tests do not click native menus or prove desktop audio capture works.
+Use the original logged-in KDE/GNOME session for the first reproduction. A
+plain SSH shell lacks some display and session variables; launching from a
+terminal inside the VM is the simplest faithful reproduction. Do not substitute
+Xvfb for this check.
+
+Back up `Documents/Prism Profiles` and the app's user-data directory before
+testing; record its real location rather than assuming every installation uses
+`~/.config/prism`. Preserve the original checkout. Keep diagnostics and backups
+in a private directory outside it.
+
+The opt-in `scripts/linux/profile-diagnostics.cjs` hook can be copied outside
+the original checkout and loaded without editing its application source:
+
+```bash
+cd /path/to/original/prism
+set -o pipefail
+ELECTRON_ENABLE_LOGGING=1 \
+NODE_OPTIONS='--require=/absolute/path/profile-diagnostics.cjs' \
+npm run dev 2>&1 | tee /absolute/path/prism-dev.log
+```
+
+This hook is for development Electron only. Confirm `[profile-trace]` appears
+with `installed` before testing. It logs native menu creation/clicks, menu events
+received by the renderer, profile IPC requests/results/errors, renderer console
+errors, and Electron's actual Documents/user-data paths with access checks.
+It does not retain native Menu objects or change profile payloads. Restart
+without `NODE_OPTIONS` to remove it. Because instrumentation can affect timing,
+confirm a diagnosed behavior again without the hook.
+
+Run this checklist and record pass/fail separately from automated results:
+
+1. Show Profiles Folder opens the resolved directory in the file manager.
+2. Save a uniquely named test profile, change a scope setting, then load it and
+ confirm restoration. Overwrite, rename, and import a copied `.prsm` file.
+3. Quit and relaunch; confirm the profile and active settings persist. Delete
+ only the uniquely named test profiles when finished.
+4. Enumerate output/input devices; play audio in the guest and verify capture
+ and scope movement. Exercise a scope popout and relaunch.
+
+For an inert menu, compare `menu-built`, `menu-click`, `renderer-received`,
+`invoke`, and `resolved/rejected` to locate the break. Record Fedora release,
+desktop, Wayland/X11, Electron version, source revision, and any displayed errors.
+Investigate filesystem paths when the request reaches profile storage; do not
+infer a permissions failure solely from a menu that closes without action.
+
+VM audio represents virtual devices, not every physical Linux interface. Report
+unexercised hardware and desktop checks as untested.
diff --git a/docs/window-docking-validation.md b/docs/window-docking-validation.md
new file mode 100644
index 0000000..4606acd
--- /dev/null
+++ b/docs/window-docking-validation.md
@@ -0,0 +1,28 @@
+# Windows docking validation
+
+Build the Electron native addon with `npm run rebuild:native`, then launch with `npm run dev`. A renderer-only preview cannot exercise AppBar reservations. Older or unavailable native addons hide the docking option.
+
+Automated checks: `npm run typecheck`, `npm run test:window-state`, `npm run test:desktop-integration`, `npm run test:profiles`, `npm run test:renderer-helpers`, and `npm run build`. The window-state suite covers docking normalization, persistence, monitor fallback, reservation lifecycle, requested versus active state, resize limits, restore geometry, and shell-induced popout geometry suppression.
+
+## Desktop checks
+
+- Enable **Reserve screen space** without first choosing an edge: it uses Bottom. Choose Top or Bottom while docked and maximize an ordinary application on that monitor. Confirm taskbars and other AppBars retain their space and other monitors retain their work areas.
+- Grow and shrink the inward edge, including the 100-pixel minimum. Outer edges and native move/maximize/resize commands must not change the dock. Repeat with solid, blurred, and clear backgrounds.
+- Click the grab handle without moving: remain docked. Drag: restore floating dimensions under the pointer. Disable through either menu: restore the original floating position and dimensions. Check both floating pin preferences.
+- Open settings at both edges. Confirm width matches the rack, small workspaces scroll, controls update live meters, theme changes propagate, and application switching leaves settings open. Test toggle, Close and focused Escape. None of these operations should resize the reservation.
+- Switch and save profiles while docked. The rack must stay docked, floating restore bounds must remain unchanged, and docking alone must not mark a profile modified. Deliberate popout moves should still persist.
+- Minimize/restore and hide/show from the tray. Hidden or minimized Prism must reserve nothing. Restart with docking enabled, then with startup hidden. The remembered monitor, edge, height and floating bounds must survive.
+- Recreate the rack through background changes, reload or crash its renderer, and quit. Settings close and reservations follow the visible, ready rack. Force-terminate a development instance and verify Windows releases its work area.
+- Cover the dock with a fullscreen application, including borderless fullscreen, then exit fullscreen. Prism yields while fullscreen is foreground and returns above ordinary windows afterward.
+- Restart Explorer. Prism must register with the new shell and reserve exactly one strip. Disconnect the selected monitor: docking turns off and the floating rack appears on the primary monitor. Reconnection must not redock it.
+- Repeat on monitors with different scaling, including changing DPI while docked and monitors at negative desktop coordinates. Check physical strip height, settings placement and pointer alignment.
+
+The shell API uses physical pixels; Electron and persisted window geometry use device-independent pixels. The native addon negotiates the rectangle against full monitor bounds and the shell. Its window subclass blocks native move/resize commands and releases registration on HWND destruction. Floating restore runs after the shell work-area message has completed, and uses current native work-area bounds to avoid Electron's stale display cache.
+
+Settings use a same-origin owned child window and a React portal from the main renderer. The child has no independent settings store. Docking preferences live in the local window-state file, never in profiles.
+
+## Validation performed
+
+The Windows development run passed typecheck, native and production builds, and 253 window-state, profile, desktop-integration and renderer checks. Live checks covered top/bottom reservations on a 1920×480 monitor and a 3440×1440 monitor with a taskbar; settings edits, theme synchronization and scrolling; profile switching/saving; solid/clear/blurred recreation; inward resizing and drag/menu restore; hide/minimize; foreground borderless fullscreen; simulated shell registration loss followed by TaskbarCreated; forced process termination; and restart persistence including the floating pin preference.
+
+Physical monitor removal, mixed-DPI changes, an actual Explorer restart, and packaged startup-hidden behavior still need manual verification. The available monitors all used 100% scaling. Missing-monitor fallback and initially hidden reservation behavior are covered by controller tests.
diff --git a/native/binding.gyp b/native/binding.gyp
index 123c3a7..c287629 100644
--- a/native/binding.gyp
+++ b/native/binding.gyp
@@ -8,8 +8,16 @@
"sources": [
"src/main.cpp",
"src/system_audio_capture_napi.cpp",
+ "src/device_input_capture_napi.cpp",
+ "src/capture_channel_selection.cpp",
+ "src/capture_channel_selection_napi.cpp",
"src/oscilloscope.cpp",
"src/spectrum.cpp",
+ "src/reference_analysis.cpp",
+ "src/reference_decode.cpp",
+ "src/reference_napi.cpp",
+ "src/waterfall.cpp",
+ "src/waterfall_napi.cpp",
"src/spectrogram.cpp",
"src/vectorscope.cpp",
"src/multiband.cpp",
@@ -17,6 +25,7 @@
"src/vumeter.cpp",
"src/lufsmeter.cpp",
"src/window_chrome.cpp",
+ "src/window_docking.cpp",
"src/dsp_utils.cpp"
],
"include_dirs": [
@@ -28,6 +37,7 @@
["OS=='mac'", {
"sources": [
"src/macos_capture.mm",
+ "src/macos_input_capture.mm",
"src/windows_capture_stub.cpp"
],
"xcode_settings": {
@@ -55,7 +65,10 @@
"avrt.lib",
"runtimeobject.lib",
"uuid.lib",
- "dwmapi.lib"
+ "ksuser.lib",
+ "dwmapi.lib",
+ "shell32.lib",
+ "comctl32.lib"
],
"msvs_settings": {
"VCCLCompilerTool": {
diff --git a/native/src/capture_channel_selection.cpp b/native/src/capture_channel_selection.cpp
new file mode 100644
index 0000000..95adb91
--- /dev/null
+++ b/native/src/capture_channel_selection.cpp
@@ -0,0 +1,170 @@
+#include "capture_channel_selection.h"
+
+#include
+#include
+
+namespace Prism::Capture {
+namespace {
+
+uint64_t readUnsignedSample(const uint8_t* data, size_t byteCount, bool bigEndian) {
+ uint64_t value = 0;
+ if (bigEndian) {
+ for (size_t index = 0; index < byteCount; ++index) {
+ value = (value << 8) | data[index];
+ }
+ } else {
+ for (size_t index = 0; index < byteCount; ++index) {
+ value |= static_cast(data[index]) << (index * 8);
+ }
+ }
+ return value;
+}
+
+float decodeSample(const uint8_t* data, const PCMFormat& format) {
+ const size_t byteCount = format.bitsPerChannel / 8;
+ if (data == nullptr || byteCount == 0 || byteCount > sizeof(uint64_t)) return 0.0f;
+
+ if (format.encoding == SampleEncoding::Float && format.bitsPerChannel == 32) {
+ uint32_t bits = static_cast(readUnsignedSample(data, byteCount, format.bigEndian));
+ float value = 0.0f;
+ std::memcpy(&value, &bits, sizeof(value));
+ return value;
+ }
+ if (format.encoding == SampleEncoding::Float && format.bitsPerChannel == 64) {
+ const uint64_t bits = readUnsignedSample(data, byteCount, format.bigEndian);
+ double value = 0.0;
+ std::memcpy(&value, &bits, sizeof(value));
+ return static_cast(value);
+ }
+ if (format.encoding != SampleEncoding::SignedInteger
+ && format.encoding != SampleEncoding::UnsignedInteger) {
+ return 0.0f;
+ }
+
+ const uint32_t validBits = format.validBitsPerChannel == 0
+ ? format.bitsPerChannel : format.validBitsPerChannel;
+ uint64_t raw = readUnsignedSample(data, byteCount, format.bigEndian);
+ if (format.highAligned) raw >>= format.bitsPerChannel - validBits;
+ raw &= (uint64_t{1} << validBits) - 1;
+ const uint64_t signBit = uint64_t{1} << (validBits - 1);
+ const int64_t signedValue = format.encoding == SampleEncoding::UnsignedInteger
+ ? static_cast(raw) - static_cast(signBit)
+ : (raw & signBit) != 0
+ ? static_cast(raw) - static_cast(uint64_t{1} << validBits)
+ : static_cast(raw);
+ const double maximum = static_cast(
+ format.normalizeByPowerOfTwo || format.encoding == SampleEncoding::UnsignedInteger
+ ? signBit : signBit - 1);
+ return maximum > 0.0 ? static_cast(static_cast(signedValue) / maximum) : 0.0f;
+}
+
+float readChannelFrame(const PCMBufferView* buffers,
+ size_t bufferCount,
+ const PCMFormat& format,
+ uint32_t channel,
+ size_t frame) {
+ const size_t bytesPerSample = format.bitsPerChannel / 8;
+ uint32_t channelBase = 0;
+ for (size_t bufferIndex = 0; bufferIndex < bufferCount; ++bufferIndex) {
+ const PCMBufferView& buffer = buffers[bufferIndex];
+ const uint32_t channelsInBuffer = std::max(1, buffer.channelCount);
+ if (channel >= channelBase + channelsInBuffer) {
+ channelBase += channelsInBuffer;
+ continue;
+ }
+
+ const size_t localChannel = channel - channelBase;
+ const size_t sampleIndex = frame * channelsInBuffer + localChannel;
+ const size_t byteOffset = sampleIndex * bytesPerSample;
+ if (buffer.data == nullptr || byteOffset + bytesPerSample > buffer.byteLength) return 0.0f;
+ return decodeSample(buffer.data + byteOffset, format);
+ }
+ return 0.0f;
+}
+
+} // namespace
+
+bool isSupportedPCMFormat(const PCMFormat& format) {
+ const uint32_t validBits = format.validBitsPerChannel == 0
+ ? format.bitsPerChannel : format.validBitsPerChannel;
+ if (format.encoding == SampleEncoding::Float) {
+ return (format.bitsPerChannel == 32 || format.bitsPerChannel == 64)
+ && validBits == format.bitsPerChannel;
+ }
+ return (format.encoding == SampleEncoding::SignedInteger
+ || format.encoding == SampleEncoding::UnsignedInteger)
+ && format.bitsPerChannel >= 8 && format.bitsPerChannel <= 32
+ && format.bitsPerChannel % 8 == 0
+ && validBits > 0 && validBits <= format.bitsPerChannel;
+}
+
+void measureSourceChannelPeaks(const PCMBufferView* buffers,
+ size_t bufferCount,
+ const PCMFormat& format,
+ size_t frameCount,
+ uint32_t sourceChannelCount,
+ float* peaksOutput) {
+ if (peaksOutput == nullptr) return;
+ std::fill_n(peaksOutput, sourceChannelCount, 0.0f);
+ if (buffers == nullptr || !isSupportedPCMFormat(format)) return;
+
+ const size_t bytesPerSample = format.bitsPerChannel / 8;
+ uint32_t channelBase = 0;
+ for (size_t bufferIndex = 0; bufferIndex < bufferCount && channelBase < sourceChannelCount; ++bufferIndex) {
+ const auto& buffer = buffers[bufferIndex];
+ const uint32_t channelsInBuffer = std::max(1, buffer.channelCount);
+ const uint32_t channelsToRead = std::min(channelsInBuffer, sourceChannelCount - channelBase);
+ if (buffer.data != nullptr) {
+ const size_t sampleCount = buffer.byteLength / bytesPerSample;
+ for (uint32_t channel = 0; channel < channelsToRead; ++channel) {
+ float peak = 0.0f;
+ for (size_t frame = 0; frame < frameCount; ++frame) {
+ const size_t sampleIndex = frame * channelsInBuffer + channel;
+ if (sampleIndex >= sampleCount) break;
+ const float sample = decodeSample(buffer.data + sampleIndex * bytesPerSample, format);
+ // Inspect IEEE bits: std::isfinite can be optimized away by -ffast-math.
+ uint32_t magnitudeBits = 0;
+ std::memcpy(&magnitudeBits, &sample, sizeof(sample));
+ magnitudeBits &= 0x7fffffffu;
+ if (magnitudeBits >= 0x7f800000u) continue;
+ float magnitude = 0.0f;
+ std::memcpy(&magnitude, &magnitudeBits, sizeof(magnitude));
+ peak = std::max(peak, magnitude);
+ }
+ peaksOutput[channelBase + channel] = peak;
+ }
+ }
+ channelBase += channelsToRead;
+ }
+}
+
+bool selectStereoChannels(const PCMBufferView* buffers,
+ size_t bufferCount,
+ const PCMFormat& format,
+ size_t frameCount,
+ uint32_t sourceChannelCount,
+ uint32_t leftChannel,
+ uint32_t rightChannel,
+ float* leftOutput,
+ float* rightOutput) {
+ if (leftOutput == nullptr || rightOutput == nullptr) return false;
+ const bool valid = buffers != nullptr
+ && bufferCount > 0
+ && isSupportedPCMFormat(format)
+ && sourceChannelCount > 0
+ && leftChannel < sourceChannelCount
+ && rightChannel < sourceChannelCount;
+ if (!valid) {
+ std::fill_n(leftOutput, frameCount, 0.0f);
+ std::fill_n(rightOutput, frameCount, 0.0f);
+ return false;
+ }
+
+ for (size_t frame = 0; frame < frameCount; ++frame) {
+ leftOutput[frame] = readChannelFrame(buffers, bufferCount, format, leftChannel, frame);
+ rightOutput[frame] = readChannelFrame(buffers, bufferCount, format, rightChannel, frame);
+ }
+ return true;
+}
+
+} // namespace Prism::Capture
diff --git a/native/src/capture_channel_selection.h b/native/src/capture_channel_selection.h
new file mode 100644
index 0000000..1be6310
--- /dev/null
+++ b/native/src/capture_channel_selection.h
@@ -0,0 +1,58 @@
+#pragma once
+
+#include
+#include
+
+namespace Prism::Capture {
+
+enum class SampleEncoding {
+ Float,
+ SignedInteger,
+ UnsignedInteger,
+ Unsupported,
+};
+
+struct PCMFormat {
+ SampleEncoding encoding = SampleEncoding::Unsupported;
+ uint32_t bitsPerChannel = 0;
+ bool bigEndian = false;
+ // Zero uses bitsPerChannel (the byte-aligned storage/container width).
+ uint32_t validBitsPerChannel = 0;
+ bool highAligned = false;
+ // Preserve the original macOS signed-PCM scale by default. PulseAudio uses
+ // the negative full-scale magnitude (e.g. 32768 for signed 16-bit PCM).
+ bool normalizeByPowerOfTwo = false;
+};
+
+bool isSupportedPCMFormat(const PCMFormat& format);
+
+struct PCMBufferView {
+ const uint8_t* data = nullptr;
+ size_t byteLength = 0;
+ uint32_t channelCount = 0;
+};
+
+/** Writes one finite absolute peak per source channel, before stereo routing. */
+void measureSourceChannelPeaks(const PCMBufferView* buffers,
+ size_t bufferCount,
+ const PCMFormat& format,
+ size_t frameCount,
+ uint32_t sourceChannelCount,
+ float* peaksOutput);
+
+/**
+ * Selects a stereo pair from one or more interleaved or planar PCM buffers.
+ * The output arrays must each have room for frameCount samples. Invalid routes
+ * are rendered as silence and reported by the return value.
+ */
+bool selectStereoChannels(const PCMBufferView* buffers,
+ size_t bufferCount,
+ const PCMFormat& format,
+ size_t frameCount,
+ uint32_t sourceChannelCount,
+ uint32_t leftChannel,
+ uint32_t rightChannel,
+ float* leftOutput,
+ float* rightOutput);
+
+} // namespace Prism::Capture
diff --git a/native/src/capture_channel_selection_napi.cpp b/native/src/capture_channel_selection_napi.cpp
new file mode 100644
index 0000000..c5a46fa
--- /dev/null
+++ b/native/src/capture_channel_selection_napi.cpp
@@ -0,0 +1,118 @@
+#include "capture_channel_selection_napi.h"
+
+#include "capture_channel_selection.h"
+
+#include
+#include
+
+namespace {
+
+Napi::Value Select(const Napi::CallbackInfo& info, bool rawPCM) {
+ Napi::Env env = info.Env();
+ if (info.Length() < 5 || !info[0].IsArray() || !info[1].IsNumber()
+ || !info[2].IsNumber() || !info[3].IsNumber() || !info[4].IsNumber()) {
+ Napi::TypeError::New(
+ env,
+ "Expected buffers, frame count, source channel count, left channel, and right channel.")
+ .ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+
+ Prism::Capture::PCMFormat format{Prism::Capture::SampleEncoding::Float, 32, false};
+ if (rawPCM) {
+ if (info.Length() < 6 || !info[5].IsObject()) {
+ Napi::TypeError::New(env, "Expected a PCM format descriptor.").ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ const auto descriptor = info[5].As();
+ const auto encoding = descriptor.Get("encoding").ToString().Utf8Value();
+ format.encoding = encoding == "float" ? Prism::Capture::SampleEncoding::Float
+ : encoding == "signed" ? Prism::Capture::SampleEncoding::SignedInteger
+ : encoding == "unsigned" ? Prism::Capture::SampleEncoding::UnsignedInteger
+ : Prism::Capture::SampleEncoding::Unsupported;
+ format.bitsPerChannel = descriptor.Get("bitsPerChannel").ToNumber().Uint32Value();
+ if (descriptor.Has("validBitsPerChannel")) {
+ format.validBitsPerChannel = descriptor.Get("validBitsPerChannel").ToNumber().Uint32Value();
+ }
+ format.bigEndian = descriptor.Get("bigEndian").ToBoolean().Value();
+ format.highAligned = descriptor.Get("highAligned").ToBoolean().Value();
+ format.normalizeByPowerOfTwo = descriptor.Get("normalizeByPowerOfTwo").ToBoolean().Value();
+ }
+
+ const Napi::Array sourceBuffers = info[0].As();
+ std::vector retainedArrays;
+ std::vector buffers;
+ retainedArrays.reserve(sourceBuffers.Length());
+ buffers.reserve(sourceBuffers.Length());
+ for (uint32_t index = 0; index < sourceBuffers.Length(); ++index) {
+ const Napi::Value value = sourceBuffers.Get(index);
+ if (value.IsNull() || value.IsUndefined()) {
+ buffers.push_back({nullptr, 0, 1});
+ continue;
+ }
+ if (!value.IsObject()) {
+ Napi::TypeError::New(env, "Each buffer must be an object or null.")
+ .ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ const Napi::Object entry = value.As();
+ const Napi::Value dataValue = entry.Get("data");
+ const Napi::Value channelCountValue = entry.Get("channelCount");
+ if (!dataValue.IsTypedArray() || !channelCountValue.IsNumber()) {
+ Napi::TypeError::New(env, "Each buffer requires typed-array data and channelCount.")
+ .ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ const Napi::TypedArray typedArray = dataValue.As();
+ if (typedArray.TypedArrayType() != (rawPCM ? napi_uint8_array : napi_float32_array)) {
+ Napi::TypeError::New(env, rawPCM ? "Buffer data must be a Uint8Array." : "Buffer data must be a Float32Array.")
+ .ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ retainedArrays.push_back(typedArray);
+ const auto& retained = retainedArrays.back();
+ buffers.push_back({
+ static_cast(retained.ArrayBuffer().Data()) + retained.ByteOffset(),
+ retained.ByteLength(),
+ channelCountValue.As().Uint32Value(),
+ });
+ }
+
+ const size_t frameCount = info[1].As().Uint32Value();
+ Napi::Float32Array left = Napi::Float32Array::New(env, frameCount);
+ Napi::Float32Array right = Napi::Float32Array::New(env, frameCount);
+ const bool valid = Prism::Capture::selectStereoChannels(
+ buffers.data(),
+ buffers.size(),
+ format,
+ frameCount,
+ info[2].As().Uint32Value(),
+ info[3].As().Uint32Value(),
+ info[4].As().Uint32Value(),
+ left.Data(),
+ right.Data());
+
+ Napi::Object result = Napi::Object::New(env);
+ const uint32_t sourceChannelCount = info[2].As().Uint32Value();
+ auto peaks = Napi::Float32Array::New(env, sourceChannelCount);
+ Prism::Capture::measureSourceChannelPeaks(
+ buffers.data(), buffers.size(), format,
+ frameCount, sourceChannelCount, peaks.Data());
+ result.Set("sourceChannelPeaks", peaks);
+ result.Set("valid", Napi::Boolean::New(env, valid));
+ result.Set("left", left);
+ result.Set("right", right);
+ return result;
+}
+
+Napi::Value SelectFloat32(const Napi::CallbackInfo& info) { return Select(info, false); }
+Napi::Value SelectPCM(const Napi::CallbackInfo& info) { return Select(info, true); }
+
+} // namespace
+
+void RegisterCaptureChannelSelection(Napi::Env env, Napi::Object exports) {
+ Napi::Object helper = Napi::Object::New(env);
+ helper.Set("selectFloat32", Napi::Function::New(env, SelectFloat32));
+ helper.Set("selectPCM", Napi::Function::New(env, SelectPCM));
+ exports.Set("captureChannelSelection", helper);
+}
diff --git a/native/src/capture_channel_selection_napi.h b/native/src/capture_channel_selection_napi.h
new file mode 100644
index 0000000..83f3a95
--- /dev/null
+++ b/native/src/capture_channel_selection_napi.h
@@ -0,0 +1,5 @@
+#pragma once
+
+#include
+
+void RegisterCaptureChannelSelection(Napi::Env env, Napi::Object exports);
diff --git a/native/src/device_input_capture.h b/native/src/device_input_capture.h
new file mode 100644
index 0000000..95605ff
--- /dev/null
+++ b/native/src/device_input_capture.h
@@ -0,0 +1,26 @@
+#pragma once
+
+#include "system_audio_capture.h"
+
+#include
+
+namespace Prism::Capture {
+
+class DeviceInputCapture {
+public:
+ virtual ~DeviceInputCapture() = default;
+
+ virtual Support getSupport() const = 0;
+ virtual std::vector listInputDevices() = 0;
+ virtual bool start(const std::string& requestedDeviceId,
+ StartResult* result,
+ std::string* errorMessage) = 0;
+ virtual ChannelRouting setChannelRouting(uint32_t left, uint32_t right) = 0;
+ virtual void stop() = 0;
+ virtual DrainResult drain(size_t maxChunks = 64) = 0;
+ virtual double nowMilliseconds() const = 0;
+};
+
+std::unique_ptr createDeviceInputCapture();
+
+} // namespace Prism::Capture
diff --git a/native/src/device_input_capture_adapter.h b/native/src/device_input_capture_adapter.h
new file mode 100644
index 0000000..7a3977d
--- /dev/null
+++ b/native/src/device_input_capture_adapter.h
@@ -0,0 +1,39 @@
+#pragma once
+
+#include "device_input_capture.h"
+
+#include
+
+namespace Prism::Capture {
+
+// A platform engine configured for input endpoints shares its buffering,
+// routing, and lifecycle with the system-audio engine, but has its own instance.
+class DeviceInputCaptureAdapter final : public DeviceInputCapture {
+public:
+ explicit DeviceInputCaptureAdapter(std::unique_ptr engine)
+ : engine_(std::move(engine)) {}
+
+ Support getSupport() const override { return engine_->getSupport(); }
+ std::vector listInputDevices() override { return engine_->listOutputDevices(); }
+ bool start(const std::string& id, StartResult* result, std::string* error) override {
+ return engine_->start(id, result, error);
+ }
+ ChannelRouting setChannelRouting(uint32_t left, uint32_t right) override {
+ return engine_->setChannelRouting(left, right);
+ }
+ void stop() override { engine_->stop(); }
+ DrainResult drain(size_t maxChunks) override { return engine_->drain(maxChunks); }
+ double nowMilliseconds() const override { return engine_->nowMilliseconds(); }
+
+private:
+ std::unique_ptr engine_;
+};
+
+inline ChannelRouting normalizeChannelRouting(ChannelRouting routing, uint32_t count) {
+ if (count == 0 || routing.left >= count || routing.right >= count) {
+ return {0, count > 1 ? 1u : 0u};
+ }
+ return routing;
+}
+
+} // namespace Prism::Capture
diff --git a/native/src/device_input_capture_napi.cpp b/native/src/device_input_capture_napi.cpp
new file mode 100644
index 0000000..9888458
--- /dev/null
+++ b/native/src/device_input_capture_napi.cpp
@@ -0,0 +1,189 @@
+#include "device_input_capture_napi.h"
+
+#include "device_input_capture.h"
+
+#include
+#include
+#include
+#include
+
+namespace {
+
+constexpr size_t kDefaultDrainChunkLimit = 64;
+constexpr size_t kMaxDrainChunkLimit = 256;
+
+std::unique_ptr& activeInputCapture() {
+ static std::unique_ptr capture =
+ Prism::Capture::createDeviceInputCapture();
+ return capture;
+}
+
+Napi::Object routingToNapi(Napi::Env env, const Prism::Capture::ChannelRouting& routing) {
+ Napi::Object result = Napi::Object::New(env);
+ result.Set("left", Napi::Number::New(env, routing.left));
+ result.Set("right", Napi::Number::New(env, routing.right));
+ return result;
+}
+
+Napi::Value GetSupport(const Napi::CallbackInfo& info) {
+ Napi::Env env = info.Env();
+ const auto support = activeInputCapture()->getSupport();
+ Napi::Object result = Napi::Object::New(env);
+ result.Set("available", Napi::Boolean::New(env, support.available));
+ result.Set("reason", support.reason.empty()
+ ? static_cast(env.Null())
+ : static_cast(Napi::String::New(env, support.reason)));
+ return result;
+}
+
+Napi::Value ListInputDevices(const Napi::CallbackInfo& info) {
+ Napi::Env env = info.Env();
+ const auto devices = activeInputCapture()->listInputDevices();
+ 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, "device"));
+ 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));
+ entry.Set("channelRoutingAvailable", Napi::Boolean::New(env, true));
+
+ Napi::Array channels = Napi::Array::New(env, device.channelCount);
+ for (uint32_t channelIndex = 0; channelIndex < device.channelCount; ++channelIndex) {
+ Napi::Object channel = Napi::Object::New(env);
+ const std::string label = channelIndex < device.channels.size()
+ && !device.channels[channelIndex].label.empty()
+ ? device.channels[channelIndex].label
+ : "Channel " + std::to_string(channelIndex + 1);
+ channel.Set("index", Napi::Number::New(env, channelIndex));
+ channel.Set("label", Napi::String::New(env, label));
+ channels.Set(channelIndex, channel);
+ }
+ entry.Set("channels", channels);
+ result.Set(static_cast(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().Utf8Value();
+ }
+ // Stop before setting startup routing: the previous device's channel count
+ // must not clamp a route intended for the new device.
+ activeInputCapture()->stop();
+ activeInputCapture()->setChannelRouting(0, 1);
+
+ if (info.Length() >= 2 && info[1].IsObject()) {
+ const Napi::Object routing = info[1].As();
+ if (routing.Has("left") && routing.Has("right")
+ && routing.Get("left").IsNumber() && routing.Get("right").IsNumber()) {
+ activeInputCapture()->setChannelRouting(
+ routing.Get("left").As().Uint32Value(),
+ routing.Get("right").As().Uint32Value());
+ }
+ }
+
+ Prism::Capture::StartResult started;
+ std::string errorMessage;
+ if (!activeInputCapture()->start(requestedDeviceId, &started, &errorMessage)) {
+ Napi::Error::New(
+ env,
+ errorMessage.empty() ? "Device input 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("sourceChannelCount", Napi::Number::New(env, started.sourceChannelCount));
+ result.Set("deviceId", Napi::String::New(env, started.deviceId));
+ result.Set("deviceLabel", Napi::String::New(env, started.deviceLabel));
+ return result;
+}
+
+Napi::Value SetChannelRouting(const Napi::CallbackInfo& info) {
+ Napi::Env env = info.Env();
+ if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
+ Napi::TypeError::New(env, "Expected left and right channel indices.")
+ .ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ return routingToNapi(
+ env,
+ activeInputCapture()->setChannelRouting(
+ info[0].As().Uint32Value(),
+ info[1].As().Uint32Value()));
+}
+
+Napi::Value Stop(const Napi::CallbackInfo& info) {
+ activeInputCapture()->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().Int64Value();
+ if (requested > 0) {
+ maxChunks = std::min(static_cast(requested), kMaxDrainChunkLimit);
+ }
+ }
+
+ Napi::Env env = info.Env();
+ auto drained = activeInputCapture()->drain(maxChunks);
+ 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(chunk.sequence)));
+ if (!chunk.sourceChannelPeaks.empty()) {
+ auto peaks = Napi::Float32Array::New(env, chunk.sourceChannelPeaks.size());
+ std::memcpy(peaks.Data(), chunk.sourceChannelPeaks.data(), chunk.sourceChannelPeaks.size() * sizeof(float));
+ entry.Set("sourceChannelPeaks", peaks);
+ }
+ chunks.Set(static_cast(index), entry);
+ }
+
+ Napi::Object result = Napi::Object::New(env);
+ result.Set("chunks", chunks);
+ result.Set("overwriteCount", Napi::Number::New(env, static_cast(drained.overwriteCount)));
+ result.Set("queueDepth", Napi::Number::New(env, drained.queueDepth));
+ return result;
+}
+
+Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
+ return Napi::Number::New(info.Env(), activeInputCapture()->nowMilliseconds());
+}
+
+} // namespace
+
+void RegisterDeviceInputCapture(Napi::Env env, Napi::Object exports) {
+ Napi::Object capture = Napi::Object::New(env);
+ capture.Set("getSupport", Napi::Function::New(env, GetSupport));
+ capture.Set("listInputDevices", Napi::Function::New(env, ListInputDevices));
+ capture.Set("start", Napi::Function::New(env, Start));
+ capture.Set("setChannelRouting", Napi::Function::New(env, SetChannelRouting));
+ 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("deviceInputCapture", capture);
+}
diff --git a/native/src/device_input_capture_napi.h b/native/src/device_input_capture_napi.h
new file mode 100644
index 0000000..eda1801
--- /dev/null
+++ b/native/src/device_input_capture_napi.h
@@ -0,0 +1,5 @@
+#pragma once
+
+#include
+
+void RegisterDeviceInputCapture(Napi::Env env, Napi::Object exports);
diff --git a/native/src/linux_capture.cpp b/native/src/linux_capture.cpp
index c265082..bb1cd33 100644
--- a/native/src/linux_capture.cpp
+++ b/native/src/linux_capture.cpp
@@ -1,4 +1,6 @@
#include "system_audio_capture.h"
+#include "device_input_capture_adapter.h"
+#include "capture_channel_selection.h"
#if defined(__linux__)
@@ -19,150 +21,78 @@ constexpr size_t kMaxQueuedChunks = 256;
constexpr size_t kDefaultDrainChunkLimit = 64;
constexpr pa_usec_t kTargetRecordFragmentMicroseconds = 10000;
-struct OutputDeviceInfo {
+struct DeviceInfo {
std::string id;
std::string label;
- std::string monitorSourceName;
+ std::string recordSourceName;
pa_sample_spec sampleSpec{};
pa_channel_map channelMap{};
bool hasChannelMap = false;
bool isDefault = false;
};
-struct CapturedChunk {
- std::vector left;
- std::vector right;
- uint32_t channelCount = 2;
- double capturedAtMilliseconds = 0.0;
- uint64_t sequence = 0;
-};
+using CapturedChunk = Prism::Capture::AudioChunk;
double monotonicMilliseconds() {
const auto now = std::chrono::steady_clock::now().time_since_epoch();
return std::chrono::duration(now).count();
}
-uint32_t readUint24LE(const uint8_t* data) {
- return static_cast(data[0]) |
- (static_cast(data[1]) << 8) |
- (static_cast(data[2]) << 16);
-}
-
-uint32_t readUint24BE(const uint8_t* data) {
- return static_cast(data[2]) |
- (static_cast(data[1]) << 8) |
- (static_cast(data[0]) << 16);
-}
-
-uint32_t readUint32LE(const uint8_t* data) {
- return static_cast(data[0]) |
- (static_cast(data[1]) << 8) |
- (static_cast(data[2]) << 16) |
- (static_cast(data[3]) << 24);
-}
-
-uint32_t readUint32BE(const uint8_t* data) {
- return static_cast(data[3]) |
- (static_cast(data[2]) << 8) |
- (static_cast(data[1]) << 16) |
- (static_cast(data[0]) << 24);
-}
-
-int32_t signExtend24(uint32_t value) {
- if ((value & 0x00800000U) != 0) {
- value |= 0xFF000000U;
- }
- return static_cast(value);
-}
-
-bool isSupportedSampleFormat(pa_sample_format_t format) {
+Prism::Capture::PCMFormat getPCMFormat(pa_sample_format_t format) {
+ using namespace Prism::Capture;
+ PCMFormat pcm;
+ pcm.normalizeByPowerOfTwo = true;
switch (format) {
- case PA_SAMPLE_U8:
- case PA_SAMPLE_S16LE:
- case PA_SAMPLE_S16BE:
- case PA_SAMPLE_S24LE:
- case PA_SAMPLE_S24BE:
- case PA_SAMPLE_S24_32LE:
- case PA_SAMPLE_S24_32BE:
- case PA_SAMPLE_S32LE:
- case PA_SAMPLE_S32BE:
- case PA_SAMPLE_FLOAT32LE:
- case PA_SAMPLE_FLOAT32BE:
- return true;
- default:
- return false;
- }
+ case PA_SAMPLE_U8: pcm.encoding = SampleEncoding::UnsignedInteger; pcm.bitsPerChannel = 8; break;
+ case PA_SAMPLE_S16LE: case PA_SAMPLE_S16BE:
+ pcm.encoding = SampleEncoding::SignedInteger; pcm.bitsPerChannel = 16; break;
+ case PA_SAMPLE_S24LE: case PA_SAMPLE_S24BE:
+ pcm.encoding = SampleEncoding::SignedInteger; pcm.bitsPerChannel = 24; break;
+ case PA_SAMPLE_S24_32LE: case PA_SAMPLE_S24_32BE:
+ pcm.encoding = SampleEncoding::SignedInteger; pcm.bitsPerChannel = 32;
+ pcm.validBitsPerChannel = 24; break;
+ case PA_SAMPLE_S32LE: case PA_SAMPLE_S32BE:
+ pcm.encoding = SampleEncoding::SignedInteger; pcm.bitsPerChannel = 32; break;
+ case PA_SAMPLE_FLOAT32LE: case PA_SAMPLE_FLOAT32BE:
+ pcm.encoding = SampleEncoding::Float; pcm.bitsPerChannel = 32; break;
+ default: break;
+ }
+ pcm.bigEndian = pa_sample_format_is_be(format) > 0;
+ return pcm;
}
-float readNormalizedSample(const uint8_t* data, pa_sample_format_t format) {
- if (data == nullptr) {
- return 0.0f;
- }
-
- switch (format) {
- case PA_SAMPLE_U8:
- return (static_cast(data[0]) - 128) / 128.0f;
- case PA_SAMPLE_S16LE:
- return static_cast(
- static_cast(data[0]) |
- (static_cast(data[1]) << 8)) /
- 32768.0f;
- case PA_SAMPLE_S16BE:
- return static_cast(
- static_cast(data[1]) |
- (static_cast(data[0]) << 8)) /
- 32768.0f;
- case PA_SAMPLE_S24LE:
- return signExtend24(readUint24LE(data)) / 8388608.0f;
- case PA_SAMPLE_S24BE:
- return signExtend24(readUint24BE(data)) / 8388608.0f;
- case PA_SAMPLE_S24_32LE: {
- uint32_t raw = readUint32LE(data) & 0x00FFFFFFU;
- return signExtend24(raw) / 8388608.0f;
- }
- case PA_SAMPLE_S24_32BE: {
- uint32_t raw = readUint32BE(data) & 0x00FFFFFFU;
- return signExtend24(raw) / 8388608.0f;
- }
- case PA_SAMPLE_S32LE:
- return static_cast(readUint32LE(data)) / 2147483648.0f;
- case PA_SAMPLE_S32BE:
- return static_cast(readUint32BE(data)) / 2147483648.0f;
- case PA_SAMPLE_FLOAT32LE: {
- const uint32_t raw = readUint32LE(data);
- float value = 0.0f;
- std::memcpy(&value, &raw, sizeof(value));
- return value;
- }
- case PA_SAMPLE_FLOAT32BE: {
- const uint32_t raw = readUint32BE(data);
- float value = 0.0f;
- std::memcpy(&value, &raw, sizeof(value));
- return value;
- }
- default:
- return 0.0f;
+std::vector channelDescriptors(const DeviceInfo& device) {
+ std::vector result;
+ for (uint32_t index = 0; index < device.sampleSpec.channels; ++index) {
+ const char* label = device.hasChannelMap && index < device.channelMap.channels
+ ? pa_channel_position_to_pretty_string(device.channelMap.map[index]) : nullptr;
+ result.push_back({index, label != nullptr ? label : "Channel " + std::to_string(index + 1)});
}
+ return result;
}
-struct SinkEnumerationState {
+struct DeviceEnumerationState {
pa_threaded_mainloop* mainloop = nullptr;
std::string defaultSinkName;
- std::vector devices;
+ std::string defaultSourceName;
+ std::vector devices;
};
void HandleServerInfo(pa_context*, const pa_server_info* info, void* userdata) {
- auto* state = static_cast(userdata);
+ auto* state = static_cast(userdata);
if (state != nullptr && info != nullptr && info->default_sink_name != nullptr) {
state->defaultSinkName = info->default_sink_name;
}
+ if (state != nullptr && info != nullptr && info->default_source_name != nullptr) {
+ state->defaultSourceName = info->default_source_name;
+ }
if (state != nullptr && state->mainloop != nullptr) {
pa_threaded_mainloop_signal(state->mainloop, 0);
}
}
void HandleSinkInfo(pa_context*, const pa_sink_info* info, int eol, void* userdata) {
- auto* state = static_cast(userdata);
+ auto* state = static_cast(userdata);
if (state == nullptr || state->mainloop == nullptr) {
return;
}
@@ -173,10 +103,10 @@ void HandleSinkInfo(pa_context*, const pa_sink_info* info, int eol, void* userda
}
if (info != nullptr && info->name != nullptr && info->monitor_source_name != nullptr) {
- OutputDeviceInfo device;
+ DeviceInfo device;
device.id = info->name;
device.label = info->description != nullptr ? info->description : info->name;
- device.monitorSourceName = info->monitor_source_name;
+ device.recordSourceName = info->monitor_source_name;
device.sampleSpec = info->sample_spec;
device.channelMap = info->channel_map;
device.hasChannelMap = info->channel_map.channels > 0;
@@ -186,6 +116,23 @@ void HandleSinkInfo(pa_context*, const pa_sink_info* info, int eol, void* userda
pa_threaded_mainloop_signal(state->mainloop, 0);
}
+void HandleSourceInfo(pa_context*, const pa_source_info* info, int eol, void* userdata) {
+ auto* state = static_cast(userdata);
+ if (state == nullptr || state->mainloop == nullptr) return;
+ if (eol == 0 && info != nullptr && info->name != nullptr
+ && info->monitor_of_sink == PA_INVALID_INDEX) {
+ DeviceInfo device;
+ device.id = info->name;
+ device.label = info->description != nullptr ? info->description : info->name;
+ device.recordSourceName = info->name;
+ device.sampleSpec = info->sample_spec;
+ device.channelMap = info->channel_map;
+ device.hasChannelMap = info->channel_map.channels > 0;
+ state->devices.push_back(std::move(device));
+ }
+ pa_threaded_mainloop_signal(state->mainloop, 0);
+}
+
class PulseContextConnection {
public:
PulseContextConnection() = default;
@@ -273,11 +220,11 @@ class PulseContextConnection {
started_ = false;
}
- bool enumerateOutputDevices(std::vector* outDevices,
+ bool enumerateDevices(bool input, std::vector* outDevices,
std::string* outErrorMessage) {
if (outDevices == nullptr) {
if (outErrorMessage != nullptr) {
- *outErrorMessage = "Could not store PulseAudio output devices.";
+ *outErrorMessage = "Could not store PulseAudio devices.";
}
return false;
}
@@ -291,7 +238,7 @@ class PulseContextConnection {
pa_threaded_mainloop_lock(mainloop_);
- SinkEnumerationState state;
+ DeviceEnumerationState state;
state.mainloop = mainloop_;
pa_operation* serverOperation =
@@ -302,7 +249,8 @@ class PulseContextConnection {
}
pa_operation* sinkOperation =
- pa_context_get_sink_info_list(context_, &HandleSinkInfo, &state);
+ input ? pa_context_get_source_info_list(context_, &HandleSourceInfo, &state)
+ : pa_context_get_sink_info_list(context_, &HandleSinkInfo, &state);
if (!waitForOperationLocked(sinkOperation, outErrorMessage)) {
pa_threaded_mainloop_unlock(mainloop_);
return false;
@@ -311,14 +259,7 @@ class PulseContextConnection {
pa_threaded_mainloop_unlock(mainloop_);
for (auto& device : state.devices) {
- device.isDefault = device.id == state.defaultSinkName;
- }
-
- if (state.devices.empty()) {
- if (outErrorMessage != nullptr) {
- *outErrorMessage = "No Linux output devices are available.";
- }
- return false;
+ device.isDefault = device.id == (input ? state.defaultSourceName : state.defaultSinkName);
}
*outDevices = std::move(state.devices);
@@ -408,6 +349,8 @@ class PulseContextConnection {
class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture {
public:
+ explicit LinuxNativeCaptureEngine(bool input = false) : input_(input) {}
+
~LinuxNativeCaptureEngine() override {
stop();
}
@@ -415,10 +358,10 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
Prism::Capture::Support getSupport() const override {
PulseContextConnection connection;
std::string errorMessage;
- std::vector devices;
+ std::vector devices;
const bool available =
connection.connect("Prism Linux Capture Probe", &errorMessage) &&
- connection.enumerateOutputDevices(&devices, &errorMessage);
+ connection.enumerateDevices(input_, &devices, &errorMessage);
return {
available,
available ? std::string() : (errorMessage.empty()
@@ -430,9 +373,9 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
std::vector listOutputDevices() override {
PulseContextConnection connection;
std::string errorMessage;
- std::vector devices;
+ std::vector devices;
if (!connection.connect("Prism Linux Capture Devices", &errorMessage) ||
- !connection.enumerateOutputDevices(&devices, &errorMessage)) {
+ !connection.enumerateDevices(input_, &devices, &errorMessage)) {
return {};
}
@@ -445,6 +388,7 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
static_cast(device.sampleSpec.rate),
static_cast(device.sampleSpec.channels),
device.isDefault,
+ channelDescriptors(device),
});
}
return result;
@@ -455,20 +399,28 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
std::string* errorMessage) override {
if (!startInternal(requestedDeviceId, errorMessage)) {
if (errorMessage != nullptr && errorMessage->empty()) {
- *errorMessage = "Native Linux monitor capture failed to start.";
+ *errorMessage = "Native Linux audio capture failed to start.";
}
return false;
}
if (result != nullptr) {
std::lock_guard lock(stateMutex_);
result->sampleRate = sampleRate_;
- result->channelCount = channelCount_;
+ result->channelCount = channelCount_ > 1 ? 2u : 1u;
+ result->sourceChannelCount = channelCount_;
result->deviceId = activeDeviceId_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
}
+ Prism::Capture::ChannelRouting setChannelRouting(uint32_t left, uint32_t right) override {
+ std::lock_guard lock(stateMutex_);
+ routing_ = active_ ? Prism::Capture::normalizeChannelRouting({left, right}, channelCount_)
+ : Prism::Capture::ChannelRouting{left, right};
+ return routing_;
+ }
+
void stop() override {
stopInternal();
}
@@ -494,13 +446,7 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
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,
- });
+ result.chunks.push_back(std::move(chunk));
}
return result;
}
@@ -558,13 +504,22 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
return false;
}
- std::vector devices;
- if (!connection_.enumerateOutputDevices(&devices, outErrorMessage)) {
+ std::vector devices;
+ if (!connection_.enumerateDevices(input_, &devices, outErrorMessage)) {
connection_.disconnect();
return false;
}
- const OutputDeviceInfo* selected = nullptr;
+ if (devices.empty()) {
+ if (outErrorMessage != nullptr) {
+ *outErrorMessage = input_ ? "No Linux input devices are available."
+ : "No Linux output devices are available.";
+ }
+ connection_.disconnect();
+ return false;
+ }
+
+ const DeviceInfo* selected = nullptr;
if (!requestedDeviceId.empty()) {
for (const auto& device : devices) {
if (device.id == requestedDeviceId) {
@@ -575,7 +530,8 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
if (selected == nullptr) {
if (outErrorMessage != nullptr) {
*outErrorMessage =
- "The selected Linux output device is no longer available.";
+ input_ ? "The selected Linux input device is no longer available."
+ : "The selected Linux output device is no longer available.";
}
connection_.disconnect();
return false;
@@ -592,10 +548,10 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
}
}
- if (!isSupportedSampleFormat(selected->sampleSpec.format)) {
+ if (!Prism::Capture::isSupportedPCMFormat(getPCMFormat(selected->sampleSpec.format))) {
if (outErrorMessage != nullptr) {
*outErrorMessage =
- "Unsupported PulseAudio sample format for Linux monitor capture.";
+ "Unsupported PulseAudio sample format for Linux audio capture.";
}
connection_.disconnect();
return false;
@@ -605,7 +561,7 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
stream_ = pa_stream_new(
connection_.context(),
- "Prism Output Monitor",
+ input_ ? "Prism Device Input" : "Prism Output Monitor",
&selected->sampleSpec,
selected->hasChannelMap ? &selected->channelMap : nullptr);
if (stream_ == nullptr) {
@@ -629,10 +585,10 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
PA_STREAM_INTERPOLATE_TIMING |
PA_STREAM_DONT_MOVE);
const int connectResult = pa_stream_connect_record(
- stream_, selected->monitorSourceName.c_str(), &requestedBufferAttr, flags);
+ stream_, selected->recordSourceName.c_str(), &requestedBufferAttr, flags);
if (connectResult < 0) {
const std::string errorMessage = buildContextErrorMessage(
- connection_.context(), "Could not start Linux monitor capture.");
+ connection_.context(), "Could not start Linux audio capture.");
pa_stream_set_read_callback(stream_, nullptr, nullptr);
pa_stream_set_state_callback(stream_, nullptr, nullptr);
pa_stream_unref(stream_);
@@ -678,6 +634,7 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
activeDeviceLabel_ = selected->label;
sampleRate_ = static_cast(sampleSpec_.rate);
channelCount_ = std::max(1, sampleSpec_.channels);
+ routing_ = Prism::Capture::normalizeChannelRouting(routing_, channelCount_);
sequence_ = 0;
}
@@ -696,7 +653,7 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
if (outErrorMessage != nullptr) {
*outErrorMessage = buildContextErrorMessage(
connection_.context(),
- "PulseAudio monitor stream failed to initialize.");
+ "PulseAudio recording stream failed to initialize.");
}
return false;
default:
@@ -706,7 +663,7 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
}
if (outErrorMessage != nullptr) {
- *outErrorMessage = "PulseAudio monitor stream is unavailable.";
+ *outErrorMessage = "PulseAudio recording stream is unavailable.";
}
return false;
}
@@ -765,6 +722,7 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
pa_sample_spec sampleSpec{};
uint32_t channelCount = 2;
uint64_t sequence = 0;
+ Prism::Capture::ChannelRouting routing;
{
std::lock_guard lock(stateMutex_);
if (!active_) {
@@ -773,6 +731,7 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
}
sampleSpec = sampleSpec_;
channelCount = channelCount_;
+ routing = routing_;
sequence = ++sequence_;
}
@@ -782,7 +741,6 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
break;
}
- const size_t bytesPerSample = pa_sample_size_of_format(sampleSpec.format);
const size_t frames = length / bytesPerFrame;
if (frames == 0) {
pa_stream_drop(stream_);
@@ -790,27 +748,20 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
}
CapturedChunk chunk;
- chunk.channelCount = channelCount;
+ chunk.channelCount = channelCount > 1 ? 2u : 1u;
chunk.capturedAtMilliseconds = monotonicMilliseconds();
chunk.sequence = sequence;
chunk.left.resize(frames);
chunk.right.resize(frames);
- if (data != nullptr) {
- const auto* rawData = static_cast(data);
- for (size_t frameIndex = 0; frameIndex < frames; ++frameIndex) {
- const uint8_t* frameData = rawData + (frameIndex * bytesPerFrame);
- const float left = readNormalizedSample(frameData, sampleSpec.format);
- const float right = channelCount > 1
- ? readNormalizedSample(frameData + bytesPerSample, sampleSpec.format)
- : left;
- chunk.left[frameIndex] = left;
- chunk.right[frameIndex] = right;
- }
- } else {
- std::fill(chunk.left.begin(), chunk.left.end(), 0.0f);
- std::fill(chunk.right.begin(), chunk.right.end(), 0.0f);
- }
+ chunk.sourceChannelPeaks.resize(channelCount);
+ const Prism::Capture::PCMBufferView buffer{
+ static_cast(data), length, channelCount};
+ const auto format = getPCMFormat(sampleSpec.format);
+ Prism::Capture::selectStereoChannels(&buffer, 1, format, frames, channelCount,
+ routing.left, routing.right, chunk.left.data(), chunk.right.data());
+ Prism::Capture::measureSourceChannelPeaks(&buffer, 1, format, frames, channelCount,
+ chunk.sourceChannelPeaks.data());
pa_stream_drop(stream_);
pushChunk(std::move(chunk));
@@ -830,6 +781,8 @@ class LinuxNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture
chunkQueue_.push_back(std::move(chunk));
}
+ const bool input_;
+ Prism::Capture::ChannelRouting routing_;
PulseContextConnection connection_;
pa_stream* stream_ = nullptr;
mutable std::mutex stateMutex_;
@@ -854,6 +807,11 @@ std::unique_ptr createSystemAudioCapture() {
return std::make_unique();
}
+std::unique_ptr createDeviceInputCapture() {
+ return std::make_unique(
+ std::make_unique(true));
+}
+
} // namespace Prism::Capture
#endif
diff --git a/native/src/macos_capture.mm b/native/src/macos_capture.mm
index f5c6403..ea3bd35 100644
--- a/native/src/macos_capture.mm
+++ b/native/src/macos_capture.mm
@@ -1,4 +1,5 @@
#include "system_audio_capture.h"
+#include "capture_channel_selection.h"
#if defined(__APPLE__)
@@ -8,9 +9,10 @@
#import
#include
+#include
+#include
#include
#include
-#include
#include
#include
#include
@@ -29,14 +31,44 @@
double sampleRate;
UInt32 channelCount;
bool isDefault;
+ std::vector channels;
};
+std::string cfStringToStdString(CFStringRef value);
+
+std::vector getChannelDescriptors(
+ AudioDeviceID deviceId,
+ AudioObjectPropertyScope scope,
+ UInt32 channelCount) {
+ std::vector channels;
+ channels.reserve(channelCount);
+ for (UInt32 index = 0; index < channelCount; ++index) {
+ std::string label = "Channel " + std::to_string(index + 1);
+ AudioObjectPropertyAddress address{
+ kAudioObjectPropertyElementName,
+ scope,
+ index + 1,
+ };
+ CFStringRef value = nullptr;
+ UInt32 size = sizeof(value);
+ if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, &value) == noErr
+ && value != nullptr) {
+ const std::string resolved = cfStringToStdString(value);
+ CFRelease(value);
+ if (!resolved.empty()) label = resolved;
+ }
+ channels.push_back({static_cast(index), label});
+ }
+ return channels;
+}
+
struct CapturedChunk {
std::vector left;
std::vector right;
UInt32 channelCount = 2;
double capturedAtMilliseconds = 0.0;
uint64_t sequence = 0;
+ std::vector sourceChannelPeaks;
};
double monotonicMilliseconds() {
@@ -225,6 +257,10 @@ UInt32 getOutputChannelCount(AudioDeviceID deviceId) {
sampleRate,
channelCount,
deviceId == defaultDeviceId,
+ getChannelDescriptors(
+ deviceId,
+ kAudioDevicePropertyScopeOutput,
+ channelCount),
});
}
@@ -246,75 +282,6 @@ bool getTapFormat(AudioObjectID tapId, AudioStreamBasicDescription* outFormat) {
return AudioObjectGetPropertyData(tapId, &address, 0, nullptr, &size, outFormat) == noErr;
}
-float decodeSignedIntegerSample(const uint8_t* data, UInt32 bytesPerSample, bool isBigEndian) {
- if (data == nullptr || bytesPerSample == 0 || bytesPerSample > 4) {
- return 0.0f;
- }
-
- int32_t rawValue = 0;
- if (isBigEndian) {
- for (UInt32 byteIndex = 0; byteIndex < bytesPerSample; ++byteIndex) {
- rawValue = (rawValue << 8) | data[byteIndex];
- }
- } else {
- for (UInt32 byteIndex = 0; byteIndex < bytesPerSample; ++byteIndex) {
- rawValue |= static_cast(data[byteIndex]) << (byteIndex * 8);
- }
- }
-
- const UInt32 totalBits = bytesPerSample * 8;
- const int32_t signMask = 1 << (totalBits - 1);
- if ((rawValue & signMask) != 0) {
- rawValue |= ~((1 << totalBits) - 1);
- }
-
- const double maxMagnitude = static_cast((1u << (totalBits - 1)) - 1u);
- if (maxMagnitude <= 0.0) {
- return 0.0f;
- }
-
- return static_cast(static_cast(rawValue) / maxMagnitude);
-}
-
-float readSampleFromFormat(const uint8_t* data,
- const AudioStreamBasicDescription& format,
- UInt32 sampleIndex) {
- if (data == nullptr) {
- return 0.0f;
- }
-
- const UInt32 bytesPerChannel = format.mBitsPerChannel / 8;
- if (bytesPerChannel == 0) {
- return 0.0f;
- }
-
- const uint8_t* samplePtr = data + static_cast(sampleIndex) * bytesPerChannel;
- const bool isFloat = (format.mFormatFlags & kAudioFormatFlagIsFloat) != 0;
- const bool isBigEndian = (format.mFormatFlags & kAudioFormatFlagIsBigEndian) != 0;
-
- if (isFloat && format.mBitsPerChannel == 32) {
- Float32 value = 0.0f;
- std::memcpy(&value, samplePtr, sizeof(Float32));
- return value;
- }
-
- if (isFloat && format.mBitsPerChannel == 64) {
- Float64 value = 0.0;
- std::memcpy(&value, samplePtr, sizeof(Float64));
- return static_cast(value);
- }
-
- if ((format.mFormatFlags & kAudioFormatFlagIsSignedInteger) != 0) {
- return decodeSignedIntegerSample(samplePtr, bytesPerChannel, isBigEndian);
- }
-
- return 0.0f;
-}
-
-bool isFormatInterleaved(const AudioStreamBasicDescription& format) {
- return (format.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0;
-}
-
std::string formatStatusMessage(const char* operation, OSStatus status) {
return std::string(operation) + " failed (" + std::to_string(static_cast(status)) + ")";
}
@@ -346,6 +313,7 @@ bool isFormatInterleaved(const AudioStreamBasicDescription& format) {
device.sampleRate,
static_cast(device.channelCount),
device.isDefault,
+ device.channels,
});
}
return result;
@@ -367,13 +335,28 @@ bool start(const std::string& requestedDeviceId,
if (result != nullptr) {
std::lock_guard lock(stateMutex_);
result->sampleRate = sampleRate_;
- result->channelCount = static_cast(channelCount_);
+ const UInt32 sourceChannelCount = channelCount_.load(std::memory_order_relaxed);
+ result->channelCount = sourceChannelCount > 1 ? 2u : 1u;
+ result->sourceChannelCount = static_cast(sourceChannelCount);
result->deviceId = activeDeviceUid_;
result->deviceLabel = activeDeviceLabel_;
}
return true;
}
+ Prism::Capture::ChannelRouting setChannelRouting(
+ uint32_t left,
+ uint32_t right) override {
+ const UInt32 count = channelCount_.load(std::memory_order_relaxed);
+ if (active_.load(std::memory_order_acquire) && count > 0) {
+ left = std::min(left, count - 1);
+ right = std::min(right, count - 1);
+ }
+ routeLeft_.store(left, std::memory_order_release);
+ routeRight_.store(right, std::memory_order_release);
+ return {left, right};
+ }
+
void stop() override {
std::lock_guard lock(stateMutex_);
stopLocked();
@@ -404,6 +387,7 @@ void stop() override {
static_cast(chunk.channelCount),
chunk.capturedAtMilliseconds,
chunk.sequence,
+ std::move(chunk.sourceChannelPeaks),
});
}
return result;
@@ -439,68 +423,74 @@ OSStatus handleIO(AudioObjectID,
const AudioTimeStamp*,
AudioBufferList*,
const AudioTimeStamp*) {
- AudioStreamBasicDescription format{};
- UInt32 channelCount = 0;
-
- {
- std::lock_guard stateLock(stateMutex_);
- if (!active_ || inputData == nullptr) {
- return noErr;
- }
- format = tapFormat_;
- channelCount = channelCount_;
- }
+ if (!active_.load(std::memory_order_acquire) || inputData == nullptr) return noErr;
+ // tapFormat_ is configured before active_ is published and is not changed until
+ // AudioDeviceStop has joined the callback, so the realtime path needs no state lock.
+ const AudioStreamBasicDescription format = tapFormat_;
+ const UInt32 channelCount = channelCount_.load(std::memory_order_relaxed);
if (inputData->mNumberBuffers == 0 || format.mBytesPerFrame == 0) {
return noErr;
}
- const bool interleaved = isFormatInterleaved(format);
const AudioBuffer& firstBuffer = inputData->mBuffers[0];
- const UInt32 frames =
- format.mBytesPerFrame == 0 ? 0 : firstBuffer.mDataByteSize / format.mBytesPerFrame;
+ const UInt32 bytesPerChannel = std::max(1, format.mBitsPerChannel / 8);
+ const UInt32 firstBufferChannels = std::max(1, firstBuffer.mNumberChannels);
+ const UInt32 frames = firstBuffer.mDataByteSize /
+ std::max(1, bytesPerChannel * firstBufferChannels);
if (frames == 0) {
return noErr;
}
CapturedChunk chunk;
- chunk.channelCount = channelCount;
+ chunk.channelCount = channelCount > 1 ? 2 : 1;
chunk.capturedAtMilliseconds = monotonicMilliseconds();
- {
- std::lock_guard stateLock(stateMutex_);
- chunk.sequence = ++sequence_;
- }
+ chunk.sequence = sequence_.fetch_add(1, std::memory_order_relaxed) + 1;
chunk.left.resize(frames);
chunk.right.resize(frames);
- if (interleaved) {
- const uint8_t* rawData = static_cast(firstBuffer.mData);
- for (UInt32 frameIndex = 0; frameIndex < frames; ++frameIndex) {
- const UInt32 sampleBaseIndex = frameIndex * std::max(1, channelCount);
- const float leftSample =
- readSampleFromFormat(rawData, format, sampleBaseIndex);
- const float rightSample = channelCount > 1
- ? readSampleFromFormat(rawData, format, sampleBaseIndex + 1)
- : leftSample;
- chunk.left[frameIndex] = leftSample;
- chunk.right[frameIndex] = rightSample;
- }
- } else {
- const uint8_t* leftData = static_cast(inputData->mBuffers[0].mData);
- const uint8_t* rightData = static_cast(
- inputData->mNumberBuffers > 1 ? inputData->mBuffers[1].mData
- : inputData->mBuffers[0].mData);
-
- for (UInt32 frameIndex = 0; frameIndex < frames; ++frameIndex) {
- chunk.left[frameIndex] =
- readSampleFromFormat(leftData, format, frameIndex);
- chunk.right[frameIndex] = inputData->mNumberBuffers > 1
- ? readSampleFromFormat(rightData, format, frameIndex)
- : chunk.left[frameIndex];
- }
- }
+ const UInt32 leftChannel = std::min(
+ routeLeft_.load(std::memory_order_acquire), channelCount - 1);
+ const UInt32 rightChannel = std::min(
+ routeRight_.load(std::memory_order_acquire), channelCount - 1);
+ constexpr size_t kMaximumAudioBuffers = 128;
+ if (inputData->mNumberBuffers > kMaximumAudioBuffers) return noErr;
+ std::array buffers{};
+ for (UInt32 bufferIndex = 0; bufferIndex < inputData->mNumberBuffers; ++bufferIndex) {
+ const AudioBuffer& buffer = inputData->mBuffers[bufferIndex];
+ buffers[bufferIndex] = {
+ static_cast(buffer.mData),
+ buffer.mDataByteSize,
+ buffer.mNumberChannels,
+ };
+ }
+ const Prism::Capture::SampleEncoding encoding =
+ (format.mFormatFlags & kAudioFormatFlagIsFloat) != 0
+ ? Prism::Capture::SampleEncoding::Float
+ : (format.mFormatFlags & kAudioFormatFlagIsSignedInteger) != 0
+ ? Prism::Capture::SampleEncoding::SignedInteger
+ : Prism::Capture::SampleEncoding::Unsupported;
+ const Prism::Capture::PCMFormat pcmFormat{
+ encoding,
+ format.mBitsPerChannel,
+ (format.mFormatFlags & kAudioFormatFlagIsBigEndian) != 0,
+ };
+ chunk.sourceChannelPeaks.resize(channelCount);
+ Prism::Capture::measureSourceChannelPeaks(
+ buffers.data(), inputData->mNumberBuffers, pcmFormat,
+ frames, channelCount, chunk.sourceChannelPeaks.data());
+ Prism::Capture::selectStereoChannels(
+ buffers.data(),
+ inputData->mNumberBuffers,
+ pcmFormat,
+ frames,
+ channelCount,
+ leftChannel,
+ rightChannel,
+ chunk.left.data(),
+ chunk.right.data());
{
std::lock_guard queueLock(chunkMutex_);
@@ -675,19 +665,28 @@ bool startInternal(const std::string& requestedDeviceUid, std::string* outErrorM
overwriteCount_ = 0;
}
- active_ = true;
+ channelCount_.store(
+ std::max(1, tapFormat_.mChannelsPerFrame > 0
+ ? tapFormat_.mChannelsPerFrame
+ : selected->channelCount),
+ std::memory_order_relaxed);
+ const UInt32 resolvedChannelCount = channelCount_.load(std::memory_order_relaxed);
+ routeLeft_.store(
+ std::min(routeLeft_.load(std::memory_order_relaxed), resolvedChannelCount - 1),
+ std::memory_order_relaxed);
+ routeRight_.store(
+ std::min(routeRight_.load(std::memory_order_relaxed), resolvedChannelCount - 1),
+ std::memory_order_relaxed);
+ active_.store(true, std::memory_order_release);
activeDeviceUid_ = selected->uid;
activeDeviceLabel_ = selected->label;
sampleRate_ = tapFormat_.mSampleRate > 0 ? tapFormat_.mSampleRate : selected->sampleRate;
- channelCount_ =
- std::max(1, tapFormat_.mChannelsPerFrame > 0 ? tapFormat_.mChannelsPerFrame
- : selected->channelCount);
- sequence_ = 0;
+ sequence_.store(0, std::memory_order_relaxed);
return true;
}
void stopLocked() {
- active_ = false;
+ active_.store(false, std::memory_order_release);
if (aggregateDeviceId_ != kUnknownObject && ioProcId_ != nullptr) {
AudioDeviceStop(aggregateDeviceId_, ioProcId_);
@@ -711,8 +710,8 @@ void stopLocked() {
activeDeviceUid_.clear();
activeDeviceLabel_.clear();
sampleRate_ = 48000.0;
- channelCount_ = 2;
- sequence_ = 0;
+ channelCount_.store(2, std::memory_order_relaxed);
+ sequence_.store(0, std::memory_order_relaxed);
std::lock_guard queueLock(chunkMutex_);
chunkQueue_.clear();
@@ -730,18 +729,20 @@ bool isSupported() const {
std::mutex chunkMutex_;
std::deque chunkQueue_;
uint64_t overwriteCount_ = 0;
- uint64_t sequence_ = 0;
+ std::atomic sequence_{0};
AudioObjectID tapId_ = kUnknownObject;
AudioObjectID aggregateDeviceId_ = kUnknownObject;
AudioDeviceIOProcID ioProcId_ = nullptr;
AudioStreamBasicDescription tapFormat_{};
- bool active_ = false;
+ std::atomic active_{false};
std::string activeDeviceUid_;
std::string activeDeviceLabel_;
double sampleRate_ = 48000.0;
- UInt32 channelCount_ = 2;
+ std::atomic channelCount_{2};
+ std::atomic routeLeft_{0};
+ std::atomic routeRight_{1};
};
} // namespace
diff --git a/native/src/macos_input_capture.mm b/native/src/macos_input_capture.mm
new file mode 100644
index 0000000..6f89823
--- /dev/null
+++ b/native/src/macos_input_capture.mm
@@ -0,0 +1,558 @@
+#include "device_input_capture.h"
+#include "capture_channel_selection.h"
+
+#if defined(__APPLE__)
+
+#import
+#import
+#import
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+constexpr size_t kMaxQueuedChunks = 256;
+constexpr size_t kDefaultDrainChunkLimit = 64;
+constexpr UInt32 kFallbackMaximumFramesPerSlice = 4096;
+constexpr AudioObjectID kUnknownObject = kAudioObjectUnknown;
+
+double monotonicMilliseconds() {
+ const auto now = std::chrono::steady_clock::now().time_since_epoch();
+ return std::chrono::duration(now).count();
+}
+
+std::string cfStringToStdString(CFStringRef value) {
+ if (value == nullptr) return {};
+ const CFIndex length = CFStringGetLength(value);
+ const CFIndex maxBytes =
+ CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1;
+ std::vector buffer(static_cast(std::max(1, maxBytes)), '\0');
+ if (!CFStringGetCString(value, buffer.data(), maxBytes, kCFStringEncodingUTF8)) return {};
+ return std::string(buffer.data());
+}
+
+template
+bool getPropertyData(AudioObjectID objectId,
+ AudioObjectPropertySelector selector,
+ AudioObjectPropertyScope scope,
+ AudioObjectPropertyElement element,
+ T* outValue) {
+ if (outValue == nullptr) return false;
+ AudioObjectPropertyAddress address{selector, scope, element};
+ UInt32 size = sizeof(T);
+ return AudioObjectGetPropertyData(objectId, &address, 0, nullptr, &size, outValue) == noErr;
+}
+
+bool getDeviceStringProperty(AudioDeviceID deviceId,
+ AudioObjectPropertySelector selector,
+ std::string* outValue) {
+ if (outValue == nullptr) return false;
+ AudioObjectPropertyAddress address{
+ selector,
+ kAudioObjectPropertyScopeGlobal,
+ kAudioObjectPropertyElementMain,
+ };
+ CFStringRef value = nullptr;
+ UInt32 size = sizeof(value);
+ if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, &value) != noErr
+ || value == nullptr) {
+ return false;
+ }
+ *outValue = cfStringToStdString(value);
+ CFRelease(value);
+ return !outValue->empty();
+}
+
+AudioDeviceID getDefaultInputDeviceId() {
+ AudioDeviceID deviceId = kUnknownObject;
+ getPropertyData(
+ kAudioObjectSystemObject,
+ kAudioHardwarePropertyDefaultInputDevice,
+ kAudioObjectPropertyScopeGlobal,
+ kAudioObjectPropertyElementMain,
+ &deviceId);
+ return deviceId;
+}
+
+UInt32 getInputChannelCount(AudioDeviceID deviceId) {
+ AudioObjectPropertyAddress address{
+ kAudioDevicePropertyStreamConfiguration,
+ kAudioDevicePropertyScopeInput,
+ kAudioObjectPropertyElementMain,
+ };
+ UInt32 size = 0;
+ if (AudioObjectGetPropertyDataSize(deviceId, &address, 0, nullptr, &size) != noErr
+ || size == 0) {
+ return 0;
+ }
+ std::vector storage(size);
+ auto* bufferList = reinterpret_cast(storage.data());
+ if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, bufferList) != noErr) {
+ return 0;
+ }
+ UInt32 count = 0;
+ for (UInt32 index = 0; index < bufferList->mNumberBuffers; ++index) {
+ count += bufferList->mBuffers[index].mNumberChannels;
+ }
+ return count;
+}
+
+double getDeviceSampleRate(AudioDeviceID deviceId) {
+ Float64 sampleRate = 48000.0;
+ if (!getPropertyData(
+ deviceId,
+ kAudioDevicePropertyNominalSampleRate,
+ kAudioObjectPropertyScopeGlobal,
+ kAudioObjectPropertyElementMain,
+ &sampleRate)
+ || sampleRate <= 0.0) {
+ return 48000.0;
+ }
+ return static_cast(sampleRate);
+}
+
+std::vector getInputChannelDescriptors(
+ AudioDeviceID deviceId,
+ UInt32 channelCount) {
+ std::vector channels;
+ channels.reserve(channelCount);
+ for (UInt32 index = 0; index < channelCount; ++index) {
+ std::string label = "Channel " + std::to_string(index + 1);
+ AudioObjectPropertyAddress address{
+ kAudioObjectPropertyElementName,
+ kAudioDevicePropertyScopeInput,
+ index + 1,
+ };
+ CFStringRef value = nullptr;
+ UInt32 size = sizeof(value);
+ if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, &value) == noErr
+ && value != nullptr) {
+ const std::string resolved = cfStringToStdString(value);
+ CFRelease(value);
+ if (!resolved.empty()) label = resolved;
+ }
+ channels.push_back({static_cast(index), label});
+ }
+ return channels;
+}
+
+struct InputDeviceInfo {
+ AudioDeviceID objectId = kUnknownObject;
+ Prism::Capture::OutputDevice descriptor;
+};
+
+std::vector enumerateInputDevices() {
+ AudioObjectPropertyAddress address{
+ kAudioHardwarePropertyDevices,
+ kAudioObjectPropertyScopeGlobal,
+ kAudioObjectPropertyElementMain,
+ };
+ UInt32 size = 0;
+ if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, 0, nullptr, &size) != noErr
+ || size == 0) {
+ return {};
+ }
+ std::vector ids(size / sizeof(AudioDeviceID), kUnknownObject);
+ if (AudioObjectGetPropertyData(
+ kAudioObjectSystemObject, &address, 0, nullptr, &size, ids.data()) != noErr) {
+ return {};
+ }
+
+ const AudioDeviceID defaultId = getDefaultInputDeviceId();
+ std::vector devices;
+ for (const AudioDeviceID id : ids) {
+ const UInt32 channelCount = getInputChannelCount(id);
+ if (channelCount == 0) continue;
+
+ std::string uid;
+ if (!getDeviceStringProperty(id, kAudioDevicePropertyDeviceUID, &uid)) continue;
+ std::string label;
+ if (!getDeviceStringProperty(id, kAudioObjectPropertyName, &label)) label = uid;
+
+ devices.push_back({
+ id,
+ {
+ uid,
+ label,
+ getDeviceSampleRate(id),
+ static_cast(channelCount),
+ id == defaultId,
+ getInputChannelDescriptors(id, channelCount),
+ },
+ });
+ }
+ return devices;
+}
+
+std::string formatStatusMessage(const char* operation, OSStatus status) {
+ return std::string(operation) + " failed (" + std::to_string(static_cast(status)) + ")";
+}
+
+class MacOSDeviceInputCapture final : public Prism::Capture::DeviceInputCapture {
+public:
+ ~MacOSDeviceInputCapture() override { stop(); }
+
+ Prism::Capture::Support getSupport() const override { return {true, {}}; }
+
+ std::vector listInputDevices() override {
+ const auto devices = enumerateInputDevices();
+ std::vector result;
+ result.reserve(devices.size());
+ for (const auto& device : devices) result.push_back(device.descriptor);
+ return result;
+ }
+
+ bool start(const std::string& requestedDeviceId,
+ Prism::Capture::StartResult* result,
+ std::string* errorMessage) override {
+ std::lock_guard lock(stateMutex_);
+ stopLocked();
+
+ const auto devices = enumerateInputDevices();
+ const InputDeviceInfo* selected = nullptr;
+ if (!requestedDeviceId.empty()) {
+ for (const auto& device : devices) {
+ if (device.descriptor.id == requestedDeviceId) {
+ selected = &device;
+ break;
+ }
+ }
+ if (selected == nullptr) {
+ if (errorMessage != nullptr) {
+ *errorMessage = "The selected macOS input device is no longer available.";
+ }
+ return false;
+ }
+ } else {
+ for (const auto& device : devices) {
+ if (device.descriptor.isDefault) {
+ selected = &device;
+ break;
+ }
+ }
+ if (selected == nullptr && !devices.empty()) selected = &devices.front();
+ }
+ if (selected == nullptr) {
+ if (errorMessage != nullptr) *errorMessage = "No macOS input devices are available.";
+ return false;
+ }
+
+ AudioComponentDescription description{};
+ description.componentType = kAudioUnitType_Output;
+ description.componentSubType = kAudioUnitSubType_HALOutput;
+ description.componentManufacturer = kAudioUnitManufacturer_Apple;
+ const AudioComponent component = AudioComponentFindNext(nullptr, &description);
+ if (component == nullptr
+ || AudioComponentInstanceNew(component, &audioUnit_) != noErr
+ || audioUnit_ == nullptr) {
+ if (errorMessage != nullptr) *errorMessage = "Could not create the Core Audio input unit.";
+ stopLocked();
+ return false;
+ }
+
+ UInt32 enabled = 1;
+ OSStatus status = AudioUnitSetProperty(
+ audioUnit_,
+ kAudioOutputUnitProperty_EnableIO,
+ kAudioUnitScope_Input,
+ 1,
+ &enabled,
+ sizeof(enabled));
+ if (status == noErr) {
+ enabled = 0;
+ status = AudioUnitSetProperty(
+ audioUnit_,
+ kAudioOutputUnitProperty_EnableIO,
+ kAudioUnitScope_Output,
+ 0,
+ &enabled,
+ sizeof(enabled));
+ }
+ if (status == noErr) {
+ AudioDeviceID deviceId = selected->objectId;
+ status = AudioUnitSetProperty(
+ audioUnit_,
+ kAudioOutputUnitProperty_CurrentDevice,
+ kAudioUnitScope_Global,
+ 0,
+ &deviceId,
+ sizeof(deviceId));
+ }
+
+ sourceChannelCount_ = std::max(1, selected->descriptor.channelCount);
+ sampleRate_ = selected->descriptor.sampleRate;
+ clientFormat_ = AudioStreamBasicDescription{};
+ clientFormat_.mSampleRate = sampleRate_;
+ clientFormat_.mFormatID = kAudioFormatLinearPCM;
+ clientFormat_.mFormatFlags = kAudioFormatFlagIsFloat
+ | kAudioFormatFlagIsPacked
+ | kAudioFormatFlagIsNonInterleaved
+ | kAudioFormatFlagsNativeEndian;
+ clientFormat_.mBytesPerPacket = sizeof(Float32);
+ clientFormat_.mFramesPerPacket = 1;
+ clientFormat_.mBytesPerFrame = sizeof(Float32);
+ clientFormat_.mChannelsPerFrame = sourceChannelCount_;
+ clientFormat_.mBitsPerChannel = 8 * sizeof(Float32);
+
+ if (status == noErr) {
+ status = AudioUnitSetProperty(
+ audioUnit_,
+ kAudioUnitProperty_StreamFormat,
+ kAudioUnitScope_Output,
+ 1,
+ &clientFormat_,
+ sizeof(clientFormat_));
+ }
+
+ AURenderCallbackStruct callback{};
+ callback.inputProc = StaticInputCallback;
+ callback.inputProcRefCon = this;
+ if (status == noErr) {
+ status = AudioUnitSetProperty(
+ audioUnit_,
+ kAudioOutputUnitProperty_SetInputCallback,
+ kAudioUnitScope_Global,
+ 0,
+ &callback,
+ sizeof(callback));
+ }
+ if (status == noErr) status = AudioUnitInitialize(audioUnit_);
+ if (status != noErr) {
+ if (errorMessage != nullptr) {
+ *errorMessage = formatStatusMessage("Configuring Core Audio input", status);
+ }
+ stopLocked();
+ return false;
+ }
+
+ maximumFramesPerSlice_ = kFallbackMaximumFramesPerSlice;
+ UInt32 propertySize = sizeof(maximumFramesPerSlice_);
+ AudioUnitGetProperty(
+ audioUnit_,
+ kAudioUnitProperty_MaximumFramesPerSlice,
+ kAudioUnitScope_Global,
+ 0,
+ &maximumFramesPerSlice_,
+ &propertySize);
+ maximumFramesPerSlice_ = std::max(1, maximumFramesPerSlice_);
+ prepareRenderBuffers();
+
+ activeDeviceId_ = selected->descriptor.id;
+ activeDeviceLabel_ = selected->descriptor.label;
+ routeLeft_.store(
+ std::min(routeLeft_.load(std::memory_order_relaxed), sourceChannelCount_ - 1),
+ std::memory_order_relaxed);
+ routeRight_.store(
+ std::min(routeRight_.load(std::memory_order_relaxed), sourceChannelCount_ - 1),
+ std::memory_order_relaxed);
+ sequence_.store(0, std::memory_order_relaxed);
+ {
+ std::lock_guard queueLock(queueMutex_);
+ queue_.clear();
+ overwriteCount_ = 0;
+ }
+
+ active_.store(true, std::memory_order_release);
+ status = AudioOutputUnitStart(audioUnit_);
+ if (status != noErr) {
+ active_.store(false, std::memory_order_release);
+ if (errorMessage != nullptr) {
+ *errorMessage = formatStatusMessage("AudioOutputUnitStart", status);
+ }
+ stopLocked();
+ return false;
+ }
+
+ if (result != nullptr) {
+ result->sampleRate = sampleRate_;
+ result->channelCount = sourceChannelCount_ > 1 ? 2u : 1u;
+ result->sourceChannelCount = sourceChannelCount_;
+ result->deviceId = activeDeviceId_;
+ result->deviceLabel = activeDeviceLabel_;
+ }
+ return true;
+ }
+
+ Prism::Capture::ChannelRouting setChannelRouting(
+ uint32_t left,
+ uint32_t right) override {
+ if (active_.load(std::memory_order_acquire) && sourceChannelCount_ > 0) {
+ left = std::min(left, sourceChannelCount_ - 1);
+ right = std::min(right, sourceChannelCount_ - 1);
+ }
+ routeLeft_.store(left, std::memory_order_release);
+ routeRight_.store(right, std::memory_order_release);
+ return {left, right};
+ }
+
+ void stop() override {
+ std::lock_guard lock(stateMutex_);
+ stopLocked();
+ }
+
+ Prism::Capture::DrainResult drain(size_t maxChunks) override {
+ const size_t limit = maxChunks == 0
+ ? kDefaultDrainChunkLimit
+ : std::min(maxChunks, kMaxQueuedChunks);
+ Prism::Capture::DrainResult result;
+ std::lock_guard lock(queueMutex_);
+ result.overwriteCount = overwriteCount_;
+ const size_t count = std::min(limit, queue_.size());
+ result.chunks.reserve(count);
+ for (size_t index = 0; index < count; ++index) {
+ result.chunks.push_back(std::move(queue_.front()));
+ queue_.pop_front();
+ }
+ result.queueDepth = queue_.size();
+ return result;
+ }
+
+ double nowMilliseconds() const override { return monotonicMilliseconds(); }
+
+private:
+ static OSStatus StaticInputCallback(void* refCon,
+ AudioUnitRenderActionFlags* actionFlags,
+ const AudioTimeStamp* timestamp,
+ UInt32,
+ UInt32 frameCount,
+ AudioBufferList*) {
+ auto* self = static_cast(refCon);
+ return self == nullptr
+ ? noErr
+ : self->captureInput(actionFlags, timestamp, frameCount);
+ }
+
+ OSStatus captureInput(AudioUnitRenderActionFlags* actionFlags,
+ const AudioTimeStamp* timestamp,
+ UInt32 frameCount) {
+ if (!active_.load(std::memory_order_acquire) || audioUnit_ == nullptr) return noErr;
+ if (frameCount == 0 || frameCount > maximumFramesPerSlice_ || renderBufferList_ == nullptr) {
+ return noErr;
+ }
+
+ for (UInt32 index = 0; index < sourceChannelCount_; ++index) {
+ renderBufferList_->mBuffers[index].mDataByteSize = frameCount * sizeof(Float32);
+ }
+ const OSStatus status = AudioUnitRender(
+ audioUnit_, actionFlags, timestamp, 1, frameCount, renderBufferList_);
+ if (status != noErr) return status;
+
+ for (UInt32 index = 0; index < sourceChannelCount_; ++index) {
+ activityBufferViews_[index].byteLength = renderBufferList_->mBuffers[index].mDataByteSize;
+ }
+
+ const uint32_t leftChannel = std::min(
+ routeLeft_.load(std::memory_order_acquire), sourceChannelCount_ - 1);
+ const uint32_t rightChannel = std::min(
+ routeRight_.load(std::memory_order_acquire), sourceChannelCount_ - 1);
+ const auto* left = static_cast(renderBufferList_->mBuffers[leftChannel].mData);
+ const auto* right = static_cast(renderBufferList_->mBuffers[rightChannel].mData);
+
+ Prism::Capture::AudioChunk chunk;
+ chunk.sourceChannelPeaks.resize(sourceChannelCount_);
+ Prism::Capture::measureSourceChannelPeaks(
+ activityBufferViews_.data(), activityBufferViews_.size(),
+ {Prism::Capture::SampleEncoding::Float, 32, false},
+ frameCount, sourceChannelCount_, chunk.sourceChannelPeaks.data());
+ chunk.left.assign(left, left + frameCount);
+ chunk.right.assign(right, right + frameCount);
+ chunk.channelCount = sourceChannelCount_ > 1 ? 2u : 1u;
+ chunk.capturedAtMilliseconds = monotonicMilliseconds();
+ chunk.sequence = sequence_.fetch_add(1, std::memory_order_relaxed) + 1;
+
+ std::lock_guard lock(queueMutex_);
+ if (queue_.size() >= kMaxQueuedChunks) {
+ queue_.pop_front();
+ ++overwriteCount_;
+ }
+ queue_.push_back(std::move(chunk));
+ return noErr;
+ }
+
+ void prepareRenderBuffers() {
+ const size_t listBytes = offsetof(AudioBufferList, mBuffers)
+ + sizeof(AudioBuffer) * sourceChannelCount_;
+ renderBufferListStorage_.assign(listBytes, 0);
+ renderBufferList_ = reinterpret_cast(renderBufferListStorage_.data());
+ renderBufferList_->mNumberBuffers = sourceChannelCount_;
+ renderSampleStorage_.assign(
+ static_cast(sourceChannelCount_) * maximumFramesPerSlice_,
+ 0.0f);
+ activityBufferViews_.resize(sourceChannelCount_);
+ for (UInt32 index = 0; index < sourceChannelCount_; ++index) {
+ auto& buffer = renderBufferList_->mBuffers[index];
+ buffer.mNumberChannels = 1;
+ buffer.mDataByteSize = maximumFramesPerSlice_ * sizeof(Float32);
+ buffer.mData = renderSampleStorage_.data()
+ + static_cast(index) * maximumFramesPerSlice_;
+ activityBufferViews_[index] = {
+ static_cast(buffer.mData), buffer.mDataByteSize, 1,
+ };
+ }
+ }
+
+ void stopLocked() {
+ active_.store(false, std::memory_order_release);
+ if (audioUnit_ != nullptr) {
+ AudioOutputUnitStop(audioUnit_);
+ AudioUnitUninitialize(audioUnit_);
+ AudioComponentInstanceDispose(audioUnit_);
+ audioUnit_ = nullptr;
+ }
+ renderBufferList_ = nullptr;
+ renderBufferListStorage_.clear();
+ renderSampleStorage_.clear();
+ activityBufferViews_.clear();
+ activeDeviceId_.clear();
+ activeDeviceLabel_.clear();
+ sampleRate_ = 48000.0;
+ sourceChannelCount_ = 2;
+ maximumFramesPerSlice_ = kFallbackMaximumFramesPerSlice;
+ sequence_.store(0, std::memory_order_relaxed);
+ std::lock_guard queueLock(queueMutex_);
+ queue_.clear();
+ overwriteCount_ = 0;
+ }
+
+ mutable std::mutex stateMutex_;
+ std::mutex queueMutex_;
+ std::deque queue_;
+ uint64_t overwriteCount_ = 0;
+ std::atomic sequence_{0};
+ std::atomic active_{false};
+ std::atomic routeLeft_{0};
+ std::atomic routeRight_{1};
+
+ AudioUnit audioUnit_ = nullptr;
+ AudioStreamBasicDescription clientFormat_{};
+ std::vector renderBufferListStorage_;
+ std::vector renderSampleStorage_;
+ std::vector activityBufferViews_;
+ AudioBufferList* renderBufferList_ = nullptr;
+ UInt32 maximumFramesPerSlice_ = kFallbackMaximumFramesPerSlice;
+ uint32_t sourceChannelCount_ = 2;
+ double sampleRate_ = 48000.0;
+ std::string activeDeviceId_;
+ std::string activeDeviceLabel_;
+};
+
+} // namespace
+
+namespace Prism::Capture {
+
+std::unique_ptr createDeviceInputCapture() {
+ return std::make_unique