ferraus-analysis 0.1.2

Audio analysis for ferraus: tempo, loudness (EBU R128), spectrum, chords, diagnostics, comparison
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
//! The `analyze ...` and `compare` commands. Each is a thin wrapper over a public analysis
//! function: it decodes the input(s), runs the analysis, records headline metrics, and
//! returns the analysis struct (numbers rounded for tidy output) as the JSON result.

use crate::{chords, compare, levels, loudness, problems, spectrum, stereo, tempo, util};
use ferraus_core::op::InputKind;
use ferraus_core::prelude::*;
use serde::Serialize;

pub fn ops() -> Vec<OpSpec> {
    vec![
        OpSpec::custom("analyze tempo", Category::Inspect, "Tempo, beat times, downbeats, and time signature", analyze_tempo)
            .about("Onset strength (spectral flux on a log-mel spectrogram) is autocorrelated and scored on a comb of metrical levels with a perceptual prior around 120 BPM; beats are placed by dynamic programming (Ellis 2007) and the tempo is refined through the beat times. Downbeats are reported only when the bar accent pattern is clear.")
            .inputs(Inputs::ONE_AUDIO)
            .param(param("range", ParamKind::Str).default("60..200").help("Tempo search range in BPM, low..high"))
            .example("ferraus analyze tempo song.wav --json", "BPM, confidence, and beat times")
            .example("ferraus analyze tempo dnb.wav --range 140..190", "Search only fast tempi")
            .example("ferraus analyze tempo ballad.wav --range 50..100 --json", "Keep a slow ballad from being read at double time"),
        OpSpec::custom("analyze loudness", Category::Inspect, "EBU R128 loudness: integrated, short-term, momentary, range, true peak", analyze_loudness)
            .about("Integrated loudness with absolute (-70 LUFS) and relative (-10 LU) gates, maximum short-term (3 s) and momentary (400 ms) loudness, loudness range (EBU Tech 3342), 4x-oversampled true peak, and sample peak. --timeline adds the momentary and short-term curves, like a loudness meter or radar.")
            .inputs(Inputs::ONE_AUDIO)
            .param(param("timeline", ParamKind::Flag).help("Add momentary and short-term loudness curves"))
            .param(param("step", ParamKind::Time).default("100ms").advanced().help("Timeline resolution (multiples of 100 ms)"))
            .example("ferraus analyze loudness master.wav --json", "Integrated LUFS, LRA, and true peak")
            .example("ferraus analyze loudness mix.wav --timeline --step 1s", "Loudness curve every second")
            .example("ferraus analyze loudness episode.mp3 --json > loudness.json", "Save the loudness report of a podcast episode for a delivery check"),
        OpSpec::custom("analyze silence", Category::Inspect, "List silent regions with start, end, and floor level", analyze_silence)
            .inputs(Inputs::ONE_AUDIO)
            .param(param("threshold", ParamKind::Gain).default("-40dB").help("Level below which audio counts as silence (RMS, loudest channel)"))
            .param(param("min", ParamKind::Time).default("300ms").help("Shortest region reported"))
            .example("ferraus analyze silence podcast.wav --threshold -50dB --min 1s", "Pauses longer than a second")
            .example("ferraus analyze silence take.wav --json", "Silent regions with the defaults (-40 dB, at least 300 ms)")
            .example("ferraus analyze silence field.wav --threshold -60dB --min 100ms --json", "Short gaps in a quiet field recording"),
        OpSpec::custom("analyze spectrum", Category::Inspect, "Long-term average spectrum in fractional-octave bands or FFT bins", analyze_spectrum)
            .about("Welch-averaged spectrum of all channels. Band levels are in dB relative to a full-scale sine (a 0 dBFS sine in a band reads 0 dB). Also reports the spectral centroid, 85% rolloff, flatness, and strongest frequency.")
            .inputs(Inputs::ONE_AUDIO)
            .param(param("resolution", ParamKind::Choice).choices(&["1/3oct", "1/6oct", "bins"]).default("1/3oct").help("Band resolution"))
            .example("ferraus analyze spectrum mix.wav --json", "Third-octave levels from 20 Hz to 20 kHz")
            .example("ferraus analyze spectrum vocal.wav --resolution 1/6oct --json", "Finer sixth-octave bands to find a resonance")
            .example("ferraus analyze spectrum hum.wav --resolution bins --json", "Raw FFT bins, for example to locate a hum line exactly"),
        OpSpec::custom("analyze clipping", Category::Inspect, "Count and locate clipped runs", analyze_clipping)
            .about("A clipped run is at least --min-run consecutive samples at or above --threshold with the same polarity. Flat-topped runs below the threshold (audio clipped and then turned down) are reported with kind 'flat'.")
            .inputs(Inputs::ONE_AUDIO)
            .param(param("threshold", ParamKind::Gain).default("0dBFS").help("Clip level"))
            .param(param("min-run", ParamKind::Int).default("3").range("1..1000").help("Consecutive samples that count as a clipped run"))
            .example("ferraus analyze clipping take.wav --json", "Clipped runs with channel and time")
            .example("ferraus analyze clipping master.wav --threshold -0.1dB --min-run 2 --json", "Count near-full-scale runs of two or more samples in a loud master")
            .example("ferraus analyze clipping old_transfer.wav --min-run 1", "Report every sample at full scale, even single ones"),
        OpSpec::custom("analyze chords", Category::Inspect, "Chord sequence with times and confidence (best-effort)", analyze_chords)
            .about("Peak-picked chroma matched against harmonic-aware templates for major, minor, 7, maj7, m7, dim, sus4, and sus2 chords, with a bass-note cue for slash chords and Viterbi smoothing. Best-effort: reliable on clear harmonic material, approximate on dense mixes.")
            .inputs(Inputs::ONE_AUDIO)
            .best_effort()
            .param(param("change-penalty", ParamKind::Float).default("0.5").advanced().help("Smoothing: higher values change chord less often"))
            .param(param("min-duration", ParamKind::Time).default("250ms").advanced().help("Shortest chord segment kept"))
            .example("ferraus analyze chords song.wav --json", "Chord labels such as C, Am7, G/B with times")
            .example("ferraus analyze chords guitar.wav --change-penalty 1.5 --json", "Fewer, steadier chord changes on a strummed part")
            .example("ferraus analyze chords piano.wav --min-duration 500ms --json > chords.json", "Drop chord segments shorter than half a second and save the result"),
        OpSpec::custom("analyze similarity", Category::Inspect, "Spectral, cepstral, and loudness similarity between two files", analyze_similarity)
            .about("For agents checking whether a processed take drifted from the original: cosine similarity of the log third-octave spectra, MFCC distance, and loudness difference combined into a 0..1 score.")
            .inputs(Inputs::exactly(2, InputKind::Audio))
            .example("ferraus analyze similarity original.wav processed.wav --json", "Score near 1 means the character is unchanged")
            .example("ferraus analyze similarity mix.wav master.wav --json", "Check that mastering kept the character of the mix")
            .example("ferraus analyze similarity take1.wav take2.wav", "Compare two takes of the same part"),
        OpSpec::custom("compare", Category::Inspect, "Null test: align b to a and report RMS and peak difference and SI-SDR", compare_op)
            .about("b is aligned to a by FFT cross-correlation (within --max-offset), resampled and channel-matched if needed, then compared sample by sample: RMS and peak of the difference, null depth, best-fit gain, SI-SDR of b against a, and loudness difference. The agent's regression test.")
            .inputs(Inputs::exactly(2, InputKind::Audio))
            .param(param("max-offset", ParamKind::Time).default("1s").help("Largest alignment offset searched"))
            .param(param("no-align", ParamKind::Flag).help("Compare sample for sample without alignment"))
            .example("ferraus compare mix.wav master_v1.wav --json", "Confirm nothing was damaged")
            .example("ferraus compare original.wav roundtrip.wav --no-align --json", "Sample-for-sample null test of a format round trip")
            .example("ferraus compare stem_sum.wav mix.wav --max-offset 5s", "Allow up to five seconds of offset when the files start at different times"),
        OpSpec::custom("analyze problems", Category::Inspect, "Diagnose clipping, DC, clicks, hum, hiss, phase, silence, and more", analyze_problems)
            .about("Each problem has a severity (low, medium, high), details, and a suggested fix that is a runnable command or fx stage, e.g. 'dc-remove', 'declip', 'highpass:freq=30Hz', or 'silence trim --edges'.")
            .inputs(Inputs::ONE_AUDIO)
            .param(param("min-severity", ParamKind::Choice).choices(&["low", "medium", "high"]).default("low").help("Only list problems at or above this severity"))
            .example("ferraus analyze problems take.wav --json", "Diagnostics with suggested fixes")
            .example("ferraus fx take.wav -o fixed.wav --fx dc-remove --fx \"highpass:freq=30Hz\"", "Apply two suggested fixes")
            .example("ferraus analyze problems interview.wav --min-severity medium --json", "Only the medium and high severity problems"),
        OpSpec::custom("analyze stats", Category::Inspect, "Per-channel peak, true peak, RMS, DC, crest factor, dynamic range", analyze_stats)
            .inputs(Inputs::ONE_AUDIO)
            .param(param("histogram", ParamKind::Switch).default("off").help("Add a 61-bin dB histogram per channel (-60..0 dBFS)"))
            .example("ferraus analyze stats take.wav --histogram on --json", "Amplitude statistics with histograms")
            .example("ferraus analyze stats mix.wav --json", "Peak, true peak, RMS, DC offset, and crest factor per channel")
            .example("ferraus analyze stats master.wav --json > stats.json", "Save the statistics of a master as JSON"),
        OpSpec::custom("analyze stereo", Category::Inspect, "Stereo image: correlation, width per band, mono compatibility", analyze_stereo)
            .about("L/R correlation broadband and below 120 Hz, mid/side balance, width and correlation per band, and a 0..1 mono-compatibility score.")
            .inputs(Inputs::ONE_AUDIO)
            .example("ferraus analyze stereo mix.wav --json", "Check the mix folds to mono")
            .example("ferraus analyze stereo master.wav", "Correlation and width per band printed for a person")
            .example("ferraus analyze stereo widened.wav --json > stereo.json", "Save the stereo report after widening to check mono compatibility"),
    ]
}

