Skip to main content

ferraus_denoise/
lib.rs

1//! # ferraus-denoise
2//!
3//! Noise reduction: learn a noise fingerprint from a region of the recording, then remove
4//! it from the whole file with a soft spectral gate; or track the noise floor adaptively;
5//! or run a speech model (DeepFilterNet 3 through ONNX Runtime by default, RNNoise built in).
6//!
7//! Commands: `denoise`, `denoise profile`, `denoise profile merge`.
8//!
9//! ## DeepFilterNet 3
10//!
11//! `--model dfn3` (the default, ADR 0006) runs the upstream ONNX export (`denoise/dfn3`:
12//! encoder, ERB decoder, DF decoder) on ONNX Runtime through `ferraus-ml`, with the STFT,
13//! feature normalization, ERB gains and deep filtering implemented in [`dfn3`] after the
14//! reference `libDF`/`enhance.py` (output matches the reference to about 1e-6). The model
15//! comes from the model cache under the download policy of ADR 0004: when it is missing and
16//! downloads are off, the command fails with exit 5 and `ferraus models pull denoise/dfn3`.
17//! It never falls back to another model silently; `--model rnnoise` is the built-in,
18//! download-free alternative. Builds without the `dfn3` feature default to RNNoise.
19
20#[cfg(feature = "dfn3")]
21pub mod dfn3;
22pub mod engine;
23pub mod metrics;
24pub mod ml;
25pub mod profile;
26pub mod stft;
27
28use engine::{Engine, GateConfig, NoiseModel};
29use ferraus_core::exec::blend;
30use ferraus_core::prelude::*;
31use profile::{NoiseProfile, ProfileSource, QuietRegion};
32use std::path::Path;
33
34/// Ops this crate contributes to the command registry.
35pub fn ops() -> Vec<OpSpec> {
36    vec![denoise_spec(), profile_spec(), merge_spec()]
37}
38
39fn denoise_spec() -> OpSpec {
40    OpSpec::transform(
41        "denoise",
42        Category::Clean,
43        "Remove steady background noise (hiss, hum, fan, room tone) or run a speech denoiser",
44        denoise,
45    )
46    .about(
47        "Learns what the noise sounds like from a noise-only region (a saved profile, \
48         --noise-range, or an automatically found quiet second) and subtracts that \
49         fingerprint from the whole file with a soft spectral gate that avoids the \
50         chirping 'musical noise' of simple gates. --mode adaptive tracks a changing \
51         noise floor with no profile; --mode ml runs a speech model (RNNoise built in, \
52         DeepFilterNet 3 through its model slot); --mode hybrid runs the profile pass and \
53         then the model on what remains. With no options it finds the quietest second \
54         and learns the noise from it. --reduction 0dB returns the input exactly.",
55    )
56    .param(param("profile", ParamKind::Path).help(
57        "Noise profile (.noiseprof JSON) learned with 'ferraus denoise profile'",
58    ))
59    .param(param("noise-range", ParamKind::Range).help(
60        "One shot: learn the noise from this noise-only region of the input, e.g. 0s..2s",
61    ))
62    .param(param("auto", ParamKind::Flag).help(
63        "One shot: find the quietest spectrally stable 1 s region and learn the noise from it (reported as noise_region in --json)",
64    ))
65    .param(
66        param("mode", ParamKind::Choice)
67            .choices(&["profile", "adaptive", "ml", "hybrid"])
68            .default("profile")
69            .help("profile: spectral gate from a noise profile; adaptive: minimum-statistics tracking with no profile; ml: speech model; hybrid: profile pass, then the model"),
70    )
71    .param(param("adaptive", ParamKind::Flag).help("Same as --mode adaptive"))
72    .param(param("reduction", ParamKind::Gain).default("12dB").range("0..60").help(
73        "How far noise is pushed down, in dB (0dB leaves the input unchanged)",
74    ))
75    .param(param("sensitivity", ParamKind::Float).default("6").range("0..48").help(
76        "Gate threshold above the noise profile: threshold = mean + sensitivity × std, scaled so 6 means 6 dB above steady hiss; higher removes more",
77    ))
78    .param(param("smoothing", ParamKind::Int).default("3").range("0..32").help(
79        "Smooth the gain mask across ± this many frequency bins (fewer chirps, slightly softer edges)",
80    ))
81    .param(param("attack", ParamKind::Time).default("20ms").help(
82        "How fast the gate opens (a matching lookahead keeps onsets intact), e.g. 20ms",
83    ).advanced())
84    .param(param("release", ParamKind::Time).default("120ms").help(
85        "How fast the gate closes after a sound ends, e.g. 120ms",
86    ).advanced())
87    .param(param("artifact-control", ParamKind::Percent).default("50%").help(
88        "Blend in a median-filtered mask and a steady noise floor: higher removes more chirping at the cost of a little more residual noise",
89    ).advanced())
90    .param(param("link", ParamKind::Switch).default("on").help(
91        "on: one gain mask for all channels so the stereo image stays put; off: per-channel masks",
92    ).advanced())
93    .param(param("preserve-transients", ParamKind::Switch).default("on").help(
94        "Detect onsets and hold the gate open through them so attacks stay crisp",
95    ).advanced())
96    .param(param("noisiness", ParamKind::Percent).default("30%").help(
97        "Adaptive mode: how much of the audio is noise; higher sets the threshold further above the tracked floor (20 dB × noisiness)",
98    ).advanced())
99    .param(param("decay", ParamKind::Percent).default("65%").help(
100        "Adaptive mode: how slowly reduction relaxes after a sound (release = 200 ms × decay unless --release is given)",
101    ).advanced())
102    .param(param("floor", ParamKind::Gain).default("0dB").help(
103        "Adaptive mode: shift the tracked noise floor in dB (positive removes more)",
104    ).advanced())
105    .param(
106        param("model", ParamKind::Choice)
107            .choices(&["dfn3", "rnnoise", "music"])
108            .default(if cfg!(feature = "dfn3") { "dfn3" } else { "rnnoise" })
109            .help("ml/hybrid modes: dfn3 = DeepFilterNet 3 (48 kHz, speech, best quality; model denoise/dfn3, 8 MB download), rnnoise = RNNoise (fast, light, built in, no download, speech), music = music-aware slot denoise/music-*"),
110    )
111    .param(param("strength", ParamKind::Percent).default("100%").help(
112        "ml/hybrid modes: how much of the model's change to apply",
113    ))
114    .param(param("residual", ParamKind::Path).help(
115        "Also write the removed component (input minus the fully processed output, before --mix) to this audio file",
116    ))
117    .param(param("dereverb", ParamKind::Flag).help(
118        "Add the best-effort de-reverb stage (model slot denoise/dereverb-*)",
119    ).advanced())
120    .example(
121        "ferraus denoise take.wav --auto -o clean.wav",
122        "Find the quietest second, learn the noise from it, and remove it",
123    )
124    .example(
125        "ferraus denoise take.wav --profile room.noiseprof --reduction 18dB -o clean.wav",
126        "Apply a saved room profile with stronger reduction",
127    )
128    .example(
129        "ferraus denoise interview.wav --noise-range 0s..1.5s --residual removed.wav -o clean.wav",
130        "One shot from a noise-only lead-in, and keep what was removed for listening",
131    )
132    .example(
133        "ferraus denoise street.wav --adaptive --reduction 10dB -o clean.wav",
134        "Follow a changing background (traffic, wind) with no profile",
135    )
136    .example(
137        "ferraus denoise call.wav --mode ml -y -o clean.wav",
138        "Speech denoising with DeepFilterNet 3 (downloads the 8 MB model once with -y)",
139    )
140    .example(
141        "ferraus denoise call.wav --mode ml --model rnnoise -o clean.wav",
142        "Speech denoising with the built-in RNNoise model (no download)",
143    )
144}
145
146fn profile_spec() -> OpSpec {
147    OpSpec::custom(
148        "denoise profile",
149        Category::Clean,
150        "Learn a noise profile (.noiseprof) from a noise-only region",
151        denoise_profile,
152    )
153    .about(
154        "Measures the noise fingerprint (per-frequency mean and spread of the level) from \
155         a region that contains only noise, and saves it as a small JSON file that \
156         'ferraus denoise --profile' applies. Choose the region with --range (default \
157         0s..2s), let ferraus find the quietest spectrally stable second with --auto, or \
158         learn from a separate noise-only recording with --from.",
159    )
160    .inputs(Inputs { min: 0, max: Some(1), kind: InputKind::Audio })
161    .param(
162        param("range", ParamKind::Range)
163            .default("0s..2s")
164            .help("Noise-only region of the input, e.g. 0s..2s or 1:02..1:04"),
165    )
166    .param(
167        param("auto", ParamKind::Flag)
168            .help("Find the quietest spectrally stable 1 s window and learn from it (the window is reported)"),
169    )
170    .param(
171        param("from", ParamKind::Path)
172            .help("Learn from this whole noise-only recording instead of a region of the input"),
173    )
174    .example(
175        "ferraus denoise profile take.wav --range 0s..2s -o room.noiseprof",
176        "Learn the room tone from the first two seconds",
177    )
178    .example(
179        "ferraus denoise profile take.wav --auto -o room.noiseprof --json",
180        "Let ferraus find the quietest second and report where it was",
181    )
182    .example(
183        "ferraus denoise profile --from roomtone.wav -o room.noiseprof",
184        "Learn from a separate room-tone recording",
185    )
186}
187
188fn merge_spec() -> OpSpec {
189    OpSpec::custom(
190        "denoise profile merge",
191        Category::Clean,
192        "Combine noise profiles from several takes into one",
193        denoise_profile_merge,
194    )
195    .about(
196        "Merges two or more .noiseprof files learned at the same sample rate. --method max \
197         keeps the louder noise of every bin (removes the worst noise seen in any take); \
198         --method mean pools the statistics as if all the noise had been profiled at once.",
199    )
200    .inputs(Inputs { min: 2, max: None, kind: InputKind::Any })
201    .param(
202        param("method", ParamKind::Choice)
203            .choices(&["max", "mean"])
204            .default("max")
205            .help("max: per-bin maximum of mean and spread; mean: frame-weighted pooled statistics"),
206    )
207    .example(
208        "ferraus denoise profile merge a.noiseprof b.noiseprof -o c.noiseprof",
209        "Keep the louder noise of two takes in every band",
210    )
211    .example(
212        "ferraus denoise profile merge day1.noiseprof day2.noiseprof day3.noiseprof --method mean -o room.noiseprof",
213        "Average the room tone of three sessions",
214    )
215    .example(
216        "ferraus denoise profile merge mic1.noiseprof mic2.noiseprof --method max -o both.noiseprof --json",
217        "One profile that covers the noisier of two microphones, with a JSON summary",
218    )
219}
220
221// ---------------------------------------------------------------------------------
222// denoise
223// ---------------------------------------------------------------------------------
224
225fn gate_config(a: &Args) -> Result<GateConfig> {
226    let reduction = a.db("reduction")?.abs() as f32;
227    let sensitivity = a.f64("sensitivity")?;
228    if !(0.0..=48.0).contains(&sensitivity) {
229        return Err(Error::usage("--sensitivity must be between 0 and 48"));
230    }
231    Ok(GateConfig {
232        reduction_db: reduction,
233        sensitivity: sensitivity as f32,
234        smoothing: a.usize("smoothing")?.min(64),
235        attack_s: a.seconds("attack")? as f32,
236        release_s: a.seconds("release")? as f32,
237        artifact_control: a.percent("artifact-control")?.clamp(0.0, 1.0) as f32,
238        link: a.switch("link", true),
239        preserve_transients: a.switch("preserve-transients", true),
240    })
241}
242
243fn resolve_mode(a: &Args) -> Result<String> {
244    let mode = a.choice("mode");
245    if a.flag("adaptive") {
246        if a.is_explicit("mode") && mode != "adaptive" {
247            return Err(Error::usage(format!("--adaptive conflicts with --mode {mode}; pass one of them")));
248        }
249        return Ok("adaptive".into());
250    }
251    Ok(mode)
252}
253
254fn region_json(r: &QuietRegion, method: &str) -> Value {
255    json!({
256        "start_s": round_s(r.start_s),
257        "end_s": round_s(r.end_s),
258        "range": format!("{:.3}s..{:.3}s", r.start_s, r.end_s),
259        "level_db": (r.level_db * 10.0).round() / 10.0,
260        "stability_db": (r.stability_db * 100.0).round() / 100.0,
261        "method": method,
262    })
263}
264
265fn round_s(x: f64) -> f64 {
266    (x * 1000.0).round() / 1000.0
267}
268
269fn load_profile(ctx: &mut OpCtx, path: &Path) -> Result<NoiseProfile> {
270    ctx.check_allowed(path)?;
271    let bytes = std::fs::read(path).map_err(|e| {
272        Error::new(
273            ErrorKind::InputMissing,
274            "input_not_found",
275            format!("cannot read noise profile {}: {e}", path.display()),
276        )
277    })?;
278    ctx.note_input(path);
279    NoiseProfile::from_json(&bytes)
280}
281
282/// Where the noise profile for the profile/hybrid passes comes from.
283fn resolve_profile(a: &Args, buf: &AudioBuffer, ctx: &mut OpCtx) -> Result<Option<NoiseProfile>> {
284    let given = [a.has("profile"), a.has("noise-range"), a.flag("auto")];
285    if given.iter().filter(|g| **g).count() > 1 {
286        return Err(Error::usage("choose one noise source: --profile, --noise-range, or --auto"));
287    }
288    if let Some(p) = a.path("profile") {
289        let prof = load_profile(ctx, &p)?;
290        if prof.sample_rate != buf.sample_rate {
291            return Err(Error::usage(format!(
292                "the profile was learned at {} Hz but the input is {} Hz",
293                prof.sample_rate, buf.sample_rate
294            ))
295            .with_hint(format!(
296                "ferraus convert <input> --sample-rate {} -o converted.wav (or learn a new profile from this file with --auto)",
297                prof.sample_rate
298            )));
299        }
300        if prof.channels != buf.channels() {
301            ctx.warn(
302                "profile_channels",
303                format!(
304                    "the profile has {} channel(s) but the input has {}; using the profile's linked statistics for every channel",
305                    prof.channels,
306                    buf.channels()
307                ),
308            );
309        }
310        ctx.note("noise_profile", json!({"path": p.display().to_string(), "frames": prof.frames}));
311        return Ok(Some(prof));
312    }
313    if let Some(r) = a.range("noise-range")? {
314        let (s, e) = r.frames(buf.sample_rate, buf.frames(), ctx.bpm())?;
315        let src = ProfileSource {
316            method: "range".into(),
317            start_s: Some(round_s(s as f64 / buf.sample_rate as f64)),
318            end_s: Some(round_s(e as f64 / buf.sample_rate as f64)),
319            ..Default::default()
320        };
321        let prof = profile::learn(buf, s, e, src)?;
322        ctx.note(
323            "noise_region",
324            json!({
325                "start_s": round_s(s as f64 / buf.sample_rate as f64),
326                "end_s": round_s(e as f64 / buf.sample_rate as f64),
327                "level_db": (prof.mean_level_db() * 10.0).round() / 10.0,
328                "method": "range",
329            }),
330        );
331        return Ok(Some(prof));
332    }
333    if !a.flag("auto") {
334        ctx.warn(
335            "no_noise_profile",
336            "no --profile, --noise-range, or --auto given: learning the noise from the quietest 1 s region (pass --auto to make this explicit)",
337        );
338    }
339    // the PRD window is 1 s; very short inputs use half their length
340    let window = if buf.duration_s() < 2.0 { (buf.duration_s() / 2.0).clamp(0.05, 1.0) } else { 1.0 };
341    match profile::find_quiet_region(buf, window) {
342        None => {
343            ctx.warn("input_too_short", "the input is too short to estimate the noise; it was left unchanged");
344            Ok(None)
345        }
346        Some(r) => {
347            if window < 1.0 {
348                ctx.warn(
349                    "short_noise_region",
350                    format!("the input is short, so the noise was learned from a {window:.2} s window"),
351                );
352            }
353            let src = ProfileSource {
354                method: "auto".into(),
355                start_s: Some(round_s(r.start_s)),
356                end_s: Some(round_s(r.end_s)),
357                ..Default::default()
358            };
359            let prof = profile::learn(buf, r.start, r.end, src)?;
360            ctx.note("noise_region", region_json(&r, "auto"));
361            // the quietest region is (near) digital silence: there is no noise floor to learn,
362            // and gating against it would only cut the program
363            if prof.mean_level_db() < -90.0 {
364                ctx.warn(
365                    "no_noise_found",
366                    format!(
367                        "the quietest region is {:.0} dB (digital silence), so there is no steady noise to remove; the input was left unchanged. Pass --noise-range to point at the noise",
368                        prof.mean_level_db()
369                    ),
370                );
371                return Ok(None);
372            }
373            Ok(Some(prof))
374        }
375    }
376}
377
378fn run_engine(cfg: GateConfig, model: &NoiseModel, buf: &AudioBuffer, ctx: &mut OpCtx) -> Result<AudioBuffer> {
379    if cfg.reduction_db == 0.0 {
380        // exact identity, bit for bit
381        return Ok(buf.clone());
382    }
383    let mut eng = Engine::new(cfg, model, buf.sample_rate, buf.channels());
384    let out = {
385        let c: &OpCtx = ctx;
386        eng.process_buffer(buf, &mut |f| {
387            c.set_progress(f);
388            c.check_cancel()
389        })?
390    };
391    ctx.metric("mean_gain_db", (eng.stats.mean_gain_db * 100.0).round() / 100.0);
392    ctx.metric("onsets", eng.stats.onsets);
393    ctx.metric("fft_size", eng.fft_size());
394    Ok(out)
395}
396
397fn model_pass(a: &Args, buf: &AudioBuffer, ctx: &mut OpCtx) -> Result<AudioBuffer> {
398    let model = a.choice("model");
399    let strength = a.percent("strength")?.clamp(0.0, 1.0) as f32;
400    let y = match model.as_str() {
401        "rnnoise" => {
402            let y = ml::rnnoise(buf)?;
403            ctx.model_used(ml::RNNOISE_NAME, ml::RNNOISE_VERSION, ml::RNNOISE_SHA256);
404            y
405        }
406        "dfn3" => ml::dfn3(buf, ctx)?,
407        "music" => {
408            return Err(ml::unavailable_slot(
409                "denoise/music-",
410                "denoise/music-roformer",
411                "--model music (music-aware denoise)",
412            ))
413        }
414        other => return Err(Error::usage(format!("unknown --model '{other}'"))),
415    };
416    ctx.warn(
417        "speech_model",
418        format!("--model {model} is a speech-tuned model; on music the default profile path (--mode profile) usually sounds better"),
419    );
420    ctx.note("model_domain", ml::speech_note(&model));
421    Ok(if strength >= 1.0 { y } else { blend(buf, &y, strength) })
422}
423
424fn denoise(a: &Args, buf: AudioBuffer, ctx: &mut OpCtx) -> Result<AudioBuffer> {
425    let mode = resolve_mode(a)?;
426    if a.flag("dereverb") {
427        // fail fast, before any processing
428        return Err(ml::unavailable_slot(
429            "denoise/dereverb-",
430            "denoise/dereverb-roformer",
431            "--dereverb (best-effort de-reverb stage)",
432        ));
433    }
434    ctx.note("mode", mode.as_str());
435    if buf.frames() == 0 {
436        return Ok(buf);
437    }
438    let mut cfg = gate_config(a)?;
439    let out = match mode.as_str() {
440        "profile" => match resolve_profile(a, &buf, ctx)? {
441            None => buf.clone(),
442            Some(p) => {
443                ctx.metric("noise_floor_db", (p.mean_level_db() * 10.0).round() / 10.0);
444                run_engine(cfg, &NoiseModel::Profile(Box::new(p)), &buf, ctx)?
445            }
446        },
447        "adaptive" => {
448            if a.has("profile") || a.has("noise-range") || a.flag("auto") {
449                ctx.warn(
450                    "ignored_profile",
451                    "adaptive mode tracks the noise itself; --profile/--noise-range/--auto were ignored",
452                );
453            }
454            if !a.is_explicit("release") {
455                cfg.release_s = 0.2 * a.percent("decay")?.clamp(0.0, 1.0) as f32;
456            }
457            let model =
458                NoiseModel::Adaptive { noisiness: a.percent("noisiness")? as f32, floor_db: a.db("floor")? as f32 };
459            run_engine(cfg, &model, &buf, ctx)?
460        }
461        "ml" => model_pass(a, &buf, ctx)?,
462        "hybrid" => {
463            let first = match resolve_profile(a, &buf, ctx)? {
464                None => buf.clone(),
465                Some(p) => run_engine(cfg, &NoiseModel::Profile(Box::new(p)), &buf, ctx)?,
466            };
467            model_pass(a, &first, ctx)?
468        }
469        other => return Err(Error::usage(format!("unknown --mode '{other}'"))),
470    };
471    // residual = what was removed
472    let mut residual = buf.clone();
473    for (rc, oc) in residual.data.iter_mut().zip(&out.data) {
474        for (r, o) in rc.iter_mut().zip(oc) {
475            *r -= o;
476        }
477    }
478    let rms_db = |b: &AudioBuffer| (lin_to_db(b.rms() as f64) * 10.0).round() / 10.0;
479    ctx.metric("input_rms_dbfs", rms_db(&buf));
480    ctx.metric("output_rms_dbfs", rms_db(&out));
481    ctx.metric("removed_rms_dbfs", rms_db(&residual));
482    if let Some(rp) = a.path("residual") {
483        ctx.write_audio(&rp, &residual, Some("residual"))?;
484    }
485    Ok(out)
486}
487
488// ---------------------------------------------------------------------------------
489// denoise profile
490// ---------------------------------------------------------------------------------
491
492fn denoise_profile(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
493    let auto = a.flag("auto");
494    let from = a.path("from");
495    let range_explicit = a.is_explicit("range");
496    if [auto, from.is_some(), range_explicit].iter().filter(|x| **x).count() > 1 {
497        return Err(Error::usage("choose one noise source: --range, --auto, or --from"));
498    }
499    if from.is_none() && ctx.inputs.is_empty() {
500        return Err(Error::usage("'denoise profile' needs an input file (or --from <noise-only file>)")
501            .with_hint("ferraus denoise profile take.wav --range 0s..2s -o room.noiseprof"));
502    }
503    let out_path = ctx.output_path(None, "noiseprof")?;
504    let (prof, region) = if let Some(f) = &from {
505        let buf = ctx.read_audio_path(f)?;
506        if !ctx.inputs.is_empty() {
507            if let Ok(info) = ctx.io.probe(ctx.input(0)?) {
508                if info.sample_rate != buf.sample_rate {
509                    ctx.warn(
510                        "sample_rate_mismatch",
511                        format!(
512                            "the noise file is {} Hz but the input is {} Hz; the profile only applies to {} Hz audio",
513                            buf.sample_rate, info.sample_rate, buf.sample_rate
514                        ),
515                    );
516                }
517            }
518        }
519        let src = ProfileSource {
520            method: "from".into(),
521            path: Some(f.display().to_string()),
522            start_s: Some(0.0),
523            end_s: Some(round_s(buf.duration_s())),
524            ..Default::default()
525        };
526        let p = profile::learn(&buf, 0, buf.frames(), src)?;
527        let region = json!({"start_s": 0.0, "end_s": round_s(buf.duration_s()), "method": "from"});
528        (p, region)
529    } else {
530        let buf = ctx.read_audio(0)?;
531        let input = ctx.input(0)?.display().to_string();
532        if auto {
533            let r = profile::find_quiet_region(&buf, 1.0)
534                .ok_or_else(|| Error::usage("the input is shorter than one analysis frame; nothing to learn from"))?;
535            if r.end_s - r.start_s < 0.99 {
536                ctx.warn(
537                    "short_noise_region",
538                    format!("the input is shorter than 1 s; learned from {:.2} s", r.end_s - r.start_s),
539                );
540            }
541            let src = ProfileSource {
542                method: "auto".into(),
543                path: Some(input),
544                start_s: Some(round_s(r.start_s)),
545                end_s: Some(round_s(r.end_s)),
546                ..Default::default()
547            };
548            let p = profile::learn(&buf, r.start, r.end, src)?;
549            let region = region_json(&r, "auto");
550            ctx.note("noise_region", region.clone());
551            (p, region)
552        } else {
553            let r = a.range("range")?.ok_or_else(|| Error::usage("missing --range"))?;
554            let (s, e) = r.frames(buf.sample_rate, buf.frames(), ctx.bpm())?;
555            let src = ProfileSource {
556                method: "range".into(),
557                path: Some(input),
558                start_s: Some(round_s(s as f64 / buf.sample_rate as f64)),
559                end_s: Some(round_s(e as f64 / buf.sample_rate as f64)),
560                ..Default::default()
561            };
562            let p = profile::learn(&buf, s, e, src)?;
563            let region = json!({
564                "start_s": round_s(s as f64 / buf.sample_rate as f64),
565                "end_s": round_s(e as f64 / buf.sample_rate as f64),
566                "method": "range",
567            });
568            (p, region)
569        }
570    };
571    ctx.write_file(&out_path, &prof.to_json(), None)?;
572    ctx.metric("noise_floor_db", (prof.mean_level_db() * 10.0).round() / 10.0);
573    Ok(profile_result(&prof, &out_path, Some(region)))
574}
575
576fn profile_result(p: &NoiseProfile, path: &Path, region: Option<Value>) -> Value {
577    let mut v = json!({
578        "profile": path.display().to_string(),
579        "sample_rate": p.sample_rate,
580        "fft_size": p.fft_size,
581        "hop_size": p.hop_size,
582        "channels": p.channels,
583        "frames": p.frames,
584        "mean_level_db": (p.mean_level_db() * 10.0).round() / 10.0,
585        "method": p.source.method,
586    });
587    if let Some(r) = region {
588        v["noise_region"] = r;
589    }
590    v
591}
592
593// ---------------------------------------------------------------------------------
594// denoise profile merge
595// ---------------------------------------------------------------------------------
596
597fn denoise_profile_merge(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
598    let method = profile::MergeMethod::parse(&a.choice("method"))?;
599    let out_path = ctx.output_path(None, "noiseprof")?;
600    let paths: Vec<std::path::PathBuf> = ctx.inputs.clone();
601    let mut profiles = Vec::with_capacity(paths.len());
602    let mut names = Vec::with_capacity(paths.len());
603    for p in &paths {
604        profiles.push(load_profile(ctx, p)?);
605        names.push(p.display().to_string());
606    }
607    let (merged, warnings) = profile::merge(&profiles, &names, method)?;
608    for w in warnings {
609        ctx.warn("profile_channels", w);
610    }
611    ctx.write_file(&out_path, &merged.to_json(), None)?;
612    let mut v = profile_result(&merged, &out_path, None);
613    v["merged_from"] = json!(names);
614    Ok(v)
615}