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