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