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, Destination, Diagnostic, Hear, Job, Output, Printed, Report,
7    SAMPLE_LIMIT, Session, Tier, cwd, execute_over, query_data,
8};
9use sva_engine::{Buffer, PSYCHOACOUSTIC_V1, answer_buffer, cache_log};
10
11use crate::args::{AnalyzeArgs, RenderArgs};
12use crate::destination::{Framing, refuse_inside, refuse_replacing, write};
13use crate::device::Hearing;
14use crate::directory::Directory;
15use crate::store::wait;
16use crate::wav::{SampleEncoding, read_channels};
17use sva_core::success_envelope;
18
19/// Only the nodes the target reaches are read; its interval and `--until` decide its range.
20pub fn render(args: &RenderArgs) -> Result<String, CliError> {
21    let here = crate::composition::composition()?;
22    let (dir, target) = crate::composition::located(&here, &args.target)?;
23    // Judged before the first is opened: a refusal on the last leaves none of them on disk.
24    for dest in args.asked.iter().filter_map(Asked::path) {
25        refuse_inside(&dir, dest)?;
26        refuse_replacing(dest, args.confirm)?;
27    }
28    let mut warnings = Vec::new();
29    let tier = crate::store::opened(&args.cache, &mut warnings)?;
30    let answered = answered(args, &dir, &target, tier, &mut warnings);
31    crate::store::persisted(tier, &mut warnings);
32    answered.map(|data| success_envelope(&data, &warnings))
33}
34
35/// The render's `data`.
36fn answered(
37    args: &RenderArgs,
38    dir: &Path,
39    target: &str,
40    tier: &Tier<Directory>,
41    warnings: &mut Vec<Diagnostic>,
42) -> Result<String, CliError> {
43    let files = sva_ast::Dir::at(dir);
44    let source = sva_core::overlaid(&files, &args.sets)?;
45    let logs = std::env::var("SVA_LOG").is_ok_and(|level| level == "debug");
46    let hearing = hearing(args)?;
47    let job = Job {
48        until: args.until.as_deref(),
49        rate: args.rate,
50        bits: args.bits,
51        asked: &args.asked,
52        threads: args.threads,
53        hear: hearing.as_ref().map(|hearing| hearing as &dyn Hear),
54        ..Job::over(&source, target)
55    };
56    let rendered = wait(execute_over(job, tier, &mut Session::default()));
57    let stopped = hearing.as_ref().and_then(Hearing::stopped);
58    let rendered = match (rendered, stopped) {
59        (_, Some(why)) => return Err(why),
60        (rendered, None) => rendered?,
61    };
62    let played = hearing.map(Hearing::finish).transpose()?;
63
64    warnings.extend(rendered.warnings.iter().map(sva_core::warning_diagnostic));
65    let rate = rendered.config.rate;
66    let stats = rendered.render.cache_stats.as_ref();
67    if let Some(why) = stats.and_then(|stats| stats.unstaged.as_deref()) {
68        let message = format!("this render stopped writing what it computed: {why}");
69        warnings.push(crate::store::warning("store.unstaged", message));
70    }
71    if let (true, Some(stats)) = (logs, stats) {
72        eprint!("{}", cache_log(stats, rate));
73    }
74    let bits = rendered.config.profile.precision_bits;
75    let interval = rendered
76        .render
77        .range
78        .map(|r| (r.start_secs(rate), r.end as f64 / f64::from(rate)));
79    let framing = Framing {
80        target: args.target.clone(),
81        rate,
82        bits: Some(bits),
83        interval,
84        profile: rendered.config.profile.name,
85        encoding: SampleEncoding::of(bits),
86        replace: args.confirm,
87    };
88
89    let kept: Vec<&Asked> = args.asked.iter().filter(|a| !plays(a)).collect();
90    let mut taken = Vec::with_capacity(kept.len());
91    for asked in &kept {
92        let node = asked.node.as_deref().unwrap_or(&rendered.target);
93        let answer = rendered.answer(node, asked.representation)?;
94        taken.push(match asked.brief {
95            true => briefed(answer),
96            false => answer,
97        });
98    }
99    let (answers, written) = routed(&kept, taken, &framing)?;
100
101    Ok(query_data(&Report {
102        target: &args.target,
103        rate,
104        bits: Some(bits),
105        interval,
106        profile: rendered.config.profile.name,
107        label: rendered.label(),
108        written: &written,
109        played: played.as_ref(),
110        answers: &answers,
111        limit: Some(SAMPLE_LIMIT),
112    }))
113}
114
115fn plays(asked: &Asked) -> bool {
116    matches!(asked.dest, Some(Destination::Alsa { .. }))
117}
118
119/// The device the render's `samples` plays on, opened before the render so a missing player
120/// refuses before anything is computed.
121fn hearing(args: &RenderArgs) -> Result<Option<Hearing>, CliError> {
122    let alsa = args.asked.iter().find_map(|a| match &a.dest {
123        Some(Destination::Alsa {
124            device,
125            buffer_secs,
126        }) => Some((a.name.as_str(), device, *buffer_secs)),
127        _ => None,
128    });
129    let Some((name, device, buffer_secs)) = alsa else {
130        return Ok(None);
131    };
132    let encoding = SampleEncoding::of(sva_core::precision_bits(args.bits)?);
133    let named = (name, args.target.as_str());
134    Hearing::open(device, buffer_secs, encoding, named).map(Some)
135}
136
137/// What stays in the envelope, and what left it for a file.
138type Routed<'a> = (Vec<Printed>, Vec<(String, &'a Path)>);
139
140/// Every reading is taken before any file is opened, so a later refusal writes nothing. A
141/// reading a device played was heard as the render pulled it, and is none of these.
142fn routed<'a>(
143    asked: &[&'a Asked],
144    taken: Vec<Answer>,
145    framing: &Framing,
146) -> Result<Routed<'a>, CliError> {
147    let mut answers = Vec::new();
148    let mut written = Vec::new();
149    for (one, answer) in asked.iter().zip(taken) {
150        let printed = Printed {
151            name: one.name.clone(),
152            answer,
153            skim: one.skim,
154        };
155        match &one.dest {
156            None => answers.push(printed),
157            Some(Destination::File(dest)) => {
158                write(&printed, dest, framing)?;
159                written.push((one.name.clone(), dest.as_path()));
160            }
161            Some(Destination::Alsa { .. }) => {
162                unreachable!("a device heard its reading as it rendered")
163            }
164        }
165    }
166    Ok((answers, written))
167}
168
169/// `ledger(brief=1)`: a node that did not clip is not news, so a ledger keeps only the ones
170/// that did.
171fn briefed(answer: Answer) -> Answer {
172    let Output::Ledger(entries) = answer.value else {
173        return answer;
174    };
175    Answer {
176        value: Output::Ledger(
177            entries
178                .into_iter()
179                .filter(|e| e.clipped == Some(true))
180                .collect(),
181        ),
182        ..answer
183    }
184}
185
186/// The same routing over a decoded external WAV instead of a rendered graph: the file's own
187/// rate stands, and it is read whole.
188pub fn analyze(args: &AnalyzeArgs) -> Result<String, CliError> {
189    let dir = cwd()?;
190    let composition = is_composition(&dir);
191    for dest in args.asked.iter().filter_map(Asked::path) {
192        if composition {
193            refuse_inside(&dir, dest)?;
194        }
195        refuse_replacing(dest, args.confirm)?;
196    }
197    let (planes, rate) = read_channels(&args.path)?;
198    let target = args.path.display().to_string();
199    let held = Buffer::of_planes(
200        rate,
201        planes
202            .iter()
203            .map(|p| p.iter().map(|v| f64::from(*v)).collect())
204            .collect(),
205    );
206    crate::args::check_frame(&args.asked, rate)?;
207    let buffer = held;
208    let interval = Some((0.0, buffer.len() as f64 / f64::from(rate)));
209    let framing = Framing {
210        target: target.clone(),
211        rate,
212        bits: None,
213        interval,
214        profile: PSYCHOACOUSTIC_V1.name,
215        encoding: SampleEncoding::Float,
216        replace: args.confirm,
217    };
218
219    let mut taken = Vec::with_capacity(args.asked.len());
220    for asked in &args.asked {
221        taken.push(
222            answer_buffer(
223                &target,
224                &buffer,
225                asked.representation,
226                PSYCHOACOUSTIC_V1.name,
227            )
228            .map_err(CliError::Engine)?,
229        );
230    }
231    let (answers, written) = routed(&args.asked.iter().collect::<Vec<_>>(), taken, &framing)?;
232    Ok(success_envelope(
233        &query_data(&Report {
234            target: &target,
235            rate,
236            bits: None,
237            interval,
238            profile: PSYCHOACOUSTIC_V1.name,
239            label: None,
240            written: &written,
241            played: None,
242            answers: &answers,
243            limit: Some(SAMPLE_LIMIT),
244        }),
245        &[],
246    ))
247}
248
249/// Whether the next parse would walk this directory, not whether it parses cleanly today.
250fn is_composition(dir: &Path) -> bool {
251    dir.join(sva_core::ROOT).is_file() || dir.join(sva_ast::VARIABLES).is_dir()
252}