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