Skip to main content

sva_engine/render/
mod.rs

1// Concern: holds a composition's decided types, the buffers a reading needed and each label | Non-concern: ordering the work (schedule.rs), a sampled shader (sampled.rs) | IO: (&Graph, target) -> Render
2
3mod answer;
4mod pointwise;
5mod sampled;
6mod slots;
7
8use std::collections::BTreeMap;
9
10use sva_ast::Graph;
11use sva_formula::{Held, NodeId, SpectralSum, hash_closed_form};
12use sva_samples::{
13    AliasScore, Buffer, FilterTrace, Frames, Horizon, Label, PSYCHOACOUSTIC_V1, Profile, stft,
14};
15
16use crate::bindings::Binding;
17use crate::cache::{Cost, Expected, Payload, PayloadKind, frames_key, symbolic_key};
18use crate::cast::Cast;
19use crate::error::{Diagnostic, EngineError, Located};
20use crate::instantiate;
21use crate::query::Ask;
22use crate::refs;
23use crate::schedule::{self, Schedule};
24use crate::typing::{self, Typing, Value};
25
26#[derive(Clone, Debug, PartialEq)]
27pub struct RenderConfig {
28    pub rate: u32,
29    pub horizon: Horizon,
30    pub profile: Profile,
31    /// What the caller means to read. An empty list is audio out, which collapses the root.
32    pub asks: Vec<Ask>,
33    /// The operation count this render may pay; the profile's own until a caller raises it.
34    pub flop_budget: u128,
35}
36
37impl RenderConfig {
38    pub fn seconds(rate: u32, secs: f64) -> RenderConfig {
39        RenderConfig {
40            rate,
41            horizon: Horizon::secs(0.0, secs),
42            profile: PSYCHOACOUSTIC_V1,
43            asks: Vec::new(),
44            flop_budget: PSYCHOACOUSTIC_V1.flop_budget,
45        }
46    }
47
48    pub fn asking(mut self, asks: Vec<Ask>) -> RenderConfig {
49        self.asks = asks;
50        self
51    }
52}
53
54pub use answer::{answer, answer_buffer, sketch_atom};
55
56pub struct Render {
57    pub root: NodeId,
58    pub tys: Typing,
59    pub buffers: BTreeMap<NodeId, Buffer>,
60    pub frames: BTreeMap<NodeId, Frames>,
61    pub symbolic: BTreeMap<NodeId, SpectralSum>,
62    pub labels: BTreeMap<NodeId, Label>,
63    pub traces: Vec<FilterTrace>,
64    pub config: RenderConfig,
65    pub schedule: Schedule,
66    pub bindings: BTreeMap<NodeId, Vec<Binding>>,
67}
68
69impl Render {
70    pub fn buffer(&self, node: NodeId) -> Option<&Buffer> {
71        self.buffers.get(&node)
72    }
73
74    pub fn id(&self, path: &str) -> Option<NodeId> {
75        self.tys.id(path)
76    }
77
78    /// The node a reading names, refusing by name where a file expanded into several.
79    pub fn node(&self, path: &str) -> Result<NodeId, EngineError> {
80        self.tys.resolve(path)
81    }
82
83    /// The multiple a reading asks this node's score against, where one asks for a score at all.
84    pub fn alias_oversample(&self, node: NodeId) -> Option<u32> {
85        self.config
86            .asks
87            .iter()
88            .find_map(|ask| match ask.representation {
89                crate::query::Representation::Alias { oversample }
90                    if self.node(&ask.node).is_ok_and(|asked| asked == node) =>
91                {
92                    Some(oversample)
93                }
94                _ => None,
95            })
96    }
97
98    pub fn alias_score(&self, node: NodeId) -> AliasScore {
99        match self.alias_oversample(node) {
100            Some(_) => AliasScore::Asked,
101            None => AliasScore::NotAsked,
102        }
103    }
104}
105
106/// Nothing is materialized that no reading asked for: a closed form answered off its spectral sum
107/// allocates no buffer at all.
108pub fn render(
109    graph: &Graph,
110    target: &str,
111    config: RenderConfig,
112    cache: Option<&dyn crate::cache::Cache>,
113) -> Result<Render, EngineError> {
114    let instances = instantiate::instantiate(graph, target)?;
115    let held = instances.instance_of(target)?;
116    let order = schedule::schedule_from(&instances, std::slice::from_ref(&held))?;
117    let tys = typing::infer_all(&instances, &order)?;
118    let root = tys
119        .id(&held)
120        .ok_or_else(|| EngineError::UnknownNode(held.clone()))?;
121    let schedule = schedule::plan(&tys, &order, root, &config.asks);
122
123    let bindings = tys
124        .paths()
125        .filter_map(|(path, id)| Some((id, resolved(&instances, path)?)))
126        .collect();
127    let mut held = Render {
128        root,
129        tys,
130        buffers: BTreeMap::new(),
131        frames: BTreeMap::new(),
132        symbolic: BTreeMap::new(),
133        labels: BTreeMap::new(),
134        traces: Vec::new(),
135        config,
136        schedule,
137        bindings,
138    };
139    affordable(&held)?;
140    for id in held.schedule.materialize.clone() {
141        materialize(&mut held, id, cache)?;
142    }
143    compose_read(&mut held);
144    stamp(&mut held);
145    Ok(held)
146}
147
148/// A reading that materializes nothing is never refused for cost: asking what a render
149/// would cost is not paying for it. Anything that runs is counted first, and a count past
150/// the budget refuses before a sample is computed.
151fn affordable(held: &Render) -> Result<(), EngineError> {
152    if held.schedule.materialize.is_empty() {
153        return Ok(());
154    }
155    let total = crate::flops::total(held);
156    if total <= held.config.flop_budget {
157        return Ok(());
158    }
159    let counted = crate::flops::tree(held);
160    let over = crate::flops::dominating(&counted).expect("a counted tree holds its root");
161    Err(EngineError::refused(Diagnostic {
162        code: "collapse.over_budget".to_string(),
163        message: format!(
164            "this render counts {} operations, over the budget of {}; `{}` dominates it at \
165             {} by {}",
166            counted.total, counted.budget, over.node, over.subtree, over.route
167        ),
168        location: Located::at(held.tys.name(held.root), None),
169        help: format!("pass --flop-budget {} to render it anyway", counted.total),
170    }))
171}
172
173/// FORMAT 9.3: the render's own label says what it cost and what it was allowed.
174fn stamp(held: &mut Render) {
175    let root = held.root;
176    let Some(label) = held.labels.remove(&root) else {
177        return;
178    };
179    let counted = crate::flops::total(held);
180    held.labels
181        .insert(root, label.costing(counted, held.config.flop_budget));
182}
183
184/// One render, every reading: a closed form a reading asks for is composed once here, and each
185/// `--as` answers off that one form rather than walking the graph again. A form that
186/// refuses is left for the reading itself to raise, in its own words.
187fn compose_read(held: &mut Render) {
188    for id in held.schedule.compose.clone() {
189        if held.symbolic.contains_key(&id) {
190            continue;
191        }
192        if let Ok(sum) = refs::spectral_sum_of(&held.tys, id, held.tys.var(id)) {
193            held.symbolic.insert(id, sum);
194        }
195    }
196}
197
198fn materialize(
199    held: &mut Render,
200    id: NodeId,
201    cache: Option<&dyn crate::cache::Cache>,
202) -> Result<(), EngineError> {
203    match held.tys.ty(id).held {
204        Held::Frames => frames_of(held, id, cache),
205        Held::Sampled => sampled::run(held, id, cache),
206        _ => collapse_closed_form(held, id, cache),
207    }
208}
209
210/// The one place a closed form becomes samples: `sample(...)` and the render root, and nothing else.
211fn collapse_closed_form(
212    held: &mut Render,
213    id: NodeId,
214    cache: Option<&dyn crate::cache::Cache>,
215) -> Result<(), EngineError> {
216    let var = held.tys.var(id);
217    let written = match refs::resolve(&held.tys, id, 0, held.tys.ty(id).held) {
218        Ok(refs::Read::Substitute(form)) => Some(*form),
219        _ => None,
220    };
221    let symbolic = written.as_ref().map(|t| symbolic_key(hash_closed_form(t)));
222    let sum = remembered(held, id, symbolic, cache)
223        .map(Ok)
224        .unwrap_or_else(|| {
225            let began = Cost::begun();
226            let found = refs::spectral_sum_of(&held.tys, id, var);
227            if let (Ok(sum), Some(key), Some(cache)) = (&found, symbolic, cache) {
228                let payload = Payload::Symbolic(Box::new(sum.clone()));
229                if cache.worth_storing(began.elapsed(), payload.bytes(), PayloadKind::Symbolic) {
230                    cache.store(key, &payload, &[], None);
231                }
232            }
233            found
234        });
235    let identity = match refs::closed_form_identity(&sum, written.as_ref()) {
236        Ok(identity) => identity,
237        Err(_) if var == sva_formula::Var::T => refs::identity(&held.tys, id)?,
238        Err(e) => return Err(e),
239    };
240    let score = held.alias_score(id);
241    let key = crate::cache::buffer_key(
242        identity,
243        held.config.rate,
244        held.config.horizon.start_secs,
245        held.tys.ty(id).width as usize,
246        score,
247    );
248    if let Ok(sum) = &sum {
249        held.symbolic.insert(id, sum.clone());
250    }
251    // FORMAT 9.3: the label belongs to the value, so an entry without one is no value.
252    if let Some((hit, label)) = warm(held, id, key, cache) {
253        held.buffers.insert(id, hit);
254        held.labels.insert(id, label);
255        return Ok(());
256    }
257    let began = Cost::begun();
258    let (buffer, label) = match (&sum, &written) {
259        (Err(_), None) => pointwise::point_sample(held, id, score)?,
260        _ => sampled_form(held, &sum, written.as_ref(), score).map_err(|e| {
261            EngineError::refused(Diagnostic {
262                code: e.code().to_string(),
263                message: e.to_string(),
264                location: Located::at(held.tys.name(id), None),
265                help: e.help().to_string(),
266            })
267        })?,
268    };
269    store(key, &buffer, &label, began.elapsed(), cache);
270    held.buffers.insert(id, buffer);
271    held.labels.insert(id, label);
272    Ok(())
273}
274
275/// A closed form with a spectral sum takes the six rows over it; one without takes the point-sampled
276/// row over the written closed form itself, which is what FORMAT 9.1's no-dual row is.
277fn sampled_form(
278    held: &Render,
279    sum: &Result<SpectralSum, EngineError>,
280    written: Option<&sva_formula::ClosedForm>,
281    score: AliasScore,
282) -> Result<(Buffer, Label), sva_samples::CollapseError> {
283    let (rate, horizon, profile) = (held.config.rate, held.config.horizon, &held.config.profile);
284    match (sum, written) {
285        (Ok(sum), written) => {
286            sva_samples::of_spectral_sum_or_point(sum, written, rate, horizon, profile, score)
287        }
288        (Err(_), Some(form)) => sva_samples::render(form, rate, horizon, profile, score),
289        (Err(_), None) => unreachable!("a closed form with neither view is point-sampled above"),
290    }
291}
292
293/// A spectral sum is rate-free, so one entry answers every rate the same closed form is read at.
294fn remembered(
295    held: &Render,
296    id: NodeId,
297    key: Option<sva_formula::Hash>,
298    cache: Option<&dyn crate::cache::Cache>,
299) -> Option<SpectralSum> {
300    let entry = cache?.load(key?, held.tys.name(id), Expected::Symbolic)?;
301    entry.payload.symbolic().cloned()
302}
303
304/// A warm entry is the same bytes the cold run would write, so a hit is returned as it
305/// stands rather than checked against a second collapse.
306fn warm(
307    held: &Render,
308    id: NodeId,
309    key: sva_formula::Hash,
310    cache: Option<&dyn crate::cache::Cache>,
311) -> Option<(Buffer, Label)> {
312    let expected = Expected::Samples {
313        rate: held.config.rate,
314        width: held.tys.ty(id).width as usize,
315        samples: held.config.horizon.len(held.config.rate).ok()?,
316    };
317    let entry = cache?.load(key, held.tys.name(id), expected)?;
318    Some((entry.payload.samples().cloned()?, entry.label?))
319}
320
321fn store(
322    key: sva_formula::Hash,
323    buffer: &Buffer,
324    label: &Label,
325    cost: std::time::Duration,
326    cache: Option<&dyn crate::cache::Cache>,
327) {
328    let Some(cache) = cache else { return };
329    let payload = Payload::Samples(Box::new(buffer.clone()));
330    if cache.worth_storing(cost, payload.bytes(), PayloadKind::Samples) {
331        cache.store(key, &payload, &[], Some(label));
332    }
333}
334
335fn frames_of(
336    held: &mut Render,
337    id: NodeId,
338    cache: Option<&dyn crate::cache::Cache>,
339) -> Result<(), EngineError> {
340    let Value::Cast(Cast::Stft { window, hop }, source) = *held.tys.value(id) else {
341        return Err(not_frames(held, id));
342    };
343    let buffer = held
344        .buffers
345        .get(&source)
346        .ok_or_else(|| not_frames(held, id))?;
347    let key = frames_key(
348        crate::cache::buffer_key(
349            refs::identity(&held.tys, source)?,
350            buffer.rate,
351            buffer.origin_secs,
352            buffer.width,
353            AliasScore::NotAsked,
354        ),
355        window,
356        hop,
357    );
358    if let Some(entry) = cache.and_then(|c| c.load(key, held.tys.name(id), Expected::Frames))
359        && let Payload::Frames(frames) = entry.payload
360    {
361        held.frames.insert(id, *frames);
362        return Ok(());
363    }
364    let began = Cost::begun();
365    let frames = stft::forward(buffer, window, hop).map_err(|e| sampled::refused(held, id, &e))?;
366    if let Some(cache) = cache {
367        let payload = Payload::Frames(Box::new(frames.clone()));
368        if cache.worth_storing(began.elapsed(), payload.bytes(), PayloadKind::Frames) {
369            cache.store(key, &payload, &[], None);
370        }
371    }
372    held.frames.insert(id, frames);
373    Ok(())
374}
375
376fn not_frames(held: &Render, id: NodeId) -> EngineError {
377    EngineError::refused(Diagnostic {
378        code: "cast.stft_needs_samples".to_string(),
379        message: format!("`{}` holds no frames to read", held.tys.name(id)),
380        location: Located::at(held.tys.name(id), None),
381        help: "write stft(sample(x), window=, hop=)".to_string(),
382    })
383}
384
385/// One instance's resolved parameters, as the composer wrote them at the call site.
386fn resolved(instances: &instantiate::Instances, path: &str) -> Option<Vec<Binding>> {
387    Some(
388        instances
389            .bindings(path)?
390            .into_iter()
391            .map(|(name, expr, cx)| Binding {
392                name: name.to_string(),
393                source: instances.render(expr, cx),
394            })
395            .collect(),
396    )
397}