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, CacheStats, Cost,
7    Crest, Detail, EnvelopeFrame, FormantFrame, Horizon, Label, LedgerEntry, Loudness,
8    LoudnessFrame, Outcome, Output, PayloadKind, Source, SpectralSum, Spectrum, StereoFrame,
9    StereoImage, Tier,
10};
11
12use crate::json::{NONE, capped, escape, list, num};
13
14/// Past this a caller reading stdout wants a narrower `--from`/`--to`, not a wall of JSON.
15pub const SAMPLE_LIMIT: usize = 4096;
16
17/// The store at `dir`, and every lookup the render made of it.
18pub struct CacheReport {
19    pub dir: String,
20    pub stats: CacheStats,
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\": \"{}\", \"held_bytes\": {}, \"max_bytes\": {}, \
459             \"evicted_bytes\": {}, \"faults\": {}, \"stats\": {} }}",
460            escape(&c.dir),
461            c.held_bytes,
462            c.max_bytes,
463            c.evicted_bytes,
464            c.faults,
465            stats_json(&c.stats)
466        ),
467        None => NONE.to_string(),
468    }
469}
470
471/// `computed` counts every miss, `stored` the misses the store kept; a lookup's `tier` is
472/// written only on a hit.
473pub fn stats_json(stats: &CacheStats) -> String {
474    let lookups = list(&stats.lookups, |l| {
475        let (outcome, tier) = match l.outcome {
476            Outcome::Hit(tier) => ("hit", format!(", \"tier\": \"{}\"", tier_name(tier))),
477            Outcome::ComputedStored => ("computed_stored", String::new()),
478            Outcome::ComputedNotStored => ("computed_not_stored", String::new()),
479        };
480        format!(
481            "{{ \"node\": \"{}\", \"key\": \"{}\", \"kind\": \"{}\", \"outcome\": \"{outcome}\"{tier} }}",
482            escape(&l.node),
483            l.key,
484            match l.kind {
485                PayloadKind::Samples => "samples",
486                PayloadKind::Frames => "frames",
487                PayloadKind::Symbolic => "symbolic",
488            }
489        )
490    });
491    format!(
492        "{{ \"nodes\": {}, \"hits\": {{ \"memory\": {}, \"persistent\": {} }}, \
493         \"computed\": {}, \"stored\": {}, \"lookups\": {lookups} }}",
494        stats.nodes(),
495        stats.hits_in(Tier::Memory),
496        stats.hits_in(Tier::Persistent),
497        stats.computed(),
498        stats.stored()
499    )
500}
501
502fn tier_name(tier: Tier) -> &'static str {
503    match tier {
504        Tier::Memory => "memory",
505        Tier::Persistent => "persistent",
506    }
507}
508
509/// What a render answered with, and where anything too big for the object went instead.
510pub struct Report<'a> {
511    pub target: &'a str,
512    pub rate: u32,
513    pub horizon: Horizon,
514    pub profile: &'a str,
515    pub label: Option<&'a Label>,
516    pub written: &'a [(String, &'a Path)],
517    pub cache: Option<&'a CacheReport>,
518    pub answers: &'a [(String, Answer)],
519    /// Readings a crate outside this pipeline answered, each already a JSON value: this
520    /// envelope only says which reading ran, under which profile, and at what rate.
521    pub analyses: &'a [(String, String)],
522    pub limit: Option<usize>,
523    pub skim: bool,
524}
525
526/// `written` names every reading that went to a file rather than into this object.
527pub fn query_data(report: &Report) -> String {
528    let written = list(report.written, |(name, path)| {
529        format!(
530            "{{ \"as\": \"{}\", \"path\": \"{}\" }}",
531            escape(name),
532            escape(&path.display().to_string())
533        )
534    });
535    let label = report
536        .label
537        .map(label_json)
538        .unwrap_or_else(|| NONE.to_string());
539    let reads = report
540        .answers
541        .iter()
542        .map(|(name, answer)| {
543            format!(
544                "\"{}\": {}",
545                escape(name),
546                answer_json(answer, report.limit, report.skim)
547            )
548        })
549        .chain(report.analyses.iter().map(|(name, value)| {
550            format!(
551                "\"{}\": {{ \"source\": \"measured\", \"profile\": \"{}\", \"rate\": {}, \
552                 \"value\": {value} }}",
553                escape(name),
554                escape(report.profile),
555                report.rate
556            )
557        }))
558        .collect::<Vec<_>>()
559        .join(",\n  ");
560    let tail = match reads.is_empty() {
561        true => String::new(),
562        false => format!(",\n  {reads}"),
563    };
564    format!(
565        "{{\n  \"target\": \"{}\",\n  \"sample_rate\": {},\n  \"profile\": \"{}\",\n  \
566         \"window\": {{ \"start_secs\": {}, \"end_secs\": {} }},\n  \"label\": {label},\n  \
567         \"written\": {written},\n  \"cache\": {}{tail}\n}}",
568        escape(report.target),
569        report.rate,
570        escape(report.profile),
571        num(report.horizon.start_secs),
572        num(report.horizon.end_secs),
573        cache_json(report.cache)
574    )
575}