Skip to main content

sva_engine/render/
answer.rs

1// Concern: takes one reading off the representation a node declares, a ledger edge by edge | Non-concern: naming the observations (query.rs), the arithmetic of one | IO: (&Render, node) -> Answer
2
3use sva_formula::closed_form::Edge;
4use sva_formula::spectral_sum::atom::{Singular, SpectralAtom};
5use sva_formula::{C64, Line, SpectralSum, Var, d_dt, envelope_read, line_atoms_read};
6use sva_samples::measure::harmony::dissonance::Crowded;
7use sva_samples::measure::harmony::{
8    self, Framing, Moment, chords, dissonance, inharmonicity, measured as framed, tonnetz,
9    voice_leading,
10};
11use sva_samples::measure::{modulation, partials, periodicity, self_similarity, sliding};
12use sva_samples::{
13    Buffer, Consumes, Extent, Peak, PitchFrame, Profile, Source, measure::bands, measure::crest,
14    measure::envelope, measure::formants, measure::loudness, measure::loudness_pattern,
15    measure::panel, measure::pitch, measure::score, measure::spectrum, measure::stereo,
16    measure_alias,
17};
18
19use crate::error::{Diagnostic, EngineError, Located};
20use crate::query::{Answer, Atom, DEFAULT_FRAME_SECS, Harmonic, Output, Representation};
21use crate::refs;
22use crate::render::Render;
23use crate::typing::Value;
24
25/// A closed form answers off its spectral sum and a buffer off its samples; its own `Ty`
26/// decides which, never a flag on the observation.
27pub fn answer(
28    render: &Render,
29    node: sva_formula::NodeId,
30    representation: Representation,
31) -> Result<Answer, EngineError> {
32    let closed = render.tys.ty(node).is_closed_form();
33    let profile = render.config.profile.name;
34    if representation == Representation::Arguments {
35        return Ok(Answer::whole(
36            Output::Arguments(arguments_under(render, node)),
37            Source::Exact,
38            profile,
39            None,
40        ));
41    }
42    if representation == Representation::Bindings {
43        return Ok(Answer::whole(
44            Output::Bindings(render.bindings.get(&node).cloned().unwrap_or_default()),
45            Source::Exact,
46            profile,
47            None,
48        ));
49    }
50    match representation.consumes(closed) {
51        Consumes::ClosedForm => {
52            let found;
53            let sum = match render.symbolic.get(&node) {
54                Some(held) => held,
55                None => match refs::spectral_sum_of(&render.tys, node, render.tys.var(node)) {
56                    Ok(held) => {
57                        found = held;
58                        &found
59                    }
60                    Err(left) => return Err(unsummed(representation, left)),
61                },
62            };
63            let (value, listed, source) = exact(render, node, representation, sum)
64                .map_err(|left| not_exact(representation, left))?;
65            Ok(Answer {
66                value,
67                source,
68                profile,
69                rate: None,
70                tail_db: listed.tail_db(),
71                dropped: listed.dropped,
72            })
73        }
74        _ => {
75            if render.buffer(node).is_none() {
76                return Err(unmaterialized(render, node, representation));
77            }
78            let buffer = render.output(node)?;
79            Ok(Answer::whole(
80                measured(render, node, representation, &buffer)?,
81                Source::Measured,
82                profile,
83                Some(render.config.rate),
84            ))
85        }
86    }
87}
88
89fn exact(
90    render: &Render,
91    node: sva_formula::NodeId,
92    representation: Representation,
93    sum: &SpectralSum,
94) -> Result<(Output, Listed, Source), EngineError> {
95    let source = Source::Exact;
96    let enumerated = || listed(render, node, sum, &render.config.profile);
97    let (value, listed) = match representation {
98        Representation::Lines => {
99            let listed = enumerated()?;
100            let held = lines(render, node, sum.var, &listed.atoms)?;
101            (Output::Lines(held), listed)
102        }
103        Representation::Spectrum {
104            frame_secs: Some(_),
105            ..
106        } => return Err(unframed(render, node, "spectrum is its lines", "the lines")),
107        Representation::Envelope {
108            frame_secs: Some(_),
109        } => {
110            return Err(unframed(
111                render,
112                node,
113                "envelope is its exact squared magnitude",
114                "the exact envelope",
115            ));
116        }
117        // A pair's spectrum is its whole line list; no estimate, no peaks.
118        Representation::Spectrum {
119            frame_secs: None, ..
120        } => {
121            let each = per_channel(render, node, sum)?;
122            let mut held = match each.as_slice() {
123                [one] => lines(render, node, sum.var, &one.atoms)?,
124                _ => in_power(
125                    each.iter()
126                        .map(|one| lines(render, node, sum.var, &one.atoms))
127                        .collect::<Result<_, _>>()?,
128                ),
129            };
130            held.sort_by(|a, b| a.hz.total_cmp(&b.hz));
131            (Output::Lines(held), Listed::joined(each))
132        }
133        Representation::Atoms => {
134            let each = per_channel(render, node, sum)?;
135            let found = each
136                .iter()
137                .enumerate()
138                .flat_map(|(c, one)| one.atoms.iter().map(move |a| atom_of(sum.var, a, c)))
139                .collect();
140            (Output::Atoms(found), Listed::joined(each))
141        }
142        // A pair states its partials; `max_notes` is the question, not a cut answer.
143        Representation::Pitch { max_notes, .. } => {
144            let each = per_channel(render, node, sum)?;
145            let partials = each
146                .iter()
147                .map(|one| {
148                    let held = lines(render, node, sum.var, &one.atoms)?;
149                    Ok(harmony::partials(held.iter().map(|l| (l.hz, l.amp))))
150                })
151                .collect::<Result<_, EngineError>>()?;
152            let mut found: Vec<Peak> = harmony::averaged(partials)
153                .into_iter()
154                .map(|p| Peak {
155                    hz: p.hz,
156                    db: 20.0 * libm::log10(p.amplitude),
157                })
158                .collect();
159            found.sort_by(|a, b| b.db.total_cmp(&a.db));
160            let start = render.read_from.ok_or_else(|| {
161                refused(
162                    render,
163                    node,
164                    "engine.not_materialized",
165                    "an exact `pitch` frame starts at the sample the render reads its root \
166                     from, and this render, not asked for `pitch`, decided none"
167                        .to_string(),
168                )
169            })?;
170            let frame = PitchFrame {
171                t_secs: start as f64 / f64::from(render.config.rate),
172                notes: pitch::name_peaks(&found, max_notes),
173            };
174            (Output::Pitch(vec![frame]), Listed::joined(each))
175        }
176        // Each series is listed first, as `atoms` lists it, so no term is differentiated away.
177        Representation::Derivative => {
178            let each = per_channel(render, node, sum)?;
179            let series_free = SpectralSum::of(
180                sum.var,
181                each.iter()
182                    .map(|one| sva_formula::Lane::of(one.atoms.clone()))
183                    .collect(),
184            );
185            let slope = d_dt(&series_free).map_err(|left| {
186                EngineError::of_closed_form(
187                    &left.refusal(),
188                    render.tys.locate(left.origin),
189                    "read the derivative of a lower order or degree",
190                )
191            })?;
192            let dropped = Listed::joined(each).dropped.into_iter().map(|l| Line {
193                amp: l.amp * C64::new(0.0, std::f64::consts::TAU * l.hz),
194                ..l
195            });
196            let differentiated = Listed {
197                atoms: slope.atoms().copied().collect(),
198                dropped: dropped.collect(),
199            };
200            (Output::Symbolic(Box::new(slope)), differentiated)
201        }
202        Representation::Envelope { frame_secs: None } => {
203            let held =
204                refs::read_through(&render.tys, |t| envelope_read(sum, t)).map_err(|left| {
205                    EngineError::of_closed_form(
206                        &left.refusal(),
207                        render.tys.locate(left.origin),
208                        "read the envelope off sample(...) for a measured one",
209                    )
210                })?;
211            (Output::Symbolic(Box::new(held.squared)), Listed::NONE)
212        }
213        Representation::Harmony {
214            reading,
215            frame_secs,
216            floor_db,
217        } => {
218            let (moments, framing, listed, end_secs) =
219                exact_moments(render, node, sum, frame_secs)?;
220            let value = harmonic(reading, &moments, (framing, end_secs), floor_db)
221                .map_err(|held| crowded(render.tys.name(node), representation, held))?;
222            (value, listed)
223        }
224        other => return Err(not_a_closed_form(render, node, other)),
225    };
226    Ok((value, listed, source))
227}
228
229/// Each instant on the frame grid over the range, each channel's partials exactly: every atom
230/// a partial at its carrier, `omega/2pi`, its complex value there merged with the channel's
231/// others at that carrier, so a decay, polynomial, Gaussian or window weighs it as it stands;
232/// then every channel in power.
233fn exact_moments(
234    render: &Render,
235    node: sva_formula::NodeId,
236    sum: &SpectralSum,
237    frame_secs: f64,
238) -> Result<(Vec<Moment>, Framing, Listed, f64), EngineError> {
239    let range = match (render.range, &render.unranged) {
240        (Some(range), _) => range,
241        (None, Some(refused)) => return Err(refused.clone()),
242        (None, None) => unreachable!("a harmonic reading decides its range before it answers"),
243    };
244    let rate = f64::from(render.config.rate);
245    let hop = ((frame_secs * rate).round() as usize).max(1);
246    let each = per_channel(render, node, sum)?;
247    let instants: Vec<f64> = (range.start..range.end)
248        .step_by(hop)
249        .map(|n| n as f64 / rate)
250        .collect();
251    let moments = match sum.var {
252        Var::F => {
253            let partials = harmony::averaged(
254                each.iter()
255                    .map(|one| {
256                        let held = lines(render, node, Var::F, &one.atoms)?;
257                        Ok(harmony::partials(held.iter().map(|l| (l.hz, l.amp))))
258                    })
259                    .collect::<Result<_, EngineError>>()?,
260            );
261            instants
262                .iter()
263                .map(|t| Moment {
264                    t_secs: *t,
265                    partials: partials.clone(),
266                })
267                .collect()
268        }
269        Var::T => instants
270            .iter()
271            .map(|t| {
272                let at = |one: &Listed| {
273                    let carried = one.atoms.iter().map(|a| {
274                        let value = (!a.is_delta()).then(|| a.smooth_at(*t)).flatten();
275                        let hz = a.exp.map_or(0.0, |e| e.omega / std::f64::consts::TAU);
276                        value
277                            .map(|v| (hz, v))
278                            .ok_or_else(|| no_partial(render, node, a, *t))
279                    });
280                    Ok(harmony::partials(carried.collect::<Result<Vec<_>, _>>()?))
281                };
282                Ok(Moment {
283                    t_secs: *t,
284                    partials: harmony::averaged(each.iter().map(at).collect::<Result<_, _>>()?),
285                })
286            })
287            .collect::<Result<_, EngineError>>()?,
288    };
289    let framing = Framing {
290        hop_secs: hop as f64 / rate,
291        window: None,
292    };
293    let end_secs = range.end as f64 / rate;
294    Ok((moments, framing, Listed::joined(each), end_secs))
295}
296
297/// One harmonic reading off a curve of partials, whichever source took them, over a render
298/// ending at `end_secs`.
299fn harmonic(
300    reading: Harmonic,
301    moments: &[Moment],
302    (framing, end_secs): (Framing, f64),
303    floor_db: f64,
304) -> Result<Output, Crowded> {
305    let chords = || chords::segment(moments, framing.hop_secs, end_secs, floor_db);
306    Ok(match reading {
307        Harmonic::Dissonance { max_pairs } => Output::Dissonance(Box::new(dissonance::measure(
308            moments, framing, floor_db, max_pairs,
309        )?)),
310        Harmonic::VoiceLeading { norm } => Output::VoiceLeading(Box::new(voice_leading::analyze(
311            chords(),
312            framing,
313            floor_db,
314            norm,
315        ))),
316        Harmonic::TonalDistance => {
317            Output::TonalDistance(Box::new(tonnetz::analyze(chords(), framing, floor_db)))
318        }
319        Harmonic::Inharmonicity => {
320            Output::Inharmonicity(Box::new(inharmonicity::measure(moments, framing, floor_db)))
321        }
322    })
323}
324
325/// An impulse stands at no frequency, and a pole has no value at its own instant.
326fn no_partial(
327    render: &Render,
328    node: sva_formula::NodeId,
329    atom: &SpectralAtom,
330    t: f64,
331) -> EngineError {
332    let mut at = render.tys.locate(atom.origin);
333    if at.node.is_empty() {
334        at = Located::at(render.tys.name(node), None);
335    }
336    let message = match atom.is_delta() {
337        true => "this term is an impulse in `t`, which stands at no one frequency, so it is \
338                 no partial"
339            .to_string(),
340        false => {
341            format!("this term has a pole at {t}s, an instant of the curve, where it has no value")
342        }
343    };
344    EngineError::refused(Diagnostic {
345        code: "read.no_partial_at_instant".to_string(),
346        message,
347        location: at,
348        help: "a harmonic reading of `sample(...)` of it, as `sample(@x([0, 1s]))`, is \
349               measured off frames of its samples instead"
350            .to_string(),
351    })
352}
353
354fn crowded(name: &str, representation: Representation, held: Crowded) -> EngineError {
355    let reading = representation.name();
356    EngineError::refused(Diagnostic {
357        code: "read.too_many_partial_pairs".to_string(),
358        message: format!(
359            "at {}s, {} partials may stand over the floor, {} pairs, past the {} asked for",
360            held.t_secs, held.partials, held.pairs, held.cap
361        ),
362        location: Located::at(name, None),
363        help: format!(
364            "ask for more pairs, as `{reading}(pairs={})`, or raise the floor so fewer partials \
365             pair, as `{reading}(floor=-30db)`",
366            held.pairs
367        ),
368    })
369}
370
371/// Each names the window or rate that would read, `longer` how to hand it a target that
372/// long and `faster` how to hand it samples at a rate over that.
373fn unread(
374    name: &str,
375    representation: Representation,
376    why: sliding::Unread,
377    longer: impl Fn(f64) -> String,
378    faster: impl Fn(f64) -> String,
379) -> EngineError {
380    let reading = representation.name();
381    let (code, message, help) = match why {
382        sliding::Unread::RateUnderBands { rate_hz, least_hz } => {
383            under_bands(reading, rate_hz, least_hz, faster)
384        }
385        sliding::Unread::WindowUnderTwoPeriods {
386            window_secs,
387            least_secs,
388        } => (
389            "read.window_under_two_periods",
390            format!(
391                "a {window_secs}s window holds under two of the shortest period `{reading}` \
392                 reads, {}s",
393                least_secs / 2.0
394            ),
395            format!(
396                "ask a window of {least_secs}s or longer, as `{reading}(window={least_secs}s)`"
397            ),
398        ),
399        sliding::Unread::TargetShorterThanWindow {
400            held_secs,
401            window_secs,
402            least_secs,
403        } => (
404            "read.target_under_window",
405            format!("the target holds {held_secs}s, under the {window_secs}s window"),
406            match least_secs <= held_secs {
407                true => format!(
408                    "{}, or ask a window as long as the target, as \
409                     `{reading}(window={held_secs}s)`",
410                    longer(window_secs)
411                ),
412                false => format!(
413                    "{}; no window under {least_secs}s holds two of the shortest period",
414                    longer(window_secs)
415                ),
416            },
417        ),
418    };
419    EngineError::refused(Diagnostic {
420        code: code.to_string(),
421        message,
422        location: Located::at(name, None),
423        help,
424    })
425}
426
427/// `faster` says how to hand a reading of ERB bands samples at the least rate that places one.
428fn under_bands(
429    reading: &str,
430    rate_hz: f64,
431    least_hz: f64,
432    faster: impl Fn(f64) -> String,
433) -> (&'static str, String, String) {
434    (
435        "read.rate_under_bands",
436        format!(
437            "`{reading}` reads ERB bands, and {rate_hz} Hz samples place none: the first band's \
438             centre stands at {:.2} Hz, over {} of the rate",
439            least_hz * bands::HIGHEST_CENTRE_OF_RATE,
440            bands::HIGHEST_CENTRE_OF_RATE
441        ),
442        faster((least_hz * 100.0).ceil() / 100.0),
443    )
444}
445
446/// Names the least rate and, through `faster`, how to hand it samples at one.
447fn rate_under(
448    name: &str,
449    held: sva_samples::RateUnderWeighting,
450    faster: impl Fn(f64) -> String,
451) -> EngineError {
452    EngineError::refused(Diagnostic {
453        code: "read.rate_under_weighting".to_string(),
454        message: format!(
455            "BS.1770's K-weighting shelf stands at {} Hz, at or past the Nyquist of {} Hz \
456             samples, where its bilinear design has no filter",
457            held.least_hz / 2.0,
458            held.rate_hz
459        ),
460        location: Located::at(name, None),
461        help: faster(held.least_hz),
462    })
463}
464
465fn block_under(name: &str, (window_secs, samples, rate): (f64, usize, u32)) -> EngineError {
466    let least = sva_samples::measure::loudness::LEAST_BLOCK_SAMPLES;
467    let secs = least as f64 / f64::from(rate);
468    EngineError::refused(Diagnostic {
469        code: "read.block_under_samples".to_string(),
470        message: format!(
471            "a {window_secs}s `loudness` window holds {samples} samples at {rate} Hz, under the \
472             {least} a block a quarter of itself apart needs"
473        ),
474        location: Located::at(name, None),
475        help: format!("ask a window of {secs}s or longer, as `loudness(window={secs}s)`"),
476    })
477}
478
479/// Names the least frame or target an all-pole fit of the order reads, `longer` how to hand
480/// it a target that long.
481fn under_order(
482    name: &str,
483    why: formants::UnderOrder,
484    longer: impl Fn(f64) -> String,
485) -> EngineError {
486    let (code, message, help) = match why {
487        formants::UnderOrder::Frame {
488            frame_secs,
489            held,
490            order,
491            least_secs,
492        } => (
493            "read.frame_under_order",
494            format!(
495                "a {frame_secs}s frame holds {held} samples, and an order-{order} all-pole fit \
496                 reads {} lags",
497                order + 1
498            ),
499            format!("ask a frame of {least_secs}s or longer, as `formants(frame={least_secs}s)`"),
500        ),
501        formants::UnderOrder::Target {
502            held,
503            order,
504            least_secs,
505        } => (
506            "read.target_under_order",
507            format!(
508                "the target holds {held} samples, and an order-{order} all-pole fit reads {} \
509                 lags",
510                order + 1
511            ),
512            longer(least_secs),
513        ),
514    };
515    EngineError::refused(Diagnostic {
516        code: code.to_string(),
517        message,
518        location: Located::at(name, None),
519        help,
520    })
521}
522
523/// Names the rate, cell or window that fits and, through `shorter` and `faster`, how to hand
524/// it a target that short or samples that fast.
525fn unfit(
526    name: &str,
527    why: self_similarity::Unfit,
528    shorter: impl Fn(f64) -> String,
529    faster: impl Fn(f64) -> String,
530) -> EngineError {
531    let (code, message, help) = match why {
532        self_similarity::Unfit::RateUnderBands { rate_hz, least_hz } => {
533            under_bands("self_similarity", rate_hz, least_hz, faster)
534        }
535        self_similarity::Unfit::TooManyCells(held) => (
536            "read.too_many_cells",
537            format!(
538                "the target's {}s at cell={}s is {} cells a side, past the {} a matrix holds",
539                held.held_secs, held.cell_secs, held.cells, held.cap
540            ),
541            format!(
542                "ask a coarser cell, as `self_similarity(cell={}s)`, or {}",
543                held.least_cell_secs,
544                shorter(held.most_secs)
545            ),
546        ),
547        self_similarity::Unfit::CellUnderBandStep {
548            cell_secs,
549            least_cell_secs,
550            band_hz,
551        } => (
552            "read.cell_under_band_step",
553            format!(
554                "a {cell_secs}s cell holds no sample of the {band_hz} Hz band envelopes its \
555                 band levels are read off"
556            ),
557            format!(
558                "ask a cell of {least_cell_secs}s or coarser, as \
559                 `self_similarity(cell={least_cell_secs}s)`"
560            ),
561        ),
562        self_similarity::Unfit::KernelPastCap {
563            window_secs,
564            kernel_cells,
565            cap,
566            cell_secs,
567            widest_secs,
568        } => (
569            "read.kernel_past_cap",
570            format!(
571                "a {window_secs}s novelty window at cell={cell_secs}s is {kernel_cells} cells \
572                 a side, past the {cap} a matrix holds"
573            ),
574            format!(
575                "ask a window of at most {widest_secs}s, as \
576                 `self_similarity(window={widest_secs}s)`, or a coarser cell"
577            ),
578        ),
579    };
580    EngineError::refused(Diagnostic {
581        code: code.to_string(),
582        message,
583        location: Located::at(name, None),
584        help,
585    })
586}
587
588/// A closed form's envelope is exact or refused, never measured unasked: a refusal of its
589/// sum says so, and names `sample(...)` as the way to ask for the measured one.
590fn not_exact(representation: Representation, left: EngineError) -> EngineError {
591    match (representation, left) {
592        (Representation::Envelope { frame_secs: None }, EngineError::Refused(cause)) => {
593            EngineError::refused(Diagnostic {
594                message: format!("this closed form has no exact envelope: {}", cause.message),
595                help: "a measured envelope is asked for explicitly: read `envelope` of \
596                       `sample(...)` of it, as `sample(@x([0, 1s]))`"
597                    .to_string(),
598                ..*cause
599            })
600        }
601        (_, left) => left,
602    }
603}
604
605/// A closed form with no atom sum has no exact partials; the measured ones are asked for
606/// explicitly, never taken in their place.
607fn unsummed(representation: Representation, left: EngineError) -> EngineError {
608    match (representation, left) {
609        (Representation::Harmony { .. }, EngineError::Refused(cause)) => {
610            EngineError::refused(Diagnostic {
611                message: format!(
612                    "`{}` of a closed form reads its exact partials, and it has none: {}",
613                    representation.name(),
614                    cause.message
615                ),
616                help: format!(
617                    "a measured `{}` is asked for explicitly: read it of `sample(...)` of it, as \
618                     `sample(@x([0, 1s]))`",
619                    representation.name()
620                ),
621                ..*cause
622            })
623        }
624        (_, left) => not_exact(representation, left),
625    }
626}
627
628/// A closed form holds no frames, so a written `frame` is refused rather than ignored.
629fn unframed(render: &Render, node: sva_formula::NodeId, is: &str, bare: &str) -> EngineError {
630    EngineError::refused(Diagnostic {
631        code: "engine.observation_needs_samples".to_string(),
632        message: format!("a closed form's {is}, and it has no frames to take it across"),
633        location: Located::at(render.tys.name(node), None),
634        help: format!("drop `frame` for {bare}, or read `sample(...)` of it to measure frames"),
635    })
636}
637
638/// Every atom the spectral sum stands for, beside the terms its series truncated away or left
639/// above the ceiling: `atoms` alone holds only the terms already written out.
640struct Listed {
641    atoms: Vec<SpectralAtom>,
642    dropped: Vec<Line>,
643}
644
645impl Listed {
646    /// A reading that enumerates no series leaves nothing out.
647    const NONE: Listed = Listed {
648        atoms: Vec::new(),
649        dropped: Vec::new(),
650    };
651
652    fn joined(each: Vec<Listed>) -> Listed {
653        let mut held = Listed::NONE;
654        for one in each {
655            held.atoms.extend(one.atoms);
656            held.dropped.extend(one.dropped);
657        }
658        held
659    }
660
661    /// The tail is the loudest line left out against the loudest one kept, so
662    /// it is one ratio over the whole answer, never a per-series one carried upward.
663    fn tail_db(&self) -> Option<f64> {
664        let loudest = |set: &mut dyn Iterator<Item = f64>| set.fold(0.0f64, f64::max);
665        let gone = loudest(&mut self.dropped.iter().map(|l| l.amp.abs()));
666        let kept = loudest(&mut self.atoms.iter().map(|a| a.c.abs()));
667        (gone > 0.0 && kept > 0.0).then(|| 20.0 * libm::log10(gone / kept))
668    }
669}
670
671/// Each lane's series enumerated under the band a rate-free reading is taken in. A series
672/// whose term no line closed form reads yields nothing, and nothing is not an exact empty answer.
673fn listed(
674    render: &Render,
675    node: sva_formula::NodeId,
676    sum: &SpectralSum,
677    profile: &Profile,
678) -> Result<Listed, EngineError> {
679    let ceiling = profile.ceiling_hz;
680    let band = (ceiling, profile.floor(ceiling), profile.half_lsb());
681    let mut held = Listed::NONE;
682    for lane in &sum.lanes {
683        held.atoms.extend(lane.clone().expanded().atoms);
684        for series in &lane.series {
685            let found = refs::read_through(&render.tys, |t| line_atoms_read(series, band, t));
686            let Some(found) = found else {
687                return Err(unenumerable(render, node));
688            };
689            held.atoms.extend(found.atoms);
690            held.dropped.extend(found.dropped);
691        }
692    }
693    Ok(held)
694}
695
696/// Each channel's lane listed on its own, in channel order.
697fn per_channel(
698    render: &Render,
699    node: sva_formula::NodeId,
700    sum: &SpectralSum,
701) -> Result<Vec<Listed>, EngineError> {
702    sum.lanes
703        .iter()
704        .map(|lane| {
705            let one = SpectralSum::of(sum.var, vec![lane.clone()]);
706            listed(render, node, &one, &render.config.profile)
707        })
708        .collect()
709}
710
711/// Every channel in power, as a measured spectrum reads them: a channel's lines at one
712/// frequency summed, their squared modulus summed over the channels, divided by the count.
713/// Channels share no phase, so each line is that power's root, real.
714fn in_power(per_channel: Vec<Vec<Line>>) -> Vec<Line> {
715    let count = per_channel.len() as f64;
716    let mut power: Vec<(Line, f64)> = Vec::new();
717    for lines in per_channel {
718        let mut channel: Vec<Line> = Vec::new();
719        for line in lines {
720            match channel
721                .iter_mut()
722                .find(|l| l.hz.to_bits() == line.hz.to_bits())
723            {
724                Some(held) => held.amp = held.amp + line.amp,
725                None => channel.push(line),
726            }
727        }
728        for line in channel {
729            let squared = line.amp.abs() * line.amp.abs();
730            match power
731                .iter_mut()
732                .find(|(l, _)| l.hz.to_bits() == line.hz.to_bits())
733            {
734                Some((_, sum)) => *sum += squared,
735                None => power.push((line, squared)),
736            }
737        }
738    }
739    power
740        .into_iter()
741        .filter(|(_, sum)| *sum > 0.0)
742        .map(|(line, sum)| Line {
743            amp: C64::real((sum / count).sqrt()),
744            ..line
745        })
746        .collect()
747}
748
749/// The exact line list of a pair: in `t` a bare turning exponential, in `f` the delta it
750/// duals to. A list every other atom was dropped from answers a spectrum this node has not.
751fn lines(
752    render: &Render,
753    node: sva_formula::NodeId,
754    var: Var,
755    atoms: &[SpectralAtom],
756) -> Result<Vec<Line>, EngineError> {
757    atoms
758        .iter()
759        .map(|a| line_of(var, a).ok_or_else(|| not_a_line(render, node, var, a)))
760        .collect()
761}
762
763fn line_of(var: Var, a: &SpectralAtom) -> Option<Line> {
764    match (var, a.sing) {
765        (Var::F, Singular::Delta { at, order: 0 }) => Some(Line::bare(at, a.c)),
766        (Var::T, Singular::Regular) => {
767            if !a.poly.is_one() || a.gauss.is_some() || a.ind.is_some() || a.pole.is_some() {
768                return None;
769            }
770            match a.exp {
771                // A constant turns at no rate, which is the line at zero hertz.
772                None => Some(Line::bare(0.0, a.c)),
773                Some(e) if e.sigma == 0.0 => Some(Line::bare(e.omega / std::f64::consts::TAU, a.c)),
774                Some(_) => None,
775            }
776        }
777        _ => None,
778    }
779}
780
781/// What gave this atom a width; a turning exponential is the line itself and never among it.
782fn widening(a: &SpectralAtom) -> Vec<&'static str> {
783    let mut held = Vec::new();
784    if !a.poly.is_one() {
785        held.push("a polynomial");
786    }
787    if a.exp.is_some_and(|e| e.sigma != 0.0) {
788        held.push("a decaying exponential");
789    }
790    if a.gauss.is_some() {
791        held.push("a Gaussian");
792    }
793    if a.pole.is_some() {
794        held.push("a pole");
795    }
796    if held.is_empty() {
797        held.push("a delta");
798    }
799    held
800}
801
802/// Convolved into a line, each shape beside the turning exponential answers a band.
803fn not_a_line(
804    render: &Render,
805    node: sva_formula::NodeId,
806    var: Var,
807    atom: &SpectralAtom,
808) -> EngineError {
809    let mut at = render.tys.locate(atom.origin);
810    if at.node.is_empty() {
811        at = Located::at(render.tys.name(node), None);
812    }
813    let message = match atom.ind {
814        _ if var == Var::F => "this term spreads over `f` rather than standing at one \
815                               frequency, and a line in `f` is a delta"
816            .to_string(),
817        Some(window) => format!(
818            "this term is windowed to [{}s, {}s), and a windowed line is that line convolved \
819             with the window's transform, which has a width and is no line",
820            window.l.value(),
821            window.r.value()
822        ),
823        None => format!(
824            "this term carries {}, and a line is a bare turning exponential: each of those \
825             convolves the line with a shape of its own width",
826            widening(atom).join(", ")
827        ),
828    };
829    EngineError::refused(Diagnostic {
830        code: "read.lines_need_unwindowed_lines".to_string(),
831        message,
832        location: at,
833        help: "`atoms` states each term as it stands, `lines` of the node under the window \
834               or envelope lists the lines it multiplies, and `pitch` or `spectrum` of \
835               `sample(...)` of it measures it frame by frame"
836            .to_string(),
837    })
838}
839
840/// One atom as the six factors it is present in, which is what `atoms` answers with.
841fn sketch_atom(a: &SpectralAtom) -> String {
842    let factors: Vec<&'static str> = a.present().iter().map(|f| f.as_str()).collect();
843    format!("{} x {}", a.c.abs(), factors.join(" times "))
844}
845
846/// Each atom of every lane as `atoms` answers it, the lane its channel.
847pub fn atoms_of(sum: &SpectralSum) -> Vec<Atom> {
848    sum.lanes
849        .iter()
850        .enumerate()
851        .flat_map(|(c, lane)| lane.atoms.iter().map(move |a| atom_of(sum.var, a, c)))
852        .collect()
853}
854
855/// The atom's own fields, read off as it holds them; a constant turns at zero and decays at
856/// none. Adding zero writes a negative zero as the zero it equals.
857fn atom_of(var: Var, a: &SpectralAtom, channel: usize) -> Atom {
858    let (sigma, omega, mu) = a.exp.map_or((0.0, 0.0, 0.0), |e| (e.sigma, e.omega, e.mu));
859    let bounded = |edge: Edge| Some(edge.value() + 0.0).filter(|x| x.is_finite());
860    Atom {
861        var,
862        channel,
863        sketch: sketch_atom(a),
864        amp: a.c,
865        turns: omega / std::f64::consts::TAU + 0.0,
866        decay: 0.0 - sigma,
867        decay_from: mu + 0.0,
868        degree: a.poly.degree,
869        polynomial_at: a.poly.at + 0.0,
870        start: a.ind.and_then(|i| bounded(i.l)),
871        end: a.ind.and_then(|i| bounded(i.r)),
872    }
873}
874
875fn measured(
876    render: &Render,
877    node: sva_formula::NodeId,
878    representation: Representation,
879    buffer: &Buffer,
880) -> Result<Output, EngineError> {
881    Ok(match representation {
882        Representation::Alias { oversample } => {
883            let reference = oversampled(render, behind(render, node)?, oversample)?;
884            Output::Alias(Box::new(worst_alias(
885                render, node, buffer, &reference, oversample,
886            )?))
887        }
888        Representation::Ledger { depth } => Output::Ledger(attributed(render, node, depth)?),
889        other => {
890            let name = render.tys.name(node);
891            let longer = |secs: f64| {
892                format!("render an interval of at least {secs}s, as `@x([0, {secs}s])` writes one")
893            };
894            let shorter = |secs: f64| {
895                format!("render an interval of at most {secs}s, as `@x([0, {secs}s])` writes one")
896            };
897            let faster = |hz: f64| format!("render at a rate over {hz} Hz, as `--rate 48000` asks");
898            return off_buffer(buffer, other).map_err(|fault| match fault {
899                NoReading::NeedsAGraph => not_a_reading(render, node, other),
900                NoReading::WidthMismatch { need, held } => width_mismatch(render, node, need, held),
901                NoReading::Crowded(held) => crowded(name, other, held),
902                NoReading::Unread(why) => unread(name, other, why, longer, faster),
903                NoReading::RateUnder(held) => rate_under(name, held, faster),
904                NoReading::BlockUnder {
905                    window_secs,
906                    samples,
907                    rate,
908                } => block_under(name, (window_secs, samples, rate)),
909                NoReading::UnderOrder(why) => under_order(name, why, longer),
910                NoReading::Unfit(why) => unfit(name, why, shorter, faster),
911            });
912        }
913    })
914}
915
916/// Why a buffer answered nothing: the reading needs the tree the node was built from, or
917/// the buffer holds other than the components the reading needs.
918#[derive(Clone, Copy, Debug, PartialEq)]
919pub enum NoReading {
920    NeedsAGraph,
921    WidthMismatch {
922        need: usize,
923        held: usize,
924    },
925    Crowded(Crowded),
926    Unread(sliding::Unread),
927    Unfit(self_similarity::Unfit),
928    RateUnder(sva_samples::RateUnderWeighting),
929    BlockUnder {
930        window_secs: f64,
931        samples: usize,
932        rate: u32,
933    },
934    UnderOrder(formants::UnderOrder),
935}
936
937/// Every reading a buffer answers on its own, with no graph behind it.
938pub fn off_buffer(buffer: &Buffer, representation: Representation) -> Result<Output, NoReading> {
939    let sr = f64::from(buffer.rate);
940    let start = buffer.origin_secs();
941    let planes = buffer.slices();
942    Ok(match representation {
943        Representation::Samples => Output::Samples(Box::new(buffer.clone())),
944        Representation::Spectrum {
945            max_peaks,
946            frame_secs,
947        } => Output::Spectrum(Box::new(spectrum::analyze(
948            &planes, sr, max_peaks, frame_secs,
949        ))),
950        Representation::Envelope { frame_secs } => Output::Envelope(envelope::trace(
951            &buffer.slices(),
952            sr,
953            start,
954            frame_secs.unwrap_or(DEFAULT_FRAME_SECS),
955        )),
956        Representation::Derivative => Output::Samples(Box::new(difference(buffer))),
957        Representation::Pitch {
958            max_notes,
959            frame_secs,
960        } => Output::Pitch(pitch::track(&planes, sr, start, frame_secs, max_notes)),
961        Representation::Formants {
962            max_formants,
963            frame_secs,
964        } => Output::Formants(
965            formants::track(
966                &planes,
967                sr,
968                start,
969                frame_secs,
970                formants::default_order(sr),
971                max_formants,
972            )
973            .map_err(NoReading::UnderOrder)?,
974        ),
975        Representation::Stereo { frame_secs } => {
976            if buffer.width() != 2 {
977                return Err(NoReading::WidthMismatch {
978                    need: 2,
979                    held: buffer.width(),
980                });
981            }
982            Output::Stereo(Box::new(stereo::analyze(
983                &planes,
984                buffer.width(),
985                sr,
986                start,
987                frame_secs,
988            )))
989        }
990        Representation::Bands => Output::Bands(Box::new(bands::analyze(&planes, sr, start))),
991        Representation::Crest => Output::Crest(Box::new(crest::analyze(&planes, sr))),
992        Representation::Score => Output::Score(Box::new(score::measure(&planes, sr, start))),
993        Representation::LoudnessPattern { frame_secs, spl_db } => {
994            Output::LoudnessPattern(Box::new(loudness_pattern::measure(
995                &planes, sr, start, frame_secs, spl_db,
996            )))
997        }
998        Representation::Panel { spl_db } => {
999            Output::Panel(Box::new(panel::measure(&planes, sr, start, spl_db)))
1000        }
1001        Representation::Loudness { window_secs } => Output::Loudness(Box::new(
1002            loudness::analyze(&planes, sr, start, window_secs).map_err(|why| match why {
1003                sva_samples::Unweighted::Rate(held) => NoReading::RateUnder(held),
1004                sva_samples::Unweighted::Block {
1005                    window_secs,
1006                    samples,
1007                } => NoReading::BlockUnder {
1008                    window_secs,
1009                    samples,
1010                    rate: buffer.rate,
1011                },
1012            })?,
1013        )),
1014        Representation::Harmony {
1015            reading,
1016            frame_secs,
1017            floor_db,
1018        } => {
1019            let (moments, framing) = framed::moments(&planes, sr, start, frame_secs);
1020            let end_secs = buffer.end() as f64 / sr;
1021            harmonic(reading, &moments, (framing, end_secs), floor_db)
1022                .map_err(NoReading::Crowded)?
1023        }
1024        Representation::Periodicity {
1025            frame_secs,
1026            window_secs,
1027            curve,
1028        } => Output::Periodicity(Box::new(
1029            periodicity::measure(&planes, sr, start, frame_secs, window_secs, curve)
1030                .map_err(NoReading::Unread)?,
1031        )),
1032        Representation::Modulation {
1033            frame_secs,
1034            window_secs,
1035            bpm,
1036        } => Output::Modulation(Box::new(
1037            modulation::measure(&planes, sr, start, frame_secs, window_secs, bpm)
1038                .map_err(NoReading::Unread)?,
1039        )),
1040        Representation::SelfSimilarity {
1041            frame_secs,
1042            cell_secs,
1043            smooth_secs,
1044            window_secs,
1045            floor_db,
1046            listed,
1047            curve,
1048        } => {
1049            let asked = self_similarity::Asked {
1050                hop_secs: frame_secs,
1051                cell_secs,
1052                smooth_secs,
1053                window_secs,
1054                floor_db,
1055                listed,
1056                curve,
1057            };
1058            Output::SelfSimilarity(Box::new(
1059                self_similarity::measure(&planes, sr, start, asked).map_err(NoReading::Unfit)?,
1060            ))
1061        }
1062        Representation::Partials {
1063            frame_secs,
1064            window_secs,
1065            hop_secs,
1066            floor_db,
1067            tracks,
1068        } => {
1069            let asked = partials::Asked {
1070                frame_secs,
1071                window_secs,
1072                hop_secs,
1073                floor_db,
1074                tracks,
1075            };
1076            Output::Partials(Box::new(partials::measure(&planes, sr, start, asked)))
1077        }
1078        _ => return Err(NoReading::NeedsAGraph),
1079    })
1080}
1081
1082/// The ledger the pull tallied of `node`, refused where none was.
1083fn attributed(
1084    render: &Render,
1085    node: sva_formula::NodeId,
1086    depth: usize,
1087) -> Result<Vec<sva_samples::LedgerEntry>, EngineError> {
1088    super::ledger::entries(render, node, depth).unwrap_or_else(|| {
1089        Err(EngineError::refused(Diagnostic {
1090            code: "engine.not_materialized".to_string(),
1091            message: format!(
1092                "a ledger is tallied block by block as the render pulls, and this render was \
1093                 not asked for `ledger` of `{}` to depth {depth}",
1094                render.tys.name(node)
1095            ),
1096            location: Located::at(render.tys.name(node), None),
1097            help: "ask for the ledger with the render it reads, as `--representation ledger`"
1098                .to_string(),
1099        }))
1100    })
1101}
1102
1103/// What every instance under `node` was lowered with, `node` first, then breadth first. The
1104/// walk is over what each node was lowered to, so it needs no buffer.
1105fn arguments_under(render: &Render, node: sva_formula::NodeId) -> Vec<crate::Arguments> {
1106    let under = |id: sva_formula::NodeId| -> Vec<sva_formula::NodeId> {
1107        match render.tys.value(id) {
1108            Value::ClosedForm(form) => refs::nodes_in(&form.body),
1109            Value::Read { source, .. } | Value::Cast(_, source) => vec![*source],
1110            Value::Op { args, .. } => args.clone(),
1111            Value::Filter {
1112                x, cutoff, q, gain, ..
1113            } => vec![*x, *cutoff, *q, *gain],
1114            Value::Solver { varying, .. } => varying.iter().map(|(_, a)| *a).collect(),
1115            Value::SelfAt { .. } | Value::Noise(_) | Value::Recorded(_) => Vec::new(),
1116        }
1117    };
1118    let mut names: Vec<&str> = Vec::new();
1119    let mut seen = std::collections::BTreeSet::from([node]);
1120    let mut level = vec![node];
1121    while !level.is_empty() {
1122        let mut next = Vec::new();
1123        for at in level {
1124            let name = render.tys.name(at);
1125            if !names.contains(&name) {
1126                names.push(name);
1127            }
1128            next.extend(under(at).into_iter().filter(|c| seen.insert(*c)));
1129        }
1130        level = next;
1131    }
1132    names
1133        .into_iter()
1134        .filter_map(|name| render.tys.arguments(name).cloned())
1135        .collect()
1136}
1137
1138/// The closed form an alias score oversamples: the node itself where it is one, and the operand of
1139/// the `sample(...)` that collapsed it where it is not.
1140fn behind(render: &Render, node: sva_formula::NodeId) -> Result<sva_formula::NodeId, EngineError> {
1141    if render.tys.ty(node).is_closed_form() {
1142        return Ok(node);
1143    }
1144    match render.tys.value(node) {
1145        crate::typing::Value::Cast(crate::cast::Cast::Sample, source) => Ok(*source),
1146        _ => Err(refused(
1147            render,
1148            node,
1149            "engine.alias_needs_a_closed_form",
1150            "an alias score is a render against the same closed form oversampled, and this node \
1151             is samples with no closed form behind it"
1152                .to_string(),
1153        )),
1154    }
1155}
1156
1157/// The score a point sampling's label carries where a reading asks for one, at its multiple.
1158pub(super) fn alias_db(
1159    render: &Render,
1160    node: sva_formula::NodeId,
1161    buffer: &Buffer,
1162    oversample: u32,
1163) -> Result<f64, EngineError> {
1164    let reference = oversampled(render, behind(render, node)?, oversample)?;
1165    Ok(worst_alias(render, node, buffer, &reference, oversample)?.asr_db)
1166}
1167
1168/// Each component scored against its own reference; the worst answers.
1169fn worst_alias(
1170    render: &Render,
1171    node: sva_formula::NodeId,
1172    buffer: &Buffer,
1173    reference: &Buffer,
1174    oversample: u32,
1175) -> Result<sva_samples::Alias, EngineError> {
1176    debug_assert_eq!(
1177        buffer.width(),
1178        reference.width(),
1179        "the reference is the same form at another rate"
1180    );
1181    let sr = f64::from(buffer.rate);
1182    let width = buffer.width().min(reference.width());
1183    let scored = (0..width).map(|c| {
1184        measure_alias(
1185            buffer.plane(c),
1186            reference.plane(c),
1187            oversample as usize,
1188            sr,
1189            buffer.origin_secs(),
1190        )
1191    });
1192    let scored = scored.collect::<Result<Vec<_>, _>>().map_err(|short| {
1193        let secs = (short.frame as f64 / sr * 1000.0).ceil() / 1000.0;
1194        EngineError::refused(Diagnostic {
1195            code: "read.target_under_frame".to_string(),
1196            message: format!(
1197                "the target holds {} samples, fewer than the {}-sample frame an alias score reads",
1198                short.held, short.frame
1199            ),
1200            location: Located::at(render.tys.name(node), None),
1201            help: format!(
1202                "render an interval of at least {secs}s, as `@x([0, {secs}s])` writes one"
1203            ),
1204        })
1205    })?;
1206    Ok(sva_samples::worst_alias(scored).expect("a buffer holds at least one component"))
1207}
1208
1209/// The node read at a multiple of the rate by its own route: what an alias score is against.
1210fn oversampled(
1211    render: &Render,
1212    node: sva_formula::NodeId,
1213    oversample: u32,
1214) -> Result<Buffer, EngineError> {
1215    let range = render.range.expect("an alias score reads a decided range");
1216    let k = i64::from(oversample);
1217    let extent = Extent::new(range.start * k, range.end * k);
1218    super::finer(render, node, oversample, extent)
1219}
1220
1221/// A first difference on the grid, which is what a derivative is once the closed form is gone:
1222/// `(x[n] - x[n-1]) * rate` at each sample from the second, as the first has no sample before it.
1223fn difference(buffer: &Buffer) -> Buffer {
1224    let step = f64::from(buffer.rate);
1225    let planes = (0..buffer.width())
1226        .map(|c| {
1227            let plane = buffer.plane(c);
1228            plane.windows(2).map(|x| (x[1] - x[0]) * step).collect()
1229        })
1230        .collect();
1231    let mut out = Buffer::of_planes(buffer.rate, planes);
1232    out.start = buffer.start + buffer.plane(0).len().min(1) as i64;
1233    out
1234}
1235
1236fn refused(render: &Render, node: sva_formula::NodeId, code: &str, message: String) -> EngineError {
1237    EngineError::refused(Diagnostic {
1238        code: code.to_string(),
1239        message,
1240        location: Located::at(render.tys.name(node), None),
1241        help: "ask for a reading this representation answers".to_string(),
1242    })
1243}
1244
1245fn unenumerable(render: &Render, node: sva_formula::NodeId) -> EngineError {
1246    refused(
1247        render,
1248        node,
1249        "read.series_not_enumerable",
1250        "a series whose term is no line lists no lines, and an empty list would read as \
1251         a node with none"
1252            .to_string(),
1253    )
1254}
1255
1256fn not_a_closed_form(render: &Render, node: sva_formula::NodeId, r: Representation) -> EngineError {
1257    refused(
1258        render,
1259        node,
1260        "engine.observation_needs_samples",
1261        format!(
1262            "`{}` reads samples, and this node is a closed form",
1263            r.name()
1264        ),
1265    )
1266}
1267
1268fn not_a_reading(render: &Render, node: sva_formula::NodeId, r: Representation) -> EngineError {
1269    refused(
1270        render,
1271        node,
1272        "engine.observation_not_wired",
1273        format!("`{}` takes no reading off a buffer here", r.name()),
1274    )
1275}
1276
1277fn width_mismatch(
1278    render: &Render,
1279    node: sva_formula::NodeId,
1280    need: usize,
1281    held: usize,
1282) -> EngineError {
1283    EngineError::refused(Diagnostic {
1284        code: "type.width_mismatch".to_string(),
1285        message: width_message(need, held, "node"),
1286        location: Located::at(render.tys.name(node), None),
1287        help: format!("read a node of exactly {need} components, as `join(@a, @b)` writes one"),
1288    })
1289}
1290
1291/// More components than a reading reads refuse too, so none goes unread under its answer.
1292fn width_message(need: usize, held: usize, holder: &str) -> String {
1293    format!("this reading needs exactly {need} components and the {holder} holds {held}")
1294}
1295
1296pub(super) fn unheld(render: &Render, node: sva_formula::NodeId) -> EngineError {
1297    refused(
1298        render,
1299        node,
1300        "engine.not_materialized",
1301        "this render held no samples of it".to_string(),
1302    )
1303}
1304
1305pub(super) fn unmaterialized(
1306    render: &Render,
1307    node: sva_formula::NodeId,
1308    r: Representation,
1309) -> EngineError {
1310    refused(
1311        render,
1312        node,
1313        "engine.not_materialized",
1314        format!("`{}` reads samples this render never held", r.name()),
1315    )
1316}
1317
1318/// One reading off a buffer nothing rendered — an external file, say — under the profile the
1319/// caller names. Every representation needing the graph behind it refuses here.
1320pub fn answer_buffer(
1321    name: &str,
1322    buffer: &Buffer,
1323    representation: Representation,
1324    profile: &'static str,
1325) -> Result<Answer, EngineError> {
1326    let refused = |code: &str, message: String| {
1327        EngineError::refused(Diagnostic {
1328            code: code.to_string(),
1329            message,
1330            location: Located::at(name, None),
1331            help: "ask for a reading a buffer answers on its own".to_string(),
1332        })
1333    };
1334    let longer = |secs: f64| format!("analyze a file holding at least {secs}s");
1335    let shorter = |secs: f64| format!("analyze a file holding at most {secs}s");
1336    let faster = |hz: f64| format!("analyze a file sampled over {hz} Hz");
1337    let value = off_buffer(buffer, representation).map_err(|fault| match fault {
1338        NoReading::NeedsAGraph => refused(
1339            "engine.observation_needs_a_graph",
1340            format!(
1341                "`{}` reads the tree a node was built from, and a file has none",
1342                representation.name()
1343            ),
1344        ),
1345        NoReading::WidthMismatch { need, held } => EngineError::refused(Diagnostic {
1346            code: "type.width_mismatch".to_string(),
1347            message: width_message(need, held, "file"),
1348            location: Located::at(name, None),
1349            help: format!("analyze a file of exactly {need} channels"),
1350        }),
1351        NoReading::Crowded(held) => crowded(name, representation, held),
1352        NoReading::Unread(why) => unread(name, representation, why, longer, faster),
1353        NoReading::Unfit(why) => unfit(name, why, shorter, faster),
1354        NoReading::RateUnder(held) => rate_under(name, held, faster),
1355        NoReading::BlockUnder {
1356            window_secs,
1357            samples,
1358            rate,
1359        } => block_under(name, (window_secs, samples, rate)),
1360        NoReading::UnderOrder(why) => under_order(name, why, longer),
1361    })?;
1362    Ok(Answer::whole(
1363        value,
1364        Source::Measured,
1365        profile,
1366        Some(buffer.rate),
1367    ))
1368}