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