/// Serialise an analysis struct with tidy rounding.
fn to_json<T: Serialize>(v: &T) -> Result<Value> {
    let mut j = serde_json::to_value(v)?;
    util::tidy_json(&mut j);
    Ok(j)
}

fn dry_plan(ctx: &OpCtx) -> Option<Value> {
    if ctx.globals.dry_run {
        let inputs: Vec<String> = ctx.inputs.iter().map(|p| p.display().to_string()).collect();
        Some(json!({"analyze": ctx.command, "inputs": inputs}))
    } else {
        None
    }
}

fn read(ctx: &mut OpCtx, i: usize) -> Result<AudioBuffer> {
    let b = ctx.read_audio(i)?;
    if b.is_empty() {
        ctx.warn("empty_input", format!("input {} has no audio frames", i + 1));
    }
    Ok(b)
}

fn finite(v: f64) -> Option<f64> {
    if v.is_finite() {
        Some(util::tidy(v))
    } else {
        None
    }
}

fn parse_bpm_range(s: &str) -> Result<(f64, f64)> {
    let parse = |t: &str| -> Result<f64> {
        let t = t.trim().trim_end_matches("bpm").trim_end_matches("BPM").trim();
        t.parse::<f64>().map_err(|_| Error::usage(format!("'{t}' is not a tempo in BPM")))
    };
    let (lo, hi) = units::parse_num_range(s, parse).map_err(|e| Error::usage(format!("--range: {}", e.message)))?;
    if !(lo > 0.0 && hi > lo && hi <= 1000.0) {
        return Err(Error::usage(format!("--range must be low..high with 0 < low < high <= 1000, got '{s}'")));
    }
    Ok((lo, hi))
}

