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