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