Skip to main content

sva_core/
query.rs

1// Concern: the observations both front ends ask for, each written as a call with its own arguments | Non-concern: argv (sva-cli's args/), taking the reading (sva-engine) | IO: (text) -> Asked
2
3use std::path::{Path, PathBuf};
4
5use sva_engine::harmony::voice_leading::Norm;
6use sva_engine::{Arg, DEFAULT_BUFFER_SECS, Given, READINGS, Representation};
7
8use crate::cli_error::CliError;
9
10pub const RETIRED: [(&str, &str); 2] = [
11    ("exact-envelope", "envelope, symbolic on a closed form"),
12    ("exact-derivative", "derivative, symbolic on a closed form"),
13];
14
15/// One reading written as a call, `spectrum(peaks=8, frame=50ms)=out.json`: its name, its
16/// arguments as written, and where it goes.
17#[derive(Clone, Debug, PartialEq)]
18pub struct Call {
19    pub name: String,
20    pub args: Vec<(String, String)>,
21    /// No destination is stdout.
22    pub dest: Option<PathBuf>,
23}
24
25/// Where a reading goes instead of stdout: a path, its extension picking the format, or a
26/// live device named after `alsa:`.
27#[derive(Clone, Debug, PartialEq)]
28pub enum Destination {
29    File(PathBuf),
30    Alsa {
31        /// Any ALSA PCM name: `default`, `hw:1`, `plughw:1,0`.
32        device: String,
33        /// `samples(buffer=50ms)`: how much the device holds ahead of what it plays.
34        buffer_secs: f64,
35    },
36}
37
38/// A call resolved to the reading it names and the arguments only a front end reads.
39#[derive(Clone, Debug, PartialEq)]
40pub struct Asked {
41    pub name: String,
42    pub representation: Representation,
43    pub dest: Option<Destination>,
44    /// `bindings(node=@voice)`: the instance whose bindings are read.
45    pub node: Option<String>,
46    /// `ledger(brief=1)`: only the rows that clipped.
47    pub brief: bool,
48    /// `ledger(skim=1)`: each row's level alone.
49    pub skim: bool,
50}
51
52pub fn is_wav(path: &Path) -> bool {
53    path.extension()
54        .is_some_and(|e| e.eq_ignore_ascii_case("wav"))
55}
56
57pub fn retired(name: &str) -> Option<&'static str> {
58    RETIRED
59        .iter()
60        .find(|(gone, _)| *gone == name)
61        .map(|(_, write)| *write)
62}
63
64/// A comma list of calls; a comma inside a call's parentheses separates its arguments, and
65/// one inside an `alsa:` device name, `hw:1,0`, stays in it where no reading follows it.
66pub fn calls(text: &str) -> Result<Vec<Call>, CliError> {
67    let mut joined: Vec<String> = Vec::new();
68    for one in split_outside(text, ',')? {
69        match joined.last_mut() {
70            Some(last) if names_a_device(last) && !names_a_reading(one) => {
71                last.push(',');
72                last.push_str(one);
73            }
74            _ => joined.push(one.to_string()),
75        }
76    }
77    joined.iter().map(|one| call(one.trim())).collect()
78}
79
80fn names_a_device(call: &str) -> bool {
81    let close = call.rfind(')').map_or(0, |at| at + 1);
82    call[close..]
83        .split_once('=')
84        .is_some_and(|(_, dest)| dest.trim_start().starts_with("alsa:"))
85}
86
87fn names_a_reading(text: &str) -> bool {
88    let head = text[..text.find(['(', '=']).unwrap_or(text.len())].trim();
89    READINGS.iter().any(|row| row.name == head) || retired(head).is_some()
90}
91
92/// `name`, `name(k=v, ...)`, either followed by `=path`.
93pub fn call(text: &str) -> Result<Call, CliError> {
94    let head_end = text.find(['(', '=']).unwrap_or(text.len());
95    let name = text[..head_end].trim().to_string();
96    if name.is_empty() {
97        return Err(unwritten(text, "it names no reading"));
98    }
99    let mut rest = &text[head_end..];
100    let mut args = Vec::new();
101    if rest.starts_with('(') {
102        let close = closing(rest).ok_or_else(|| unwritten(text, "its `(` is never closed"))?;
103        let inner = rest[1..close].trim();
104        if !inner.is_empty() {
105            for arg in split_outside(inner, ',')? {
106                let Some((key, value)) = arg.split_once('=') else {
107                    return Err(unwritten(
108                        text,
109                        &format!("`{}` is no named argument; write `key=value`", arg.trim()),
110                    ));
111                };
112                args.push((key.trim().to_string(), value.trim().to_string()));
113            }
114        }
115        rest = &rest[close + 1..];
116    }
117    let dest = match rest.trim_start().strip_prefix('=') {
118        Some("") => {
119            return Err(unwritten(
120                text,
121                "`=` names no destination; drop it to read the reading under \
122                 `data.representations`",
123            ));
124        }
125        Some(path) => Some(PathBuf::from(path)),
126        None if rest.trim().is_empty() => None,
127        None => {
128            return Err(unwritten(
129                text,
130                &format!("`{}` follows the call", rest.trim()),
131            ));
132        }
133    };
134    Ok(Call { name, args, dest })
135}
136
137/// Every call resolved. An answer keys each reading by its name, so a name asked twice
138/// would print one key twice and a JSON reader would keep only the last; it refuses.
139pub fn asked_all(calls: &[Call]) -> Result<Vec<Asked>, CliError> {
140    let mut out: Vec<Asked> = Vec::with_capacity(calls.len());
141    for call in calls {
142        let one = asked(call)?;
143        if out.iter().any(|held| held.name == one.name) {
144            return Err(CliError::usage(
145                "query.duplicate",
146                format!(
147                    "`{}` is asked more than once; ask each reading once, every argument in \
148                 its one call",
149                    one.name
150                ),
151            ));
152        }
153        out.push(one);
154    }
155    Ok(out)
156}
157
158impl Asked {
159    pub fn path(&self) -> Option<&Path> {
160        match &self.dest {
161            Some(Destination::File(path)) => Some(path),
162            Some(Destination::Alsa { .. }) | None => None,
163        }
164    }
165}
166
167/// The reading a call names, each argument read as that reading takes it.
168pub fn asked(call: &Call) -> Result<Asked, CliError> {
169    let name = call.name.as_str();
170    if let Some(write) = retired(name) {
171        return Err(CliError::usage(
172            "query.retired",
173            format!("`{name}` left the language; write `{write}`"),
174        ));
175    }
176    let Some(row) = READINGS.iter().find(|row| row.name == name) else {
177        let names: Vec<&str> = READINGS.iter().map(|row| row.name).collect();
178        return Err(CliError::usage(
179            "query.unknown_representation",
180            format!(
181                "unknown representation `{name}`; the representations are {}",
182                names.join(", ")
183            ),
184        ));
185    };
186    let (mut given, mut node, mut brief, mut skim) = (Given::default(), None, false, false);
187    let mut buffer = None;
188    for (key, raw) in &call.args {
189        let Some(arg) = row.takes.iter().find(|arg| arg.key() == key) else {
190            let keys: Vec<&str> = row.takes.iter().map(|arg| arg.key()).collect();
191            return Err(CliError::usage(
192                "query.unknown_argument",
193                match keys[..] {
194                    [] => format!("`{name}` takes no arguments, not `{key}`"),
195                    _ => format!("`{name}` takes {}, not `{key}`", keys.join(", ")),
196                },
197            ));
198        };
199        match arg {
200            Arg::Peaks => given.peaks = count(raw, key)?,
201            Arg::Depth => given.depth = count(raw, key)?,
202            Arg::Frame => given.frame = Some(seconds(raw, key)?),
203            Arg::Oversample => given.oversample = factor(raw)?,
204            Arg::Brief => brief = switch(raw, key)?,
205            Arg::Skim => skim = switch(raw, key)?,
206            Arg::Node => node = Some(self::node(raw)?),
207            Arg::Buffer => buffer = Some(microseconds(raw, key)?),
208            Arg::Floor => given.floor_db = decibels(raw, key)?,
209            Arg::Norm => given.norm = norm(raw)?,
210            Arg::Pairs => given.max_pairs = count(raw, key)?,
211            Arg::Window => given.window = Some(seconds(raw, key)?),
212            Arg::Curve => given.curve = switch(raw, key)?,
213            Arg::Bpm => given.bpm = Some(tempo(raw, key)?),
214            Arg::Cell => given.cell_secs = seconds(raw, key)?,
215            Arg::Smooth => given.smooth_secs = seconds(raw, key)?,
216        }
217    }
218    if row.takes.contains(&Arg::Node) && node.is_none() {
219        return Err(CliError::usage(
220            "query.missing_argument",
221            format!("`{name}` reads one instance's {name}; name it, as `{name}(node=@voice)`"),
222        ));
223    }
224    Ok(Asked {
225        name: call.name.clone(),
226        representation: row.representation(&given),
227        dest: destination(name, call.dest.as_deref(), buffer)?,
228        node,
229        brief,
230        skim,
231    })
232}
233
234/// `alsa:` opens a device name, and only a device has a buffer to size.
235fn destination(
236    name: &str,
237    dest: Option<&Path>,
238    buffer: Option<f64>,
239) -> Result<Option<Destination>, CliError> {
240    let device = dest.and_then(|d| d.to_str()?.strip_prefix("alsa:"));
241    match (dest, device, buffer) {
242        (_, Some(""), _) => Err(CliError::usage(
243            "destination.no_device",
244            format!(
245                "`{name}=alsa:` names no device; write an ALSA device after it, as \
246                 `alsa:default` or `alsa:hw:1`"
247            ),
248        )),
249        (_, Some(device), _) => Ok(Some(Destination::Alsa {
250            device: device.to_string(),
251            buffer_secs: buffer.unwrap_or(DEFAULT_BUFFER_SECS),
252        })),
253        (_, None, Some(_)) => Err(CliError::usage(
254            "query.buffer_without_device",
255            format!(
256                "`buffer` sizes a live device's buffer, and `{name}` names none; write \
257                 `{name}(buffer=...)=alsa:<device>`, or drop `buffer`"
258            ),
259        )),
260        (dest, None, None) => Ok(dest.map(|d| Destination::File(d.to_path_buf()))),
261    }
262}
263
264/// `raw` split at each `sep` no parenthesis encloses.
265fn split_outside(raw: &str, sep: char) -> Result<Vec<&str>, CliError> {
266    let (mut depth, mut from, mut out) = (0i32, 0, Vec::new());
267    for (at, c) in raw.char_indices() {
268        match c {
269            '(' => depth += 1,
270            ')' => depth -= 1,
271            c if c == sep && depth == 0 => {
272                out.push(&raw[from..at]);
273                from = at + c.len_utf8();
274            }
275            _ => {}
276        }
277        if depth < 0 {
278            return Err(unwritten(raw, "a `)` closes nothing"));
279        }
280    }
281    out.push(&raw[from..]);
282    Ok(out)
283}
284
285/// The byte of the `)` that closes the `(` `text` opens with.
286fn closing(text: &str) -> Option<usize> {
287    let mut depth = 0i32;
288    for (at, c) in text.char_indices() {
289        match c {
290            '(' => depth += 1,
291            ')' => {
292                depth -= 1;
293                if depth == 0 {
294                    return Some(at);
295                }
296            }
297            _ => {}
298        }
299    }
300    None
301}
302
303fn unwritten(text: &str, why: &str) -> CliError {
304    CliError::usage(
305        "query.malformed",
306        format!(
307            "`{text}` is no reading: {why}. write a call, as `spectrum(peaks=8, frame=50ms)`, \
308             `=path` after it to write it to a file"
309        ),
310    )
311}
312
313pub fn wav_path(raw: &str) -> Result<PathBuf, CliError> {
314    match is_wav(Path::new(raw)) {
315        true => Ok(PathBuf::from(raw)),
316        false => Err(CliError::usage(
317            "analyze.not_wav",
318            format!("`{raw}` is no `.wav` file; mp3 and every other format are out of scope"),
319        )),
320    }
321}
322
323fn count(raw: &str, key: &str) -> Result<usize, CliError> {
324    raw.parse::<usize>().map_err(|_| {
325        CliError::usage(
326            "query.bad_value",
327            format!("`{key}` needs a whole count, got `{raw}`"),
328        )
329    })
330}
331
332fn factor(raw: &str) -> Result<u32, CliError> {
333    match raw.parse::<u32>() {
334        Ok(k) if (2..=16).contains(&k) && k.is_power_of_two() => Ok(k),
335        _ => Err(CliError::usage(
336            "query.bad_value",
337            format!("`oversample` needs a power of two from 2 to 16, got `{raw}`"),
338        )),
339    }
340}
341
342/// A positive time the language spells, `50ms` or `1.5s`; a bare number is seconds.
343pub fn seconds(raw: &str, key: &str) -> Result<f64, CliError> {
344    let held = match sva_ast::tokenize(raw).as_deref() {
345        Ok([one]) => match one.kind {
346            sva_ast::TokenKind::Num(v)
347            | sva_ast::TokenKind::Time(v, sva_ast::SpanUnit::Seconds) => Some(v),
348            _ => None,
349        },
350        _ => None,
351    };
352    match held {
353        Some(v) if v > 0.0 && v.is_finite() => Ok(v),
354        _ => Err(CliError::usage(
355            "query.bad_value",
356            format!("`{key}` needs a positive time, as `50ms` or `0.05`, got `{raw}`"),
357        )),
358    }
359}
360
361/// A bare positive number of beats per minute.
362fn tempo(raw: &str, key: &str) -> Result<f64, CliError> {
363    let held = match sva_ast::tokenize(raw).as_deref() {
364        Ok([one]) => match one.kind {
365            sva_ast::TokenKind::Num(v) => Some(v),
366            _ => None,
367        },
368        _ => None,
369    };
370    match held {
371        Some(v) if v > 0.0 && v.is_finite() => Ok(v),
372        _ => Err(CliError::usage(
373            "query.bad_value",
374            format!("`{key}` needs a positive tempo in beats per minute, as `140`, got `{raw}`"),
375        )),
376    }
377}
378
379/// A device takes its buffer in whole microseconds, as a C `int`.
380fn microseconds(raw: &str, key: &str) -> Result<f64, CliError> {
381    let secs = seconds(raw, key)?;
382    match (1.0..=f64::from(i32::MAX)).contains(&(secs * 1e6).round()) {
383        true => Ok(secs),
384        false => Err(CliError::usage(
385            "query.bad_value",
386            format!(
387                "`{key}` is a whole number of microseconds from 1us to {}s, got `{raw}`",
388                i32::MAX / 1_000_000
389            ),
390        )),
391    }
392}
393
394/// A level the language spells in decibels, `-40db`; a bare number names no unit.
395fn decibels(raw: &str, key: &str) -> Result<f64, CliError> {
396    use sva_ast::{LogUnit::Decibels, TokenKind};
397    let kinds: Option<Vec<TokenKind>> = sva_ast::tokenize(raw)
398        .ok()
399        .map(|tokens| tokens.into_iter().map(|t| t.kind).collect());
400    let held = match kinds.as_deref() {
401        Some([TokenKind::Log(v, Decibels)]) => Some(*v),
402        Some([TokenKind::Minus, TokenKind::Log(v, Decibels)]) => Some(-v),
403        _ => None,
404    };
405    match held {
406        Some(v) if v.is_finite() => Ok(v),
407        _ => Err(CliError::usage(
408            "query.bad_value",
409            format!("`{key}` needs a level in decibels, as `-40db`, got `{raw}`"),
410        )),
411    }
412}
413
414fn norm(raw: &str) -> Result<Norm, CliError> {
415    [Norm::L1, Norm::L2]
416        .into_iter()
417        .find(|n| n.key() == raw)
418        .ok_or_else(|| {
419            CliError::usage(
420                "query.bad_value",
421                format!("`norm` is `l1` or `l2`, got `{raw}`"),
422            )
423        })
424}
425
426fn switch(raw: &str, key: &str) -> Result<bool, CliError> {
427    match raw {
428        "1" => Ok(true),
429        "0" => Ok(false),
430        _ => Err(CliError::usage(
431            "query.bad_value",
432            format!("`{key}` is `1` or `0`, got `{raw}`"),
433        )),
434    }
435}
436
437/// A ref as the language writes one, `@voice`.
438fn node(raw: &str) -> Result<String, CliError> {
439    match raw.strip_prefix('@') {
440        Some(path) if !path.is_empty() => Ok(path.to_string()),
441        _ => Err(CliError::usage(
442            "query.bad_value",
443            format!("`node` names an instance as a ref, as `@voice`, not `{raw}`"),
444        )),
445    }
446}