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