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, CliError, Job, Output, Printed, Report, SAMPLE_LIMIT, Store, cwd, execute,
7    execute_through, query_data,
8};
9use sva_engine::{
10    Buffer, DEFAULT_FRAME_SECS, PSYCHOACOUSTIC_V1, Representation, answer_buffer, cache_log,
11};
12
13use crate::args::{AnalyzeArgs, RenderArgs};
14use crate::destination::{Framing, refuse_inside, refuse_replacing, write, write_analysis};
15use crate::directory::Directory;
16use crate::store::wait;
17use crate::wav::{SampleEncoding, read_channels};
18use sva_core::success_envelope;
19
20/// Only the nodes the target reaches are read; its interval and `--until` decide its range.
21pub fn render(args: &RenderArgs) -> Result<String, CliError> {
22    let here = crate::composition::composition()?;
23    let (dir, target) = crate::composition::located(&here, &args.target)?;
24    // Judged before the first is opened: a refusal on the last leaves none of them on disk.
25    for asked in &args.asked {
26        if let Some(dest) = asked.dest.as_deref() {
27            refuse_inside(&dir, dest)?;
28            refuse_replacing(dest, args.confirm)?;
29        }
30    }
31    let Some(store) = crate::store::opened(&args.cache)? else {
32        return answered(args, &dir, &target, None);
33    };
34    let answered = answered(args, &dir, &target, Some(store));
35    let stored = crate::store::persisted(store);
36    answered.and_then(|json| stored.map(|()| json))
37}
38
39fn answered(
40    args: &RenderArgs,
41    dir: &Path,
42    target: &str,
43    store: Option<&Store<Directory>>,
44) -> Result<String, CliError> {
45    let source = sva_ast::Dir::at(dir);
46    let logs = std::env::var("SVA_LOG").is_ok_and(|level| level == "debug");
47    let job = Job {
48        until: args.until.as_deref(),
49        rate: args.rate,
50        bits: args.bits,
51        asked: &args.asked,
52        flop_budget: args.flop_budget,
53        ..Job::over(&source, target)
54    };
55    let rendered = match store {
56        Some(store) => wait(execute_through(job, store))?,
57        None => execute(job)?,
58    };
59
60    let rate = rendered.config.rate;
61    if let (true, Some(stats)) = (logs, &rendered.render.cache_stats) {
62        eprint!("{}", cache_log(stats, rate));
63    }
64    let bits = rendered.config.profile.precision_bits;
65    let interval = rendered
66        .render
67        .range
68        .map(|r| (r.start_secs(rate), r.end as f64 / f64::from(rate)));
69    let framing = Framing {
70        target: args.target.clone(),
71        rate,
72        bits: Some(bits),
73        interval,
74        profile: rendered.config.profile.name,
75        encoding: SampleEncoding::of(bits),
76        replace: args.confirm,
77    };
78
79    let mut taken = Vec::with_capacity(args.asked.len());
80    for asked in &args.asked {
81        let node = asked.node.as_deref().unwrap_or(&rendered.target);
82        let answer = rendered.answer(node, asked.representation)?;
83        taken.push(match asked.brief {
84            true => briefed(answer),
85            false => answer,
86        });
87    }
88    let (answers, written) = routed(&args.asked, taken, &framing)?;
89
90    Ok(success_envelope(
91        &query_data(&Report {
92            target: &args.target,
93            rate,
94            bits: Some(bits),
95            interval,
96            profile: rendered.config.profile.name,
97            label: rendered.label(),
98            written: &written,
99            answers: &answers,
100            analyses: &[],
101            limit: Some(SAMPLE_LIMIT),
102        }),
103        &[],
104    ))
105}
106
107/// What stays in the envelope, and what left it for a file.
108type Routed<'a> = (Vec<Printed>, Vec<(String, &'a Path)>);
109
110/// Every reading is taken before any file is opened, so a later refusal writes nothing.
111fn routed<'a>(
112    asked: &'a [Asked],
113    taken: Vec<Answer>,
114    framing: &Framing,
115) -> Result<Routed<'a>, CliError> {
116    let mut answers = Vec::new();
117    let mut written = Vec::new();
118    for (one, answer) in asked.iter().zip(taken) {
119        let printed = Printed {
120            name: one.name.clone(),
121            answer,
122            skim: one.skim,
123        };
124        match one.dest.as_deref() {
125            None => answers.push(printed),
126            Some(dest) => {
127                write(&printed, dest, framing)?;
128                written.push((one.name.clone(), dest));
129            }
130        }
131    }
132    Ok((answers, written))
133}
134
135/// `ledger(brief=1)`: a node that did not clip is not news, so a ledger keeps only the ones
136/// that did.
137fn briefed(answer: Answer) -> Answer {
138    let Output::Ledger(entries) = answer.value else {
139        return answer;
140    };
141    Answer {
142        value: Output::Ledger(
143            entries
144                .into_iter()
145                .filter(|e| e.clipped == Some(true))
146                .collect(),
147        ),
148        ..answer
149    }
150}
151
152/// The same routing over a decoded external WAV instead of a rendered graph: the file's own
153/// rate stands, and it is read whole.
154pub fn analyze(args: &AnalyzeArgs) -> Result<String, CliError> {
155    let dir = cwd()?;
156    let composition = is_composition(&dir);
157    let destinations = args
158        .asked
159        .iter()
160        .map(|a| a.dest.as_deref())
161        .chain(args.analyses.iter().map(|a| a.dest.as_deref()));
162    for dest in destinations {
163        let Some(dest) = dest else {
164            continue;
165        };
166        if composition {
167            refuse_inside(&dir, dest)?;
168        }
169        refuse_replacing(dest, args.confirm)?;
170    }
171    let (planes, rate) = read_channels(&args.path)?;
172    let target = args.path.display().to_string();
173    let held = Buffer::of_planes(
174        rate,
175        planes
176            .iter()
177            .map(|p| p.iter().map(|v| f64::from(*v)).collect())
178            .collect(),
179    );
180    crate::args::check_frame(&args.asked, rate)?;
181    let buffer = held;
182    let interval = Some((0.0, buffer.len() as f64 / f64::from(rate)));
183    let framing = Framing {
184        target: target.clone(),
185        rate,
186        bits: None,
187        interval,
188        profile: PSYCHOACOUSTIC_V1.name,
189        encoding: SampleEncoding::Float,
190        replace: args.confirm,
191    };
192
193    let mut taken = Vec::with_capacity(args.asked.len());
194    for asked in &args.asked {
195        taken.push(
196            answer_buffer(
197                &target,
198                &buffer,
199                asked.representation,
200                PSYCHOACOUSTIC_V1.name,
201            )
202            .map_err(CliError::Engine)?,
203        );
204    }
205    let heard = analysed(args, &buffer)?;
206    let (answers, mut written) = routed(&args.asked, taken, &framing)?;
207    let mut analyses = Vec::new();
208    for (analysis, value) in args.analyses.iter().zip(heard) {
209        match analysis.dest.as_deref() {
210            None => analyses.push((analysis.name.clone(), value)),
211            Some(dest) => {
212                write_analysis(&analysis.name, &value, dest, &framing)?;
213                written.push((analysis.name.clone(), dest));
214            }
215        }
216    }
217    Ok(success_envelope(
218        &query_data(&Report {
219            target: &target,
220            rate,
221            bits: None,
222            interval,
223            profile: PSYCHOACOUSTIC_V1.name,
224            label: None,
225            written: &written,
226            answers: &answers,
227            analyses: &analyses,
228            limit: Some(SAMPLE_LIMIT),
229        }),
230        &[],
231    ))
232}
233
234/// The readings `sva-analysis` answers off a buffer alone. Every field its `Request` names is
235/// taken from this one file: a `.wav` carries no tempo and no node behind it.
236fn analysed(args: &AnalyzeArgs, buffer: &Buffer) -> Result<Vec<String>, CliError> {
237    if args.analyses.is_empty() {
238        return Ok(Vec::new());
239    }
240    let rate = f64::from(buffer.rate);
241    let mono: Vec<f32> = buffer.plane(0).iter().map(|s| *s as f32).collect();
242    let against = match args.analyses.iter().find_map(|a| a.against.as_deref()) {
243        Some(path) => {
244            let (planes, _) = read_channels(path)?;
245            Some(planes.first().cloned().unwrap_or_default())
246        }
247        None => None,
248    };
249    let name = args.path.display().to_string();
250    let read = |representation| {
251        answer_buffer(&name, buffer, representation, PSYCHOACOUSTIC_V1.name).map(|a| a.value)
252    };
253    let envelope = match read(Representation::Envelope { frame_secs: None }) {
254        Ok(Output::Envelope(frames)) => frames,
255        _ => Vec::new(),
256    };
257    let image = match read(Representation::Stereo {
258        frame_secs: DEFAULT_FRAME_SECS,
259    }) {
260        Ok(Output::Stereo(found)) => Some(*found),
261        _ => None,
262    };
263    let request = sva_analysis::Request {
264        samples: &mono,
265        sample_rate: rate,
266        start_secs: 0.0,
267        frame_secs: DEFAULT_FRAME_SECS,
268        tempo: None,
269        envelope: &envelope,
270        stereo: image.as_ref(),
271        against: against.as_deref(),
272        gated: false,
273        input_envelope: None,
274    };
275    let mut out = Vec::with_capacity(args.analyses.len());
276    for analysis in &args.analyses {
277        out.push(
278            sva_analysis::run(&analysis.name, &request)
279                .map_err(|sva_analysis::AnalysisError(why)| CliError::Usage(why))?,
280        );
281    }
282    Ok(out)
283}
284
285/// Whether the next parse would walk this directory, not whether it parses cleanly today.
286fn is_composition(dir: &Path) -> bool {
287    dir.join(sva_core::ROOT).is_file() || dir.join(sva_ast::VARIABLES).is_dir()
288}