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::{DEFAULT_FRAME_SECS, Representation};
6
7use crate::cli_error::CliError;
8
9pub const REPRESENTATIONS: [&str; 17] = [
10    "lines",
11    "atoms",
12    "spectrum",
13    "envelope",
14    "derivative",
15    "samples",
16    "ledger",
17    "pitch",
18    "formants",
19    "stereo",
20    "bands",
21    "crest",
22    "loudness",
23    "onsets",
24    "alias",
25    "bindings",
26    "arguments",
27];
28
29pub const RETIRED: [(&str, &str); 2] = [
30    ("exact-envelope", "envelope, symbolic on a closed form"),
31    ("exact-derivative", "derivative, symbolic on a closed form"),
32];
33
34pub const DEFAULT_OVERSAMPLE: u32 = 4;
35pub const DEFAULT_MAX_PEAKS: usize = 16;
36pub const DEFAULT_LEDGER_DEPTH: usize = 3;
37
38/// One reading written as a call, `spectrum(peaks=8, frame=50ms)=out.json`: its name, its
39/// arguments as written, and where it goes.
40#[derive(Clone, Debug, PartialEq)]
41pub struct Call {
42    pub name: String,
43    pub args: Vec<(String, String)>,
44    /// No destination is stdout.
45    pub dest: Option<PathBuf>,
46}
47
48/// A call resolved to the reading it names and the arguments only a front end reads.
49#[derive(Clone, Debug, PartialEq)]
50pub struct Asked {
51    pub name: String,
52    pub representation: Representation,
53    pub dest: Option<PathBuf>,
54    /// `bindings(node=@voice)`: the instance whose bindings are read.
55    pub node: Option<String>,
56    /// `ledger(brief=1)`: only the rows that clipped.
57    pub brief: bool,
58    /// `ledger(skim=1)`: each row's level alone.
59    pub skim: bool,
60}
61
62pub fn is_wav(path: &Path) -> bool {
63    path.extension()
64        .is_some_and(|e| e.eq_ignore_ascii_case("wav"))
65}
66
67pub fn retired(name: &str) -> Option<&'static str> {
68    RETIRED
69        .iter()
70        .find(|(gone, _)| *gone == name)
71        .map(|(_, write)| *write)
72}
73
74/// A comma list of calls; a comma inside a call's parentheses separates its arguments.
75pub fn calls(text: &str) -> Result<Vec<Call>, CliError> {
76    split_outside(text, ',')?
77        .into_iter()
78        .map(|one| call(one.trim()))
79        .collect()
80}
81
82/// `name`, `name(k=v, ...)`, either followed by `=path`.
83pub fn call(text: &str) -> Result<Call, CliError> {
84    let head_end = text.find(['(', '=']).unwrap_or(text.len());
85    let name = text[..head_end].trim().to_string();
86    if name.is_empty() {
87        return Err(unwritten(text, "it names no reading"));
88    }
89    let mut rest = &text[head_end..];
90    let mut args = Vec::new();
91    if rest.starts_with('(') {
92        let close = closing(rest).ok_or_else(|| unwritten(text, "its `(` is never closed"))?;
93        let inner = rest[1..close].trim();
94        if !inner.is_empty() {
95            for arg in split_outside(inner, ',')? {
96                let Some((key, value)) = arg.split_once('=') else {
97                    return Err(unwritten(
98                        text,
99                        &format!("`{}` is no named argument; write `key=value`", arg.trim()),
100                    ));
101                };
102                args.push((key.trim().to_string(), value.trim().to_string()));
103            }
104        }
105        rest = &rest[close + 1..];
106    }
107    let dest = match rest.trim_start().strip_prefix('=') {
108        Some("") => {
109            return Err(unwritten(
110                text,
111                "`=` names no destination; drop it to read the reading under \
112                 `data.representations`",
113            ));
114        }
115        Some(path) => Some(PathBuf::from(path)),
116        None if rest.trim().is_empty() => None,
117        None => {
118            return Err(unwritten(
119                text,
120                &format!("`{}` follows the call", rest.trim()),
121            ));
122        }
123    };
124    Ok(Call { name, args, dest })
125}
126
127/// The reading a call names, each argument read as that reading takes it.
128pub fn asked(call: &Call) -> Result<Asked, CliError> {
129    let name = call.name.as_str();
130    if let Some(write) = retired(name) {
131        return Err(CliError::Usage(format!(
132            "`{name}` left the language; write `{write}`"
133        )));
134    }
135    let mut out = Asked {
136        name: call.name.clone(),
137        representation: Representation::from_name(name).unwrap_or(Representation::Samples),
138        dest: call.dest.clone(),
139        node: None,
140        brief: false,
141        skim: false,
142    };
143    let (mut peaks, mut frame, mut depth, mut oversample) = (
144        DEFAULT_MAX_PEAKS,
145        None,
146        DEFAULT_LEDGER_DEPTH,
147        DEFAULT_OVERSAMPLE,
148    );
149    let takes: &[&str] = match name {
150        "spectrum" | "pitch" | "formants" => &["peaks", "frame"],
151        "envelope" | "stereo" => &["frame"],
152        "ledger" => &["depth", "brief", "skim"],
153        "alias" => &["oversample"],
154        "bindings" => &["node"],
155        known if Representation::from_name(known).is_some() => &[],
156        other => {
157            return Err(CliError::Usage(format!(
158                "unknown representation `{other}`; the representations are {}",
159                REPRESENTATIONS.join(", ")
160            )));
161        }
162    };
163    for (key, raw) in &call.args {
164        if !takes.contains(&key.as_str()) {
165            return Err(CliError::Usage(match takes {
166                [] => format!("`{name}` takes no arguments, not `{key}`"),
167                some => format!("`{name}` takes {}, not `{key}`", some.join(", ")),
168            }));
169        }
170        match key.as_str() {
171            "peaks" => peaks = count(raw, key)?,
172            "depth" => depth = count(raw, key)?,
173            "frame" => frame = Some(seconds(raw, key)?),
174            "oversample" => oversample = factor(raw)?,
175            "brief" => out.brief = switch(raw, key)?,
176            "skim" => out.skim = switch(raw, key)?,
177            _ => out.node = Some(node(raw)?),
178        }
179    }
180    if name == "bindings" && out.node.is_none() {
181        return Err(CliError::Usage(
182            "`bindings` reads one instance's bindings; name it, as `bindings(node=@voice)`"
183                .to_string(),
184        ));
185    }
186    let framed = frame.unwrap_or(DEFAULT_FRAME_SECS);
187    out.representation = match name {
188        "spectrum" => Representation::Spectrum {
189            max_peaks: peaks,
190            frame_secs: frame,
191        },
192        "envelope" => Representation::Envelope { frame_secs: frame },
193        "pitch" => Representation::Pitch {
194            max_notes: peaks,
195            frame_secs: framed,
196        },
197        "formants" => Representation::Formants {
198            max_formants: peaks,
199            frame_secs: framed,
200        },
201        "stereo" => Representation::Stereo { frame_secs: framed },
202        "ledger" => Representation::Ledger { depth },
203        "alias" => Representation::Alias { oversample },
204        _ => out.representation,
205    };
206    Ok(out)
207}
208
209/// `raw` split at each `sep` no parenthesis encloses.
210fn split_outside(raw: &str, sep: char) -> Result<Vec<&str>, CliError> {
211    let (mut depth, mut from, mut out) = (0i32, 0, Vec::new());
212    for (at, c) in raw.char_indices() {
213        match c {
214            '(' => depth += 1,
215            ')' => depth -= 1,
216            c if c == sep && depth == 0 => {
217                out.push(&raw[from..at]);
218                from = at + c.len_utf8();
219            }
220            _ => {}
221        }
222        if depth < 0 {
223            return Err(unwritten(raw, "a `)` closes nothing"));
224        }
225    }
226    out.push(&raw[from..]);
227    Ok(out)
228}
229
230/// The byte of the `)` that closes the `(` `text` opens with.
231fn closing(text: &str) -> Option<usize> {
232    let mut depth = 0i32;
233    for (at, c) in text.char_indices() {
234        match c {
235            '(' => depth += 1,
236            ')' => {
237                depth -= 1;
238                if depth == 0 {
239                    return Some(at);
240                }
241            }
242            _ => {}
243        }
244    }
245    None
246}
247
248fn unwritten(text: &str, why: &str) -> CliError {
249    CliError::Usage(format!(
250        "`{text}` is no reading: {why}. write a call, as `spectrum(peaks=8, frame=50ms)`, \
251         `=path` after it to write it to a file"
252    ))
253}
254
255pub fn wav_path(raw: &str) -> Result<PathBuf, CliError> {
256    match is_wav(Path::new(raw)) {
257        true => Ok(PathBuf::from(raw)),
258        false => Err(CliError::Usage(format!(
259            "`{raw}` is no `.wav` file; mp3 and every other format are out of scope"
260        ))),
261    }
262}
263
264fn count(raw: &str, key: &str) -> Result<usize, CliError> {
265    raw.parse::<usize>()
266        .map_err(|_| CliError::Usage(format!("`{key}` needs a whole count, got `{raw}`")))
267}
268
269fn factor(raw: &str) -> Result<u32, CliError> {
270    match raw.parse::<u32>() {
271        Ok(k) if (2..=16).contains(&k) && k.is_power_of_two() => Ok(k),
272        _ => Err(CliError::Usage(format!(
273            "`oversample` needs a power of two from 2 to 16, got `{raw}`"
274        ))),
275    }
276}
277
278/// A positive time the language spells, `50ms` or `1.5s`; a bare number is seconds.
279pub fn seconds(raw: &str, key: &str) -> Result<f64, CliError> {
280    let held = match sva_ast::tokenize(raw).as_deref() {
281        Ok([one]) => match one.kind {
282            sva_ast::TokenKind::Num(v)
283            | sva_ast::TokenKind::Time(v, sva_ast::SpanUnit::Seconds) => Some(v),
284            _ => None,
285        },
286        _ => None,
287    };
288    match held {
289        Some(v) if v > 0.0 && v.is_finite() => Ok(v),
290        _ => Err(CliError::Usage(format!(
291            "`{key}` needs a positive time, as `50ms` or `0.05`, got `{raw}`"
292        ))),
293    }
294}
295
296fn switch(raw: &str, key: &str) -> Result<bool, CliError> {
297    match raw {
298        "1" => Ok(true),
299        "0" => Ok(false),
300        _ => Err(CliError::Usage(format!(
301            "`{key}` is `1` or `0`, got `{raw}`"
302        ))),
303    }
304}
305
306/// A ref as the language writes one, `@voice`.
307fn node(raw: &str) -> Result<String, CliError> {
308    match raw.strip_prefix('@') {
309        Some(path) if !path.is_empty() => Ok(path.to_string()),
310        _ => Err(CliError::Usage(format!(
311            "`node` names an instance as a ref, as `@voice`, not `{raw}`"
312        ))),
313    }
314}