Audio harness
Prove and debug what a Pulp processor actually emits — the audio observability harness (signal generators, metrics, assertions, RenderScenario, effect contracts) plus the offline Audio Doctor analyzers (magnitude/frequency response, THD/THD+N). TRIGGER on phrases like "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting / aliasing", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "magnitude response curve", "compare before/after a DSP refactor". Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.From its SKILL.md
npx -y skills add danielraffel/burl --skill audio-harnessAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
44.8 KB, as published. Nobody here has run it
Audio harness (observability + validation)
Pulp's agent-first way to turn "I can't hear it" / "does this sound right?" into
inspectable, deterministic signal evidence — without a device, speakers, or a
debugger. You are reading this skill because you need to prove, measure, debug, or
regression-guard the audio a Pulp Processor emits.
Everything here is the test/tool layer (test/support/audio_*), driven by
pulp::format::HeadlessHost. Nothing in this skill runs on the realtime audio
thread — measurements analyze buffers that have already left process(). The
realtime probe path is a separate concern (see Roadmap).
When to rope this skill in
- "There's no sound / it sounds wrong" — render the path offline and read the facts (peak/RMS/silence/frequency/NaN) instead of guessing.
- Building or changing a filter / oscillator / compressor / delay / sampler — state the contract and prove it (frequency response, delay time, decay, bypass-nulls).
- A regression hunt — "64 frames is fine but 128 is silent", "changing the sample rate chipmunked it", "the test tone toggle produces nothing".
- A DSP refactor — compare old vs new renders (exact / numeric / spectral).
- "How distorted is this?" — THD / THD+N and a harmonic breakdown.
- "What does this lowpass actually do at 8 kHz?" — a magnitude-response curve.
The layering (each layer builds only on the ones below — no back-edges)
signal generators → metrics → assertions → artifacts → scenarios → contracts → doctor
| Layer | Header (test/support/) | What it gives you |
|---|---|---|
| Generators | audio_test_signals.hpp, audio_signal_generators.hpp | Deterministic stimulus: sine/square/saw, impulse(+train), step, DC, multi-sine, swept sine, seeded white/pink/brown noise, stepped automation + MIDI note scripts. No clocks, no random_device. |
| Metrics | audio_metrics.hpp | analyze() → BufferMetrics: peak, RMS, DC, NaN/Inf, clip count, silence-run; estimate_frequency() (zero-crossing, documented limits); to_dbfs; summarize() (agent-readable signal description). |
| Assertions | audio_assertions.hpp | assert_no_nan_inf / not_clipped / silent / not_silent / peak_between / rms_between / frequency_near / null_near / channels_independent — each returns CheckResult{passed,message} with dBFS/Hz/cents messages, never a bare float. |
| Artifacts | audio_artifacts.hpp | BufferMetrics → JSON (schema_version + provenance) for failing CI/local runs. |
| Scenarios | render_scenario.hpp | RenderScenario builder over HeadlessHost (factory, sample rate, block size, channels, duration, input/MIDI/param scripts); render() → ScenarioResult. run_matrix() (SR × block sweeps) + assert_block_partition_invariant(). |
| Contracts | audio_contracts.hpp | AudioContract — a named claim + scenario + accumulated CheckResults; failures read contract '<name>': .... Family helpers expect_{passthrough,silence_preserved,tone,finite_and_unclipped}. |
| Doctor (offline) | audio_doctor.hpp, audio_doctor_artifacts.hpp | Plugin-Doctor-style measurements: response_relative_to_input() (magnitude/frequency response curve + attenuation_db_at(hz)), measure_thd() (THD / THD+N + harmonic breakdown), curve JSON artifacts. FFT lives test-side only — never core/view/runtime. |
Read test/support/README.md for the authoritative layering contract.
Run the proofs
# Build + run the whole harness (Release — Debug is meaningless for DSP timing/levels)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(sysctl -n hw.ncpu) --target \
pulp-test-audio-support pulp-test-render-scenario pulp-test-audio-contracts \
pulp-test-audio-doctor pulp-test-golden pulp-test-audio-matrix pulp-test-audio-tone-regression
ctest --test-dir build -R 'audio|golden|render|contract|doctor' --output-on-failure
The /audio-harness slash command wraps this. JSON metric/curve artifacts (on failure or
on demand) land under a temp pulp-audio-metrics/ dir and are INFO-logged.
Copy-this patterns
Describe / debug a render (the "no sound" workflow):
auto m = analyze(rendered, 48000.0);
INFO(summarize(m, estimate_frequency(rendered.channel(0), 48000.0)));
REQUIRE(assert_not_silent(m, -60.0).passed); // which stage went silent?
State and prove a DSP contract:
auto sc = RenderScenario(create_my_lowpass)
.name("mylp.attenuates_8k").sample_rate(48000.0).block_size(128)
.input(Sine{.hz = 8000.0f, .dbfs = -12.0f}).set_param(kCutoff, 200.0f);
AudioContract c("mylp.attenuates_8k", sc);
c.expect(expect_finite_and_unclipped(c.result()))
.expect(assert_block_partition_invariant(sc, {64,128,256}));
REQUIRE(c.verify().passed); // failure says `contract 'mylp.attenuates_8k': ...`
Measure it like Plugin Doctor (offline Doctor):
auto curve = response_relative_to_input(sc, {50.0, 8000.0});
REQUIRE(curve.attenuation_db_at(8000.0) >= 20.0); // "drops ≥20 dB at 8 kHz"
auto thd = measure_thd(sc, /*fundamental_hz=*/999.0); // steady bin-coherent sine
// thd.thd_percent(), thd.thd_plus_n, thd.harmonics[...]
Discipline that keeps it trustworthy
- Release only. A Debug DSP/UI build mismeasures levels and timing.
- Analyzer Determinism Contract — every spectral/estimator assertion declares its stimulus, window (rectangular for a bin-coherent tone or an impulse; Hann for broadband — a Hann window annihilates an impulse at n=0), warm-up trim, estimator, seed, sample rate, and tolerance class. State them in the test so a red is a real DSP change, not an analyzer artifact.
- Named tolerances, never magic numbers — see the plan's Threshold Policy.
- Layering is one-way. Doctor may use scenarios + FFT; nothing below may
include
audio_doctor/audio_contracts.
Live inspection (Audio Inspector window) — landed
For live signal inspection while a standalone app / hosted graph runs, there is
a separate developer tool window: pulp::view::AudioInspectorWindow
(core/view/.../audio_inspector_window.hpp). It is a sibling of the layout
inspector, not a tab — open it via its CommandRegistry command
kToggleAudioInspector (default Cmd/Ctrl+Shift+A, rebindable) or the
/audio-inspect slash command. It shows meters (peak/RMS/clip/NaN-Inf/silence),
the observed probe stage, a copied fixed-capacity waveform, channel balance + an
L/R level-match ratio, and a device/runtime summary — all polled once per UI tick
from a realtime-safe AudioProbeSnapshot (it never touches the audio thread). It
honestly shows a "no probe" / "stale" state rather than faking zeros. Live data
requires the standalone output-boundary tap, which is gated behind
PULP_ENABLE_AUDIO_PROBES. NOTE: the "L/R match" is a level ratio, not a
phase/Pearson correlation (the RT snapshot carries no inter-sample L*R term yet).
This is for watching what is currently flowing; controlled-stimulus measurement (response/THD/etc.) is the offline Doctor above.
Launch the Audio Inspector in the standalone host
StandaloneApp::run_with_editor() wires the inspector to the host's
output-boundary probe automatically (behind PULP_ENABLE_AUDIO_PROBES, default
ON for dev/examples). At runtime, toggle it with Cmd/Ctrl+Shift+A, or open it
on launch by setting PULP_AUDIO_INSPECTOR in the environment:
# Live: feed a test signal so output is non-silent, then open the inspector.
PULP_AUDIO_INSPECTOR=1 ./build/examples/<app>/pulp-<app>
CLI shortcuts (resolve the standalone binary + set the env vars for you):
pulp run --audio-inspector # open the live inspector window
pulp run --audio-probe-json /tmp/probe.json # headless: dump probe JSON + exit
pulp run --audio-scope-json /tmp/scope.json # headless: dump live sample-window scope JSON + exit
pulp audio scope --input-wav out.wav --json /tmp/scope.json --png /tmp/scope.png
For MCP clients, use the existing pulp-mcp tools instead of creating a new
MCP server. pulp_audio_probe_json is a one-shot wrapper around
pulp run --audio-probe-json, accepts optional target and frames, and
returns scalar probe counters as structuredContent. pulp_audio_scope
returns pulp.audio.scope.v1 sample-window acquisition/measurements; live
target mode may open the audio device, while input_wav mode is speakerless
offline and can also write a PNG artifact.
Agent triage pattern:
- Start with
pulp_audio_probe_jsonfor the target you are debugging. - If the tool errors or no JSON is written, report a launch/probe problem, not a DSP conclusion.
- If
callbacks == 0, increaseframesor inspect standalone startup/device lifecycle. - If callbacks advance but
peak_max == 0andrms_max == 0, treat the observed output boundary as truly silent; debug routing, input stimulus, bypass/mute state, graph wiring, or the processor output branch. - If
clip_count/clipped_blocksornan_inf_count/nan_blocksare non-zero, prioritize DSP/gain/state initialization bugs. - If the live snapshot is healthy but the user says the sound is wrong, switch
to
pulp_audio_scope/pulp audio scope --input-wavfor sample-window facts, or to an offline render + Audio Doctor for THD/response/residual checks. The scalar probe cannot prove THD, response, phase, latency, or perceptual quality.
--audio-probe-json is the programmatic readout for agents: it writes
output_probe().latest() (+ the AudioStats subset) as a flat JSON object —
stage, sample_rate, block_size, channel_count, sequence_number,
peak_max/rms_max, peak_dbfs/rms_dbfs (null on true silence),
clip_count, nan_inf_count, clipped_blocks, nan_blocks,
silence_run_blocks, callbacks, then exits. The mapping is the pure
pulp::audio::audio_probe_snapshot_to_json() helper
(pulp/audio/audio_probe_json.hpp); the frame delay reuses --frames /
PULP_FRAMES. This is the live counterpart to the offline
pulp audio validate Doctor below. See docs/guides/audio-inspector.md.
PULP_AUDIO_INSPECTOR also enables the probe's capture ring (sized to the panel
display width), so the inspector paints a live waveform and not just meters —
the default probe config is summary-only. The toggle routes through a
shell-owned CommandRegistry via route_global_keys (the root view's
on_global_key), which is independent of the layout inspector's on_global_click
(Cmd/Ctrl+I) — both tools coexist on one window without clobbering.
Headless proof: a screenshot run with the inspector open also captures the inspector's OWN window surface next to the main screenshot:
# Writes /tmp/x.png (main) AND /tmp/x.audio-inspector.png (the live panel).
PULP_AUDIO_INSPECTOR=1 ./build/examples/<app>/pulp-<app> \
--screenshot /tmp/x.png --screenshot-frame-delay 90
The sibling <stem>.audio-inspector.png is only written when the inspector
window is visible and the host has GPU capture (WindowHost::capture_png()).
CLI: pulp audio validate <verb> (offline, over WAVs / artifact bundles)
The harness analyzers are also reachable from the shipped CLI, nested under the
existing pulp audio command (the model/excerpt-find/read-bundle verbs are
untouched). The CLI is not tied to a plugin, so it analyzes captured audio
files and stored audio-run/ artifacts — not live processor instantiation
(controlled-stimulus render stays the test-side RenderScenario). It links the
reusable pulp::audio-analysis lib (tools/audio/analysis, namespace
pulp::test::audio), the same file-analysis code the test harness uses; no
test/ library is linked into the CLI and no FFT leaks into a runtime build.
# Agent-readable signal summary (peak/RMS/DC/dominant pitch); --json for machine output
pulp audio validate summarize out.wav [--json]
# Offline Audio Doctor: THD/THD+N and/or spectrum magnitude at checkpoints.
# Writes a schema-versioned JSON curve artifact to the temp dir.
pulp audio validate doctor out.wav --thd [--fundamental 1000]
pulp audio validate doctor out.wav --response 100,1000,8000
# Null/spectral diff verdict (exits nonzero past tolerance)
pulp audio validate compare before.wav after.wav [--mode null|spectral] [--tolerance -120]
# Re-check a stored assertions.json (or an audio-run dir holding one); nonzero on failure
pulp audio validate assert audio-run/assertions.json
assertions.json schema: {"schema_version":1,"assertions":[{...}]} where each
entry has a check (not_silent, silent, no_nan_inf, peak_below,
frequency_near), a file (relative to the JSON), and the check's named
tolerance (min_rms_dbfs, ceiling_dbfs, expected_hz + tolerance_cents,
...). The /audio-harness slash command documents these verbs and points at the
offline render path below.
Offline plugin render (pulp audio render)
pulp audio render --plugin <bundle> --out <file.wav> (--duration-ms <n> | --duration-frames <n>) is the scenario-driven counterpart to validate: it
loads an explicit plugin bundle through pulp::host::PluginSlot (the generic CLI
has no registered factory, so a bundle is the only offline render source),
drives it block-by-block from declarative flags, writes an int16 WAV, and emits
the same pulp::audio-analysis metrics JSON as validate summarize --json
(--manifest <file> / --json). No DAW, no audio device. Drive it with
--input-signal silence|sine:<hz>[,<dbfs>] or --input <file.wav> (used as-is
at --sample-rate — no resampling), --param <id>=<value>[@frame], and
--midi note:<note>,<vel>,<on>[,<off>].
--param values are PLAIN domain (the parameter's native min..max), not
normalized [0,1] — matching PluginSlot::set_parameter /
ParameterEvent::value. Parameters are delivered sample-accurately: the
stepper windows each block's events with per-block sample offsets and the queue
is forwarded straight to PluginSlot::process, which every loader applies at the
event offset (CLAP/VST3/AU sample-accurate; LV2 block-rate by its control-port
contract). We do NOT also call set_parameter — that would double-apply each
change (once at offset 0, once at its real offset). MIDI is likewise
sample-accurate. (A plugin that reads its own params once per block still steps
at block boundaries — that is the plugin's rate, not the CLI's.) The block
stepper is a deliberate, callback-driven parallel to OfflineRenderHost::render
(PluginSlot has no ProcessContext, so it can't reuse the core renderer
directly); a block-partition-invariance test guards the two against drift.
Live capture-to-WAV — two modes, both LANDED
pulp run taps the standalone's output boundary into a WAV the offline pulp audio validate verbs read, then exits. Pick the mode by which window you need:
--audio-capture-wav <file>(earliest, int16). Dumps the EARLIEST window after the stream starts (drop-on-full FIFO). Robust forvalidate summarize/assert(presence / level / clip / NaN); the wrong window for steady-statedoctorand quantization-limited forcompare.--audio-capture-frames <n>sets the window.--audio-capture-rolling <file>(last-N, float or int24). Keeps the LAST (steady-state) window in aRollingAudioCaptureBufferand writes a float WAV (no int16 floor) — the windowdoctor(THD/response) andcompare(sub-−96 dBFS residuals) actually want.--audio-capture-rolling-frames <n>sets the window;--audio-capture-rolling-format int24swaps the float WAV for int24 (≈ −144 dBFS floor, smaller, universal DAW compatibility). Uses the hold protocol so the off-RT materialize is safe while the audio thread is still appending. One capture mode per invocation (mutually exclusive with--audio-inspector/--audio-scope-json/--audio-capture-wav).
WAV writing is pulp::audio::write_wav_file(path, data, WavBitDepth) —
Int16 (default overload), Int24, or Float32.
Roadmap
The Phase-7 offline-render and live-capture slices have all landed: pulp audio render (offline plugin render, sample-accurate --param @frame), pulp run --audio-capture-wav (earliest-window int16), and pulp run --audio-capture-rolling (last-N, float or int24). The live realtime output tap
they read from is gated behind PULP_ENABLE_AUDIO_PROBES (see Live inspection
above). The harness's offline/live capture surface is feature-complete; further
work is open-ended (e.g. additional analysis verbs), not a tracked backlog.
Sibling: the Audio Quality Lab (reference-vs-candidate perceptual artifacts)
This skill covers presence / level / THD / response. For reference-vs-candidate
perceptual artifact detection — "did this DSP change make it sound worse?"
(transient smear, dulling, metallic fizz, graininess) — there is a separate opt-in
developer/CI tool, tools/audio/quality-lab/ (Python, numpy + soundfile). It is
additive: it does NOT change anything here and is never required to run the basic
harness or ctest.
-
Install (managed):
pulp tool install audio-quality-lab— provisions an isolated venv under~/.pulp/tools/(the samepulp toollane asffmpeg/uv/importers;pulp tool listshows it). Thenpulp tool run audio-quality-lab -- <args>. A plaincd tools/audio/quality-lab && pip install -r requirements.txtvenv works identically. -
Subcommands (after
python -m quality_lab.cliorpulp tool run audio-quality-lab --):run --case {drum,tonal} --degradation <kind>(synthetic case + listenable clips),engine [--input <wav>] --character <c>(validate the REAL stretch engine, reference-free on a dry input),engine-baseline(regression gate: did an engine change make it worse?),corpus list|add(versioned, license-guarded corpus). -
Aligns a candidate to a reference (onset-map + local cross-correlation), runs the detectors (
transient_sharpness,spectral_centroid,hf_fizz,spectral_flux,hnr— tonal noise/roughness via autocorrelation HNR; plus the standalonestereo_widthfor image-collapse/phase damage on(N,2)stereo arrays; and the EXPERIMENTALonset_drift— timing/groove drift via event-time residual after common-latency removal, advisory-only until promoted), and writes areport.jsonwith per-onset localization, coverage/confidence, and provenance. -
Credibility: detectors are validated against an independent textbook phase vocoder (
reference_pv.py) AND the real product engine, not just their own synthetic degradation. -
engine/engine-baselineneed a builtstretchcli.engine.resolve()finds it via, in order: thePULP_STRETCHCLIenv-path, abuild/examples/offline-stretch/stretchclifound by walking up from the current directory (so the engine path works even when the lab ispulp tool install-ed into a managed venv, as long as you run it from a Pulp checkout that built stretchcli), then the package-relative repo build. Absent → a clean, actionableskipped(build command + env-path), never a failure. Build it withcmake --build build --target stretchcli. -
Maturity gate: each detector has
maturity(experimental/beta/stable). Anexperimentaldetector runs and reports under the report'sadvisoryblock but itsfired/low_coverageare excluded from theverdictANDengine_baseline— route every verdict/gate path throughschema.detectors_for_verdict/detectors_for_engine_baseline, never re-implement the filter. New detectors shipexperimental. (Plan: planning/2026-06-29-audio-quality-lab-postmvp-detectors-maturity-gate.md.) -
Perceptual models (
perceptual.py, opt-in, license-fenced): full-reference, music/general-audio quality predictors, each behind its OWN env-path and eachskipped-independently when absent — set only the ones you want.evaluate()consults all: ViSQOL (PULP_VISQOL_BIN, Apache-2.0, MOS-LQO), PEAQ (PULP_PEAQ_BIN, GPL, ITU-R BS.1387 ODG), AQUA-Tk (PULP_AQUATK_BIN, GPL-3.0, ODG). Results land underreport["perceptual"], ADVISORY ONLY. Deliberately excludes speech/telephony (PESQ, POLQA) and no-reference neural speech (DNSMOS, NISQA) metrics — wrong domain/contract. -
MIR structural oracle (
mir.py, opt-in, license-fenced): aubio (PULP_AUBIO_BIN, GPL-3.0) — a feature extractor, NOT a quality metric, so it is a SEPARATE layer fromperceptual.py. Gives an independent onset-count cross-check underreport["advisory"]["mir_oracles"]to non-circularly validateonset_drift. ADVISORY, never a gate/baseline. License fence for both layers: GPL tools (PEAQ/AQUA-Tk/aubio) are reached ONLY via a developer-set env-path over a process boundary, never bundled, fetched, linked, or added totool-registry.json— so no copyleft obligation attaches and no runtime warning is needed (consistent with how the lab already treats PEAQ). -
Advisory reviewer (
reviewer.py, opt-inPULP_QLAB_REVIEWER_CMDsubprocess,run --review): an LLM/multimodal model names artifacts in plain language underadvisory.reviewers. ADVISORY ONLY — never changes the verdict/gate; skip-when-absent; no network by default. Validate withreviewer.score_agreement(precision/recall vs the synthetic answer key) before trusting it. -
Tuning loop (
loop.py,quality-lab loop, EXPERIMENTAL): scores candidates with the gate-participating detectors (experimental detectors excluded), ranks them, and writes PROPOSAL-ONLY label updates tocorpus/LABEL_PROPOSALS.json— neverMANIFEST.json, never auto-promotes.goodhart_guardrefuses a candidate that games one detector while regressing another (normalized Pareto + held-out slice + NEEDS-EAR). -
Agent compare report (
compare.py,quality-lab compare before.wav after.wav): the agent-facing measure → compare → judge surface. Level-matches, runs one curated axis (--profile), and emits a typed evidence envelope + an action-oriented verdict (regression_suspected/material_change_detected/no_material_change_detected/inconclusive/invalid). Six axes today — a new axis is one_AXESregistry entry incompare.py, the shared_measure/_verdictmachinery does the rest:--profileaxis measures bad direction ( bad_sign)tonal-balancetonal_balanceLTAS spectral-centroid shift duller (−1) added-hfadded_hfband-relative ≥8 kHz fraction ratio (dB) added fizz (+1) noise-roughnessnoise_roughnessharmonic-to-noise ratio drop (dB) rougher/noisier (−1) graininessgraininessrelative spectral-flux increase grainier (+1) stereo-widthstereo_widthRMS(side)/RMS(mid) width + interchannel correlation narrower/collapsed (−1) transient-integritytransient_integrityper-onset attack-smear deficit (onset-aligned) softer/smeared (+1) noise-roughness(HNR, pitch range 40–1000 Hz so bass fundamentals count) andgraininess(relativemean_spectral_fluxincrease) are meaningful on tonal/sustained material — a caller-declared contract (the caller picks the profile), carried as a standing summary caveat + both per-signal scalars in the payload, NOT an automatic tonal/percussive classifier in the honesty path (which would just be a tunable threshold on the same statistic). Only mathematically degenerate inputs gonot_applicable(a flux reference below an epsilon floor — the div-by-zero edge).stereo-widthis the one axis fed the ORIGINAL 2-channel signal (viaaudio_io.load_wav_multichannel, the_Axis.needs_stereoflag, and the_measure_stereopath) rather than the mono downmix; mono input on either side isnot_applicable, its metric is level-invariant (no level-match), and a candidate whose correlation goes negative is flagged out of phase (mono-incompatible) in the summary.transient-integrity(the_Axis.needs_onsetsflag) is "did my DSP soften/smear the attacks?" — it detects+matches onsets (align.detect_onsets/map_onsets), locks each attack (dsp.local_align), and compares the high-band attack rise (dsp.attack_rise, the SAME primitive thetransient_sharpnessdetector uses — extracted to dsp so there is no forked DSP). One-directional (a softening is the regression; a sharper candidate reads no change); needs onset-bearing material (< 3 matched onsets →not_applicable); self-aligns per onset so--alignis a no-op and the global sample-domain advisory is suppressed. That takes compare to 6 of the lab's 7 detectors reachable from the agent-facing surface (up from 2).compare.STEREO_PROFILES/ONSET_PROFILESmark the capability-specific axes, andNET_DEFAULT_PROFILES(the regression net's default) excludes them so they don't emit spuriousnot_applicablerows on the wrong material — a suite opts in.compare.MONO_PROFILES/compare.STEREO_PROFILESsplit which axes need 2-channel input — the regression net defaults to the mono set so a mono comparison doesn't emit a spurious not_applicable stereo-width row.The
added-hfaxis measures the ratio of the ≥8 kHz energy fraction (10·log10(frac_cand / frac_ref), default ±3 dB), NOT the absolute fraction delta — the absolute delta is signal-dependent and effectively blind on a bass-heavy source (an amp render's HF fraction is ~1e-4, so even a clearly harsh addition barely moves it; only the null-residual corroboration caught it before). The ratio is invariant to a broadband gain / the level-match, so it flags a real harshness change on a dark source that the absolute delta missed. Both band fractions are floor-clamped, so a zero-HF reference (fizz on a dark source) or candidate (brickwall low-pass) gives a large finite dB delta, neverinf→ a falseinvalid. It flags HF loss as material too (a negative dB delta) — only added fizz is the bad direction. A large EQ move outside the band (e.g. a big LF shelf) still leaves some residual sensitivity — far less than the absolute metric, not zero. The threshold is a dB magnitude with its own per-axis range, so the shipped CLI / MCP pass any positive threshold through to the Python registry (the single source of truth for the valid range) rather than a hardcoded fraction bound.Intent-safe:
regression_suspectedneeds--reference-role goldenAND a change in the axis's bad direction (a duller candidate can't trip theadded-hfaxis; added fizz can't trip it on apeercompare); otherwise the change is the neutralmaterial_change_detected. Advisory: exits non-zero only oninvalid(couldn't measure), never for a judgment — the gate primitive stayspulp audio validate compare. Each axis has its own materiality default (--thresholdoverrides). Report shape (quality_lab.compare.v1) is owned byschema.py; every envelope carriesstatus/applicable/materiality/provenance. Use this when an agent must weigh in on a DSP change with cited evidence rather than a bare pass/fail.Corroboration + raw comparators (
report["advisory"], off-gate). When the primary measurement succeeds, the report also carries anadvisorynamespace: a deterministicnull_residualraw comparator (level-matched sample-domain residual RMS relative to the reference,db_rel_reference; lower = more identical) and acorroborationblock. Corroboration is a materiality cross-check — NOT a trust score: it reports only whether that independent, algorithm-agnostic residual also registers a material change (corroborated/not_corroborated), under the same level-matched global contract. Both arematurity: experimental,participates_in_verdict: false— they NEVER move the verdict. The valuable signal is disagreement:not_corroboratedwithaxis_exceeds:false, raw_material:truemeans a real sample-domain difference this axis can't see (e.g. a pure delay — try another profile);axis_exceeds:true, raw_material:falsemeans a marginal/phase-only change to treat with more caution. That disagreement is now PROMOTED to the headline (it used to sit buried in the advisory): a top-levelreport["headline_flags"](always present, empty when there's nothing to flag) carries a structured{flag, detail, expected_for}—uncaptured_material_differenceoraxis_change_without_residual— and a matching sentence is appended tosummary. The flag is STRUCTURED, not prose, precisely so the known false-alarm class is machine-suppressible: every time/pitch-variant A/B (chorus, phaser, bendr, any modulated effect) legitimately readsnot_corroboratedforever because the phase-sensitive residual always disagrees with a tonal axis, souncaptured_material_differencecarriesexpected_for:["time_variant_processing"]— a caller doing time-variant work filters on that rather than treating the flag as noise. It still NEVER moves the verdict. Length is handled honestly: the candidate is level-matched over the common region and the shorter signal zero-padded to the longer length, so a dropped/added tail with content raises the residual on its own (reads material) while trailing silence contributes ~0 (stays immaterial) — a truncated render can never masquerade as a near-identity match, and the sample counts are reported in the comparator'sdetail. The residual is phase/delay-sensitive and alignment-free by contract, so it corroborates materiality, never audibility. Cite it — never treat agreement as a confidence score (the plan's explicit non-goal: agreement ≠ trust). Deliberately NOT built: the GPL aubio / AGPL Essentia feature-extractor menu — feature extractors are never verdicts, and an env-path license fence is disproportionate surface for the value; the pure-numpy residual carries the corroboration story license-free.Time-alignment (
--align latency). The axes are alignment-free, so a constant delay/offset (delay plugin, tempo-sampler start offset) doesn't move the tonal verdict but makes the phase-sensitive null-residual false-alarmnot_corroborated.--align latencyestimates one constant lag (dsp.estimate_global_lag— normalized cross-correlation of the attack envelopes), trims to a common time base (dsp.apply_lag_trim), and measures the aligned pair: a pure delay then readsno_material_change+ corroborated, and a real change behind a delay is measured cleanly. The outcome is disclosed on the envelope (alignment: {policy: "fixed-latency-trim", lag_samples, confidence, applied}) and the summary. It refuses below a confidence floor (recordspolicy: "not_aligned", measures unaligned) — a wrong alignment is worse than none. Default--align none= zero behavior change.--alignthreads throughcompare_files→ CLI → MCP (pulp_audio_compare'salignparam) → the/audio-compareslash command.The
--aligngrammar + per-mode dispatch live inalignment.py(extracted from compare.py in Tier 3 / T3.1):alignment.parse("mode[:param]") → AlignSpecandalignment.apply(spec, ref, cand, sr, *, needs_stereo, needs_onsets) → (ref, cand, record), dispatched through a_HANDLERSdict so a new warp class is one entry.alignment.pyimportsschema+dsp(nevercompare— capability facts arrive as booleans; no import cycle); the record shape stays inschema.py; the prose lives inalignment.describe(record). Not to be confused withalign.py(onset primitives) which the onset-anchored warp handlers consume.--align varispeed:R(Tier 3 / T3.1) — a caller-declared tape-style speed change (candidate = reference resampled by R, pitch+time coupled). A resample is EXACT for this class, so the handler resamples the candidate back to the reference length (dsp.resample_to_length— a pure-numpy Kaiser-windowed-sinc resampler, no scipy) and the WHOLE alignment-free pipeline including the sample residual applies (clean varispeed →no_material_change+ corroborated). It verifies the declared ratio against the observed duration ratio (±2%) and refuses (not_aligned) otherwise — never resampling to a wrong length. Anti-masking holds (a defect behind the varispeed still flags). Honest physical limit: a speed-up (R<1) bandlimits to a lower Nyquist, so near-Nyquist energy is legitimately lost (correct varispeed behavior, matches an ideal brickwall to ~1% — not a resampler artifact).--align stretch:R(Tier 3 / T3.2) — a declared PITCH-PRESERVING time-stretch (PV/bendr). No exact inverse exists, so the axes measure the UNWARPED pair directly (LTAS centroid / HF fraction / HNR / width are time-averages → warp-invariant). Two axes are warp-normalized off the ratio, threaded via theAlignSpec: graininess iswarp_aware(_Axis.warp_aware) and measures the candidate flux atmean_spectral_flux(..., hop_scale=R)(spectral_flux.v2-warp— else a clean stretch reads a false "smoother"); corroboration binds todsp.ltas_log_spectral_distance_db(a phase-blind, length-independentltas_residualcomparator) because the sample residual is invalid across a stretch (the null residual is still emitted, marked not-a-corroborator)._align_stretchreturns the pair UNCHANGED after verifying §6.1 (duration within ±3% of R,R∈[0.25,4]) and §6.3 that a single uniform ratio fits — onset material: MAD of the per-onset residual from theref_t·Rprediction ≤ 15 ms with coverage ≥ 0.5; sustained: ratio-mappedsmooth_energy_envcorrelation ≥ floor — else REFUSE (not_aligned, "non-uniformly warped"). The_WARP_MODESset gates the capability short-circuit (a warp is applied/routed for onset/stereo axes, not skipped like a constant lag);_MEASURE_UNWARPED_MODES= {stretch, pitch} drives the per-axis warp normalization + corroborator swap (varispeed is NOT in it — it resamples back, so the standard pipeline applies verbatim).--align pitch:S(Tier 3 / T3.3) — a declared DURATION-PRESERVING pitch shift (S semitones, parsed with an optionalst/semitonessuffix;spec.paramis the signed semitones). Time base unchanged, so the axes measure the pair directly; tonal-balance (alsowarp_aware) compensates the LTAS centroid viadsp.pitch_compensated_centroid_shift(a perfect shift moves the centroid by the pitch ratio → the axis reports deviation-from-expected,spectral_centroid.v2-pitch), and the corroborator shifts the reference LTAS to its expected position first (dsp.ltas_logfreq_shift, via theref_shift_ratioarg onltas_log_spectral_distance_db)._align_pitchverifies|S| ≤ 24 st- duration preserved (a length change ⇒ not a pure pitch shift) and refuses otherwise. The warp-aware
kernels take the accepted
AlignSpec(spec=) and each reads only its own class offspec.mode/spec.param— the generalized threading that replaced T3.2's singleflux_hop_scaleparam. Known advisory imperfection: the log-frequency LTAS compensation carries bin-interpolation error on discrete partials, so a clean shift can readnot_corroborated(advisory only, never the verdict). a non-constant warp / DTW stays on the engine path, not compare.
--align ratio:auto(Tier 3 / T3.4) — ESTIMATE a uniform stretch ratio, double-gated (§6.2): two independent estimators — the duration ratio and the onset-time slope (dsp.theil_sen_slopeovermap_onsetspairs, ≥ 6) — must agree within 2%, else REFUSE (not_aligned, "ratio estimators disagree" / "needs ≥ 6 matched onsets"). On agreement_align_ratio_autodelegates to_align_stretchwith the onset-slope estimate (interior evidence) and tags the recordestimated=True+ both estimator values. The estimated ratio reaches the warp-aware axes viaalignment.effective_spec(spec, record), which resolves aratio:autospec tostretch:<estimated>so graininess hop-scales off the ACCEPTED transform, not the "auto" request string. Reliable on onset-bearing uniform EXPANSIONS; compressions (map_onsets drift) + non-uniform + sustained refuse (conservative — declare stretch:R). This is the last declared-warp class; a non-constant warp / DTW stays on the engine path, not compare.Honesty disclosures (what compare admits it can't see). Every axis EXCEPT
stereo-widthmean-downmixes to mono, so on those a stereo/spatial change (widener, panner, M/S) is invisible. When a file was multichannel the report says so on the mono axes:provenance.ref_channels/cand_channels, adownmixnote on the measurement envelope, and a one-line summary clause — a stereo-widener whose mid is unchanged readsno_material_changeontonal-balancebut the report never hides that imaging was discarded, and pointing the same pair at--profile stereo-widthcatches the change directly (the downmix note is correctly suppressed there, since that axis DID read the stereo image). Theadded-hfaxis reportsnot_applicable(→inconclusive) at low sample rates where the ≥8 kHz band collapses to a degenerate handful of LTAS bins (e.g. sr = 16 kHz is the single Nyquist bin) instead of a confident "no change" over nothing. A DC offset concentrates energy in LTAS bin 0 and drags the centroid down — reading as false tonal "dulling" — so the advisory carries adc_offsetraw comparator (per-signal means +presentflag) and, when present, a summary sentence telling you to high-pass before comparing rather than chase phantom dullness. All three are presentation/advisory only — like corroboration, they never move the verdict. - duration preserved (a length change ⇒ not a pure pitch shift) and refuses otherwise. The warp-aware
kernels take the accepted
-
Shipped surfaces (same measurement, three entry points): the Python
quality-lab compareis the dev-loop surface;pulp audio compare <ref> <cand> [--profile …] [--reference-role …] [--json …]is the shipped CLI verb (thin delegator —tools/cli/cmd_audio_compare.cpplocates the opt-in managed tool and forwards, so no DSP links into the CLI);pulp_audio_compareis the MCP tool mirroring it (returns the report JSON). All three run the identical axis logic. The/audio-compareslash command wraps the CLI. Install the tool once:pulp tool install audio-quality-lab. -
Golden-render regression net (
quality_lab.regression_net,quality-lab regression-net --manifest net.json). The daily-driver loop: keep a golden render per plugin/preset,pulp audio render --plugin …the candidate, andcompareacross every wired axis, emitting a table (plugin | profile | verdict | corroboration | flags). Fail policy is the contract: the fail signal keys off axis verdicts only — exit 1 iff an axis reportsregression_suspected; a pair that couldn't be measured (missing/corrupt render →invalid, or a bad manifest) is a separate ERROR (exit 2), never greenlit as clean; exit 0 = all measured, none regressed. The corroboration column is informational and never affects the exit code — the modulated family (chorus/phaser/flanger/tremolo/ring-mod) is time-variant so it readsnot_corroboratedforever (theexpected_for:["time_variant_processing"]flag). This is advisory reporting attached to a change; gating proper stayspulp audio validate compare. The module is the reusable REFERENCE — a plugin suite (pulp-classic-effects, bendr, GPU NAM) wires its own renders intorun_netand commits a portable manifest (paths resolve relative to it). Read the change class, not the backend: timbral + modulated changes are valid through the net; bendr fixed-ratio A/Bs are valid, but bendr ratio changes / tempo-sampler offsets are time-misaligned (the residual false-alarms) → use the reference-free engine path instead. See the guide's "Golden-render regression net" section. -
Live plugin before/after (measure a real plugin, then judge the change).
compareneeds two WAVs; capture them from a live/hosted plugin with the existing steady-state tap, then judge:# capture the KNOWN-GOOD baseline (announce audio + cap duration per the local-dev etiquette) pulp run --plugin before.clap --audio-capture-rolling before.wav --headless # tear down promptly # …apply the DSP change, rebuild… pulp run --plugin after.clap --audio-capture-rolling after.wav --headless pulp audio compare before.wav after.wav --reference-role golden # base = goldenUse
--audio-capture-rolling(last-N steady-state window — the regioncomparewants), NOT--audio-capture-wav(earliest int16 window).--audio-capture-rollingopens the live audio device: announce before launching, cap the wall-clock, and tear down (see the local-dev audio etiquette in CLAUDE.md). For a fully offline capture of an explicit bundle,pulp audio render --plugin <bundle> --out cand.wav …writes a WAV without a live device — feed that tocomparethe same way. This is the "measure a plugin with the scope/inspector and compare findings" path. -
Guide:
docs/guides/audio-quality-lab.md; module map + deferred-detector status:tools/audio/quality-lab/README.md.
Compare/measure design rationale & non-goals (why we built it this way / what we skipped)
Durable "why / watch-out-for" notes so this isn't re-litigated (rationale, not workflow history):
- A vertical agent report before a shared measurement record. The harness + lab + inspector
already measure; the gap was orchestration + a defended verdict. A universal
{ref,cand,delta}scalar is too lossy to defend a judgment, so measurements are typed evidence envelopes and curves/THD stay first-class. (Two independent adversarial reviews converged on this.) - Cross-tool "agreement = trust" is NOT a signal and is deliberately absent — different algorithms disagree for legitimate reasons; treating agreement as trust manufactures overconfidence. If ever added, it reads as "corroborated under a comparable-axis contract."
- Doctor
--response/--thdare NOT valid on arbitrary before/after WAVs —--responseis a peak-normalized self-spectrum (not a transfer response); THD is gate-grade only for a steady bin-coherent sine. They need a controlled stimulus, so they're excluded fromcompareand join later behind stimulus-supplying profiles. - Alignment is
not_requiredfor both current axes — global LTAS metrics (centroid shift, HF fraction) are alignment-free; the envelope records the policy, we don't build general alignment for them. A future transients/timing axis will need a real alignment policy first. - Axes are a per-profile registry, added one entry at a time — each axis declares its
axis/tool/bad_sign/default_threshold/kernel/summarize; the shared_measuredoes applicability + level-match + materiality and_verdictdoes the intent-safe mapping.bad_signis the sign of the delta that is a regression against a golden reference (−1 = a drop is bad, +1 = an increase is bad). Only axes on the same contract (global, delta-in-(0,1), self- applicability) belong here — stimulus/alignment-dependent measures do NOT. regression_suspectedrequires--reference-role golden— "worse" assumes the reference is the known-good baseline; without that, a change is neutral. It ALSO requires the change to be in the axis's bad direction (bad_sign), so a legitimately-brighter or HF-reduced candidate reads asmaterial_change_detected, not a false regression.- Axis thresholds are per-axis and unit-bound to (0, 1) — every current axis's delta is a
dimensionless ratio in (0, 1), so the threshold guard is
0 < t < 1. A dB-unit axis (e.g. HNR drop) is deliberately deferred until the threshold validation is made per-axis; don't shove a dB metric onto the (0,1) contract. - Speech/perceptual & feature-extractor scope is bounded — PESQ/POLQA/DNSMOS/NISQA are speech/ telephony (out of scope for full-reference music); aubio/librosa/Essentia are feature extractors, not MOS predictors (advisory raw-comparators behind profiles, never verdicts); scalar-only where the scalar is the calibrated deliverable (PEAQ/AQUA-Tk ODG). GPL/AGPL tools stay behind the env-path license fence.
audio_io.load_wavmono-downmixes — fine for tonal balance; a future stereo-image axis must NOT use that loader.