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