Skip to main content

sva_core/
lib.rs

1// Concern: the parse->tempo->render pipeline shared by both front ends | Non-concern: argv (sva-cli), JS bindings (sva-wasm) | IO: (a Source, a target) -> Rendered, a Stream or CliError
2
3mod answer;
4mod builtins;
5mod cli_error;
6mod duration;
7pub mod json;
8mod lint_code;
9mod outline;
10mod output;
11mod query;
12mod tempo;
13
14pub use answer::{
15    CacheReport, Report, SAMPLE_LIMIT, answer_json, label_json, query_data, stats_json, value_json,
16};
17pub use builtins::{Builtins, Callable, Crossing, builtins, builtins_data};
18pub use cli_error::{CliError, LintViolation, lint_diagnostic};
19pub use lint_code::LintCode;
20pub use outline::outline_data;
21pub use output::{Diagnostic, Severity, diagnostics_json, error_envelope, success_envelope};
22pub use query::{
23    Asked, DEFAULT_LEDGER_DEPTH, DEFAULT_MAX_PEAKS, DEFAULT_OVERSAMPLE, DEFAULT_SILENT_BITS,
24    DEFAULT_SILENT_MAX_SECS, REPRESENTATIONS, RETIRED, Shaping, WindowEdge, is_wav,
25    representation_for, retired, silence, silent_edge, window_edge, window_for,
26};
27pub use tempo::refuse_unresolved_bars;
28
29use std::path::Path;
30
31use sva_ast::{Dir, Graph, Refusal, Source, SpanUnit};
32use sva_engine::{
33    Ask, BindingFault, Cache, DEFAULT_SAMPLE_RATE, EngineError, PSYCHOACOUSTIC_V1, Render,
34    RenderConfig, StreamConfig, render_until_silent, render_with_slots,
35};
36
37pub use sva_engine::{Checkpoint, Silent, Stream};
38
39pub use sva_engine::{Answer, Horizon, Label, Output, Representation};
40pub use sva_engine::{DiskCache, MemoryCache, Slots};
41
42pub const ROOT: &str = "master";
43pub const PROBE: &str = "probe";
44
45pub const DEFAULT_SECONDS: f64 = 1.0;
46
47pub struct Rendered {
48    pub config: RenderConfig,
49    pub target: String,
50    pub render: Render,
51    /// So a structural check needs no second parse, and an ad-hoc target is the same `probe`.
52    pub graph: Graph,
53}
54
55impl Rendered {
56    pub fn answer(&self, node: &str, representation: Representation) -> Result<Answer, CliError> {
57        let id = self.render.node(node).map_err(CliError::Engine)?;
58        let mut answer =
59            sva_engine::answer(&self.render, id, representation).map_err(CliError::Engine)?;
60        self.attribute(&mut answer)?;
61        Ok(answer)
62    }
63
64    /// An alias score is only readable beside what else moves with the rate: a sampled loop
65    /// is a different signal at the oversampled rate, and the engine cannot see the graph.
66    fn attribute(&self, answer: &mut Answer) -> Result<(), CliError> {
67        let Output::Alias(alias) = &mut answer.value else {
68            return Ok(());
69        };
70        alias.rate_dependent = sva_engine::rate_dependent(&self.graph, &self.target)
71            .map_err(CliError::Engine)?
72            .len();
73        alias.instances = self.render.tys.paths().count();
74        Ok(())
75    }
76
77    pub fn label(&self) -> Option<&sva_engine::Label> {
78        self.render.labels.get(&self.render.root)
79    }
80}
81
82/// A `target` the composition holds is that node; anything else is argv math.
83pub struct Job<'a> {
84    pub source: &'a dyn Source,
85    pub target: Option<&'a str>,
86    pub from: Option<WindowEdge>,
87    pub until: Option<WindowEdge>,
88    pub sample_rate: Option<u32>,
89    pub cache: Option<&'a dyn Cache>,
90    /// Only what the target reaches, so a node nothing reaches is never read or refused.
91    pub reaching: bool,
92    /// The instance every reading is taken of; the target itself where this is `None`.
93    pub reading: Option<&'a str>,
94    pub representations: Vec<Representation>,
95    /// The operation count the caller acknowledges paying; the profile's own where `None`.
96    pub flop_budget: Option<u128>,
97    pub volatile: &'a [String],
98    pub slots: Option<&'a Slots>,
99    /// Render until silence is proven, in place of `until`.
100    pub silent: Option<Silent>,
101}
102
103impl<'a> Job<'a> {
104    pub fn over(source: &'a dyn Source) -> Job<'a> {
105        Job {
106            source,
107            target: None,
108            from: None,
109            until: None,
110            sample_rate: None,
111            cache: None,
112            reaching: false,
113            reading: None,
114            representations: Vec::new(),
115            flop_budget: None,
116            volatile: &[],
117            slots: None,
118            silent: None,
119        }
120    }
121}
122
123fn settle(job: &Job, asked: Option<&str>) -> Result<(Graph, String, RenderConfig), CliError> {
124    let mut graph = match job.reaching {
125        false => prepared(job.source)?,
126        true => settled(sva_ast::load_reaching(
127            job.source,
128            &roots_of(job.source, asked)?
129                .iter()
130                .map(String::as_str)
131                .collect::<Vec<_>>(),
132        ))?,
133    };
134    let target = match asked {
135        None => ROOT.to_string(),
136        Some(name) if graph.defines(name) => name.to_string(),
137        Some(text) => {
138            define_probe_for(&mut graph, text)?;
139            PROBE.to_string()
140        }
141    };
142    let until = match job.silent {
143        Some(silent) => Some(WindowEdge::Secs(silent.max_secs)),
144        None => job.until,
145    };
146    let mut config = config_for(&graph, &target, job.from, until, job.sample_rate)?;
147    if let Some(budget) = job.flop_budget {
148        config.flop_budget = budget;
149    }
150    config.volatile = job.volatile.to_vec();
151    let node = job.reading.unwrap_or(&target);
152    config.asks = job
153        .representations
154        .iter()
155        .map(|representation| Ask {
156            node: node.to_string(),
157            representation: *representation,
158        })
159        .collect();
160    Ok((graph, target, config))
161}
162
163/// `trace` names one instance of a parameterized file as `<path>(<name>=<value>, ..)`.
164fn instance_call(text: &str) -> Option<(&str, &str)> {
165    let (path, rest) = text.split_once('(')?;
166    let binds = rest.strip_suffix(')')?.trim();
167    (!binds.is_empty() && all_named(binds)).then_some((path, binds))
168}
169
170/// Only commas and equals outside a bind's own parens count: a bind's value may be a call.
171fn all_named(binds: &str) -> bool {
172    let mut depth = 0i32;
173    let mut named = false;
174    for c in binds.chars() {
175        match c {
176            '(' => depth += 1,
177            ')' if depth == 0 => return false,
178            ')' => depth -= 1,
179            '=' if depth == 0 => named = true,
180            ',' if depth == 0 && !std::mem::take(&mut named) => return false,
181            _ => {}
182        }
183    }
184    named && depth == 0
185}
186
187/// The ref an instance name stands for, so `render` and `trace` answer for the same node.
188fn instance_read(graph: &Graph, text: &str) -> Option<sva_ast::Expr> {
189    let (path, binds) = instance_call(text)?;
190    if !graph.defines(path) {
191        return None;
192    }
193    sva_ast::parse_expr(&format!("@{path}(t, {binds})")).ok()
194}
195
196pub fn execute(job: Job) -> Result<Rendered, CliError> {
197    let cache = job.cache;
198    let rendered = rendered(job);
199    if let Some(store) = cache {
200        store.sweep();
201    }
202    rendered
203}
204
205fn rendered(job: Job) -> Result<Rendered, CliError> {
206    let (graph, target, config) = settle(&job, job.target)?;
207    let refused = match rendering(&job, &graph, &target, config) {
208        Ok(render) => {
209            return Ok(Rendered {
210                config: render.config.clone(),
211                render,
212                target,
213                graph,
214            });
215        }
216        Err(refused) => refused,
217    };
218    match instances_behind(job.source, &target, &refused) {
219        Some(held) if held.len() == 1 => at_instance(&job, &held[0]),
220        Some(held) => Err(CliError::Engine(EngineError::AmbiguousNode(target, held))),
221        None => Err(CliError::Engine(refused)),
222    }
223}
224
225pub fn instances_behind(
226    source: &dyn Source,
227    target: &str,
228    refused: &EngineError,
229) -> Option<Vec<String>> {
230    unbound(refused)
231        .then(|| instances_of(source, target))
232        .flatten()
233}
234
235fn unbound(refused: &EngineError) -> bool {
236    matches!(
237        refused,
238        EngineError::Binding {
239            fault: BindingFault::Unbound(..),
240            ..
241        }
242    )
243}
244
245/// The instances a whole composition expanded a file into; a render reaches none of them.
246fn instances_of(source: &dyn Source, target: &str) -> Option<Vec<String>> {
247    let graph = prepared(source).ok()?;
248    let (instances, _) = sva_engine::instantiate::from_roots(&graph, &[ROOT.to_string()]).ok()?;
249    let held: Vec<String> = instances.instances_of(target).collect();
250    (!held.is_empty()).then_some(held)
251}
252
253/// One instance is the node the caller meant, read as if they had named it themselves.
254fn at_instance(job: &Job, instance: &str) -> Result<Rendered, CliError> {
255    let (graph, target, config) = settle(job, Some(instance))?;
256    let render = rendering(job, &graph, &target, config).map_err(CliError::Engine)?;
257    Ok(Rendered {
258        config: render.config.clone(),
259        render,
260        target,
261        graph,
262    })
263}
264
265/// The horizon the caller named, or the one silence ends.
266fn rendering(
267    job: &Job,
268    graph: &Graph,
269    target: &str,
270    config: RenderConfig,
271) -> Result<Render, EngineError> {
272    match job.silent {
273        Some(silent) => render_until_silent(graph, target, config, silent, job.cache, job.slots),
274        None => render_with_slots(graph, target, config, job.cache, job.slots),
275    }
276}
277
278/// `job`'s target settled as a render of it is, each of `bindings` a named argument on it.
279pub fn stream(job: &Job, block: usize, bindings: &[(String, f64)]) -> Result<Stream, CliError> {
280    let (graph, target, config) = settle(job, job.target)?;
281    let config = StreamConfig {
282        rate: config.rate,
283        block,
284        silent: job.silent,
285    };
286    Stream::open(&graph, &target, bindings, config).map_err(CliError::Engine)
287}
288
289pub fn run(dir: &Path) -> Result<Rendered, CliError> {
290    execute(Job::over(&Dir::at(dir)))
291}
292
293pub fn probe(dir: &Path, expression: &str) -> Result<Rendered, CliError> {
294    execute(Job {
295        target: Some(expression),
296        ..Job::over(&Dir::at(dir))
297    })
298}
299
300pub fn cwd() -> Result<std::path::PathBuf, CliError> {
301    std::env::current_dir()
302        .map_err(|e| CliError::Io(format!("could not read the current directory: {e}")))
303}
304
305pub fn prepared(source: &dyn Source) -> Result<Graph, CliError> {
306    settled(sva_ast::load(source))
307}
308
309/// Argv math: `render`, `trace` and `lint` each refuse a target no node answers for here,
310/// under one code and one message.
311pub fn define_probe_for(graph: &mut Graph, text: &str) -> Result<(), CliError> {
312    let expr = match instance_read(graph, text) {
313        Some(read) => read,
314        None if names_a_missing_node(text) => {
315            return Err(CliError::NotFound(format!(
316                "`{text}` is not a node this composition defines"
317            )));
318        }
319        None => sva_ast::parse_expr(text).map_err(|d| {
320            CliError::BadProbe(format!(
321                "`{text}` is not a node in this composition, and does not parse as an \
322                 expression: {}",
323                d.message
324            ))
325        })?,
326    };
327    define_probe(graph, expr)
328}
329
330/// Only a path, or instance syntax, is a node; the rest is math the parser and engine answer
331/// for. `t` and `A4` are spelled like paths and read as values.
332fn names_a_missing_node(text: &str) -> bool {
333    match instance_call(text) {
334        Some((path, _)) => sva_ast::names_a_node(path),
335        None => !text.contains('(') && sva_ast::names_a_node(text) && !reads_as_a_value(text),
336    }
337}
338
339fn reads_as_a_value(text: &str) -> bool {
340    match sva_ast::parse_expr(text) {
341        Ok(sva_ast::Expr::Lit(_)) => true,
342        Ok(sva_ast::Expr::Var(name)) => sva_engine::instantiate::is_reserved(&name),
343        _ => false,
344    }
345}
346
347/// Argv math settled the way a file is; `concat` is no file-only dialect.
348fn define_probe(graph: &mut Graph, expr: sva_ast::Expr) -> Result<(), CliError> {
349    let defined = graph
350        .define_arranged(PROBE, expr)
351        .map_err(|r| CliError::Refusals(vec![r]))?;
352    if !defined {
353        return Err(CliError::BadProbe(format!(
354            "this composition already has a node named `{PROBE}`"
355        )));
356    }
357    tempo::refuse_unresolved_bars(graph)
358}
359
360pub fn settled(loaded: Result<Graph, Vec<Refusal>>) -> Result<Graph, CliError> {
361    let mut graph = loaded.map_err(CliError::Refusals)?;
362    tempo::resolve(&mut graph)?;
363    graph.desugar_arrangement().map_err(CliError::Refusals)?;
364    Ok(graph)
365}
366
367/// Read by name rather than through a ref.
368pub const RESERVED_VARIABLES: [&str; 3] = ["bpm", "meter", "key"];
369
370/// A path the source answers for is a node; anything else is math, and its reads are roots.
371pub fn roots_of(source: &dyn Source, target: Option<&str>) -> Result<Vec<String>, CliError> {
372    let mut roots: Vec<String> = RESERVED_VARIABLES
373        .iter()
374        .flat_map(|n| [(*n).to_string(), format!("{}/{n}", sva_ast::VARIABLES)])
375        .collect();
376    match target {
377        None => roots.push(ROOT.to_string()),
378        Some(target) if source.get(target).map_err(CliError::Io)?.is_some() => {
379            roots.push(target.to_string());
380        }
381        Some(text) => match instance_call(text).map(|(path, _)| path) {
382            Some(path) if source.get(path).map_err(CliError::Io)?.is_some() => {
383                roots.push(path.to_string());
384            }
385            _ => {
386                if let Ok(expr) = sva_ast::parse_expr(text) {
387                    roots.extend(sva_ast::reads_of(PROBE, &expr));
388                }
389            }
390        },
391    }
392    Ok(roots)
393}
394
395/// The horizon a reading runs against: the window the caller named, else the node's own
396/// stated extent, reaching back to whatever a `crop` declares before zero.
397pub fn config_for(
398    graph: &Graph,
399    root: &str,
400    from: Option<WindowEdge>,
401    until: Option<WindowEdge>,
402    sample_rate: Option<u32>,
403) -> Result<RenderConfig, CliError> {
404    let declared = duration::widest_crop(graph, root);
405    let length = match graph.span(root) {
406        Some(span) if span.unit == SpanUnit::Seconds => Some(span.amount),
407        Some(span) => {
408            return Err(CliError::BadTempo(format!(
409                "`{root}`'s span is still {} bars, unresolved; give bpm/meter, or a seconds span",
410                span.amount
411            )));
412        }
413        None => declared.end,
414    };
415    let asked = window_for(from, until, graph.seconds_per_bar())?;
416    let end = match asked.end_secs.is_finite() {
417        true => asked.end_secs,
418        false => length.unwrap_or(DEFAULT_SECONDS),
419    };
420    let stated = !matches!(from, None | Some(WindowEdge::End));
421    let start = match stated {
422        true => asked.start_secs,
423        false => declared.start.unwrap_or(0.0).min(0.0),
424    };
425    if end <= start {
426        return Err(CliError::Usage(format!(
427            "a reading runs from {start}s to {end}s, which is no window; give --to past --from"
428        )));
429    }
430    Ok(RenderConfig {
431        rate: sample_rate.unwrap_or(DEFAULT_SAMPLE_RATE),
432        horizon: Horizon::secs(start, end),
433        profile: PSYCHOACOUSTIC_V1,
434        asks: Vec::new(),
435        flop_budget: PSYCHOACOUSTIC_V1.flop_budget,
436        volatile: Vec::new(),
437    })
438}