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