fn analyze_tempo(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    let (lo, hi) = parse_bpm_range(&a.str_or("range", "60..200"))?;
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let buf = read(ctx, 0)?;
    let opts = tempo::TempoOptions { min_bpm: lo, max_bpm: hi, ..Default::default() };
    let r = tempo::detect_tempo(&buf, &opts);
    if r.beats.is_empty() {
        ctx.warn("no_beats", "no regular beat was found");
    } else if r.confidence < 0.3 {
        ctx.warn("low_confidence", format!("tempo confidence is low ({:.2})", r.confidence));
    }
    ctx.metric("bpm", util::tidy(r.bpm));
    ctx.metric("confidence", util::tidy(r.confidence));
    ctx.metric("beats", r.beats.len());
    ctx.metric("time_signature", &r.time_signature);
    to_json(&r)
}

fn analyze_loudness(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    let step = if a.flag("timeline") { Some(a.seconds("step")?.max(0.1)) } else { None };
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let buf = read(ctx, 0)?;
    let r = loudness::loudness_with(&buf, step);
    if !r.integrated_lufs.is_finite() {
        ctx.warn("unmeasurable", "integrated loudness is undefined (silence or shorter than 400 ms)");
    }
    ctx.metric("integrated_lufs", finite(r.integrated_lufs));
    ctx.metric("loudness_range_lu", util::tidy(r.loudness_range_lu));
    ctx.metric("true_peak_dbtp", util::tidy(r.true_peak_dbtp));
    ctx.metric("short_term_max_lufs", finite(r.short_term_max_lufs));
    to_json(&r)
}

