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