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