fn analyze_silence(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    let thr = a.db("threshold")?;
    let min = a.seconds("min")?;
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let buf = read(ctx, 0)?;
    let r = levels::detect_silence(&buf, thr, min);
    ctx.metric("regions", r.regions.len());
    ctx.metric("total_silence_s", util::tidy(r.total_silence_s));
    to_json(&r)
}

fn analyze_spectrum(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    let res = spectrum::Resolution::parse(&a.choice("resolution"))
        .ok_or_else(|| Error::usage("--resolution must be 1/3oct, 1/6oct, or bins"))?;
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let buf = read(ctx, 0)?;
    let r = spectrum::spectrum(&buf, res);
    ctx.metric("centroid_hz", util::tidy(r.centroid_hz));
    ctx.metric("rolloff_hz", util::tidy(r.rolloff_hz));
    to_json(&r)
}

fn analyze_clipping(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    let thr = a.db("threshold")?;
    let min_run = a.usize("min-run")?.max(1);
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let buf = read(ctx, 0)?;
    let r = levels::detect_clipping(&buf, thr, min_run);
    if r.truncated {
        ctx.warn("truncated", format!("only the first {} of {} runs are listed", r.runs.len(), r.count));
    }
    ctx.metric("clipped_runs", r.count);
    ctx.metric("clipped_samples", r.clipped_samples);
    to_json(&r)
}

fn analyze_chords(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    let opts = chords::ChordOptions {
        change_penalty: a.f64("change-penalty")?.max(0.0),
        min_duration_s: a.seconds("min-duration")?.max(0.0),
    };
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let buf = read(ctx, 0)?;
    let r = chords::detect_chords(&buf, &opts);
    ctx.metric("segments", r.segments.len());
    ctx.metric("distinct_chords", r.distinct_chords);
    to_json(&r)
}

fn analyze_similarity(_a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let a = read(ctx, 0)?;
    let b = read(ctx, 1)?;
    let r = compare::similarity(&a, &b);
    ctx.metric("score", util::tidy(r.score));
    to_json(&r)
}

