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