Skip to main content

sva_core/
answer.rs

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