fn compare_op(args: &Args, ctx: &mut OpCtx) -> Result<Value> {
    let opts =
        compare::CompareOptions { max_offset_s: args.seconds("max-offset")?.max(0.0), no_align: args.flag("no-align") };
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let a = read(ctx, 0)?;
    let b = read(ctx, 1)?;
    let r = compare::compare(&a, &b, &opts);
    for n in &r.notes {
        ctx.warn("conformed", n.clone());
    }
    if r.length_difference_s.abs() > 0.01 {
        let d = r.length_difference_s;
        let rel = if d > 0.0 { "longer" } else { "shorter" };
        ctx.warn("length_differs", format!("b is {:.3} s {rel} than a", d.abs()));
    }
    ctx.metric("si_sdr_db", util::tidy(r.si_sdr_db));
    ctx.metric("rms_diff_db", util::tidy(r.rms_diff_db));
    ctx.metric("offset_samples", r.offset_samples);
    ctx.metric("verdict", &r.verdict);
    to_json(&r)
}

fn analyze_problems(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    let min = match a.choice("min-severity").as_str() {
        "high" => problems::Severity::High,
        "medium" => problems::Severity::Medium,
        _ => problems::Severity::Low,
    };
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let buf = read(ctx, 0)?;
    let mut r = problems::diagnose(&buf);
    r.problems.retain(|p| p.severity >= min);
    ctx.metric("high", r.counts.high);
    ctx.metric("medium", r.counts.medium);
    ctx.metric("low", r.counts.low);
    ctx.metric("clean", r.clean);
    to_json(&r)
}

fn analyze_stats(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    let hist = a.switch("histogram", false);
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let buf = read(ctx, 0)?;
    let r = levels::stats(&buf, hist);
    ctx.metric("peak_db", util::tidy(r.overall.peak_db));
    ctx.metric("true_peak_dbtp", util::tidy(r.overall.true_peak_dbtp));
    ctx.metric("rms_db", util::tidy(r.overall.rms_db));
    ctx.metric("crest_factor_db", util::tidy(r.overall.crest_factor_db));
    to_json(&r)
}

