Skip to main content

sva_cli/
render.rs

1// Concern: runs one render or one file analysis and routes every reading it answered | Non-concern: argv (args/), the JSON shape (sva-core) | IO: (RenderArgs or AnalyzeArgs) -> JSON or CliError
2
3use std::path::Path;
4
5use sva_core::{
6    Answer, Asked, CacheReport, CliError, Horizon, Job, Output, Report, SAMPLE_LIMIT, cwd, execute,
7    query_data, window_for,
8};
9use sva_engine::{
10    Buffer, Cache, DEFAULT_FRAME_SECS, DiskCache, PSYCHOACOUSTIC_V1, Representation, answer_buffer,
11};
12
13use crate::args::{AnalyzeArgs, RenderArgs};
14use crate::destination::{Framing, refuse_inside, refuse_replacing, write, write_analysis};
15use crate::wav::{SampleEncoding, read_channels};
16use sva_core::success_envelope;
17
18/// A target renders FROM itself, so only its dependency subtree is evaluated and its own
19/// extent decides the length.
20pub fn render(args: &RenderArgs) -> Result<String, CliError> {
21    let dir = crate::composition::composition(args.dir.as_deref())?;
22    // Judged before the first is opened: a refusal on the last leaves none of them on disk.
23    for asked in &args.asked {
24        if let Some(dest) = asked.dest.as_deref() {
25            refuse_inside(&dir, dest)?;
26            refuse_replacing(dest, args.confirm)?;
27        }
28    }
29    let store = args.cache.then(DiskCache::discover).flatten();
30    let cache = store.as_ref().map(|c| c as &dyn Cache);
31    let source = sva_ast::Dir::at(&dir);
32    let rendered = execute(Job {
33        target: args.target.as_deref(),
34        from: args.from,
35        until: args.to,
36        sample_rate: args.sample_rate,
37        cache,
38        reading: args.node.as_deref(),
39        representations: args.asked.iter().map(|a| a.representation).collect(),
40        // A named target pulls its own closure; nothing it never reads is parsed at all.
41        reaching: args.target.is_some(),
42        flop_budget: args.flop_budget,
43        ..Job::over(&source)
44    })?;
45
46    let node = args.node.clone().unwrap_or_else(|| rendered.target.clone());
47    let framing = Framing {
48        target: rendered.target.clone(),
49        rate: rendered.config.rate,
50        horizon: rendered.config.horizon,
51        profile: rendered.config.profile.name,
52        encoding: match args.pcm16 {
53            true => SampleEncoding::Pcm16,
54            false => SampleEncoding::Float,
55        },
56        skim: args.skim,
57        replace: args.confirm,
58    };
59
60    let mut taken = Vec::with_capacity(args.asked.len());
61    for asked in &args.asked {
62        let answer = rendered.answer(&node, asked.representation)?;
63        taken.push(match args.brief {
64            true => briefed(answer),
65            false => answer,
66        });
67    }
68    let (answers, written) = routed(&args.asked, taken, &framing)?;
69
70    let report = cache.map(|c| CacheReport {
71        dir: c
72            .dir()
73            .map_or_else(|| "memory".to_string(), |d| d.display().to_string()),
74        stats: rendered
75            .render
76            .cache_stats
77            .clone()
78            .expect("a render handed a store records every lookup it made"),
79        held_bytes: c.held_bytes(),
80        max_bytes: c.max_bytes(),
81        evicted_bytes: c.evicted_bytes(),
82        faults: c.faults(),
83    });
84    Ok(success_envelope(
85        &query_data(&Report {
86            target: &rendered.target,
87            rate: rendered.config.rate,
88            horizon: rendered.config.horizon,
89            profile: rendered.config.profile.name,
90            label: rendered.label(),
91            written: &written,
92            cache: report.as_ref(),
93            answers: &answers,
94            analyses: &[],
95            limit: Some(SAMPLE_LIMIT),
96            skim: args.skim,
97        }),
98        &[],
99    ))
100}
101
102/// What stays in the envelope, and what left it for a file.
103type Routed<'a> = (Vec<(String, Answer)>, Vec<(String, &'a Path)>);
104
105/// Every reading is taken before any file is opened, so a later refusal writes nothing.
106fn routed<'a>(
107    asked: &'a [Asked],
108    taken: Vec<Answer>,
109    framing: &Framing,
110) -> Result<Routed<'a>, CliError> {
111    let mut answers = Vec::new();
112    let mut written = Vec::new();
113    for (one, answer) in asked.iter().zip(taken) {
114        match one.dest.as_deref() {
115            None => answers.push((one.name.clone(), answer)),
116            Some(dest) => {
117                write(&one.name, &answer, dest, framing)?;
118                written.push((one.name.clone(), dest));
119            }
120        }
121    }
122    Ok((answers, written))
123}
124
125/// `--brief`: a node that did not clip is not news, so a ledger keeps only the ones that did.
126fn briefed(answer: Answer) -> Answer {
127    let Output::Ledger(entries) = answer.value else {
128        return answer;
129    };
130    Answer {
131        value: Output::Ledger(
132            entries
133                .into_iter()
134                .filter(|e| e.clipped == Some(true))
135                .collect(),
136        ),
137        ..answer
138    }
139}
140
141/// The same routing over a decoded external WAV instead of a rendered graph: the file's own
142/// rate stands, and its own length is the horizon.
143pub fn analyze(args: &AnalyzeArgs) -> Result<String, CliError> {
144    let dir = cwd()?;
145    let composition = is_composition(&dir);
146    let destinations = args
147        .asked
148        .iter()
149        .map(|a| a.dest.as_deref())
150        .chain(args.analyses.iter().map(|(_, dest)| dest.as_deref()));
151    for dest in destinations {
152        let Some(dest) = dest else {
153            continue;
154        };
155        if composition {
156            refuse_inside(&dir, dest)?;
157        }
158        refuse_replacing(dest, args.confirm)?;
159    }
160    let (planes, rate) = read_channels(&args.path)?;
161    let target = args.path.display().to_string();
162    let held = Buffer::of_planes(
163        rate,
164        planes
165            .iter()
166            .map(|p| p.iter().map(|v| f64::from(*v)).collect())
167            .collect(),
168    );
169    let asked_window = window_for(args.from, args.to, None)?;
170    let duration = held.len() as f64 / f64::from(rate);
171    let horizon = Horizon::secs(
172        asked_window.start_secs,
173        match asked_window.end_secs.is_finite() {
174            true => asked_window.end_secs,
175            false => duration,
176        },
177    );
178    if horizon.span() <= 0.0 {
179        return Err(CliError::Usage(format!(
180            "a reading runs from {}s to {}s, which is no window; give --to past --from",
181            horizon.start_secs, horizon.end_secs
182        )));
183    }
184    // A window past the end asks for samples that do not exist.
185    if horizon.start_secs >= duration || horizon.end_secs > duration {
186        return Err(CliError::Usage(format!(
187            "{} is {duration}s long, and the window asked for runs to {}s",
188            args.path.display(),
189            horizon.end_secs
190        )));
191    }
192    crate::args::check_frame(&args.asked, rate)?;
193    let buffer = sliced(&held, horizon);
194    let framing = Framing {
195        target: target.clone(),
196        rate,
197        horizon,
198        profile: PSYCHOACOUSTIC_V1.name,
199        encoding: SampleEncoding::Float,
200        skim: false,
201        replace: args.confirm,
202    };
203
204    let mut taken = Vec::with_capacity(args.asked.len());
205    for asked in &args.asked {
206        taken.push(
207            answer_buffer(
208                &target,
209                &buffer,
210                asked.representation,
211                PSYCHOACOUSTIC_V1.name,
212            )
213            .map_err(CliError::Engine)?,
214        );
215    }
216    let heard = analysed(args, &buffer, horizon)?;
217    let (answers, mut written) = routed(&args.asked, taken, &framing)?;
218    let mut analyses = Vec::new();
219    for ((name, dest), value) in args.analyses.iter().zip(heard) {
220        match dest.as_deref() {
221            None => analyses.push((name.clone(), value)),
222            Some(dest) => {
223                write_analysis(name, &value, dest, &framing)?;
224                written.push((name.clone(), dest));
225            }
226        }
227    }
228    Ok(success_envelope(
229        &query_data(&Report {
230            target: &target,
231            rate,
232            horizon,
233            profile: PSYCHOACOUSTIC_V1.name,
234            label: None,
235            written: &written,
236            cache: None,
237            answers: &answers,
238            analyses: &analyses,
239            limit: Some(SAMPLE_LIMIT),
240            skim: false,
241        }),
242        &[],
243    ))
244}
245
246/// The readings `sva-analysis` answers off a buffer alone. Every field its `Request` names is
247/// taken from this one file: a `.wav` carries no tempo and no node behind it.
248fn analysed(
249    args: &AnalyzeArgs,
250    buffer: &Buffer,
251    horizon: Horizon,
252) -> Result<Vec<String>, CliError> {
253    if args.analyses.is_empty() {
254        return Ok(Vec::new());
255    }
256    let rate = f64::from(buffer.rate);
257    let mono: Vec<f32> = buffer.plane(0).iter().map(|s| *s as f32).collect();
258    let against = match &args.against {
259        Some(path) => {
260            let (planes, _) = read_channels(path)?;
261            Some(planes.first().cloned().unwrap_or_default())
262        }
263        None => None,
264    };
265    let name = args.path.display().to_string();
266    let read = |representation| {
267        answer_buffer(&name, buffer, representation, PSYCHOACOUSTIC_V1.name).map(|a| a.value)
268    };
269    let envelope = match read(Representation::Envelope { frame_secs: None }) {
270        Ok(Output::Envelope(frames)) => frames,
271        _ => Vec::new(),
272    };
273    let image = match read(Representation::Stereo {
274        frame_secs: DEFAULT_FRAME_SECS,
275    }) {
276        Ok(Output::Stereo(found)) => Some(*found),
277        _ => None,
278    };
279    let request = sva_analysis::Request {
280        samples: &mono,
281        sample_rate: rate,
282        start_secs: horizon.start_secs,
283        frame_secs: DEFAULT_FRAME_SECS,
284        tempo: None,
285        envelope: &envelope,
286        stereo: image.as_ref(),
287        against: against.as_deref(),
288        gated: false,
289        input_envelope: None,
290    };
291    let mut out = Vec::with_capacity(args.analyses.len());
292    for (name, _) in &args.analyses {
293        out.push(
294            sva_analysis::run(name, &request)
295                .map_err(|sva_analysis::AnalysisError(why)| CliError::Usage(why))?,
296        );
297    }
298    Ok(out)
299}
300
301/// Whether the next parse would walk this directory, not whether it parses cleanly today.
302fn is_composition(dir: &Path) -> bool {
303    dir.join(sva_core::ROOT).is_file() || dir.join(sva_ast::VARIABLES).is_dir()
304}
305
306/// A file has no horizon of its own, so the window is taken off the samples it holds.
307fn sliced(buffer: &Buffer, horizon: Horizon) -> Buffer {
308    let rate = f64::from(buffer.rate);
309    let start = ((horizon.start_secs * rate).round().max(0.0) as usize).min(buffer.len());
310    let end = ((horizon.end_secs * rate).round().max(0.0) as usize).clamp(start, buffer.len());
311    let mut out = Buffer::of_planes(
312        buffer.rate,
313        (0..buffer.width)
314            .map(|c| buffer.plane(c)[start..end].to_vec())
315            .collect(),
316    );
317    out.origin_secs = horizon.start_secs;
318    out
319}