Skip to main content

sva_engine/render/
answer.rs

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