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, Diagnostic, Job, Output, Printed, Report, SAMPLE_LIMIT, Tier, cwd,
7    execute_over, 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 mut warnings = Vec::new();
32    let tier = crate::store::opened(&args.cache, &mut warnings)?;
33    let answered = answered(args, &dir, &target, tier, &mut warnings);
34    crate::store::persisted(tier, &mut warnings);
35    answered.map(|data| success_envelope(&data, &warnings))
36}
37
38/// The render's `data`.
39fn answered(
40    args: &RenderArgs,
41    dir: &Path,
42    target: &str,
43    tier: &Tier<Directory>,
44    warnings: &mut Vec<Diagnostic>,
45) -> Result<String, CliError> {
46    let source = sva_ast::Dir::at(dir);
47    let logs = std::env::var("SVA_LOG").is_ok_and(|level| level == "debug");
48    let job = Job {
49        until: args.until.as_deref(),
50        rate: args.rate,
51        bits: args.bits,
52        asked: &args.asked,
53        flop_budget: args.flop_budget,
54        ..Job::over(&source, target)
55    };
56    let rendered = wait(execute_over(job, tier))?;
57
58    let rate = rendered.config.rate;
59    let stats = rendered.render.cache_stats.as_ref();
60    if let Some(why) = stats.and_then(|stats| stats.unstaged.as_deref()) {
61        let message = format!("this render stopped writing what it computed: {why}");
62        warnings.push(crate::store::warning("store.unstaged", message));
63    }
64    if let (true, Some(stats)) = (logs, stats) {
65        eprint!("{}", cache_log(stats, rate));
66    }
67    let bits = rendered.config.profile.precision_bits;
68    let interval = rendered
69        .render
70        .range
71        .map(|r| (r.start_secs(rate), r.end as f64 / f64::from(rate)));
72    let framing = Framing {
73        target: args.target.clone(),
74        rate,
75        bits: Some(bits),
76        interval,
77        profile: rendered.config.profile.name,
78        encoding: SampleEncoding::of(bits),
79        replace: args.confirm,
80    };
81
82    let mut taken = Vec::with_capacity(args.asked.len());
83    for asked in &args.asked {
84        let node = asked.node.as_deref().unwrap_or(&rendered.target);
85        let answer = rendered.answer(node, asked.representation)?;
86        taken.push(match asked.brief {
87            true => briefed(answer),
88            false => answer,
89        });
90    }
91    let (answers, written) = routed(&args.asked, taken, &framing)?;
92
93    Ok(query_data(&Report {
94        target: &args.target,
95        rate,
96        bits: Some(bits),
97        interval,
98        profile: rendered.config.profile.name,
99        label: rendered.label(),
100        written: &written,
101        answers: &answers,
102        analyses: &[],
103        limit: Some(SAMPLE_LIMIT),
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}