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