wavelens
A command-line Audio QA tool for objective audio testing that compares a reference WAV file against a device-under-test (DUT) copy — after a codec, a transcode, a broadcast chain, or any other processing step — and reports exactly how (and whether) they differ. Point it at two files; it hands back a self-contained HTML report with a pass/fail verdict and the specifics behind it.
Overview
wavelens answers one question, precisely: does this DUT file sound the same as this reference file, and if not, how? It runs a battery of independent checks — an exact sample comparison, a level-matching check, a distortion/noise scan, a spectral (frequency-response) comparison, a loudness comparison, and a few optional deeper checks — and folds the results into one HTML report you can open in any browser.
Every report is self-contained — a single HTML file with everything embedded (charts, data, styling). Nothing to install to view it, nothing else to send along with it.
wavelens is part of the WaveLens toolkit. wavscan answers a point-in-time frequency/level question about a single file; wavsummary rolls up a whole directory of wavelens reports into one pass/fail summary. Two more tools are free, separate downloads from github.com/wavelens-dev/wavelens-free-tools: wavgen generates synthetic reference signals (tones, sweeps, noise) if you need test material, and wavchan splits, packs and joins channels. This guide covers wavelens itself.
Input requirements
- File format: 16-bit, 24-bit or 32-bit integer PCM, or 32-bit or 64-bit floating-point WAV, plain or WAVEX. Anything else (8-bit, compressed, non-PCM) is rejected at load time with a clear error — never silently converted.
- Floating-point files: values above 0 dBFS are read as they are and are not treated as clipping. A file containing NaN or infinite samples is refused, and the error names the first one. When either file is floating-point, Bit-Exactness measures the size of a difference in 24-bit steps.
- Sample rate: 22,050 Hz to 192,000 Hz inclusive. Covers every standard rate in common use (22.05k, 24k, 32k, 44.1k, 48k, 88.2k, 96k, 176.4k, 192k).
- Channels: mono or stereo. Reference and DUT must have the same channel count.
- A valid WaveLens license — see License setup below.
Have 5.1/7.1/other multichannel content? Split it into stereo/mono pairs first (the free wavchan tool does this with wavchan split; download it here), then run wavelens once per pair. wavelens itself only ever compares one mono or stereo pair per invocation.
License setup
wavelens requires an active license file to run. It looks for one in this order — the first mechanism that resolves wins:
- The
--licenseflag — an explicit path, always wins if given. - The
WAVELENS_LICENSE_FILEenvironment variable, if set. - A defaults file's
general.license_pathsetting — see The defaults file. - Zero configuration: a file named exactly
wavelens.licsitting in the same folder as thewavelensexecutable.
# explicit path wavelens analyze --license /path/to/wavelens.lic --ref ref.wav --dut dut.wav --out report.html # or, once per shell session export WAVELENS_LICENSE_FILE=/path/to/wavelens.lic wavelens analyze --ref ref.wav --dut dut.wav --out report.html # or simplest: drop wavelens.lic next to the wavelens executable and pass nothing
Not sure which license file wavelens would actually pick up, or whether it's still valid? Run wavelens license info — it tells you exactly which of the four mechanisms resolved, and the license's status.
Don't have a license file, or need a renewal? Get one at wavelens.dev, or contact us.
Quick start
Compare a reference file against a DUT and write an HTML report:
wavelens analyze --ref reference.wav --dut device_under_test.wav --out report.html
# output:
wrote report.html (overall: FAIL -- Level Fidelity, Spectral Fidelity)
Open report.html in any browser. The one-line stdout summary gives the overall verdict and, when it is FAIL, the checks that failed it. The report has the full picture, including which checks determine the overall pass/fail (a byte-exact mismatch alone doesn't fail a lossy-codec comparison by default — see The checks it runs).
New to WaveLens? Your first comparison walks through these steps one at a time and says what to expect at each one.
Commands
Run wavelens --help (or -h) to print the full usage in the terminal. It also works after a command, for example wavelens analyze --help, and needs no license.
wavelens analyzecompare a reference and a DUT file, produce a full reportThe main command. Aligns the two files (so it doesn't matter if the DUT has extra silence at the start, for example), then runs every check and writes one HTML report — always (when the files can't be compared, a small error report; see Exit status). Add --json if you also want the raw numeric results as JSON, for feeding into your own tooling.
| Flag | Required | Description |
|---|---|---|
| --ref <ref.wav> | required | The known-good reference file. |
| --dut <dut.wav> | required | The file being checked against the reference. |
| --out <path> | required | Where to write the report — see below. |
| --json | optional | Also write the raw results as a .json file alongside the HTML. |
| --config <file.json> | optional | Override individual checks' tolerances for this one run — see Per-run overrides. |
| --exit-code | optional | Exit with status 2 when the overall verdict is FAIL — see Exit status. |
--out has two modes: if it names an existing directory (or ends in /), the report is written there, named after your DUT file (report_dir/ + device.wav → report_dir/device.html) — handy when scripting a batch of comparisons. Otherwise, --out is used as the exact file path you want.
By default, three short status lines print to stderr while a comparison runs — Aligning..., Analyzing..., Writing report... — so a long file doesn't leave you staring at a blank terminal wondering if it's still working. They go to stderr only, so they never mix into the one-line summary on stdout that your scripts may read. Pass --quiet to suppress them, e.g. for a batch script where the extra lines would just clutter a log.
Exit status. 0 means the report was written — whatever the verdict, so a FAIL does not stop a script by itself. 1 means wavelens could not complete the comparison (an unreadable or invalid file, files in different formats, an alignment failure) or could not write its output. Once the command line and license are accepted, a comparison that cannot be completed leaves a small error report at the report path in its place — verdict ERROR, the files and the reason (with --json, a JSON that says the same) — so wavsummary counts the run instead of missing it, and an older report at that path is not left standing for a run that has since failed. A mistake in the command itself, such as a missing flag or an invalid --config, writes no report. Add --exit-code and a FAIL verdict exits 2 instead, with the report still written — so a pipeline can tell a failed comparison (2) from a broken run (1). The same verdict is in the results JSON as overall_verdict ("PASS" or "FAIL"), gating_failure_count and gating_failures (the failing checks, comma-separated).
# stop the pipeline on a FAIL verdict wavelens analyze --exit-code --quiet --ref ref.wav --dut dut.wav --out report.html # exit 0: PASS · exit 2: FAIL · exit 1: could not run
# exact output path wavelens analyze --ref ref.wav --dut dut.wav --out results/my_report.html # directory mode -- named automatically from the DUT's filename wavelens analyze --ref ref.wav --dut dut.wav --out results/ # also write the raw numbers as JSON wavelens analyze --ref ref.wav --dut dut.wav --out report.html --json
wavelens trimextract clean content from a sync-tone-wrapped recordingIf your file was generated with a leading/trailing sync-tone marker (the free wavgen tool can add one with its --synctone flag), trim finds both markers and writes out just the clean content between them, with a small silence guard on each side. Fails loudly — with no output file — if it can't confidently locate both markers, rather than guessing.
| Flag | Required | Description |
|---|---|---|
| --input <in.wav> | required | The sync-tone-wrapped recording. |
| --out <out.wav> | required | Where to write the trimmed content. |
| --report <report.json> | optional | Write detection diagnostics (offsets, detection confidence) as JSON instead of printing them. |
wavelens trim --input raw_capture.wav --out clean.wav # capture the detection diagnostics (offsets, detection confidence) as JSON instead of stdout wavelens trim --input raw_capture.wav --out clean.wav --report offsets.json
wavelens plotvisualize a single file — no reference, no comparisonWhen you just want to look at one signal rather than compare it against anything — its spectrum and its full waveform over time — plot renders a self-contained, zoomable HTML page for that one file. No verdicts, since there's nothing being compared. For stereo input, every section (Spectrum, Frequency (Linear), Signal Overview, Time Energy) renders a full Left Channel view followed by a full Right Channel view — not one channel standing in for both.
| Flag | Required | Description |
|---|---|---|
| --input <in.wav> | required | The file to visualize. |
| --out <path> | required | Same directory-vs-exact-path behavior as analyze's --out. |
| --json | optional | Also write the file's own metadata as JSON. |
wavelens plot --input mystery_file.wav --out plot.html # also write the file's own metadata as JSON wavelens plot --input mystery_file.wav --out plot.html --json
wavelens license infocheck what license you're actually running underPrints the license file wavelens would use for a real run, which of the four lookup mechanisms found it, and its status. Unlike every other command, this one still runs and tells you what's wrong even when the license is invalid or expired — diagnosing that is its whole job.
wavelens license info
# example output
License File: /opt/wavelens/wavelens.lic (next-to-executable default)
License ID: b7b64b7e-904c-47d2-9c7c-47e00d463949
Customer: you@example.com
Name: Jane Doe
Product: wavelens
License Type: PERSONAL
Expiry: 2027-01-01
Signature: VALID
License Status: ACTIVE
Takes the same --license flag as every other command, if you want to check a specific file rather than whatever would resolve automatically.
Reading reports
Every report opens with an Overall Verdict — PASS or FAIL — at the top, followed by a row of navigation tabs, one per check that ran. A tab is marked when its own check failed, so you can jump straight to the section that needs attention instead of scrolling through everything.
Below that, each check gets its own section: what it measured, the numbers, and (for a failure) exactly where in the file the worst discrepancy was found. Charts are interactive — zoom and pan on the spectral and waveform-overview charts to inspect a specific region closely.
The full raw numeric results are embedded in every HTML report, whether or not you asked for a separate .json file with --json — the JSON option is purely a convenience for scripting against, not the only place the data lives. The results start with the overall verdict (overall_verdict, gating_failure_count, gating_failures), so a script can read pass/fail and the failing checks without parsing anything else.
The checks it runs
Six checks run on every comparison by default; two more are available if you ask for them with a flag. Before any of them, Signal Alignment time-syncs the reference and DUT — that step gates the whole comparison (it can stop the run early with an alignment error) but isn't counted among the six, since it doesn't produce a graded pass/fail result of its own. See Reading reports above for where it shows up in the report. Immediately after it, a length-coverage check confirms the aligned DUT actually contains the reference's entire length — see the callout below.
| Check | What it answers | Default | Skip with | Gates overall verdict |
|---|---|---|---|---|
| Bit-Exactness | Are the two files byte-for-byte identical, sample by sample? | on | --no-bitexact | off by default — --bitexact turns it on |
| Level Fidelity | Does the DUT's level (amplitude) track the reference's, sample by sample, within tolerance? | on | always runs | on by default — --levelfidelity-ref-only turns it off |
| Artifact Detection | Are there localized glitches — dropouts, clicks, spikes — that don't belong? | on | always runs | on by default — --artifact-ref-only turns it off |
| Spectral Fidelity | Does the DUT's frequency content (its spectrum) match the reference's? | on | always runs | always — no flag |
| Distortion (THD+N) | How much unwanted extra energy did the DUT add relative to the reference? | on | --no-distortion | always, when it runs — no flag |
| Loudness | Do the two files' overall perceived loudness, loudness range, and peak level match (ITU-R BS.1770, the broadcast-standard loudness measurement)? | on | --no-loudness | off by default — --loudness turns it on |
| Time Domain | A deeper, whole-file per-sample percentage-difference scan. | off | turn on with --deepscan | always, when on — no flag |
| Impulse Template | A deeper, transient-focused per-sample comparison. | off | turn on with --impulse | always, when on — no flag |
By default, a Bit-Exactness failure alone does not fail the overall verdict. Five of the always-on checks are tolerance-based and can genuinely pass a well-functioning lossy pipeline (a codec round-trip, for example) even though the files aren't byte-identical — Bit-Exactness structurally can't pass that same case. If your workflow specifically needs lossless verification, add --bitexact to require it for a PASS.
Loudness works the same way, for the opposite reason. It still runs and its reading is always shown — badged Reference Only instead of Pass/Fail — but doesn't fail the overall verdict by default. A lossy codec or a processing chain can shift the loudness or raise the true peak by design, so treating it as gating by default would fail comparisons that have nothing wrong with them. Add --loudness to make a real Loudness failure count toward the overall verdict.
Artifact Detection and Level Fidelity can't be skipped, but either can be made non-gating. Unlike Bit-Exactness/Loudness above, both still gate the overall verdict by default — that hasn't changed. --artifact-ref-only and --levelfidelity-ref-only flip that to the same non-gating posture Loudness already has: the scan still runs, every field is still in the JSON output, and the card still shows the real result, just badged Reference Only instead of Pass/Fail. Nothing is hidden either way — only whether a failure here counts toward the headline PASS/FAIL changes.
Level Fidelity also catches sustained defects, not just rare outliers. Its main check is a 99th-percentile (p99) tolerance, so a rare, isolated glitch (a single corrupted sample, say) doesn't fail an otherwise-clean multi-hour file. A genuine, sustained defect, though, can be too small a fraction of a long file to move a percentile, even if it lasts a full second and is clearly audible. So Level Fidelity also tracks the longest continuous run of over-tolerance samples and fails if that run lasts at least min_sustained_run_ms (default 20ms) — independent of what percentage of the file it represents. You won't normally need to touch this; it's mentioned here so a report showing a "Sustained Run" figure alongside P99 makes sense.
Level Fidelity's own tolerance defaults to 1.02dB (level_fidelity.p99_tolerance_db) — just above 1dB, so a genuinely flat ~1dB level difference doesn't fail on measurement noise alone. Loosen or tighten it per run with --config; see Per-run overrides.
Artifact Detection separates the events that can fail the check from minor ones. Every localized event it finds is listed in the report, but only a counted event can fail the check. An outlier sample counts when it stands out by more than 4 times the detection threshold (artifact.min_severity), or when it stands out by more than 100 times that threshold measured against the whole file rather than just its immediate neighborhood (artifact.min_global_severity) — that second test is what catches a short burst of noise, which looks only mildly unusual next to its own neighbors whatever its level. A run of exact silence or full scale counts when it lasts at least 8 samples (artifact.min_silence_clip_run); above 48 kHz that number and the size of the neighborhood each sample is measured against (64 samples, artifact.window_samples) grow in proportion to the sample rate — 16 and 128 at 96 kHz, 32 and 256 at 192 kHz — so they cover the same stretch of time at every rate, and the report's Config table shows the values used. Anything else is a minor event: it appears on the card (as Minor Events, next to Events Counted) but doesn't change the verdict. That keeps the check from failing a lossy pipeline over the isolated one- or two-sample wobbles a codec routinely leaves, while a real dropout, click or burst still fails. To count every event, set min_severity and min_silence_clip_run to 1 and min_global_severity to 0 in --config.
Spectral Fidelity looks at one moment of the file by default, and at the whole file for sweeps. Unless told otherwise it compares a single frame taken from the middle of the aligned region, and the report says which moment (“Checked at 0:05.0 of 0:10.0”). For steady content (a tone, a multitone, noise) one frame is representative. For content whose spectrum changes over time it is not, and a fault elsewhere in the file would go unseen. With --freqsweep or --ampsweep the whole file is scanned instead: frame after frame, start to end, with every frame judged against the same tolerances and the result pooled into one verdict. The scan leaves out the first and the last 4096 samples (about 85 ms each at 48 kHz; above 48 kHz the number grows with the sample rate, so it stays about 85 ms, for example 8192 at 96 kHz), because every lossy codec has a start-up transient in its first few tens of milliseconds and a tail in its last, and on a loud sweep that begins at full level the start alone can fail a transparent codec; a fault inside those samples is still caught by the other checks, such as Artifact Detection and Level Fidelity. The report then says “Checked across the whole file except the first and last 85 ms (58 points)” and shows when the worst bin occurred. The scan's last frame is placed to end exactly where the scanned range ends, so it always reaches the end of the sweep, whatever the file's length. To scan from the very first to the very last sample, or to leave out more, set spectral_fidelity.sweep_edge_skip_samples through --config (a number of samples at any rate; 0 means no skip; it applies to each end). A whole-file scan fails on the same two conditions as a single frame — the 99th-percentile error across all compared bins being over tolerance, or any peak bin being over tolerance — counted over every frame. A fault confined to a very small part of a long file can therefore still be diluted by the rest; the card shows the time of the worst bin so you can look there.
The Ref Peak Level and DUT Peak Level tiles show the level of the loudest tone, not of the loudest bin. A tone that falls between two FFT bins reads up to 1.4 dB low in the bin itself, so each tile takes the loudest bin and its two neighbours, estimates how far off the bin centre the tone sits and adds back the loss the window causes at that offset: a −6 dBFS tone reads −6.0 dB wherever it falls. This is a display value only — the verdict compares ref and DUT bin by bin and does not use it. It assumes one clear tone; with another component within about three bins, or for noise, the tile is no better than the bin reading and can be up to 1.4 dB higher than it.
Any other content can be scanned the same way by setting spectral_fidelity.continuous to true in a --config file — for example a steady tone with an intermittent fault, or a step sequence. Setting it to false turns the whole-file scan off even under a sweep profile. spectral_fidelity.overlap_fraction (0 to 0.5, default 0) makes consecutive frames overlap: more frames, a longer run and a finer timeline. It only applies while the whole-file scan is on. To take a handful of independent looks spread across the file instead, use --snapshots N; the two modes can’t be combined. Distortion scans the same way with distortion.continuous and distortion.overlap_fraction; its reading is the worst frame’s.
Spectral Fidelity's FFT size defaults to 8192 samples up to 48 kHz, 16384 above that up to 96 kHz, and 32768 above 96 kHz (spectral_fidelity.fft_size) — that keeps the same 5.9 Hz per bin and the same 171 ms frame at every sample rate, so a close pair of components is resolved, and a result judged, the same way at 48, 96 and 192 kHz. The report's Configuration table shows the size used and, for a rate above 48 kHz, “built-in default for this sample rate”. A clip too short to hold one frame of the scaled size uses the next smaller one (never below 8192). Setting fft_size yourself overrides all of this. It is shared with Distortion, so one setting sizes both checks' FFT rather than two independently-configurable ones. It has to be one of 1024, 2048, 4096, 8192, 16384, 32768; any other value is rejected with an error at run time rather than silently rounded to the nearest valid size. Larger values trade time resolution for finer frequency resolution — you'd rarely need to change this, but it's overridable per run with --config the same way Level Fidelity's tolerance above is.
A length-coverage check runs right after alignment, and unlike every check above, there's no flag to soften it. Successful alignment only means wavelens found the right time offset — it doesn't guarantee the DUT actually contains all of the reference's content. A DUT that's materially shorter than the reference (an incomplete capture, say) can still align perfectly on the portion it does have, and every check above only ever looks at that same covered portion — so without this check, a badly incomplete DUT could pass every single check and still read as an overall PASS. If more than 1% of the reference's length falls outside the aligned overlap, the report shows a "Length mismatch" warning and the Overall Verdict fails, even if every individual check above passed. A DUT that's longer than the reference is not a problem by itself — a filter tail or a capture that ran a little past the end is normal and only flagged if it somehow left part of the reference itself uncovered. Raise general.length_mismatch_tolerance_pct in --config only if your own content has a real, expected partial-coverage gap.
A silent channel never blocks the report. If a reference or DUT channel has near-silent content (below general.low_level_signal_threshold_db, -70dBFS by default), analyze still produces a full report — whether the silence is intentional (both sides silent, nothing to test) or a genuine defect (the DUT went silent when it shouldn't have, or leaked content into a channel meant to stay silent). If both sides are genuinely silent, it reads as a clean pass; if only one side is silent, the mismatch is caught by Bit-Exactness/Level Fidelity and reported as a real FAIL, the same as any other discrepancy. The Signal Alignment card explains exactly what happened per channel whenever this applies — look there if a report's alignment numbers don't match what you expected on a very quiet test file.
Distortion doesn't fit every kind of content
Distortion (THD+N) is a single-tone measurement — it needs one dominant frequency to measure against. If your reference is multitone, broadband noise, or heavily clipped content with no single dominant tone, this check isn't meaningful and you should skip it with --no-distortion.
Understanding a failure
Each failed check's section shows the specific numbers that tripped its tolerance, and — where it makes sense for that check — exactly where in the file (which sample, which frame, which frequency) the worst point was found. Loudness is the one exception: it's a whole-file measurement, so there's no single "worst sample" to point to.
A quick way to triage a large batch of reports: check the nav tabs first. A report with only Bit-Exactness marked (and nothing else) is very different from one with Spectral Fidelity and Distortion both marked — the first might be an expected lossy-pipeline result, the second is a real, audible discrepancy worth investigating.
For stereo files, Signal Overview (the always-present full-waveform card at the top of the report) shows a full Left Channel envelope followed by a full Right Channel envelope — not just one channel's waveform standing in for both. Failure-position markers only ever apply to a specific channel's own data, so they only ever appear on that channel's chart, not overlaid on the other one.
A reading right at a tolerance line gets a tiny allowance for measurement noise. No real DSP pipeline (dB conversion, FFT, bit-depth quantization) produces an exact number for something like a synthesized "flat 1dB shift" — the true measurement lands a few millionths of a dB to one side or the other, depending on the specific frequency and level involved, not on anything wrong with your content. Level Fidelity, Spectral Fidelity, and Loudness all absorb that noise with a very small built-in allowance before deciding pass/fail, so a signal that's genuinely indistinguishable from your tolerance gives the same result whichever frequency happens to be tested. This doesn't loosen what you configure — a real difference worth catching is many orders of magnitude larger than the allowance, so nothing that should fail starts passing.
The defaults file
Rather than pass the same flags on every run, drop a wavelens-defaults.cfg file next to the wavelens executable. It's picked up automatically — no flag needed. Without it, wavelens simply uses its built-in defaults; the file only lets you replace them with your own.
{
// wavelens-defaults.cfg -- // comments are supported
"general": {
"license_path": "/opt/wavelens/wavelens.lic"
},
"analysis": {
"level_fidelity": {
"p99_tolerance_db": 1.2
},
"loudness": {
"integrated_tolerance_lu": 0.75
}
}
}
Want to use a defaults file from a non-default location, or check the file itself explicitly? Pass --defaults-cfg <path> on any command — available everywhere --license is.
wavelens analyze --defaults-cfg /opt/wavelens/wavelens-defaults.cfg --ref ref.wav --dut dut.wav --out report.html
If you point --defaults-cfg at a file that doesn't exist or won't parse, that's treated as a real error — it doesn't silently fall back to built-in defaults. That's specific to an explicit request; the automatic next-to-executable lookup, by contrast, is silently skipped if the file just isn't there.
Seeing every default at once
A ready-made wavelens-defaults.cfg ships next to the wavelens executable. It lists every tunable field as a compact // "field": built-in-value line, all still commented out — so it's exactly as inert as having no defaults file at all, every field falling straight through to the built-in default — and doubles as a one-page reference for what every setting defaults to. Because it sits next to the executable under exactly this name, it's picked up automatically; to change a default, uncomment that one line and edit its value, and leave everything else as it is.
Used unedited, it changes nothing at all — with every field commented out, it behaves identically to no defaults file being present. That also means it's safe to keep around indefinitely: a file made only of commented-out lines never goes stale against a future built-in change, since it was never actually setting anything. (Once you uncomment a line, that value is yours — it won't follow a later change to the built-in default.)
Tester identity in the footer
Every report's footer shows the tool version, license, and timestamp. If you also want it to show who ran the test — useful for a report handed to a client, or archived for your own records — set any of test_engineer, test_product, test_company_name in your defaults file's general section, and flip tester_info_enabled to true. None of the four is set by default. A defaults file with the identity values set but tester_info_enabled left false (or omitted) doesn't show them — you need both the identity values and tester_info_enabled: true for anything to appear.
{
"general": {
"test_engineer": "J. Rivera",
"test_product": "SuperBass",
"test_company_name": "Acme Audio Labs",
"tester_info_enabled": true
}
}
All three identity fields are optional and independent — set only the ones that apply. The footer combines them as Tested by ENGINEER · COMPANY · product PRODUCT, each part present only if you set it; an empty string ("") is treated the same as leaving the field out entirely.
test_company_name is for when the organization running the test isn't the one that built test_product — a lab testing a client's implementation, for example. It's descriptive text for the report, not tied to your license in any way — it doesn't need to match your license's own organization field.
Named shortcuts: general.default_profile
For the common "just turn this on" case, default_profile expands to the individual field(s) it implies, instead of you writing those out by hand. It belongs to the analysis settings' general section: in a --config file that's the top-level "general" section; in wavelens-defaults.cfg it's the "general" section nested under "analysis". (The defaults file's own top-level "general" section, which holds the tester-identity fields above, is a different one.)
// --config file: turn on Time Domain, same effect as --deepscan
{
"general": {
"default_profile": "time_domain"
}
}
// wavelens-defaults.cfg: same thing, one level deeper
{
"analysis": {
"general": {
"default_profile": "impulse_domain"
}
}
}
| Value | Expands to |
|---|---|
| "time_domain" | Turns on Time Domain — same as --deepscan. |
| "impulse_domain" | Turns on Impulse Template — same as --impulse. |
| "bit_accuracy" | Adds the tolerant Bit-Exactness reading — same as --bitaccuracy. |
| "amplitude_sweep" | Sets Spectral Fidelity's profile to amplitude-sweep — same as --ampsweep. |
| "frequency_sweep" | Sets Spectral Fidelity's profile to frequency-sweep — same as --freqsweep. |
| "bit_exact" | No-op — already the built-in default. Lets you state that intent explicitly without changing any behavior. |
| "frequency_domain" | No-op, same reasoning as "bit_exact" above. |
Any other value is rejected — analyze stops with an error naming all seven valid values, rather than silently ignoring a typo. An explicit field set anywhere else in the same file always wins over whatever the preset would have implied for that field — for example, default_profile: "time_domain" alongside an explicit "time_domain": { "enabled": false } in the same config leaves Time Domain off, the explicit value winning as usual.
This is equivalent to passing the corresponding CLI flags — reach for whichever is more convenient for how you're running wavelens: a flag for an ad-hoc run, default_profile for a config file you reuse across many runs.
Shared low-level-signal threshold: general.low_level_signal_threshold_db
A single dB level below which content counts as "quiet," shared across three places that would otherwise each need their own copy of the same number: fine alignment's own silence detection, Spectral Fidelity, and Time Domain. All three already default to -70.0dB on their own, so setting this field doesn't change anything by itself — it just lets you move that line once instead of three times. Same nesting rule as default_profile above — top level in a --config file, under "analysis" in wavelens-defaults.cfg.
{
"general": {
"low_level_signal_threshold_db": -60.0
}
}
An explicit spectral_fidelity.low_level_signal_threshold_db or time_domain.low_level_signal_threshold_db (in either --config or wavelens-defaults.cfg) still takes precedence over this shared one for that check. Fine alignment has no check-specific field of its own, so for alignment this shared field — or the built-in default — is all there is.
The matching tolerance fields — spectral_fidelity.low_level_signal_tolerance_db (a dB figure) and time_domain.low_level_signal_tolerance_pct (a percentage) — stay independent of this setting. Both checks can run in the same analyze call (--deepscan adds Time Domain alongside the always-on Spectral Fidelity), and the two fields use different units, so one shared number couldn't serve both.
Length-mismatch tolerance: general.length_mismatch_tolerance_pct
How much of the reference file's own length the aligned DUT may leave uncovered before analyze flags a length mismatch and fails the overall verdict — the setting behind the length-coverage check (see The checks it runs) that catches a DUT materially shorter than its reference, rather than silently scoring only the portion that lined up. Default 1.0 (percent).
{
"general": {
"length_mismatch_tolerance_pct": 2.0
}
}
Unlike the threshold field above, this one has no per-check override — it applies once, to the whole comparison.
Alignment tuning
Two fields live under a "alignment" section, for the rare case where a file's own timing characteristics trip up the default detection:
{
"alignment": {
"fine_align_agreement_tolerance_samples": 2,
"sync_search_window_sec": 45.0
}
}
fine_align_agreement_tolerance_samples (default 2) — for stereo input, the left and right channels are aligned independently, and if their computed offsets disagree by more than this many samples, analyze stops with an alignment error rather than risk comparing mis-synced audio. If you know your DUT introduces a genuine, small per-channel delay difference (some multiband processors do), raise this value to allow for it.
sync_search_window_sec (default 45.0) — how many seconds trim scans, from each end of the file inward, looking for the leading/trailing sync-tone burst (analyze itself never uses this field — it only matters when a file still carries the sync-tone wrapper and needs trim run on it first). The default already covers a typical hardware capture's pre-roll (silence, or the analyzer's own noise floor, before the tone — an Audio Precision capture, for example); widen it further if yours runs past roughly 40 seconds. It's also capped at half the file's own total length regardless of what you set, so the leading and trailing searches can never overlap on a short file — trim prints a note on stderr whenever that cap actually reduces the value you configured, so a failure caused by it is diagnosable rather than a mystery.
A few more advanced alignment fields exist beyond these two, for harder cases — contact support if the above don't resolve an alignment issue you're hitting.
Per-run overrides
For a one-off comparison that needs different tolerances than your usual defaults, pass --config <file.json> to analyze. Anything the file doesn't mention falls back to your defaults file (or the built-in default, if you don't have one) — you only need to specify what you're actually changing.
// loose_tolerance.json -- just this one run's Level Fidelity tolerance
{
"level_fidelity": {
"p99_tolerance_db": 2.0
}
}
wavelens analyze --ref ref.wav --dut dut.wav --out report.html --config loose_tolerance.json
A ready-to-edit example ships next to the executable as user-config.json. It's a sample --config file for a single run — never loaded automatically, so pass it explicitly (analyze --config user-config.json) — using the same field names as the "analysis" section of wavelens-defaults.cfg. It's a sampler of different kinds of override, not a recommended setup: Spectral Fidelity's peak_tolerance_db (0.1), low_level_signal_threshold_db (-65.0) and signal_tolerance_db (4.0); Level Fidelity's p99_tolerance_db (1.5); Distortion switched off with distortion.enabled: false (the config-file form of --no-distortion); and Artifact Detection made non-gating with artifact.gates_overall: false (the config-file form of --artifact-ref-only). Copy it, delete the entries you don't need, and keep only what a given run actually changes.
Precedence, highest first: --config for this run > your wavelens-defaults.cfg > the built-in default. A convenience flag like --bitexact (below) always wins outright over either config file.
A blank value in a config file isn't always the same as leaving the key out — it depends on the setting. For a numeric setting like p99_tolerance_db above, a blank value ("" or null) is safely ignored and falls back to the next tier, same as if you'd never mentioned it. For a text setting like level_fidelity.profile, a blank value is treated as an actual, explicit choice — it won't break anything (a blank profile just behaves like the ordinary default), but the report's Configuration table will show that setting credited to your config file with nothing visible in it, which can look like a mistake at a glance. If a config file is generated or templated automatically, it's worth making sure a text field is left out entirely rather than filled in blank, to avoid that confusing display. (The one exception is the tester-identity fields — see Tester identity in the footer — those are specifically built to treat a blank value the same as omitting it.)
Turning checks on and off
| Flag | Effect |
|---|---|
| --deepscan | Turns on the Time Domain check (off by default). |
| --impulse | Turns on the Impulse Template check (off by default). |
| --no-distortion | Skips Distortion entirely — no scan, no fields, no report card. Use for content THD+N can't meaningfully measure (see above). |
| --no-loudness | Skips Loudness entirely. |
| --loudness | Makes a Loudness failure fail the overall verdict (it doesn't, by default — the card is badged Reference Only instead). |
| --bitexact | Makes a Bit-Exactness failure fail the overall verdict (it doesn't, by default). |
| --no-bitexact | Skips Bit-Exactness entirely — for a batch where you already know every DUT won't be byte-identical and the badge is just noise. |
| --bitaccuracy | Adds a second, tolerant Bit-Exactness reading alongside the strict one — useful when "close enough" bit-level accuracy is what you actually care about, not perfect equality. |
| --levelfidelity-ref-only | Makes a Level Fidelity failure not fail the overall verdict (it gates by default). The scan still runs and the card still shows the real result, badged Reference Only instead of Pass/Fail. Level Fidelity can't be skipped entirely — no flag turns off the scan itself. |
| --artifact-ref-only | Same as --levelfidelity-ref-only, for Artifact Detection — gates by default, this makes a failure non-gating without hiding it. Artifact Detection can't be skipped entirely either. |
| --ampsweep / --freqsweep | For amplitude-sweep or frequency-sweep reference content specifically — tells Spectral Fidelity what kind of signal it's looking at, and scans the whole file instead of one frame (see Spectral Fidelity above). Mutually exclusive with each other. |
| --snapshots N | Take N independent spectral snapshots across the file instead of one, for content whose spectrum is expected to change over time. |
--bitexact and --no-bitexact can't both be set — they're opposites. --bitaccuracy is independent of both and can be combined with either. Same rule for --loudness and --no-loudness — one asks for strict gating, the other asks to skip the check entirely, so they can't both be set either.
--levelfidelity-ref-only and --artifact-ref-only don't have an opposite flag the way --bitexact/--loudness do — Level Fidelity and Artifact Detection already gate the overall verdict by default, so there's nothing to opt into. Neither check can be skipped entirely either; these two flags only control gating, not whether the scan runs.
Full flag reference
| Flag | Commands | Description |
|---|---|---|
| --license <file.lic> | all | Explicit license file path. |
| --defaults-cfg <file.cfg> | all | Explicit defaults-file path. |
| --ref <ref.wav> | analyze | Reference file. |
| --dut <dut.wav> | analyze | Device-under-test file. |
| --input <in.wav> | trim, plot | The single file to process. |
| --out <path> | analyze, trim, plot | Output path (directory-or-exact-file, see analyze). |
| --json | analyze, plot | Also write raw results as JSON. |
| --report <path> | trim | Write detection diagnostics as JSON. |
| --config <file.json> | analyze | Per-run tolerance overrides. |
| --deepscan | analyze | Turn on Time Domain. |
| --impulse | analyze | Turn on Impulse Template. |
| --ampsweep | analyze | Spectral Fidelity: amplitude-sweep profile; scans the whole file. |
| --freqsweep | analyze | Spectral Fidelity: frequency-sweep profile; scans the whole file. |
| --snapshots N | analyze | N independent spectral snapshots. |
| --bitaccuracy | analyze | Add a tolerant Bit-Exactness reading. |
| --bitexact | analyze | Bit-Exactness failure fails the overall verdict. |
| --no-bitexact | analyze | Skip Bit-Exactness entirely. |
| --no-distortion | analyze | Skip Distortion entirely. |
| --no-loudness | analyze | Skip Loudness entirely. |
| --loudness | analyze | Loudness failure fails the overall verdict (reference-only by default). |
| --levelfidelity-ref-only | analyze | Level Fidelity failure doesn't fail the overall verdict (gates by default; can't be skipped entirely). |
| --artifact-ref-only | analyze | Artifact Detection failure doesn't fail the overall verdict (gates by default; can't be skipped entirely). |
| --exit-code | analyze | Exit with status 2 when the overall verdict is FAIL (default: 0 whenever the report was written). |
| --quiet | analyze | Suppress the Aligning.../Analyzing.../Writing report... status lines (printed to stderr by default). |
Examples
Standard codec/pipeline verification
Byte-for-byte equality isn't expected (it's a lossy pipeline), so leave Bit-Exactness non-gating — the default. This suits test signals (tones, sweeps, multitones) and regression checks; for music or speech through a lossy codec, see the next example:
wavelens analyze --ref original.wav --dut after_codec.wav --out report.html
Lossy codec on music or speech: compare against a golden output
WaveLens compares waveforms, so a perceptual codec that sounds transparent still fails on music or speech — typically Level Fidelity and Distortion, which compare sample by sample, and often Spectral Fidelity. To catch a regression in a codec, an encoder setting or a build, keep the decoded output of a version you trust and compare every new decoded output against it. With the same codec and settings the two are identical or very close, so any change stands out:
# golden_decoded.wav: the decoded output of the build you trust; new_decoded.wav: the same content from the new build wavelens analyze --ref golden_decoded.wav --dut new_decoded.wav --out report.html --bitexact
Use --bitexact when the codec is deterministic, so any difference fails the verdict; leave it off to see how large a difference is. Whether a codec is transparent to the ear is a listening question, and WaveLens does not make that judgment.
Strict lossless verification
Confirming a "lossless" claim actually holds — require byte-exactness to pass:
wavelens analyze --ref original.wav --dut should_be_identical.wav --out report.html --bitexact
Broadcast-safety verification
Loudness runs by default but doesn't gate the overall verdict — its reading is shown as Reference Only, since a lossy codec or a processing chain can shift the loudness or raise the true peak on purpose (see above). If loudness/delivery compliance is actually what you're validating, require it to pass:
wavelens analyze --ref original.wav --dut delivery_master.wav --out report.html --loudness
Multitone or noise-based content
Skip Distortion (THD+N doesn't apply — see above):
wavelens analyze --ref multitone_ref.wav --dut multitone_dut.wav --out report.html --no-distortion
Frequency-sweep content with a tighter loudness tolerance
wavelens analyze --ref sweep_ref.wav --dut sweep_dut.wav --out report.html \ --freqsweep --config tight_loudness.json
Here tight_loudness.json contains { "loudness": { "integrated_tolerance_lu": 0.25 } } — a tighter Loudness tolerance than the default 0.5.
Amplitude-sweep content
wavelens analyze --ref ampsweep_ref.wav --dut ampsweep_dut.wav --out report.html --ampsweep
Deeper checks for content you're specifically suspicious of
Time Domain and Impulse Template are off by default because they're slower, whole-file scans — worth turning on when you already suspect a problem and want more evidence:
wavelens analyze --ref original.wav --dut suspect.wav --out report.html --deepscan --impulse
A tolerant bit-level reading alongside the strict one
Keep Bit-Exactness non-gating but also see how close the DUT gets, in dB, when it isn't byte-identical:
wavelens analyze --ref original.wav --dut dut.wav --out report.html --bitaccuracy
Content whose spectrum changes over time
Take multiple spectral snapshots across the file instead of one fixed midpoint:
wavelens analyze --ref long_program.wav --dut long_program_dut.wav --out report.html --snapshots 5
Content with an intermittent fault
A steady tone, a step sequence or any other content with no sweep profile that you still want checked from start to end:
wavelens analyze --ref tone_ref.wav --dut tone_dut.wav --out report.html --config whole_file.json
Here whole_file.json contains { "spectral_fidelity": { "continuous": true } }.
Skipping a check that's expected to fail across a whole batch
Rather than a permanent FAIL badge on every report, drop the check entirely — no scan, no field, no card. Works the same way for any always-on check:
# e.g. every DUT in this batch is a lossy re-encode, so Bit-Exactness never applies wavelens analyze --ref ref.wav --dut dut.wav --out report.html --no-bitexact # e.g. content with no meaningful loudness target (short effects, clicks) wavelens analyze --ref ref.wav --dut dut.wav --out report.html --no-loudness
Errors & troubleshooting
| Message | Meaning & fix |
|---|---|
| error: license INVALID -- no license found at '...' | Run wavelens license info to see exactly which lookup mechanism was expected to resolve and why it didn't. |
| error: license EXPIRED -- license expired on ... | Your license has expired. Renew at wavelens.dev, or contact us. |
| error: license INVALID -- license has been revoked | This license is no longer valid. Contact us if you think that's a mistake. |
| error: failed to read --ref/--dut '...' (not a supported 16/24/32-bit integer or 32/64-bit float PCM WAV file) | Check the file is really 16/24/32-bit integer PCM or 32/64-bit floating-point — see Input requirements. |
| error: ... sample rate ... is outside the supported range [22050, 192000] Hz | The file's sample rate is out of range — see Input requirements. |
| error: ... has N channel(s) -- wavelens only accepts mono or stereo input | Split multichannel content first with the free wavchan tool (wavchan split). |
| error: --bitexact and --no-bitexact cannot both be set | Pick one — they're opposites. |
| error: --loudness and --no-loudness cannot both be set | Pick one — they're opposites. |
| error: --ampsweep and --freqsweep cannot both be set | Spectral Fidelity's profile is a single choice — pick the one that matches your content, not both. |
| error: unrecognized argument '...' | Check the flag name and that it's valid for the command you're running. |
| error: alignment failed -- no consistent match ... | Real, non-silent content did not correlate anywhere that was checked: the whole file, its first and last second, or a wider search. A silent channel doesn't trigger this — see the callout in The checks it runs. Wrong files, unrelated content or heavy corruption — check you're comparing the right ref/DUT pair. |
| error: alignment failed -- ... polarity-inverted ... | One or both DUT channels match the reference with the sign flipped (a mis-wired or inverted capture chain). Flip the polarity of that channel and run again. |
| error: alignment failed -- ... outside the +-0.100 s search window ... | The DUT matches the reference but starts further out than the search covers. Set alignment.fine_align_max_search_sec in wavelens-defaults.cfg to the value the message suggests, or trim both files so they start together. Widen it only for a real delay of that size: with tone-like content a wide window can lock onto the wrong period. |
| error: alignment failed -- ... the start of the files lines up ... but the end lines up ... | The DUT sits at a different offset at the end than at the start: samples were skipped or repeated in between, or the two clocks run at different speeds (the message gives the difference in samples, and the rate in ppm if it were a steady drift). WaveLens compares both files at one fixed offset, so it stops instead of reporting a failure that is really a timing fault. Fix the capture, or cut the files where they drift apart and compare the pieces. |
| error: alignment failed -- ... the first/last N s lines up ... but ... does not | The files match in one part and not in another: a splice, a long dropout, inserted or missing audio, or a different or truncated ending. The message says which parts match. Cut the files where they diverge and compare the matching parts. |
| error: alignment failed -- the channels disagree by N samples ... | Left and right line up at different offsets by more than alignment.fine_align_agreement_tolerance_samples (default 2). If the DUT really delays one channel, raise that setting; otherwise check the capture. |
FAQ
Can WaveLens tell me whether a lossy codec is transparent on music?
No. A perceptual codec keeps the sound but not the exact waveform, and WaveLens's checks compare waveforms, so music or speech through a lossy codec fails Level Fidelity and Distortion, and often Spectral Fidelity, even when it sounds transparent (320 kbps AAC or MP3, for example). On test signals the verdicts fall in a sensible order: tones and sweeps pass at high bitrates and fail at low ones, and a sweep shows where a codec cuts off the top of the spectrum. For real content, use WaveLens to catch regressions — compare each new decoded output against a golden output from a build you trust (see Examples) — and judge transparency by listening.
Why did my report show Overall Verdict: PASS even though Bit-Exactness failed?
That's expected by default — see the callout in The checks it runs. Byte-exactness isn't required for an overall pass unless you pass --bitexact. The report itself always calls this out explicitly so it doesn't read as "everything succeeded" when one check structurally couldn't.
Why did my report FAIL even though every individual check passed?
Check the Signal Alignment card for a "Length mismatch" warning. Every check only ever looks at the region where ref and DUT actually overlap after alignment — if the DUT is materially shorter than the reference (an incomplete capture, most commonly), that covered region can pass every check cleanly while a real chunk of the reference was never compared at all. This one check has no softening flag, unlike every other check in this guide, because there's no legitimate reason to want a materially incomplete comparison to read as a clean PASS. See the callout in The checks it runs.
Why does my Loudness card say "Reference Only" instead of Pass or Fail?
Same idea as Bit-Exactness above, for the opposite reason: Loudness runs and its reading is always shown, but doesn't gate the overall verdict unless you pass --loudness. Its True Peak check compares the DUT's true peak with the reference's, so a DUT that peaks more than 0.5 dB higher fails it — codec overshoot is the usual cause — see the True Peak question below.
Why does a silent test file still produce a report?
A silent channel (ref, DUT, or both, below general.low_level_signal_threshold_db) never fails alignment on its own — see the callout in The checks it runs. You get a real report either way: a clean pass if both sides are genuinely silent, or a real FAIL from Bit-Exactness/Level Fidelity if only one side is — whether that's a DUT that went silent or one that leaked content into a channel meant to stay silent. The error: alignment failed messages are reserved for genuine problems — see Errors & troubleshooting.
When does True Peak fail?
True Peak compares the DUT with the reference: the DUT's true peak may exceed the reference's by at most 0.5 dB (loudness.true_peak_tolerance_db), and a DUT that peaks lower always passes. Identical files therefore always pass, even when the reference is mastered at full scale and itself reads above 0dBTP.
True Peak estimates the reconstructed analog waveform between the samples (oversampled, matching what a real DAC does), not just the samples on the grid. A file can keep its integrated loudness and still clip on reconstruction — a lossy codec or a limiter in the chain can raise those inter-sample peaks — and this is the check that catches it, independently of the Integrated Loudness delta.
To also hold the DUT to a fixed delivery ceiling such as −1.0dBTP (the EBU R128 convention), set loudness.true_peak_ceiling_dbtp via --config or the defaults file. It is off unless set, and when set it applies in addition to the comparison with the reference. Loudness doesn't gate the overall verdict by default (see the question above); add --loudness to make a True Peak or loudness failure count.
Can I compare more than one DUT to a reference in one command?
Not in a single invocation — wavelens analyze always compares exactly one reference to exactly one DUT. Loop over your DUT files from a script instead; see Examples.
My files are 5.1 surround. Can wavelens compare them directly?
No — wavelens only ever takes mono or stereo input. Split wider layouts into stereo/mono pairs first with the free wavchan tool's wavchan split command, then run wavelens analyze once per pair.
Do I need --json to get the raw numbers?
No — every HTML report already embeds the full raw results. --json is only for when you want that same data as a separate file, e.g. for your own scripts to parse.
What's the difference between analyze and plot?
analyze compares two files and produces a verdict. plot just visualizes one file — no comparison, no pass/fail, useful when you want to look at a signal's spectrum and waveform without a reference to check it against.