fn analyze_stereo(_a: &Args, ctx: &mut OpCtx) -> Result<Value> {
    if let Some(p) = dry_plan(ctx) {
        return Ok(p);
    }
    let buf = read(ctx, 0)?;
    if buf.channels() < 2 {
        ctx.warn("mono_input", "the input is mono; it folds to mono perfectly");
    }
    let r = stereo::stereo_analysis(&buf);
    ctx.metric("correlation", util::tidy(r.correlation));
    ctx.metric("correlation_below_120hz", util::tidy(r.correlation_below_120hz));
    ctx.metric("mono_compatibility", util::tidy(r.mono_compatibility));
    to_json(&r)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ferraus_core::op::Runner;
    use ferraus_core::testing::{self, sine, stereo, TempDir};

    fn spec(name: &str) -> OpSpec {
        ops().into_iter().find(|o| o.name == name).unwrap()
    }

    fn run(name: &str, pairs: &[(&str, &str)], inputs: Vec<std::path::PathBuf>) -> (Value, OpCtx<'static>) {
        let s = spec(name);
        let a = testing::args(&s, pairs);
        let mut c = testing::ctx();
        c.command = s.name.to_string();
        c.inputs = inputs;
        let v = match s.runner {
            Runner::Custom(f) => f(&a, &mut c).unwrap(),
            _ => unreachable!(),
        };
        (v, c)
    }

    #[test]
    fn registry_accepts_all_ops() {
        let r = testing::leak_registry(ops());
        for name in [
            "analyze tempo",
            "analyze loudness",
            "analyze silence",
            "analyze spectrum",
            "analyze clipping",
            "analyze chords",
            "analyze similarity",
            "compare",
            "analyze problems",
            "analyze stats",
            "analyze stereo",
        ] {
            assert!(r.get(name).is_some(), "{name}");
        }
        assert!(spec("analyze chords").best_effort);
        assert!(ops().iter().all(|o| o.category == Category::Inspect));
    }

    #[test]
    fn loudness_op_end_to_end() {
        let d = TempDir::new("analysis-loud");
        let p = d.path("tone.wav");
        testing::write_wav(&p, &stereo(sine(48000, 1000.0, 5.0, ferraus_core::db_to_lin(-23.0) as f32)));
        let (v, c) = run("analyze loudness", &[("timeline", "true"), ("step", "1s")], vec![p]);
        assert!((v["integrated_lufs"].as_f64().unwrap() + 23.0).abs() < 0.1);
        assert_eq!(v["timeline"]["t_s"].as_array().unwrap().len(), 5);
        assert!(c.metrics.contains_key("integrated_lufs"));
    }

    #[test]
    fn tempo_and_compare_ops() {
        let d = TempDir::new("analysis-ops");
        let a = d.path("a.wav");
        let b = d.path("b.wav");
        let clicks = testing::clicks(22050, 0.5, 6.0, 0.8);
        testing::write_wav(&a, &clicks);
        let mut delayed = AudioBuffer::new(22050, 1, 100);
        delayed.append(&clicks);
        testing::write_wav(&b, &delayed);
        let (v, c) = run("analyze tempo", &[("range", "60..200")], vec![a.clone()]);
        assert!((v["bpm"].as_f64().unwrap() - 120.0).abs() < 1.0);
        assert!(c.metrics.contains_key("bpm"));
        let (v, _) = run("compare", &[], vec![a.clone(), b.clone()]);
        assert_eq!(v["offset_samples"].as_i64().unwrap(), 100);
        let (v, _) = run("analyze similarity", &[], vec![a.clone(), b]);
        assert!(v["score"].as_f64().unwrap() > 0.9);
        // bad range
        let s = spec("analyze tempo");
        let args = testing::args(&s, &[("range", "200..60")]);
        let mut c = testing::ctx();
        c.inputs = vec![a];
        if let Runner::Custom(f) = s.runner {
            assert!(f(&args, &mut c).is_err());
        }
    }

    #[test]
    fn other_ops_return_results() {
        let d = TempDir::new("analysis-misc");
        let p = d.path("x.wav");
        let mut buf = AudioBuffer::new(48000, 2, 48000);
        buf.append(&stereo(sine(48000, 440.0, 2.0, 0.5)));
        testing::write_wav(&p, &buf);
        let (v, _) = run("analyze silence", &[], vec![p.clone()]);
        assert_eq!(v["regions"].as_array().unwrap().len(), 1);
        let (v, _) = run("analyze spectrum", &[("resolution", "1/6oct")], vec![p.clone()]);
        assert!(v["bands"].as_array().unwrap().len() > 50);
        let (v, _) = run("analyze clipping", &[], vec![p.clone()]);
        assert_eq!(v["count"].as_u64().unwrap(), 0);
        let (v, _) = run("analyze stats", &[("histogram", "on")], vec![p.clone()]);
        assert!(v["per_channel"][0]["histogram"]["counts"].is_array());
        let (v, _) = run("analyze problems", &[], vec![p.clone()]);
        assert!(v["problems"].as_array().unwrap().iter().any(|x| x["problem"] == "silence_head"));
        let (v, _) = run("analyze problems", &[("min-severity", "high")], vec![p.clone()]);
        assert!(v["problems"].as_array().unwrap().is_empty());
        let (v, _) = run("analyze stereo", &[], vec![p.clone()]);
        assert!(v["correlation"].as_f64().unwrap() > 0.99);
        let (v, _) = run("analyze chords", &[], vec![p]);
        assert_eq!(v["best_effort"], true);
    }

    #[test]
    fn dry_run_does_not_decode() {
        let s = spec("analyze loudness");
        let a = testing::args(&s, &[]);
        let mut c = testing::ctx();
        c.globals.dry_run = true;
        c.inputs = vec!["/nonexistent.wav".into()];
        if let Runner::Custom(f) = s.runner {
            assert!(f(&a, &mut c).is_ok());
        }
    }
}