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 crate::json::{NONE, capped, escape, latest, list, num};
13
14/// Past this a caller reading stdout wants a later interval start, not a wall of JSON.
15pub const SAMPLE_LIMIT: usize = 4096;
16
17fn maybe(v: Option<f64>) -> String {
18    v.map(num).unwrap_or_else(|| NONE.to_string())
19}
20
21fn counted(v: Option<usize>) -> String {
22    v.map_or_else(|| NONE.to_string(), |c| c.to_string())
23}
24
25fn ledger_json(entries: &[LedgerEntry], skim: bool) -> String {
26    list(entries, |e| {
27        let clipped = e
28            .clipped
29            .map_or_else(|| NONE.to_string(), |c| c.to_string());
30        let channel = counted(e.channel);
31        if skim {
32            return format!(
33                "\n    {{ \"node\": \"{}\", \"channel\": {channel}, \"rms\": {}, \"peak\": {}, \"clipped\": {clipped} }}",
34                escape(&e.node),
35                num(e.rms),
36                num(e.peak)
37            );
38        }
39        format!(
40            "\n    {{ \"node\": \"{}\", \"channel\": {channel}, \"depth\": {}, \"unit\": \"{}\", \
41             \"control\": {}, \"rms\": {}, \"peak\": {}, \"min\": {}, \"max\": {}, \
42             \"share\": {}, \"clipped\": {clipped} }}",
43            escape(&e.node),
44            e.depth,
45            e.kind.unit().name(),
46            !e.kind.is_audio(),
47            num(e.rms),
48            num(e.peak),
49            num(e.min),
50            num(e.max),
51            maybe(e.share)
52        )
53    })
54}
55
56fn spectrum_json(s: &Spectrum) -> String {
57    format!(
58        "{{ \"frame_size\": {}, \"frames\": {}, \"resolution_hz\": {}, \"rms\": {}, \
59         \"centroid_hz\": {}, \"rolloff85_hz\": {}, \"peaks\": {}, \"bands\": {} }}",
60        s.frame_size,
61        s.frames,
62        num(s.resolution_hz),
63        num(s.rms),
64        num(s.centroid_hz),
65        num(s.rolloff85_hz),
66        list(&s.peaks, |p| format!(
67            "{{ \"hz\": {}, \"db\": {} }}",
68            num(p.hz),
69            num(p.db)
70        )),
71        list(&s.bands, |b| format!(
72            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"db\": {} }}",
73            num(b.lo_hz),
74            num(b.hi_hz),
75            num(b.db)
76        ))
77    )
78}
79
80fn stereo_json(image: &StereoImage) -> String {
81    let frame = |f: &StereoFrame| {
82        format!(
83            "\n      {{ \"t\": {}, \"correlation\": {}, \"mid_rms\": {}, \"side_rms\": {}, \
84             \"width\": {}, \"balance_db\": {}, \"mono_db\": {} }}",
85            num(f.t_secs),
86            num(f.correlation),
87            num(f.mid_rms),
88            num(f.side_rms),
89            num(f.width),
90            num(f.balance_db),
91            num(f.mono_db)
92        )
93    };
94    format!(
95        "{{ \"channels\": {}, \"overall\": {}, \"frames\": {} }}",
96        image.channels,
97        frame(&image.overall),
98        list(&image.frames, frame)
99    )
100}
101
102fn formants_json(f: &FormantFrame) -> String {
103    format!(
104        "\n    {{ \"t\": {}, \"order\": {}, \"energy\": {}, \"residual\": {}, \"formants\": {} }}",
105        num(f.t_secs),
106        f.order,
107        num(f.energy),
108        num(f.residual),
109        list(&f.formants, |v| format!(
110            "{{ \"hz\": {}, \"bandwidth_hz\": {}, \"db\": {} }}",
111            num(v.hz),
112            num(v.bandwidth_hz),
113            num(v.db)
114        ))
115    )
116}
117
118fn band_json(b: &BandTrack, start_secs: f64, rate_hz: f64, limit: Option<usize>) -> String {
119    let shown = limit.map_or(b.rms.len(), |cap| b.rms.len().min(cap));
120    format!(
121        "\n    {{ \"centre_hz\": {}, \"cam\": {}, \"erb_hz\": {}, \"q\": {}, \"peak\": {}, \
122         \"time_to_peak_secs\": {}, \"rise_10_90_secs\": {}, \
123         \"floor\": {{ \"peak\": {}, \"time_to_peak_secs\": {}, \"rise_10_90_secs\": {} }}, \
124         \"rms\": {} }}",
125        num(b.centre_hz),
126        num(b.cam),
127        num(b.erb_hz),
128        num(b.q),
129        num(b.peak),
130        maybe(b.time_to_peak_secs),
131        maybe(b.rise_10_90_secs),
132        num(b.floor.peak),
133        num(b.floor.time_to_peak_secs),
134        maybe(b.floor.rise_10_90_secs),
135        capped(
136            &b.rms,
137            shown,
138            |n| start_secs + n as f64 / rate_hz,
139            |v| num(*v),
140        )
141    )
142}
143
144fn bands_json(b: &Bands, limit: Option<usize>) -> String {
145    format!(
146        "{{ \"rate_hz\": {}, \"start_secs\": {}, \"bands\": {} }}",
147        num(b.rate_hz),
148        num(b.start_secs),
149        list(&b.bands, |band| band_json(
150            band,
151            b.start_secs,
152            b.rate_hz,
153            limit
154        ))
155    )
156}
157
158fn loudness_json(l: &Loudness, limit: Option<usize>) -> String {
159    let frames = |fs: &[LoudnessFrame]| {
160        let shown = limit.map_or(fs.len(), |cap| fs.len().min(cap));
161        capped(
162            fs,
163            shown,
164            |n| fs[n].t,
165            |f| format!("\n    {{ \"t\": {}, \"lufs\": {} }}", num(f.t), num(f.lufs)),
166        )
167    };
168    format!(
169        "{{ \"integrated_lufs\": {}, \"range_lu\": {}, \"momentary_max_lufs\": {}, \
170         \"short_term_max_lufs\": {}, \"sample_peak\": {}, \"sample_peak_dbfs\": {}, \
171         \"peak_note\": \"{}\", \"momentary\": {}, \"short_term\": {} }}",
172        maybe(l.integrated_lufs),
173        maybe(l.range_lu),
174        maybe(l.momentary_max_lufs),
175        maybe(l.short_term_max_lufs),
176        num(l.sample_peak),
177        maybe(l.sample_peak_dbfs),
178        escape(l.peak_note),
179        frames(&l.momentary),
180        frames(&l.short_term)
181    )
182}
183
184fn crest_json(c: &Crest) -> String {
185    let band = |b: &BandCrest| {
186        format!(
187            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"centre_hz\": {}, \"peak\": {}, \
188             \"rms\": {}, \"crest_db\": {}, \"counted\": {} }}",
189            num(b.lo_hz),
190            num(b.hi_hz),
191            num(b.centre_hz),
192            num(b.peak),
193            num(b.rms),
194            num(b.crest_db),
195            b.counted
196        )
197    };
198    format!(
199        "{{ \"broadband_crest_db\": {}, \"spread_db\": {}, \"widest_band_hz\": {}, \
200         \"tightest_band_hz\": {}, \"counted_under_db\": {}, \"bands\": {} }}",
201        num(c.broadband_crest_db),
202        maybe(c.spread_db),
203        maybe(c.widest_band_hz),
204        maybe(c.tightest_band_hz),
205        num(c.counted_under_db),
206        list(&c.bands, band)
207    )
208}
209
210fn onsets_json(o: &Onsets) -> String {
211    format!(
212        "{{ \"onsets\": {}, \"resolution_secs\": {}, \"ioi_histogram\": {} }}",
213        list(&o.onsets, |t| format!(
214            "{{ \"t_secs\": {}, \"strength\": {} }}",
215            num(t.t_secs),
216            num(t.strength)
217        )),
218        num(o.resolution_secs),
219        list(&o.ioi_histogram, |b| format!(
220            "{{ \"lo_secs\": {}, \"hi_secs\": {}, \"count\": {} }}",
221            num(b.lo_secs),
222            num(b.hi_secs),
223            b.count
224        )),
225    )
226}
227
228fn alias_json(a: &Alias) -> String {
229    let band = |b: &AliasBand| {
230        format!(
231            "\n    {{ \"lo_hz\": {}, \"hi_hz\": {}, \"signal_db\": {}, \"alias_db\": {}, \
232             \"nmr_db\": {} }}",
233            num(b.lo_hz),
234            num(b.hi_hz),
235            num(b.signal_db),
236            num(b.alias_db),
237            num(b.nmr_db)
238        )
239    };
240    format!(
241        "{{ \"oversample\": {}, \"sample_rate\": {}, \"frame_size\": {}, \"frames\": {}, \
242         \"scored_frames\": {}, \"playback_db_spl\": {}, \"asr_db\": {}, \"nmr_db\": {}, \
243         \"nmr_peak_db\": {}, \"peak_at_secs\": {}, \"audible\": {}, \
244         \"instances\": {}, \"bands\": {} }}",
245        a.oversample,
246        num(a.sample_rate),
247        a.frame_size,
248        a.frames,
249        a.scored_frames,
250        num(a.playback_db_spl),
251        num(a.asr_db),
252        num(a.nmr_db),
253        num(a.nmr_peak_db),
254        num(a.peak_at_secs),
255        a.audible,
256        a.instances,
257        list(&a.bands, band)
258    )
259}
260
261fn binding_json(b: &Binding) -> String {
262    format!(
263        "\n    {{ \"name\": \"{}\", \"source\": \"{}\" }}",
264        escape(&b.name),
265        escape(&b.source)
266    )
267}
268
269fn arguments_json(a: &Arguments) -> String {
270    let at = |s: sva_ast::ByteSpan| format!("{{ \"start\": {}, \"end\": {} }}", s.start, s.end);
271    let calls = list(&a.calls, |c| {
272        let arguments = list(&c.arguments, |x| {
273            format!(
274                "{{ \"name\": \"{}\", \"value\": {}, \"written\": {} }}",
275                escape(&x.name),
276                num(x.value),
277                x.written
278            )
279        });
280        format!(
281            "\n      {{ \"name\": \"{}\", \"at\": {}, \"arguments\": {arguments} }}",
282            escape(&c.name),
283            at(c.at)
284        )
285    });
286    let chosen = list(&a.chosen, |c| {
287        format!(
288            "\n      {{ \"name\": \"{}\", \"at\": {}, \"operands\": {}, \"chosen\": {} }}",
289            escape(&c.name),
290            at(c.at),
291            list(&c.operands, |v| num(*v)),
292            c.chosen
293        )
294    });
295    format!(
296        "\n    {{ \"node\": \"{}\", \"calls\": {calls}, \"chosen\": {chosen} }}",
297        escape(&a.node)
298    )
299}
300
301/// One object per component: a buffer is planar, and the component IS the channel.
302fn samples_json(b: &Buffer, limit: Option<usize>) -> String {
303    let component = |c: usize| {
304        let plane = b.plane(c);
305        let shown = limit.map_or(plane.len(), |cap| plane.len().min(cap));
306        format!(
307            "\n    {{ \"channel\": {c}, \"values\": {} }}",
308            capped(
309                plane,
310                shown,
311                |n| b.origin_secs() + n as f64 / f64::from(b.rate),
312                |v| num(*v)
313            )
314        )
315    };
316    let components: Vec<usize> = (0..b.width()).collect();
317    format!(
318        "{{ \"rate\": {}, \"origin_secs\": {}, \"width\": {}, \"components\": {} }}",
319        b.rate,
320        num(b.origin_secs()),
321        b.width(),
322        list(&components, |c| component(*c))
323    )
324}
325
326fn symbolic_json(n: &SpectralSum) -> String {
327    let terms: Vec<String> = n.atoms().map(sva_engine::sketch_atom).collect();
328    format!(
329        "{{ \"var\": \"{}\", \"lanes\": {}, \"terms\": {} }}",
330        match n.var {
331            sva_engine::Var::T => "t",
332            sva_engine::Var::F => "f",
333        },
334        n.lanes.len(),
335        crate::json::strings(&terms)
336    )
337}
338
339/// `limit` caps the arrays a stdout reader scrolls past; `None` writes every value. `skim`
340/// only changes the ledger.
341pub fn value_json(output: &Output, limit: Option<usize>, skim: bool) -> String {
342    match output {
343        Output::Lines(lines) => list(lines, line_json),
344        Output::Atoms(sketches) => crate::json::strings(sketches),
345        Output::Symbolic(sum) => symbolic_json(sum),
346        Output::Samples(buffer) => samples_json(buffer, limit),
347        Output::Ledger(entries) => ledger_json(entries, skim),
348        Output::Spectrum(s) => spectrum_json(s),
349        Output::Stereo(image) => stereo_json(image),
350        Output::Bands(b) => bands_json(b, limit),
351        Output::Loudness(l) => loudness_json(l, limit),
352        Output::Crest(c) => crest_json(c),
353        Output::Onsets(o) => onsets_json(o),
354        Output::Alias(a) => alias_json(a),
355        Output::Bindings(b) => list(b, binding_json),
356        Output::Arguments(a) => list(a, arguments_json),
357        Output::Envelope(frames) => list(frames, |f: &EnvelopeFrame| {
358            format!(
359                "\n    {{ \"t\": {}, \"rms\": {}, \"peak\": {} }}",
360                num(f.t_secs),
361                num(f.rms),
362                num(f.peak)
363            )
364        }),
365        Output::Pitch(frames) => list(frames, |f| {
366            format!(
367                "\n    {{ \"t\": {}, \"notes\": {} }}",
368                num(f.t_secs),
369                list(&f.notes, |n| format!(
370                    "{{ \"note\": \"{}\", \"hz\": {}, \"cents\": {}, \"db\": {}, \
371                     \"harmonic_of\": {} }}",
372                    escape(&n.name),
373                    num(n.hz),
374                    num(n.cents),
375                    num(n.db),
376                    maybe(n.harmonic_of)
377                ))
378            )
379        }),
380        Output::Formants(frames) => list(frames, formants_json),
381    }
382}
383
384fn line_json(l: &sva_engine::Line) -> String {
385    format!(
386        "\n    {{ \"hz\": {}, \"re\": {}, \"im\": {}, \"db\": {} }}",
387        num(l.hz),
388        num(l.amp.re),
389        num(l.amp.im),
390        num(20.0 * l.amp.abs().log10())
391    )
392}
393
394/// FORMAT 14.3: every answer says which reading ran, under which profile, and at what rate.
395/// A reading that truncated a series names what it left out, so the kept list is never read
396/// as the whole of it.
397pub fn answer_json(answer: &Answer, limit: Option<usize>, skim: bool) -> String {
398    format!(
399        "{{ \"source\": \"{}\", \"profile\": \"{}\", \"rate\": {}, \"value\": {}, \
400         \"dropped\": {}, \"tail_db\": {} }}",
401        match answer.source {
402            Source::Exact => "exact",
403            Source::Measured => "measured",
404        },
405        escape(answer.profile),
406        answer
407            .rate
408            .map_or_else(|| NONE.to_string(), |r| r.to_string()),
409        value_json(&answer.value, limit, skim),
410        list(&answer.dropped, line_json),
411        maybe(answer.tail_db)
412    )
413}
414
415/// What one collapse row states beyond its name, as JSON fields.
416fn detail_json(detail: &Detail) -> String {
417    match detail {
418        Detail::Lines {
419            summed,
420            dropped,
421            dropped_more,
422            tail_db,
423            ..
424        } => format!(
425            ", \"summed\": {summed}, \"dropped\": {}, \"dropped_more\": {dropped_more}, \
426             \"tail_db\": {}",
427            list(dropped, |d| format!(
428                "{{ \"hz\": {}, \"db\": {} }}",
429                num(d.hz),
430                num(d.db)
431            )),
432            maybe(*tail_db)
433        ),
434        Detail::Cropped { tail_db, .. } => format!(", \"tail_db\": {}", maybe(*tail_db)),
435        Detail::Point {
436            alias_db, tail_db, ..
437        } => format!(
438            ", \"alias_db\": {}, \"tail_db\": {}",
439            maybe(*alias_db),
440            maybe(*tail_db)
441        ),
442        Detail::Roundtrip { edited, .. } => format!(", \"edited\": {edited}"),
443        Detail::Continuous { .. } | Detail::Reading { .. } => String::new(),
444        Detail::Added { parts } => format!(
445            ", \"addends\": {}",
446            list(parts, |part| format!(
447                "{{ \"rule\": \"{}\"{} }}",
448                part.rule().as_str(),
449                detail_json(part)
450            ))
451        ),
452    }
453}
454
455/// The collapse label beside the reading it belongs to, per FORMAT 9.3. `detail` is the one
456/// place a key may be absent: each `rule` is its own shape, and its fields belong to it.
457pub fn label_json(label: &Label) -> String {
458    let detail = detail_json(&label.detail);
459    let cutting = label
460        .cutting_below_silence_threshold
461        .as_ref()
462        .map_or(NONE.to_string(), cutting_below_silence_threshold_json);
463    let rest = format!(
464        ", \"moved_s\": {}, \"pruned\": {cutting}",
465        maybe(label.moved)
466    );
467    format!(
468        "{{ \"source\": \"{}\", \"profile\": \"{}\", \"rate\": {}, \"rule\": \"{}\"{detail}{rest} }}",
469        match label.source {
470            Source::Exact => "exact",
471            Source::Measured => "measured",
472        },
473        escape(label.profile),
474        label.rate,
475        escape(label.rule().as_str())
476    )
477}
478
479/// The silence threshold, and each node treated as silent from a sample on, under the JSON
480/// keys `pruned`, `db` and `cuts`.
481pub fn cutting_below_silence_threshold_json(cutting: &CuttingBelowSilenceThreshold) -> String {
482    let cuts = list(&cutting.treated_as_silent_from_sample, |(node, from)| {
483        format!("{{ \"node\": \"{}\", \"from\": {from} }}", escape(node))
484    });
485    format!(
486        "{{ \"db\": {}, \"cuts\": {cuts} }}",
487        num(cutting.silence_threshold_dbfs)
488    )
489}
490
491pub fn work_json(work: &Work) -> String {
492    format!(
493        "{{ \"samples\": {}, \"computed_samples\": {} }}",
494        work.samples, work.computed_samples
495    )
496}
497
498/// `computed` counts every miss, `stored` the misses memory kept and `replaced` the volatile
499/// misses kept in place of their last value; memory's figures are as the render left it, and
500/// `tier` what passed between memory and the disk meanwhile.
501pub fn stats_json(stats: &CacheStats) -> String {
502    stats_with(stats, "")
503}
504
505/// `stats_json` over a stream's latest lookups, `dropped`: the latest nodes its live edits
506/// started silent, and `pruned`: where it cuts below the silence threshold. Each list's
507/// `pagination.count` counts all it made.
508pub fn stream_stats_json(
509    stats: &CacheStats,
510    (dropped, made): (&[&str], usize),
511    cutting: &CuttingBelowSilenceThreshold,
512) -> String {
513    let dropped = latest(dropped, made, |name| format!("\"{}\"", escape(name)));
514    let pruned = cutting_below_silence_threshold_json(cutting);
515    stats_with(
516        stats,
517        &format!(", \"dropped\": {dropped}, \"pruned\": {pruned}"),
518    )
519}
520
521fn stats_with(stats: &CacheStats, extra: &str) -> String {
522    let made = stats.shed + stats.lookups.len();
523    let lookups = latest(&stats.lookups, made, |l| {
524        let outcome = match l.outcome {
525            Outcome::Hit => "hit",
526            Outcome::ComputedStored => "computed_stored",
527            Outcome::ComputedNotStored => "computed_not_stored",
528            Outcome::ComputedReplaced => "computed_replaced",
529            Outcome::Extended => "extended",
530            Outcome::Prefix => "prefix",
531            Outcome::Reused => "reused",
532        };
533        format!(
534            "{{ \"node\": \"{}\", \"key\": \"{}\", \"kind\": \"{}\", \"outcome\": \"{outcome}\" }}",
535            escape(&l.node),
536            l.key,
537            match l.kind {
538                PayloadKind::Segments => "segments",
539                PayloadKind::Frames => "frames",
540                PayloadKind::Run => "run",
541            }
542        )
543    });
544    format!(
545        "{{ \"nodes\": {}, \"hits\": {}, \"computed\": {}, \"stored\": {}, \"replaced\": {}, \"extended\": {}, \"reused\": {}, \
546         \"bytes\": {}, \"max_bytes\": {}, \"entries\": {}, \"evictions\": {}, \
547         \"tier\": {}, \"lookups\": {lookups}{extra} }}",
548        stats.nodes(),
549        stats.hits(),
550        stats.computed(),
551        stats.stored(),
552        stats.replaced(),
553        stats.extended(),
554        stats.reused(),
555        stats.bytes,
556        stats.max_bytes,
557        stats.entries,
558        stats.evictions,
559        counters_json(&stats.tier)
560    )
561}
562
563/// What passed between memory and the disk beneath it.
564pub fn counters_json(counters: &Counters) -> String {
565    format!(
566        "{{ \"disk_lookups\": {}, \"disk_reads\": {}, \"disk_read_bytes\": {}, \
567         \"promotions\": {}, \"writebacks\": {}, \"hits\": {}, \"evictions\": {}, \
568         \"probation_evictions\": {}, \"protected_evictions\": {} }}",
569        counters.disk_lookups,
570        counters.disk_reads,
571        counters.disk_read_bytes,
572        counters.promotions,
573        counters.writebacks,
574        counters.hits,
575        counters.evictions(),
576        counters.probation_evictions,
577        counters.protected_evictions
578    )
579}
580
581/// One answer as a report prints it.
582pub struct Printed {
583    pub name: String,
584    pub answer: Answer,
585    /// `ledger(skim=1)`: each row's level alone.
586    pub skim: bool,
587}
588
589/// What a render answered with, and where anything too big for the object went instead.
590pub struct Report<'a> {
591    pub target: &'a str,
592    pub rate: u32,
593    /// The precision every sample was written to; `None` for a file read back.
594    pub bits: Option<i32>,
595    /// The seconds the readings were taken over; `None` where none read samples.
596    pub interval: Option<(f64, f64)>,
597    pub profile: &'a str,
598    pub label: Option<&'a Label>,
599    pub written: &'a [(String, &'a Path)],
600    pub answers: &'a [Printed],
601    pub limit: Option<usize>,
602}
603
604/// `written` names every reading that went to a file rather than into `representations`.
605pub fn query_data(report: &Report) -> String {
606    let written = list(report.written, |(name, path)| {
607        format!(
608            "{{ \"representation\": \"{}\", \"path\": \"{}\" }}",
609            escape(name),
610            escape(&path.display().to_string())
611        )
612    });
613    let label = report
614        .label
615        .map(label_json)
616        .unwrap_or_else(|| NONE.to_string());
617    let reads = report
618        .answers
619        .iter()
620        .map(|printed| {
621            format!(
622                "\"{}\": {}",
623                escape(&printed.name),
624                answer_json(&printed.answer, report.limit, printed.skim)
625            )
626        })
627        .collect::<Vec<_>>()
628        .join(",\n    ");
629    let interval = report.interval.map_or(NONE.to_string(), |(start, end)| {
630        format!(
631            "{{ \"start_secs\": {}, \"end_secs\": {} }}",
632            num(start),
633            num(end)
634        )
635    });
636    format!(
637        "{{\n  \"target\": \"{}\",\n  \"sample_rate\": {},\n  \"bits\": {},\n  \
638         \"profile\": \"{}\",\n  \"interval\": {interval},\n  \"label\": {label},\n  \
639         \"written\": {written},\n  \"representations\": {{\n    {reads}\n  }}\n}}",
640        escape(report.target),
641        report.rate,
642        report.bits.map_or(NONE.to_string(), |b| b.to_string()),
643        escape(report.profile),
644    )
645}