Skip to main content

sva_core/
answer.rs

1// Concern: writes one reading and the render around it as the `data` object | Non-concern: the error envelope (output.rs), subcommand JSON (sva-cli) | IO: (Answer) -> a JSON string
2
3use std::path::Path;
4
5use sva_engine::{
6    Alias, AliasBand, Answer, Arguments, BandCrest, BandTrack, Bands, Binding, Boot, BootPhase,
7    Buffer, CacheStats, Counters, Crest, CuttingBelowSilenceThreshold, Detail, EngineStats,
8    EnvelopeFrame, FormantFrame, Kind, Label, LedgerEntry, Loudness, LoudnessFrame, Onsets,
9    Outcome, Output, PayloadKind, Phase, Source, SpectralSum, Spectrum, StereoFrame, StereoImage,
10    Work, Worked,
11};
12
13use sva_engine::harmony::Framing;
14use sva_engine::harmony::chords::{self, Chord};
15use sva_engine::harmony::dissonance::{self, Dissonance, Extreme};
16use sva_engine::harmony::inharmonicity::{self, Inharmonicity, Series, Weighted};
17use sva_engine::harmony::tonnetz::{self, Quality, TonalDistance, Triad};
18use sva_engine::harmony::voice_leading::{self, VoiceLeading};
19use sva_engine::modulation::{self, Modulation};
20use sva_engine::partials::{self, Partials};
21use sva_engine::periodicity::{self, Periodicity, Track, Window};
22use sva_engine::pitch as pitch_names;
23use sva_engine::self_similarity::{self, SelfSimilarity};
24use sva_engine::sliding::Stats;
25
26use crate::cli_error::CliError;
27use crate::json::{NONE, capped, escape, latest, list, num};
28use crate::query::{Asked, Spelled, Taken};
29
30/// Past this a caller reading stdout wants a later interval start, not a wall of JSON.
31pub const SAMPLE_LIMIT: usize = 4096;
32
33fn maybe(v: Option<f64>) -> String {
34    v.map(num).unwrap_or_else(|| NONE.to_string())
35}
36
37fn counted(v: Option<usize>) -> String {
38    v.map_or_else(|| NONE.to_string(), |c| c.to_string())
39}
40
41fn ledger_json(entries: &[LedgerEntry], skim: bool) -> String {
42    list(entries, |e| {
43        let clipped = e
44            .clipped
45            .map_or_else(|| NONE.to_string(), |c| c.to_string());
46        let channel = counted(e.channel);
47        if skim {
48            return format!(
49                "\n    {{ \"node\": \"{}\", \"channel\": {channel}, \"rms\": {}, \"peak\": {}, \"clipped\": {clipped} }}",
50                escape(&e.node),
51                num(e.rms),
52                num(e.peak)
53            );
54        }
55        format!(
56            "\n    {{ \"node\": \"{}\", \"channel\": {channel}, \"depth\": {}, \"unit\": \"{}\", \
57             \"control\": {}, \"rms\": {}, \"peak\": {}, \"min\": {}, \"max\": {}, \
58             \"share\": {}, \"clipped\": {clipped} }}",
59            escape(&e.node),
60            e.depth,
61            e.kind.unit().name(),
62            !e.kind.is_audio(),
63            num(e.rms),
64            num(e.peak),
65            num(e.min),
66            num(e.max),
67            maybe(e.share)
68        )
69    })
70}
71
72fn spectrum_json(s: &Spectrum) -> String {
73    format!(
74        "{{ \"frame_size\": {}, \"frames\": {}, \"resolution_hz\": {}, \"rms\": {}, \
75         \"centroid_hz\": {}, \"rolloff85_hz\": {}, \"peaks\": {}, \"bands\": {} }}",
76        s.frame_size,
77        s.frames,
78        num(s.resolution_hz),
79        num(s.rms),
80        num(s.centroid_hz),
81        num(s.rolloff85_hz),
82        list(&s.peaks, |p| format!(
83            "{{ \"hz\": {}, \"db\": {} }}",
84            num(p.hz),
85            num(p.db)
86        )),
87        list(&s.bands, |b| format!(
88            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"db\": {} }}",
89            num(b.lo_hz),
90            num(b.hi_hz),
91            num(b.db)
92        ))
93    )
94}
95
96fn stereo_json(image: &StereoImage) -> String {
97    let frame = |f: &StereoFrame| {
98        format!(
99            "\n      {{ \"t\": {}, \"correlation\": {}, \"mid_rms\": {}, \"side_rms\": {}, \
100             \"width\": {}, \"balance_db\": {}, \"mono_db\": {} }}",
101            num(f.t_secs),
102            num(f.correlation),
103            num(f.mid_rms),
104            num(f.side_rms),
105            num(f.width),
106            num(f.balance_db),
107            num(f.mono_db)
108        )
109    };
110    format!(
111        "{{ \"channels\": {}, \"overall\": {}, \"frames\": {} }}",
112        image.channels,
113        frame(&image.overall),
114        list(&image.frames, frame)
115    )
116}
117
118fn formants_json(f: &FormantFrame) -> String {
119    format!(
120        "\n    {{ \"t\": {}, \"order\": {}, \"energy\": {}, \"residual\": {}, \"formants\": {} }}",
121        num(f.t_secs),
122        f.order,
123        num(f.energy),
124        num(f.residual),
125        list(&f.formants, |v| format!(
126            "{{ \"hz\": {}, \"bandwidth_hz\": {}, \"db\": {} }}",
127            num(v.hz),
128            num(v.bandwidth_hz),
129            num(v.db)
130        ))
131    )
132}
133
134fn band_json(b: &BandTrack, start_secs: f64, rate_hz: f64, limit: Option<usize>) -> String {
135    let shown = limit.map_or(b.rms.len(), |cap| b.rms.len().min(cap));
136    format!(
137        "\n    {{ \"centre_hz\": {}, \"cam\": {}, \"erb_hz\": {}, \"q\": {}, \"peak\": {}, \
138         \"time_to_peak_secs\": {}, \"rise_10_90_secs\": {}, \
139         \"floor\": {{ \"peak\": {}, \"time_to_peak_secs\": {}, \"rise_10_90_secs\": {} }}, \
140         \"rms\": {} }}",
141        num(b.centre_hz),
142        num(b.cam),
143        num(b.erb_hz),
144        num(b.q),
145        num(b.peak),
146        maybe(b.time_to_peak_secs),
147        maybe(b.rise_10_90_secs),
148        num(b.floor.peak),
149        num(b.floor.time_to_peak_secs),
150        maybe(b.floor.rise_10_90_secs),
151        capped(
152            &b.rms,
153            shown,
154            |n| start_secs + n as f64 / rate_hz,
155            |v| num(*v),
156        )
157    )
158}
159
160fn bands_json(b: &Bands, limit: Option<usize>) -> String {
161    format!(
162        "{{ \"rate_hz\": {}, \"start_secs\": {}, \"bands\": {} }}",
163        num(b.rate_hz),
164        num(b.start_secs),
165        list(&b.bands, |band| band_json(
166            band,
167            b.start_secs,
168            b.rate_hz,
169            limit
170        ))
171    )
172}
173
174fn loudness_json(l: &Loudness, limit: Option<usize>) -> String {
175    let frames = |fs: &[LoudnessFrame]| {
176        let shown = limit.map_or(fs.len(), |cap| fs.len().min(cap));
177        capped(
178            fs,
179            shown,
180            |n| fs[n].t,
181            |f| format!("\n    {{ \"t\": {}, \"lufs\": {} }}", num(f.t), num(f.lufs)),
182        )
183    };
184    format!(
185        "{{ \"integrated_lufs\": {}, \"range_lu\": {}, \"momentary_max_lufs\": {}, \
186         \"short_term_max_lufs\": {}, \"sample_peak\": {}, \"sample_peak_dbfs\": {}, \
187         \"peak_note\": \"{}\", \"momentary_secs\": {}, \"hop_secs\": {}, \"momentary\": {}, \
188         \"short_term\": {} }}",
189        maybe(l.integrated_lufs),
190        maybe(l.range_lu),
191        maybe(l.momentary_max_lufs),
192        maybe(l.short_term_max_lufs),
193        num(l.sample_peak),
194        maybe(l.sample_peak_dbfs),
195        escape(l.peak_note),
196        num(l.momentary_secs),
197        num(l.hop_secs),
198        frames(&l.momentary),
199        frames(&l.short_term)
200    )
201}
202
203fn crest_json(c: &Crest) -> String {
204    let band = |b: &BandCrest| {
205        format!(
206            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"centre_hz\": {}, \"peak\": {}, \
207             \"rms\": {}, \"crest_db\": {}, \"counted\": {} }}",
208            num(b.lo_hz),
209            num(b.hi_hz),
210            num(b.centre_hz),
211            num(b.peak),
212            num(b.rms),
213            num(b.crest_db),
214            b.counted
215        )
216    };
217    format!(
218        "{{ \"broadband_crest_db\": {}, \"spread_db\": {}, \"widest_band_hz\": {}, \
219         \"tightest_band_hz\": {}, \"counted_under_db\": {}, \"bands\": {} }}",
220        num(c.broadband_crest_db),
221        maybe(c.spread_db),
222        maybe(c.widest_band_hz),
223        maybe(c.tightest_band_hz),
224        num(c.counted_under_db),
225        list(&c.bands, band)
226    )
227}
228
229fn onsets_json(o: &Onsets) -> String {
230    format!(
231        "{{ \"onsets\": {}, \"resolution_secs\": {}, \"ioi_histogram\": {} }}",
232        list(&o.onsets, |t| format!(
233            "{{ \"t_secs\": {}, \"strength\": {} }}",
234            num(t.t_secs),
235            num(t.strength)
236        )),
237        num(o.resolution_secs),
238        list(&o.ioi_histogram, |b| format!(
239            "{{ \"lo_secs\": {}, \"hi_secs\": {}, \"count\": {} }}",
240            num(b.lo_secs),
241            num(b.hi_secs),
242            b.count
243        )),
244    )
245}
246
247fn alias_json(a: &Alias) -> String {
248    let band = |b: &AliasBand| {
249        format!(
250            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"signal_db\": {}, \"alias_db\": {}, \
251             \"nmr_db\": {} }}",
252            num(b.lo_hz),
253            num(b.hi_hz),
254            num(b.signal_db),
255            num(b.alias_db),
256            num(b.nmr_db)
257        )
258    };
259    format!(
260        "{{ \"oversample\": {}, \"sample_rate\": {}, \"frame_size\": {}, \"frames\": {}, \
261         \"scored_frames\": {}, \"playback_db_spl\": {}, \"asr_db\": {}, \"nmr_db\": {}, \
262         \"nmr_peak_db\": {}, \"peak_at_secs\": {}, \"audible\": {}, \
263         \"instances\": {}, \"bands\": {} }}",
264        a.oversample,
265        num(a.sample_rate),
266        a.frame_size,
267        a.frames,
268        a.scored_frames,
269        num(a.playback_db_spl),
270        num(a.asr_db),
271        num(a.nmr_db),
272        num(a.nmr_peak_db),
273        num(a.peak_at_secs),
274        a.audible,
275        a.instances,
276        list(&a.bands, band)
277    )
278}
279
280/// The fields every harmonic reading opens with: where its partials came from, the floor
281/// under them, and the time axis its curve steps along.
282fn framing_json(framing: &Framing, floor_db: f64) -> String {
283    let window = framing.window.map_or(NONE.to_string(), |w| {
284        format!(
285            "{{ \"size\": {}, \"secs\": {}, \"resolution_hz\": {} }}",
286            w.size,
287            num(w.secs),
288            num(w.resolution_hz)
289        )
290    });
291    format!(
292        "\"partials\": \"{}\", \"floor_db\": {}, \"hop_secs\": {}, \"window\": {window}",
293        escape(framing.method()),
294        num(floor_db),
295        num(framing.hop_secs)
296    )
297}
298
299fn extreme_json(e: Option<Extreme>) -> String {
300    e.map_or(NONE.to_string(), |e| {
301        format!(
302            "{{ \"value\": {}, \"t\": {} }}",
303            num(e.value),
304            num(e.t_secs)
305        )
306    })
307}
308
309fn dissonance_json(d: &Dissonance) -> String {
310    format!(
311        "{{ \"model\": \"{}\", \"constants\": {{ \"a\": {}, \"b\": {}, \"d_star\": {}, \
312         \"s1\": {}, \"s2\": {} }}, \"amplitude\": \"product\", {}, \"mean\": {}, \
313         \"max\": {}, \"min\": {}, \"curve\": {} }}",
314        escape(dissonance::MODEL),
315        num(dissonance::A),
316        num(dissonance::B),
317        num(dissonance::D_STAR),
318        num(dissonance::S1),
319        num(dissonance::S2),
320        framing_json(&d.framing, d.floor_db),
321        maybe(d.mean),
322        extreme_json(d.max),
323        extreme_json(d.min),
324        list(&d.frames, |f| format!(
325            "\n    {{ \"t\": {}, \"roughness\": {}, \"roughness_bounds\": [{}, {}], \
326             \"partials\": {} }}",
327            num(f.t_secs),
328            num(f.roughness),
329            num(f.roughness_bounds.0),
330            num(f.roughness_bounds.1),
331            f.partials
332        ))
333    )
334}
335
336fn notes_json(notes: &[i64]) -> String {
337    list(notes, |n| format!("\"{}\"", pitch_names::note_name(*n)))
338}
339
340fn chord_fields(c: &Chord) -> String {
341    format!(
342        "\"start_secs\": {}, \"end_secs\": {}, \"notes\": {}, \"unresolved\": {}",
343        num(c.start_secs),
344        num(c.end_secs),
345        notes_json(&c.notes),
346        notes_json(&c.unresolved)
347    )
348}
349
350fn voice_leading_json(v: &VoiceLeading) -> String {
351    format!(
352        "{{ \"model\": \"{}\", \"norm\": \"{}\", \"chords_by\": \"{}\", {}, \
353         \"total\": {}, \"chords\": {}, \"transitions\": {} }}",
354        escape(voice_leading::MODEL),
355        v.norm.key(),
356        escape(chords::METHOD),
357        framing_json(&v.framing, v.floor_db),
358        num(v.total),
359        list(&v.chords, |c| format!("\n    {{ {} }}", chord_fields(c))),
360        list(&v.transitions, |t| format!(
361            "\n    {{ \"t\": {}, \"from\": {}, \"to\": {}, \"distance\": {}, \
362             \"mapping\": {} }}",
363            num(t.at_secs),
364            notes_json(&t.from),
365            notes_json(&t.to),
366            num(t.distance),
367            list(&t.mapping, |(from, to)| format!(
368                "{{ \"from\": \"{}\", \"to\": \"{}\" }}",
369                pitch_names::note_name(*from),
370                pitch_names::note_name(*to)
371            ))
372        ))
373    )
374}
375
376fn triad_json(t: Option<Triad>) -> String {
377    t.map_or(NONE.to_string(), |t| {
378        format!(
379            "{{ \"root\": \"{}\", \"quality\": \"{}\" }}",
380            pitch_names::class_name(i64::from(t.root)),
381            match t.quality {
382                Quality::Major => "major",
383                Quality::Minor => "minor",
384            }
385        )
386    })
387}
388
389fn tonal_distance_json(d: &TonalDistance) -> String {
390    let tonic = d.tonic.map_or(NONE.to_string(), |(triad, t)| {
391        format!(
392            "{{ \"triad\": {}, \"t\": {} }}",
393            triad_json(Some(triad)),
394            num(t)
395        )
396    });
397    format!(
398        "{{ \"model\": \"{}\", \"chords_by\": \"{}\", {}, \"tonic\": {tonic}, \
399         \"chords\": {}, \"transitions\": {} }}",
400        escape(tonnetz::MODEL),
401        escape(chords::METHOD),
402        framing_json(&d.framing, d.floor_db),
403        list(&d.chords, |c| format!(
404            "\n    {{ {}, \"triad\": {}, \"tension\": {} }}",
405            chord_fields(&c.chord),
406            triad_json(c.triad),
407            counted(c.tension)
408        )),
409        list(&d.transitions, |t| format!(
410            "\n    {{ \"t\": {}, \"from\": {}, \"to\": {}, \"distance\": {}, \
411             \"path\": {} }}",
412            num(t.at_secs),
413            triad_json(t.from),
414            triad_json(t.to),
415            counted(t.path.as_ref().map(Vec::len)),
416            t.path.as_ref().map_or(NONE.to_string(), |p| format!(
417                "\"{}\"",
418                p.iter().map(|s| s.letter()).collect::<String>()
419            ))
420        ))
421    )
422}
423
424fn weighted_json(w: Option<Weighted>) -> String {
425    w.map_or(NONE.to_string(), |w| {
426        format!(
427            "{{ \"value\": {}, \"bounds\": [{}, {}] }}",
428            num(w.value),
429            num(w.bounds.0),
430            num(w.bounds.1)
431        )
432    })
433}
434
435fn bounds_json(b: Option<(f64, f64)>) -> String {
436    b.map_or(NONE.to_string(), |(lo, hi)| {
437        format!("[{}, {}]", num(lo), num(hi))
438    })
439}
440
441fn series_json(s: &Series) -> String {
442    let fit = s.fit.map_or(NONE.to_string(), |f| {
443        format!(
444            "{{ \"b\": {}, \"b_bounds\": {}, \"f0\": {}, \"f0_bounds\": {} }}",
445            num(f.b),
446            bounds_json(f.b_bounds),
447            num(f.f0_hz),
448            bounds_json(f.f0_bounds)
449        )
450    });
451    format!(
452        "{{ \"f1\": {}, \"cents\": {}, \"fit\": {fit}, \"members\": {} }}",
453        num(s.members[0].partial.hz),
454        weighted_json(s.cents),
455        list(&s.members, |m| format!(
456            "{{ \"n\": {}, \"hz\": {}, \"hz_bounds\": {}, \"amplitude\": {}, \
457             \"amplitude_bounds\": {}, \"cents\": {}, \"cents_bounds\": {} }}",
458            m.n,
459            num(m.partial.hz),
460            bounds_json(Some(m.partial.hz_bounds)),
461            num(m.partial.amplitude),
462            bounds_json(Some(m.partial.amplitude_bounds)),
463            num(m.cents),
464            bounds_json(Some(m.cents_bounds))
465        ))
466    )
467}
468
469fn inharmonicity_json(r: &Inharmonicity) -> String {
470    format!(
471        "{{ \"model\": \"{}\", {}, \"cents_mean\": {}, \"cents_max\": {}, \
472         \"unexplained_mean\": {}, \"curve\": {} }}",
473        escape(inharmonicity::MODEL),
474        framing_json(&r.framing, r.floor_db),
475        maybe(r.cents_mean),
476        extreme_json(r.cents_max),
477        maybe(r.unexplained_mean),
478        list(&r.frames, |f| format!(
479            "\n    {{ \"t\": {}, \"cents\": {}, \"unexplained\": {}, \"series\": {} }}",
480            num(f.t_secs),
481            weighted_json(f.cents),
482            weighted_json(f.unexplained),
483            list(&f.series, series_json)
484        ))
485    )
486}
487
488fn spread_json(s: Option<Stats>) -> String {
489    s.map_or(NONE.to_string(), |s| {
490        format!(
491            "{{ \"mean\": {}, \"min\": {}, \"max\": {} }}",
492            num(s.mean),
493            extreme_json(Some(s.min)),
494            extreme_json(Some(s.max))
495        )
496    })
497}
498
499/// `curve` is left out where not asked, and each of its heights null where the envelope is
500/// constant.
501fn periodicity_window_json(w: &Window) -> String {
502    let curve = w.curve.as_ref().map_or(String::new(), |c| {
503        let heights: Vec<String> = c.iter().map(|h| num(*h)).collect();
504        format!(", \"curve\": [{}]", heights.join(", "))
505    });
506    format!(
507        "\n      {{ \"t\": {}, \"from\": {}, \"to\": {}, \"drift_bpm\": {}, \"peaks\": {}{curve} }}",
508        num(w.t_secs),
509        num(w.from_secs),
510        num(w.to_secs),
511        maybe(w.drift_bpm),
512        list(&w.peaks, |p| format!(
513            "{{ \"period_secs\": {}, \"period_bounds_secs\": {}, \"bpm\": {}, \
514             \"bpm_bounds\": {}, \"height\": {} }}",
515            num(p.period_secs),
516            bounds_json(p.period_bounds_secs),
517            num(p.bpm),
518            bounds_json(p.bpm_bounds),
519            num(p.height)
520        ))
521    )
522}
523
524fn periodicity_track_json(t: &Track) -> String {
525    let centres: Vec<String> = t.centres_hz.iter().map(|hz| num(*hz)).collect();
526    format!(
527        "\n    {{ \"group\": \"{}\", \"centres_hz\": [{}], \"summary\": {{ \
528         \"windows_with_peak\": {}, \"bpm\": {}, \"height\": {}, \"drift_bpm\": {} }}, \
529         \"windows\": {} }}",
530        t.group.key(),
531        centres.join(", "),
532        t.summary.windows_with_peak,
533        spread_json(t.summary.bpm),
534        spread_json(t.summary.height),
535        spread_json(t.summary.drift_bpm),
536        list(&t.windows, periodicity_window_json)
537    )
538}
539
540fn periodicity_json(p: &Periodicity) -> String {
541    format!(
542        "{{ \"model\": \"{}\", \"hop_secs\": {}, \"window_secs\": {}, \"envelope_hz\": {}, \
543         \"lag_step_secs\": {}, \"min_period_secs\": {}, \"max_period_secs\": {}, \
544         \"split_hz\": {}, \"smoothing\": {{ \"hz\": {}, \"poles\": {} }}, \"tracks\": {} }}",
545        escape(periodicity::MODEL),
546        num(p.hop_secs),
547        num(p.window_secs),
548        num(p.envelope_hz),
549        num(p.lag_step_secs),
550        num(p.min_period_secs),
551        num(p.max_period_secs),
552        num(periodicity::SPLIT_HZ),
553        maybe(periodicity::SMOOTHING.hz),
554        periodicity::SMOOTHING.poles,
555        list(&p.tracks, periodicity_track_json)
556    )
557}
558
559/// `period_beats` and its bounds are left out where no `bpm` was asked; `depth_key` names the
560/// depth's unit.
561fn modulation_peak_json(p: &modulation::Peak, depth_key: &str) -> String {
562    let beats = p.period_beats.map_or(String::new(), |b| {
563        format!(
564            ", \"period_beats\": {}, \"period_beats_bounds\": {}",
565            num(b),
566            bounds_json(p.period_beats_bounds)
567        )
568    });
569    format!(
570        "{{ \"hz\": {}, \"hz_bounds\": {}{beats}, \"{depth_key}\": {}, \
571         \"{depth_key}_bounds\": {} }}",
572        num(p.hz),
573        bounds_json(p.hz_bounds),
574        num(p.depth),
575        bounds_json(p.depth_bounds)
576    )
577}
578
579fn modulation_window_json(w: &modulation::Window) -> String {
580    let centroid = w.centroid.as_ref().map_or(NONE.to_string(), |c| {
581        format!(
582            "{{ \"mean_hz\": {}, \"peaks\": {} }}",
583            num(c.mean_hz),
584            list(&c.peaks, |p| modulation_peak_json(p, "depth_hz"))
585        )
586    });
587    format!(
588        "\n      {{ \"t\": {}, \"from\": {}, \"to\": {}, \"level\": {}, \"peaks\": {}, \
589         \"centroid\": {centroid} }}",
590        num(w.t_secs),
591        num(w.from_secs),
592        num(w.to_secs),
593        num(w.level),
594        list(&w.peaks, |p| modulation_peak_json(p, "depth"))
595    )
596}
597
598fn modulation_track_json(t: &modulation::Track) -> String {
599    let centres: Vec<String> = t.centres_hz.iter().map(|hz| num(*hz)).collect();
600    format!(
601        "\n    {{ \"group\": \"{}\", \"centres_hz\": [{}], \"summary\": {{ \
602         \"windows_with_peak\": {}, \"hz\": {}, \"depth\": {}, \"centroid_hz\": {}, \
603         \"centroid_depth_hz\": {} }}, \"windows\": {} }}",
604        t.group.key(),
605        centres.join(", "),
606        t.summary.windows_with_peak,
607        spread_json(t.summary.hz),
608        spread_json(t.summary.depth),
609        spread_json(t.summary.centroid_hz),
610        spread_json(t.summary.centroid_depth_hz),
611        list(&t.windows, modulation_window_json)
612    )
613}
614
615fn modulation_json(m: &Modulation) -> String {
616    let splits: Vec<String> = modulation::SPLITS_HZ.iter().map(|hz| num(*hz)).collect();
617    format!(
618        "{{ \"model\": \"{}\", \"hop_secs\": {}, \"window_secs\": {}, \"envelope_hz\": {}, \
619         \"bin_step_hz\": {}, \"resolution_hz\": {}, \"min_hz\": {}, \"max_hz\": {}, \
620         \"bpm\": {}, \"splits_hz\": [{}], \"smoothing\": {{ \"hz\": {}, \"poles\": {} }}, \
621         \"passband_gain_at_max_hz\": {}, \"tracks\": {} }}",
622        escape(modulation::MODEL),
623        num(m.hop_secs),
624        num(m.window_secs),
625        num(m.envelope_hz),
626        num(m.bin_step_hz),
627        num(m.resolution_hz),
628        num(m.min_hz),
629        num(m.max_hz),
630        maybe(m.bpm),
631        splits.join(", "),
632        maybe(modulation::SMOOTHING.hz),
633        modulation::SMOOTHING.poles,
634        num(m.passband_gain_at_max_hz),
635        list(&m.tracks, modulation_track_json)
636    )
637}
638
639fn span_json(span: (f64, f64)) -> String {
640    format!("{{ \"from\": {}, \"to\": {} }}", num(span.0), num(span.1))
641}
642
643fn numbers(values: &[f64]) -> String {
644    let held: Vec<String> = values.iter().map(|v| num(*v)).collect();
645    format!("[{}]", held.join(", "))
646}
647
648/// A knot table both as arrays and as the knots `pwl(t, ...)` takes, `unit` after each value.
649fn knots_json(c: &partials::Curve, unit: &str) -> String {
650    let table: Vec<String> = c
651        .times
652        .iter()
653        .zip(&c.values)
654        .map(|(t, v)| format!("{}s, {}{unit}", num(*t), num(*v)))
655        .collect();
656    format!(
657        "{{ \"times\": {}, \"values\": {}, \"knots\": \"{}\", \"error\": {} }}",
658        numbers(&c.times),
659        numbers(&c.values),
660        table.join(", "),
661        num(c.error)
662    )
663}
664
665fn partial_json(t: &partials::Track) -> String {
666    format!(
667        "\n    {{ \"kind\": \"{}\", \"hz\": {}, \"frequency\": {}, \"amplitude\": {}, \
668         \"phase\": {}, \"flutter_db\": {}, \"coherence\": {}, \"bandwidth_hz\": {}, \
669         \"bound\": {{ \"resolution_hz\": {}, \"resolution_secs\": {}, \"residual_db\": {} }} }}",
670        t.kind.key(),
671        num(t.hz),
672        knots_json(&t.frequency, "hz"),
673        knots_json(&t.amplitude, ""),
674        maybe(t.phase),
675        num(t.flutter_db),
676        num(t.coherence),
677        maybe(t.bandwidth_hz),
678        num(t.resolution_hz),
679        num(t.resolution_secs),
680        maybe(t.residual_db)
681    )
682}
683
684fn partials_json(p: &Partials) -> String {
685    format!(
686        "{{ \"model\": \"{}\", \"frame_secs\": {}, \"window_secs\": {}, \"hop_secs\": {}, \
687         \"floor_db\": {}, \"detected\": {}, \"residual_db\": {}, \"tracks\": {} }}",
688        escape(partials::MODEL),
689        num(p.frame_secs),
690        num(p.window_secs),
691        num(p.hop_secs),
692        num(p.floor_db),
693        p.detected,
694        maybe(p.residual_db),
695        list(&p.tracks, partial_json)
696    )
697}
698
699fn self_similarity_cell_json(c: &self_similarity::Cell) -> String {
700    format!(
701        "\n    {{ \"t\": {}, \"from\": {}, \"to\": {}, \"chroma\": {}, \"levels_db\": {} }}",
702        num(c.t_secs),
703        num(c.from_secs),
704        num(c.to_secs),
705        numbers(&c.chroma),
706        numbers(&c.levels_db)
707    )
708}
709
710/// `matrix` is left out where not asked.
711fn self_similarity_view_json(v: &self_similarity::Reading, from_secs: f64, step: f64) -> String {
712    let matrix = v.matrix.as_ref().map_or(String::new(), |rows| {
713        let rows: Vec<String> = rows
714            .iter()
715            .map(|r| format!("\n      {}", numbers(r)))
716            .collect();
717        format!(", \"matrix\": [{}]", rows.join(","))
718    });
719    format!(
720        "\n    {{ \"view\": \"{}\", \"stripes_found\": {}, \"stripes\": {}, \
721         \"novelty\": {{ \"from_secs\": {}, \"step_secs\": {}, \"values\": {} }}, \
722         \"peaks_found\": {}, \"peaks\": {}{matrix} }}",
723        v.view.key(),
724        v.stripes_found,
725        list(&v.stripes, |s| format!(
726            "\n      {{ \"shift_semitones\": {}, \"lag_secs\": {}, \"earlier\": {}, \
727             \"later\": {}, \"cells\": {}, \"similarity_mean\": {}, \"similarity_min\": {} }}",
728            s.shift_semitones,
729            num(s.lag_secs),
730            span_json(s.earlier),
731            span_json(s.later),
732            s.cells,
733            num(s.similarity_mean),
734            num(s.similarity_min)
735        )),
736        num(from_secs),
737        num(step),
738        numbers(&v.novelty),
739        v.peaks_found,
740        list(&v.peaks, |p| format!(
741            "{{ \"t\": {}, \"novelty\": {} }}",
742            num(p.t_secs),
743            num(p.novelty)
744        ))
745    )
746}
747
748/// `cells` is left out where not asked.
749fn self_similarity_json(s: &SelfSimilarity) -> String {
750    let cells = s.cells.as_ref().map_or(String::new(), |c| {
751        format!(", \"cells\": {}", list(c, self_similarity_cell_json))
752    });
753    format!(
754        "{{ \"model\": \"{}\", \"hop_secs\": {}, \"frame_window_secs\": {}, \
755         \"resolution_hz\": {}, \"cell_secs\": {}, \"frames_per_cell\": {}, \
756         \"cell_count\": {}, \"max_cells\": {}, \"smooth_cells\": {}, \"kernel_cells\": {}, \
757         \"floor_db\": {}, \"listed\": {}, \"min_hz\": {}, \"max_hz\": {}, \
758         \"centres_hz\": {}{cells}, \"views\": {} }}",
759        escape(self_similarity::MODEL),
760        num(s.hop_secs),
761        num(s.frame_window_secs),
762        num(s.resolution_hz),
763        num(s.cell_secs),
764        s.frames_per_cell,
765        s.cell_count,
766        self_similarity::MAX_CELLS,
767        s.smooth_cells,
768        s.kernel_cells,
769        num(s.floor_db),
770        s.listed,
771        num(s.min_hz),
772        num(s.max_hz),
773        numbers(&s.centres_hz),
774        list(&s.views, |v| self_similarity_view_json(
775            v,
776            s.novelty_from_secs,
777            s.cell_secs
778        ))
779    )
780}
781
782fn binding_json(b: &Binding) -> String {
783    format!(
784        "\n    {{ \"name\": \"{}\", \"source\": \"{}\" }}",
785        escape(&b.name),
786        escape(&b.source)
787    )
788}
789
790fn arguments_json(a: &Arguments) -> String {
791    let at = |s: sva_ast::ByteSpan| format!("{{ \"start\": {}, \"end\": {} }}", s.start, s.end);
792    let calls = list(&a.calls, |c| {
793        let arguments = list(&c.arguments, |x| {
794            format!(
795                "{{ \"name\": \"{}\", \"value\": {}, \"written\": {} }}",
796                escape(&x.name),
797                num(x.value),
798                x.written
799            )
800        });
801        format!(
802            "\n      {{ \"name\": \"{}\", \"at\": {}, \"arguments\": {arguments} }}",
803            escape(&c.name),
804            at(c.at)
805        )
806    });
807    let chosen = list(&a.chosen, |c| {
808        format!(
809            "\n      {{ \"name\": \"{}\", \"at\": {}, \"operands\": {}, \"chosen\": {} }}",
810            escape(&c.name),
811            at(c.at),
812            list(&c.operands, |v| num(*v)),
813            c.chosen
814        )
815    });
816    format!(
817        "\n    {{ \"node\": \"{}\", \"calls\": {calls}, \"chosen\": {chosen} }}",
818        escape(&a.node)
819    )
820}
821
822/// One object per component: a buffer is planar, and the component IS the channel.
823fn samples_json(b: &Buffer, limit: Option<usize>) -> String {
824    let component = |c: usize| {
825        let plane = b.plane(c);
826        let shown = limit.map_or(plane.len(), |cap| plane.len().min(cap));
827        format!(
828            "\n    {{ \"channel\": {c}, \"values\": {} }}",
829            capped(
830                plane,
831                shown,
832                |n| b.origin_secs() + n as f64 / f64::from(b.rate),
833                |v| num(*v)
834            )
835        )
836    };
837    let components: Vec<usize> = (0..b.width()).collect();
838    format!(
839        "{{ \"rate\": {}, \"origin_secs\": {}, \"width\": {}, \"components\": {} }}",
840        b.rate,
841        num(b.origin_secs()),
842        b.width(),
843        list(&components, |c| component(*c))
844    )
845}
846
847fn symbolic_json(n: &SpectralSum) -> String {
848    format!(
849        "{{ \"var\": \"{}\", \"lanes\": {}, \"terms\": {} }}",
850        match n.var {
851            sva_engine::Var::T => "t",
852            sva_engine::Var::F => "f",
853        },
854        n.lanes.len(),
855        list(&sva_engine::atoms_of(n), atom_json)
856    )
857}
858
859/// `limit` caps the arrays a stdout reader scrolls past; `None` writes every value. `skim`
860/// only changes the ledger.
861pub fn value_json(output: &Output, limit: Option<usize>, skim: bool) -> String {
862    match output {
863        Output::Lines(lines) => list(lines, line_json),
864        Output::Atoms(atoms) => list(atoms, atom_json),
865        Output::Symbolic(sum) => symbolic_json(sum),
866        Output::Samples(buffer) => samples_json(buffer, limit),
867        Output::Ledger(entries) => ledger_json(entries, skim),
868        Output::Spectrum(s) => spectrum_json(s),
869        Output::Stereo(image) => stereo_json(image),
870        Output::Bands(b) => bands_json(b, limit),
871        Output::Loudness(l) => loudness_json(l, limit),
872        Output::Crest(c) => crest_json(c),
873        Output::Onsets(o) => onsets_json(o),
874        Output::Alias(a) => alias_json(a),
875        Output::Dissonance(d) => dissonance_json(d),
876        Output::VoiceLeading(v) => voice_leading_json(v),
877        Output::TonalDistance(d) => tonal_distance_json(d),
878        Output::Inharmonicity(r) => inharmonicity_json(r),
879        Output::Periodicity(p) => periodicity_json(p),
880        Output::Modulation(m) => modulation_json(m),
881        Output::SelfSimilarity(s) => self_similarity_json(s),
882        Output::Partials(p) => partials_json(p),
883        Output::Bindings(b) => list(b, binding_json),
884        Output::Arguments(a) => list(a, arguments_json),
885        Output::Envelope(frames) => list(frames, |f: &EnvelopeFrame| {
886            format!(
887                "\n    {{ \"t\": {}, \"rms\": {}, \"peak\": {} }}",
888                num(f.t_secs),
889                num(f.rms),
890                num(f.peak)
891            )
892        }),
893        Output::Pitch(frames) => list(frames, |f| {
894            format!(
895                "\n    {{ \"t\": {}, \"notes\": {} }}",
896                num(f.t_secs),
897                list(&f.notes, |n| format!(
898                    "{{ \"note\": \"{}\", \"hz\": {}, \"cents\": {}, \"db\": {}, \
899                     \"harmonic_of\": {} }}",
900                    escape(&n.name),
901                    num(n.hz),
902                    num(n.cents),
903                    num(n.db),
904                    maybe(n.harmonic_of)
905                ))
906            )
907        }),
908        Output::Formants(frames) => list(frames, formants_json),
909    }
910}
911
912/// Each place on the sum's own axis in its unit, and the turn in the unit dual to it: in `t`
913/// seconds and hertz, in `f` the reverse; `null` is a side the window leaves unbounded.
914fn atom_json(a: &sva_engine::Atom) -> String {
915    let (at, per, turns) = match a.var {
916        sva_engine::Var::T => ("secs", "sec", "hz"),
917        sva_engine::Var::F => ("hz", "hz", "secs"),
918    };
919    format!(
920        "\n    {{ \"channel\": {}, \"sketch\": \"{}\", \"{turns}\": {}, \"re\": {}, \"im\": {}, \
921         \"db\": {}, \"decay_per_{per}\": {}, \"decay_from_{at}\": {}, \"degree\": {}, \
922         \"polynomial_at_{at}\": {}, \"start_{at}\": {}, \"end_{at}\": {} }}",
923        a.channel,
924        escape(&a.sketch),
925        num(a.turns),
926        num(a.amp.re),
927        num(a.amp.im),
928        num(20.0 * a.amp.abs().log10()),
929        num(a.decay),
930        num(a.decay_from),
931        a.degree,
932        num(a.polynomial_at),
933        maybe(a.start),
934        maybe(a.end)
935    )
936}
937
938fn line_json(l: &sva_engine::Line) -> String {
939    format!(
940        "\n    {{ \"hz\": {}, \"re\": {}, \"im\": {}, \"db\": {} }}",
941        num(l.hz),
942        num(l.amp.re),
943        num(l.amp.im),
944        num(20.0 * l.amp.abs().log10())
945    )
946}
947
948/// Every answer says which reading ran, under which profile, and at what rate.
949/// A reading that truncated a series names what it left out, so the kept list is never read
950/// as the whole of it.
951pub fn answer_json(printed: &Printed, limit: Option<usize>) -> String {
952    let answer = &printed.answer;
953    let args = printed
954        .args
955        .iter()
956        .map(|(key, value)| {
957            let value = match value {
958                None => NONE.to_string(),
959                Some(Spelled::Number(n)) => n.clone(),
960                Some(Spelled::Text(t)) => format!("\"{}\"", escape(t)),
961            };
962            format!("\"{key}\": {value}")
963        })
964        .collect::<Vec<_>>()
965        .join(", ");
966    let args = match args.is_empty() {
967        true => "{}".to_string(),
968        false => format!("{{ {args} }}"),
969    };
970    format!(
971        "{{ \"representation\": \"{}\", \"args\": {args}, \"source\": \"{}\", \"profile\": \"{}\", \
972         \"rate\": {}, \"value\": {}, \"dropped\": {}, \"tail_db\": {} }}",
973        escape(&printed.name),
974        match answer.source {
975            Source::Exact => "exact",
976            Source::Measured => "measured",
977        },
978        escape(answer.profile),
979        answer
980            .rate
981            .map_or_else(|| NONE.to_string(), |r| r.to_string()),
982        value_json(&answer.value, limit, printed.skim),
983        list(&answer.dropped, line_json),
984        maybe(answer.tail_db)
985    )
986}
987
988/// What one collapse row states beyond its name, as JSON fields.
989fn detail_json(detail: &Detail) -> String {
990    match detail {
991        Detail::Lines {
992            summed,
993            dropped,
994            dropped_more,
995            tail_db,
996            ..
997        } => format!(
998            ", \"summed\": {summed}, \"dropped\": {}, \"dropped_more\": {dropped_more}, \
999             \"tail_db\": {}",
1000            list(dropped, |d| format!(
1001                "{{ \"hz\": {}, \"db\": {} }}",
1002                num(d.hz),
1003                num(d.db)
1004            )),
1005            maybe(*tail_db)
1006        ),
1007        Detail::Cropped { tail_db, .. } => format!(", \"tail_db\": {}", maybe(*tail_db)),
1008        Detail::Point {
1009            alias_db, tail_db, ..
1010        } => format!(
1011            ", \"alias_db\": {}, \"tail_db\": {}",
1012            maybe(*alias_db),
1013            maybe(*tail_db)
1014        ),
1015        Detail::Roundtrip { edited, .. } => format!(", \"edited\": {edited}"),
1016        Detail::Continuous { .. } | Detail::Reading { .. } => String::new(),
1017        Detail::Added { parts } => format!(
1018            ", \"addends\": {}",
1019            list(parts, |part| format!(
1020                "{{ \"rule\": \"{}\"{} }}",
1021                part.rule().as_str(),
1022                detail_json(part)
1023            ))
1024        ),
1025    }
1026}
1027
1028/// The collapse label beside the reading it belongs to. `detail` is the one
1029/// place a key may be absent: each `rule` is its own shape, and its fields belong to it.
1030pub fn label_json(label: &Label) -> String {
1031    let detail = detail_json(&label.detail);
1032    let cutting = label
1033        .cutting_below_silence_threshold
1034        .as_ref()
1035        .map_or(NONE.to_string(), cutting_below_silence_threshold_json);
1036    let rest = format!(
1037        ", \"moved_s\": {}, \"pruned\": {cutting}, \"resampled\": {}",
1038        maybe(label.moved),
1039        list(&label.resampled, resampled_json)
1040    );
1041    format!(
1042        "{{ \"source\": \"{}\", \"profile\": \"{}\", \"rate\": {}, \"rule\": \"{}\"{detail}{rest} }}",
1043        match label.source {
1044            Source::Exact => "exact",
1045            Source::Measured => "measured",
1046        },
1047        escape(label.profile),
1048        label.rate,
1049        escape(label.rule().as_str())
1050    )
1051}
1052
1053/// A recording read at another rate: the bound its windowed sinc holds the passband to.
1054pub fn resampled_json(r: &sva_engine::Resampled) -> String {
1055    format!(
1056        "{{ \"from_hz\": {}, \"to_hz\": {}, \"passband_hz\": {}, \"stopband_hz\": {}, \
1057         \"ripple_db\": {}, \"stopband_db\": {} }}",
1058        r.from_hz,
1059        r.to_hz,
1060        num(r.passband_hz),
1061        num(r.stopband_hz),
1062        num(r.ripple_db),
1063        num(r.stopband_db)
1064    )
1065}
1066
1067/// The silence threshold, and each node treated as silent from a sample on, under the JSON
1068/// keys `pruned`, `db` and `cuts`.
1069pub fn cutting_below_silence_threshold_json(cutting: &CuttingBelowSilenceThreshold) -> String {
1070    let cuts = list(&cutting.treated_as_silent_from_sample, |(node, from)| {
1071        format!("{{ \"node\": \"{}\", \"from\": {from} }}", escape(node))
1072    });
1073    format!(
1074        "{{ \"db\": {}, \"cuts\": {cuts} }}",
1075        num(cutting.silence_threshold_dbfs)
1076    )
1077}
1078
1079pub fn work_json(work: &Work) -> String {
1080    format!(
1081        "{{ \"samples\": {}, \"computed_samples\": {} }}",
1082        work.samples, work.computed_samples
1083    )
1084}
1085
1086/// `computed` counts every miss, `stored` the misses memory kept and `replaced` the volatile
1087/// misses kept in place of their last value; memory's figures are as the render left it, and
1088/// `tier` what passed between memory and the disk meanwhile.
1089pub fn stats_json(stats: &CacheStats) -> String {
1090    stats_with(stats, "")
1091}
1092
1093/// `stats_json` over a stream's latest lookups, `dropped`: the latest nodes its live edits
1094/// started silent, and `pruned`: where it cuts below the silence threshold. Each list's
1095/// `pagination.count` counts all it made.
1096pub fn stream_stats_json(
1097    stats: &CacheStats,
1098    (dropped, made): (&[&str], usize),
1099    cutting: &CuttingBelowSilenceThreshold,
1100) -> String {
1101    let dropped = latest(dropped, made, |name| format!("\"{}\"", escape(name)));
1102    let pruned = cutting_below_silence_threshold_json(cutting);
1103    stats_with(
1104        stats,
1105        &format!(", \"dropped\": {dropped}, \"pruned\": {pruned}"),
1106    )
1107}
1108
1109fn stats_with(stats: &CacheStats, extra: &str) -> String {
1110    let made = stats.shed + stats.lookups.len();
1111    let lookups = latest(&stats.lookups, made, |l| {
1112        let outcome = match l.outcome {
1113            Outcome::Hit => "hit",
1114            Outcome::ComputedStored => "computed_stored",
1115            Outcome::ComputedNotStored => "computed_not_stored",
1116            Outcome::ComputedReplaced => "computed_replaced",
1117            Outcome::Extended => "extended",
1118            Outcome::Prefix => "prefix",
1119            Outcome::Reused => "reused",
1120        };
1121        format!(
1122            "{{ \"node\": \"{}\", \"key\": \"{}\", \"kind\": \"{}\", \"outcome\": \"{outcome}\" }}",
1123            escape(&l.node),
1124            l.key,
1125            match l.kind {
1126                PayloadKind::Segments => "segments",
1127                PayloadKind::Frames => "frames",
1128                PayloadKind::Run => "run",
1129            }
1130        )
1131    });
1132    format!(
1133        "{{ \"nodes\": {}, \"hits\": {}, \"computed\": {}, \"stored\": {}, \"replaced\": {}, \"extended\": {}, \"reused\": {}, \
1134         \"bytes\": {}, \"max_bytes\": {}, \"entries\": {}, \"evictions\": {}, \
1135         \"tier\": {}, \"lookups\": {lookups}{extra} }}",
1136        stats.nodes(),
1137        stats.hits(),
1138        stats.computed(),
1139        stats.stored(),
1140        stats.replaced(),
1141        stats.extended(),
1142        stats.reused(),
1143        stats.bytes,
1144        stats.max_bytes,
1145        stats.entries,
1146        stats.evictions,
1147        counters_json(&stats.tier)
1148    )
1149}
1150
1151/// What passed between memory and the disk beneath it.
1152pub fn counters_json(counters: &Counters) -> String {
1153    format!(
1154        "{{ \"disk_lookups\": {}, \"disk_reads\": {}, \"disk_read_bytes\": {}, \
1155         \"promotions\": {}, \"writebacks\": {}, \"hits\": {}, \"evictions\": {}, \
1156         \"probation_evictions\": {}, \"protected_evictions\": {} }}",
1157        counters.disk_lookups,
1158        counters.disk_reads,
1159        counters.disk_read_bytes,
1160        counters.promotions,
1161        counters.writebacks,
1162        counters.hits,
1163        counters.evictions(),
1164        counters.probation_evictions,
1165        counters.protected_evictions
1166    )
1167}
1168
1169fn worked_json(worked: &Worked) -> String {
1170    let lookups = worked.lookups;
1171    format!(
1172        "\"samples\": {}, \"cached_samples\": {}, \"cached_from_store_samples\": {}, \
1173         \"computed_samples\": {}, \"lookups\": {{ \"hit\": {}, \
1174         \"from_memory\": {}, \"from_store\": {}, \"prefix\": {}, \"miss\": {}, \"new\": {}, \
1175         \"reused\": {} }}, \"tier\": {}",
1176        worked.samples,
1177        worked.work.cached_samples,
1178        worked.work.stored_samples,
1179        worked.work.computed_samples,
1180        lookups.hit,
1181        lookups.hit - lookups.from_store,
1182        lookups.from_store,
1183        lookups.prefix,
1184        lookups.miss,
1185        lookups.new,
1186        lookups.reused,
1187        counters_json(&worked.tier)
1188    )
1189}
1190
1191/// One report of the engine-stats subscription, as `sva-wasm` declares `EngineStats`.
1192pub fn engine_stats_json(stats: &EngineStats) -> String {
1193    let call = stats.call.as_ref().map_or("null".to_string(), |call| {
1194        let kind = match call.kind {
1195            Kind::Render => "render",
1196            Kind::Stream => "stream",
1197            Kind::Edit => "edit",
1198            Kind::Persist => "persist",
1199        };
1200        let phase = match call.phase {
1201            Phase::Parsing => "parsing",
1202            Phase::Typing => "typing",
1203            Phase::LookingUp => "looking_up",
1204            Phase::Planning => "planning",
1205            Phase::Fetching => "fetching",
1206            Phase::Delivering => "delivering",
1207            Phase::Finishing => "finishing",
1208            Phase::Storing => "storing",
1209            Phase::Done => "done",
1210            Phase::Failed => "failed",
1211            Phase::Abandoned => "abandoned",
1212        };
1213        let count = |n: Option<u64>| n.map_or("null".to_string(), |n| n.to_string());
1214        let (total, planned) = (count(call.total_samples), count(call.planned_samples));
1215        let typing = call.typing.map_or("null".to_string(), |typing| {
1216            format!(
1217                "{{ \"instances\": {}, \"nodes\": {}, \"typed\": {} }}",
1218                typing.instances, typing.nodes, typing.typed
1219            )
1220        });
1221        format!(
1222            "{{ \"id\": {}, \"kind\": \"{kind}\", \"target\": \"{}\", \"phase\": \"{phase}\", \
1223             \"total_samples\": {total}, \"planned_samples\": {planned}, \"typing\": {typing}, \
1224             {} }}",
1225            call.id,
1226            escape(&call.target),
1227            worked_json(&call.counts)
1228        )
1229    });
1230    let boot = stats
1231        .boot
1232        .map_or("null".to_string(), |boot| boot_json(&boot));
1233    let state = match (
1234        stats.boot.is_some_and(|boot| boot.under_way()),
1235        stats.working,
1236    ) {
1237        (true, _) => "booting",
1238        (false, 0) => "idle",
1239        (false, _) => "working",
1240    };
1241    format!(
1242        "{{ \"state\": \"{state}\", \"working\": {}, \"boot\": {boot}, \"call\": {call}, \
1243         \"totals\": {{ {} }} }}",
1244        stats.working,
1245        worked_json(&stats.totals)
1246    )
1247}
1248
1249fn boot_json(boot: &Boot) -> String {
1250    let phase = match boot.phase {
1251        BootPhase::StartingThreads => "starting_threads",
1252        BootPhase::ThreadsStarted => "threads_started",
1253        BootPhase::ThreadsFailed => "threads_failed",
1254        BootPhase::OpeningStore => "opening_store",
1255        BootPhase::ReadingIndex => "reading_index",
1256        BootPhase::IndexRead => "index_read",
1257        BootPhase::StoreFailed => "store_failed",
1258        BootPhase::Opened => "opened",
1259    };
1260    let count = |n: Option<u64>| n.map_or("null".to_string(), |n| n.to_string());
1261    let store = boot.store.map_or("null".to_string(), |store| {
1262        format!(
1263            "{{ \"max_bytes\": {}, \"index_bytes\": {}, \"entries\": {}, \"bytes\": {} }}",
1264            store.max_bytes,
1265            count(store.read.map(|read| read.0)),
1266            count(store.read.map(|read| read.1 as u64)),
1267            count(store.read.map(|read| read.2)),
1268        )
1269    });
1270    format!(
1271        "{{ \"phase\": \"{phase}\", \"threads\": {}, \"store\": {store} }}",
1272        count(boot.threads.map(|n| n as u64))
1273    )
1274}
1275
1276/// One answer as a report prints it.
1277pub struct Printed {
1278    pub name: String,
1279    /// Every argument the reading takes as `Asked::args` spells it, so a reading read back
1280    /// states what it was read with.
1281    pub args: Vec<(&'static str, Option<Spelled>)>,
1282    pub answer: Answer,
1283    /// `ledger(skim=1)`: each row's level alone.
1284    pub skim: bool,
1285}
1286
1287impl Printed {
1288    /// `answer` to `asked`, its arguments at `rate`, bars in `seconds_per_bar`.
1289    pub fn of(
1290        asked: &Asked,
1291        answer: Answer,
1292        rate: u32,
1293        seconds_per_bar: Option<f64>,
1294    ) -> Result<Printed, CliError> {
1295        let taken = match (answer.source, &answer.value) {
1296            (Source::Exact, _) => Taken::Exact,
1297            (Source::Measured, Output::Spectrum(s)) => Taken::Measured {
1298                fitted_frame: Some(s.frame_size),
1299            },
1300            (Source::Measured, _) => Taken::Measured { fitted_frame: None },
1301        };
1302        Ok(Printed {
1303            name: asked.name.clone(),
1304            args: asked.args(rate, seconds_per_bar, taken)?,
1305            answer,
1306            skim: asked.skim,
1307        })
1308    }
1309}
1310
1311/// What a render answered with, and where anything too big for the object went instead.
1312pub struct Report<'a> {
1313    pub target: &'a str,
1314    pub rate: u32,
1315    /// The precision every sample was written to; `None` for a file read back.
1316    pub bits: Option<i32>,
1317    /// The seconds the readings were taken over; `None` where none read samples.
1318    pub interval: Option<(f64, f64)>,
1319    pub profile: &'a str,
1320    pub label: Option<&'a Label>,
1321    pub written: &'a [(String, &'a Path)],
1322    /// Absent where no reading named a live device.
1323    pub played: Option<&'a Played>,
1324    pub answers: &'a [Printed],
1325    pub limit: Option<usize>,
1326}
1327
1328/// What a live device played, as its player reported it back.
1329#[derive(Clone, Debug, PartialEq)]
1330pub struct Played {
1331    pub representation: String,
1332    /// As the destination named it, `alsa:hw:1`.
1333    pub device: String,
1334    pub seconds: f64,
1335    pub rate: u32,
1336    pub channels: usize,
1337    /// The ALSA sample format the samples were sent in, `FLOAT_LE`.
1338    pub format: &'static str,
1339    pub buffer_secs: f64,
1340    /// What the device gave for the buffer asked; `None` where the player stated none.
1341    pub granted_buffer_secs: Option<f64>,
1342    pub underruns: usize,
1343    /// What saw the underruns `underruns` counts, and what it cannot see.
1344    pub underruns_seen_by: &'static str,
1345}
1346
1347fn played_json(played: &Played) -> String {
1348    format!(
1349        "{{ \"representation\": \"{}\", \"device\": \"{}\", \"seconds\": {}, \
1350         \"sample_rate\": {}, \"channels\": {}, \"format\": \"{}\", \"buffer\": {{ \
1351         \"asked_secs\": {}, \"granted_secs\": {} }}, \"underruns\": {{ \"count\": {}, \
1352         \"seen_by\": \"{}\" }} }}",
1353        escape(&played.representation),
1354        escape(&played.device),
1355        num(played.seconds),
1356        played.rate,
1357        played.channels,
1358        played.format,
1359        num(played.buffer_secs),
1360        maybe(played.granted_buffer_secs),
1361        played.underruns,
1362        escape(played.underruns_seen_by),
1363    )
1364}
1365
1366/// `written` names every reading that went to a file rather than into `representations`.
1367pub fn query_data(report: &Report) -> String {
1368    let written = list(report.written, |(name, path)| {
1369        format!(
1370            "{{ \"representation\": \"{}\", \"path\": \"{}\" }}",
1371            escape(name),
1372            escape(&path.display().to_string())
1373        )
1374    });
1375    let label = report
1376        .label
1377        .map(label_json)
1378        .unwrap_or_else(|| NONE.to_string());
1379    let reads = report
1380        .answers
1381        .iter()
1382        .map(|printed| {
1383            format!(
1384                "\"{}\": {}",
1385                escape(&printed.name),
1386                answer_json(printed, report.limit)
1387            )
1388        })
1389        .collect::<Vec<_>>()
1390        .join(",\n    ");
1391    let interval = report.interval.map_or(NONE.to_string(), |(start, end)| {
1392        format!(
1393            "{{ \"start_secs\": {}, \"end_secs\": {} }}",
1394            num(start),
1395            num(end)
1396        )
1397    });
1398    let played = report.played.map_or(String::new(), |played| {
1399        format!("\n  \"played\": {},", played_json(played))
1400    });
1401    format!(
1402        "{{\n  \"target\": \"{}\",\n  \"sample_rate\": {},\n  \"bits\": {},\n  \
1403         \"profile\": \"{}\",\n  \"interval\": {interval},\n  \"label\": {label},\n  \
1404         \"written\": {written},{played}\n  \"representations\": {{\n    {reads}\n  }}\n}}",
1405        escape(report.target),
1406        report.rate,
1407        report.bits.map_or(NONE.to_string(), |b| b.to_string()),
1408        escape(report.profile),
1409    )
1410}