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