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