1#[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
34pub 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
221fn 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
282fn 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 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 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 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 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 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
488fn 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
593fn 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}