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, BandCrest, BandTrack, Bands, Binding, Buffer, Cost, Crest, Detail,
7    EnvelopeFrame, FormantFrame, Horizon, Label, LedgerEntry, Loudness, LoudnessFrame, Output,
8    Source, SpectralSum, Spectrum, StereoFrame, StereoImage,
9};
10
11use crate::json::{NONE, capped, escape, list, num};
12
13/// Past this a caller reading stdout wants a narrower `--from`/`--to`, not a wall of JSON.
14pub const SAMPLE_LIMIT: usize = 4096;
15
16/// Which nodes were actually evaluated; everything else came back from the cache at `dir`.
17pub struct CacheReport {
18    pub dir: String,
19    pub reused: usize,
20    pub evaluated: Vec<String>,
21    pub held_bytes: u64,
22    pub max_bytes: u64,
23    pub evicted_bytes: u64,
24    pub faults: u64,
25}
26
27fn maybe(v: Option<f64>) -> String {
28    v.map(num).unwrap_or_else(|| NONE.to_string())
29}
30
31fn counted(v: Option<usize>) -> String {
32    v.map_or_else(|| NONE.to_string(), |c| c.to_string())
33}
34
35fn ledger_json(entries: &[LedgerEntry], skim: bool) -> String {
36    list(entries, |e| {
37        let clipped = e
38            .clipped
39            .map_or_else(|| NONE.to_string(), |c| c.to_string());
40        let channel = counted(e.channel);
41        if skim {
42            return format!(
43                "\n    {{ \"node\": \"{}\", \"channel\": {channel}, \"rms\": {}, \"peak\": {}, \"clipped\": {clipped} }}",
44                escape(&e.node),
45                num(e.rms),
46                num(e.peak)
47            );
48        }
49        format!(
50            "\n    {{ \"node\": \"{}\", \"channel\": {channel}, \"depth\": {}, \"unit\": \"{}\", \
51             \"control\": {}, \"rms\": {}, \"peak\": {}, \"min\": {}, \"max\": {}, \
52             \"share\": {}, \"clipped\": {clipped} }}",
53            escape(&e.node),
54            e.depth,
55            e.kind.unit().name(),
56            !e.kind.is_audio(),
57            num(e.rms),
58            num(e.peak),
59            num(e.min),
60            num(e.max),
61            maybe(e.share)
62        )
63    })
64}
65
66fn spectrum_json(s: &Spectrum) -> String {
67    format!(
68        "{{ \"frame_size\": {}, \"frames\": {}, \"resolution_hz\": {}, \"rms\": {}, \
69         \"centroid_hz\": {}, \"rolloff85_hz\": {}, \"peaks\": {}, \"bands\": {} }}",
70        s.frame_size,
71        s.frames,
72        num(s.resolution_hz),
73        num(s.rms),
74        num(s.centroid_hz),
75        num(s.rolloff85_hz),
76        list(&s.peaks, |p| format!(
77            "{{ \"hz\": {}, \"db\": {} }}",
78            num(p.hz),
79            num(p.db)
80        )),
81        list(&s.bands, |b| format!(
82            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"db\": {} }}",
83            num(b.lo_hz),
84            num(b.hi_hz),
85            num(b.db)
86        ))
87    )
88}
89
90fn stereo_json(image: &StereoImage) -> String {
91    let frame = |f: &StereoFrame| {
92        format!(
93            "\n      {{ \"t\": {}, \"correlation\": {}, \"mid_rms\": {}, \"side_rms\": {}, \
94             \"width\": {}, \"balance_db\": {}, \"mono_db\": {} }}",
95            num(f.t_secs),
96            num(f.correlation),
97            num(f.mid_rms),
98            num(f.side_rms),
99            num(f.width),
100            num(f.balance_db),
101            num(f.mono_db)
102        )
103    };
104    format!(
105        "{{ \"channels\": {}, \"overall\": {}, \"frames\": {} }}",
106        image.channels,
107        frame(&image.overall),
108        list(&image.frames, frame)
109    )
110}
111
112fn formants_json(f: &FormantFrame) -> String {
113    format!(
114        "\n    {{ \"t\": {}, \"order\": {}, \"energy\": {}, \"residual\": {}, \"formants\": {} }}",
115        num(f.t_secs),
116        f.order,
117        num(f.energy),
118        num(f.residual),
119        list(&f.formants, |v| format!(
120            "{{ \"hz\": {}, \"bandwidth_hz\": {}, \"db\": {} }}",
121            num(v.hz),
122            num(v.bandwidth_hz),
123            num(v.db)
124        ))
125    )
126}
127
128fn band_json(b: &BandTrack, start_secs: f64, rate_hz: f64, limit: Option<usize>) -> String {
129    let shown = limit.map_or(b.rms.len(), |cap| b.rms.len().min(cap));
130    format!(
131        "\n    {{ \"centre_hz\": {}, \"cam\": {}, \"erb_hz\": {}, \"q\": {}, \"peak\": {}, \
132         \"time_to_peak_secs\": {}, \"rise_10_90_secs\": {}, \
133         \"floor\": {{ \"peak\": {}, \"time_to_peak_secs\": {}, \"rise_10_90_secs\": {} }}, \
134         \"rms\": {} }}",
135        num(b.centre_hz),
136        num(b.cam),
137        num(b.erb_hz),
138        num(b.q),
139        num(b.peak),
140        maybe(b.time_to_peak_secs),
141        maybe(b.rise_10_90_secs),
142        num(b.floor.peak),
143        num(b.floor.time_to_peak_secs),
144        maybe(b.floor.rise_10_90_secs),
145        capped(
146            &b.rms,
147            shown,
148            |n| start_secs + n as f64 / rate_hz,
149            |v| num(*v),
150        )
151    )
152}
153
154fn bands_json(b: &Bands, limit: Option<usize>) -> String {
155    format!(
156        "{{ \"rate_hz\": {}, \"start_secs\": {}, \"bands\": {} }}",
157        num(b.rate_hz),
158        num(b.start_secs),
159        list(&b.bands, |band| band_json(
160            band,
161            b.start_secs,
162            b.rate_hz,
163            limit
164        ))
165    )
166}
167
168fn loudness_json(l: &Loudness, limit: Option<usize>) -> String {
169    let frames = |fs: &[LoudnessFrame]| {
170        let shown = limit.map_or(fs.len(), |cap| fs.len().min(cap));
171        capped(
172            fs,
173            shown,
174            |n| fs[n].t,
175            |f| format!("\n    {{ \"t\": {}, \"lufs\": {} }}", num(f.t), num(f.lufs)),
176        )
177    };
178    format!(
179        "{{ \"integrated_lufs\": {}, \"range_lu\": {}, \"momentary_max_lufs\": {}, \
180         \"short_term_max_lufs\": {}, \"sample_peak\": {}, \"sample_peak_dbfs\": {}, \
181         \"peak_note\": \"{}\", \"momentary\": {}, \"short_term\": {} }}",
182        maybe(l.integrated_lufs),
183        maybe(l.range_lu),
184        maybe(l.momentary_max_lufs),
185        maybe(l.short_term_max_lufs),
186        num(l.sample_peak),
187        maybe(l.sample_peak_dbfs),
188        escape(l.peak_note),
189        frames(&l.momentary),
190        frames(&l.short_term)
191    )
192}
193
194fn crest_json(c: &Crest) -> String {
195    let band = |b: &BandCrest| {
196        format!(
197            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"centre_hz\": {}, \"peak\": {}, \
198             \"rms\": {}, \"crest_db\": {}, \"counted\": {} }}",
199            num(b.lo_hz),
200            num(b.hi_hz),
201            num(b.centre_hz),
202            num(b.peak),
203            num(b.rms),
204            num(b.crest_db),
205            b.counted
206        )
207    };
208    format!(
209        "{{ \"broadband_crest_db\": {}, \"spread_db\": {}, \"widest_band_hz\": {}, \
210         \"tightest_band_hz\": {}, \"counted_under_db\": {}, \"bands\": {} }}",
211        num(c.broadband_crest_db),
212        maybe(c.spread_db),
213        maybe(c.widest_band_hz),
214        maybe(c.tightest_band_hz),
215        num(c.counted_under_db),
216        list(&c.bands, band)
217    )
218}
219
220/// `rate_dependent` is what stops the figure reading as pure alias: a sampled loop is a
221/// different signal at the oversampled rate.
222fn alias_json(a: &Alias) -> String {
223    let band = |b: &AliasBand| {
224        format!(
225            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"signal_db\": {}, \"alias_db\": {}, \
226             \"nmr_db\": {} }}",
227            num(b.lo_hz),
228            num(b.hi_hz),
229            num(b.signal_db),
230            num(b.alias_db),
231            num(b.nmr_db)
232        )
233    };
234    format!(
235        "{{ \"oversample\": {}, \"sample_rate\": {}, \"frame_size\": {}, \"frames\": {}, \
236         \"scored_frames\": {}, \"playback_db_spl\": {}, \"asr_db\": {}, \"nmr_db\": {}, \
237         \"nmr_peak_db\": {}, \"peak_at_secs\": {}, \"audible\": {}, \"rate_dependent\": {}, \
238         \"instances\": {}, \"bands\": {} }}",
239        a.oversample,
240        num(a.sample_rate),
241        a.frame_size,
242        a.frames,
243        a.scored_frames,
244        num(a.playback_db_spl),
245        num(a.asr_db),
246        num(a.nmr_db),
247        num(a.nmr_peak_db),
248        num(a.peak_at_secs),
249        a.audible,
250        a.rate_dependent,
251        a.instances,
252        list(&a.bands, band)
253    )
254}
255
256fn binding_json(b: &Binding) -> String {
257    format!(
258        "\n    {{ \"name\": \"{}\", \"source\": \"{}\" }}",
259        escape(&b.name),
260        escape(&b.source)
261    )
262}
263
264/// One object per component: a buffer is planar, and the component IS the channel.
265fn samples_json(b: &Buffer, limit: Option<usize>) -> String {
266    let component = |c: usize| {
267        let plane = b.plane(c);
268        let shown = limit.map_or(plane.len(), |cap| plane.len().min(cap));
269        format!(
270            "\n    {{ \"channel\": {c}, \"values\": {} }}",
271            capped(
272                plane,
273                shown,
274                |n| b.origin_secs + n as f64 / f64::from(b.rate),
275                |v| num(*v)
276            )
277        )
278    };
279    let components: Vec<usize> = (0..b.width).collect();
280    format!(
281        "{{ \"rate\": {}, \"origin_secs\": {}, \"width\": {}, \"components\": {} }}",
282        b.rate,
283        num(b.origin_secs),
284        b.width,
285        list(&components, |c| component(*c))
286    )
287}
288
289fn symbolic_json(n: &SpectralSum) -> String {
290    let terms: Vec<String> = n.atoms().map(sva_engine::sketch_atom).collect();
291    format!(
292        "{{ \"var\": \"{}\", \"lanes\": {}, \"terms\": {} }}",
293        match n.var {
294            sva_engine::Var::T => "t",
295            sva_engine::Var::F => "f",
296        },
297        n.lanes.len(),
298        crate::json::strings(&terms)
299    )
300}
301
302/// A cost tree, deepest row last, each already folded to the share it is worth printing.
303fn flops_json(tree: &sva_engine::FlopTree) -> String {
304    format!(
305        "{{ \"total\": {}, \"budget\": {}, \"rows\": {} }}",
306        tree.total,
307        tree.budget,
308        list(&tree.rows, |r: &sva_engine::FlopRow| format!(
309            "\n    {{ \"depth\": {}, \"node\": \"{}\", \"own\": {}, \"subtree\": {}, \
310             \"percent\": {}, \"route\": \"{}\", \"shared\": {} }}",
311            r.depth,
312            escape(&r.node),
313            r.own,
314            r.subtree,
315            num(r.percent),
316            escape(r.route),
317            r.shared
318        ))
319    )
320}
321
322/// `limit` caps the arrays a stdout reader scrolls past; `None` writes every value. `skim`
323/// only changes the ledger.
324pub fn value_json(output: &Output, limit: Option<usize>, skim: bool) -> String {
325    match output {
326        Output::Lines(lines) => list(lines, line_json),
327        Output::Atoms(sketches) => crate::json::strings(sketches),
328        Output::Symbolic(sum) => symbolic_json(sum),
329        Output::Samples(buffer) => samples_json(buffer, limit),
330        Output::Ledger(entries) => ledger_json(entries, skim),
331        Output::Spectrum(s) => spectrum_json(s),
332        Output::Stereo(image) => stereo_json(image),
333        Output::Bands(b) => bands_json(b, limit),
334        Output::Loudness(l) => loudness_json(l, limit),
335        Output::Crest(c) => crest_json(c),
336        Output::Alias(a) => alias_json(a),
337        Output::Bindings(b) => list(b, binding_json),
338        Output::Flops(tree) => flops_json(tree),
339        Output::Envelope(frames) => list(frames, |f: &EnvelopeFrame| {
340            format!(
341                "\n    {{ \"t\": {}, \"rms\": {}, \"peak\": {} }}",
342                num(f.t_secs),
343                num(f.rms),
344                num(f.peak)
345            )
346        }),
347        Output::Pitch(frames) => list(frames, |f| {
348            format!(
349                "\n    {{ \"t\": {}, \"notes\": {} }}",
350                num(f.t_secs),
351                list(&f.notes, |n| format!(
352                    "{{ \"note\": \"{}\", \"hz\": {}, \"cents\": {}, \"db\": {}, \
353                     \"harmonic_of\": {} }}",
354                    escape(&n.name),
355                    num(n.hz),
356                    num(n.cents),
357                    num(n.db),
358                    maybe(n.harmonic_of)
359                ))
360            )
361        }),
362        Output::Formants(frames) => list(frames, formants_json),
363    }
364}
365
366fn line_json(l: &sva_engine::Line) -> String {
367    format!(
368        "\n    {{ \"hz\": {}, \"re\": {}, \"im\": {}, \"db\": {} }}",
369        num(l.hz),
370        num(l.amp.re),
371        num(l.amp.im),
372        num(20.0 * l.amp.abs().log10())
373    )
374}
375
376/// FORMAT 14.3: every answer says which reading ran, under which profile, and at what rate.
377/// A reading that truncated a series names what it left out, so the kept list is never read
378/// as the whole of it.
379pub fn answer_json(answer: &Answer, limit: Option<usize>, skim: bool) -> String {
380    format!(
381        "{{ \"source\": \"{}\", \"profile\": \"{}\", \"rate\": {}, \"value\": {}, \
382         \"dropped\": {}, \"tail_db\": {} }}",
383        match answer.source {
384            Source::Exact => "exact",
385            Source::Measured => "measured",
386        },
387        escape(answer.profile),
388        answer
389            .rate
390            .map_or_else(|| NONE.to_string(), |r| r.to_string()),
391        value_json(&answer.value, limit, skim),
392        list(&answer.dropped, line_json),
393        maybe(answer.tail_db)
394    )
395}
396
397/// What one row of the collapse table states beyond its name, as JSON fields.
398fn detail_json(detail: &Detail) -> String {
399    match detail {
400        Detail::Lines {
401            placed,
402            summed,
403            dropped,
404            dropped_more,
405            terms,
406            tail_db,
407            ..
408        } => format!(
409            ", \"placed\": {placed}, \"summed\": {summed}, \"dropped\": {}, \
410             \"dropped_more\": {dropped_more}, \"terms\": {}, \"tail_db\": {}",
411            list(dropped, |d| format!(
412                "{{ \"hz\": {}, \"db\": {} }}",
413                num(d.hz),
414                num(d.db)
415            )),
416            counted(*terms),
417            maybe(*tail_db)
418        ),
419        Detail::Cropped { tail_db, .. } => format!(", \"tail_db\": {}", maybe(*tail_db)),
420        Detail::Point { alias_db, .. } => format!(", \"alias_db\": {}", maybe(*alias_db)),
421        Detail::Spectrum { wrap_db, .. } => format!(", \"wrap_db\": {}", num(*wrap_db)),
422        Detail::Roundtrip { edited, .. } => format!(", \"edited\": {edited}"),
423        Detail::Continuous { .. } | Detail::Reading { .. } => String::new(),
424        Detail::Added { parts } => format!(
425            ", \"addends\": {}",
426            list(parts, |part| format!(
427                "{{ \"rule\": \"{}\"{} }}",
428                part.rule().as_str(),
429                detail_json(part)
430            ))
431        ),
432    }
433}
434
435/// The collapse label beside the reading it belongs to, per FORMAT 9.3. `detail` is the one
436/// place a key may be absent: each `rule` is its own shape, and its fields belong to it.
437pub fn label_json(label: &Label) -> String {
438    let detail = detail_json(&label.detail);
439    let cost = match label.cost {
440        Some(Cost { flops, budget }) => format!(", \"flops\": {flops}, \"flop_budget\": {budget}"),
441        None => format!(", \"flops\": {NONE}, \"flop_budget\": {NONE}"),
442    };
443    format!(
444        "{{ \"source\": \"{}\", \"profile\": \"{}\", \"rate\": {}, \"rule\": \"{}\"{detail}{cost} }}",
445        match label.source {
446            Source::Exact => "exact",
447            Source::Measured => "measured",
448        },
449        escape(label.profile),
450        label.rate,
451        escape(label.rule().as_str())
452    )
453}
454
455fn cache_json(cache: Option<&CacheReport>) -> String {
456    match cache {
457        Some(c) => format!(
458            "{{ \"dir\": \"{}\", \"reused\": {}, \"held_bytes\": {}, \"max_bytes\": {}, \
459             \"evicted_bytes\": {}, \"faults\": {}, \"evaluated\": {} }}",
460            escape(&c.dir),
461            c.reused,
462            c.held_bytes,
463            c.max_bytes,
464            c.evicted_bytes,
465            c.faults,
466            crate::json::strings(&c.evaluated)
467        ),
468        None => NONE.to_string(),
469    }
470}
471
472/// What a render answered with, and where anything too big for the object went instead.
473pub struct Report<'a> {
474    pub target: &'a str,
475    pub rate: u32,
476    pub horizon: Horizon,
477    pub profile: &'a str,
478    pub label: Option<&'a Label>,
479    pub written: &'a [(String, &'a Path)],
480    pub cache: Option<&'a CacheReport>,
481    pub answers: &'a [(String, Answer)],
482    /// Readings a crate outside this pipeline answered, each already a JSON value: this
483    /// envelope only says which reading ran, under which profile, and at what rate.
484    pub analyses: &'a [(String, String)],
485    pub limit: Option<usize>,
486    pub skim: bool,
487}
488
489/// `written` names every reading that went to a file rather than into this object.
490pub fn query_data(report: &Report) -> String {
491    let written = list(report.written, |(name, path)| {
492        format!(
493            "{{ \"as\": \"{}\", \"path\": \"{}\" }}",
494            escape(name),
495            escape(&path.display().to_string())
496        )
497    });
498    let label = report
499        .label
500        .map(label_json)
501        .unwrap_or_else(|| NONE.to_string());
502    let reads = report
503        .answers
504        .iter()
505        .map(|(name, answer)| {
506            format!(
507                "\"{}\": {}",
508                escape(name),
509                answer_json(answer, report.limit, report.skim)
510            )
511        })
512        .chain(report.analyses.iter().map(|(name, value)| {
513            format!(
514                "\"{}\": {{ \"source\": \"measured\", \"profile\": \"{}\", \"rate\": {}, \
515                 \"value\": {value} }}",
516                escape(name),
517                escape(report.profile),
518                report.rate
519            )
520        }))
521        .collect::<Vec<_>>()
522        .join(",\n  ");
523    let tail = match reads.is_empty() {
524        true => String::new(),
525        false => format!(",\n  {reads}"),
526    };
527    format!(
528        "{{\n  \"target\": \"{}\",\n  \"sample_rate\": {},\n  \"profile\": \"{}\",\n  \
529         \"window\": {{ \"start_secs\": {}, \"end_secs\": {} }},\n  \"label\": {label},\n  \
530         \"written\": {written},\n  \"cache\": {}{tail}\n}}",
531        escape(report.target),
532        report.rate,
533        escape(report.profile),
534        num(report.horizon.start_secs),
535        num(report.horizon.end_secs),
536        cache_json(report.cache)
537    )
538}