Skip to main content

ferraus_core/
op.rs

1//! Operation specs: the single definition of every command. The CLI, `fx` stages,
2//! pipeline stages, JSON Schemas, MCP tools, `describe --agent`, and the docs are all
3//! generated from these, so parameter names and help text are written exactly once.
4
5use crate::buffer::AudioBuffer;
6use crate::ctx::OpCtx;
7use crate::error::{Error, Result};
8use crate::units::{self, Gain, Grid, SyncTime, Time, TimeRange};
9use serde::Serialize;
10use serde_json::{Map, Value};
11use std::collections::BTreeMap;
12use std::path::PathBuf;
13
14/// The value type of a parameter. Every type is parsed from a string by the shared
15/// unit parser, so CLI flags, fx strings, pipeline YAML, and MCP JSON accept the same text.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ParamKind {
19    /// A presence flag; `true`/`false`/`on`/`off` also accepted in fx and pipelines.
20    Flag,
21    /// `on`/`off`, `true`/`false`.
22    Switch,
23    Int,
24    Float,
25    Str,
26    Path,
27    /// A time point or duration (`1.5s`, `4b`, `01:23`).
28    Time,
29    /// `start..end`.
30    Range,
31    /// `-6dB`, `0.5x`, `-14LUFS`, `-1dBTP`.
32    Gain,
33    /// `120Hz`, `2.5kHz`, `C4`.
34    Freq,
35    /// `4:1`.
36    Ratio,
37    /// `80%` or `0.8`.
38    Percent,
39    /// `1/16`, `1/8T`.
40    Grid,
41    /// `80Hz..8kHz`, `C2..C6`.
42    FreqRange,
43    /// One of a fixed set of words.
44    Choice,
45    /// Cents or semitones.
46    Pitch,
47    /// Free-form list (comma separated).
48    List,
49}
50
51impl ParamKind {
52    pub fn type_name(self) -> &'static str {
53        match self {
54            ParamKind::Flag => "flag",
55            ParamKind::Switch => "on|off",
56            ParamKind::Int => "integer",
57            ParamKind::Float => "number",
58            ParamKind::Str => "text",
59            ParamKind::Path => "path",
60            ParamKind::Time => "time",
61            ParamKind::Range => "range",
62            ParamKind::Gain => "gain",
63            ParamKind::Freq => "frequency",
64            ParamKind::Ratio => "ratio",
65            ParamKind::Percent => "percent",
66            ParamKind::Grid => "grid",
67            ParamKind::FreqRange => "frequency range",
68            ParamKind::Choice => "choice",
69            ParamKind::Pitch => "cents|semitones",
70            ParamKind::List => "list",
71        }
72    }
73
74    /// Validate a raw value without needing an input.
75    pub fn validate(self, raw: &str, choices: &[&str]) -> Result<()> {
76        // `auto` and `off` are accepted sentinels for measured kinds; ops resolve them
77        // (the `opt_*` accessors return None for them).
78        if matches!(raw, "auto" | "off")
79            && matches!(
80                self,
81                ParamKind::Time
82                    | ParamKind::Gain
83                    | ParamKind::Freq
84                    | ParamKind::Percent
85                    | ParamKind::Ratio
86                    | ParamKind::Pitch
87                    | ParamKind::FreqRange
88                    | ParamKind::Range
89            )
90        {
91            return Ok(());
92        }
93        match self {
94            ParamKind::Flag | ParamKind::Switch => parse_bool(raw).map(|_| ()),
95            ParamKind::Int => {
96                raw.trim().parse::<i64>().map(|_| ()).map_err(|_| Error::usage(format!("'{raw}' is not an integer")))
97            }
98            ParamKind::Float => {
99                raw.trim().parse::<f64>().map(|_| ()).map_err(|_| Error::usage(format!("'{raw}' is not a number")))
100            }
101            ParamKind::Str | ParamKind::Path | ParamKind::List => Ok(()),
102            ParamKind::Time => {
103                if raw.contains('/') {
104                    Grid::parse(raw).map(|_| ())
105                } else {
106                    Time::parse(raw).map(|_| ())
107                }
108            }
109            ParamKind::Range => TimeRange::parse(raw).map(|_| ()),
110            ParamKind::Gain => Gain::parse(raw).map(|_| ()),
111            ParamKind::Freq => units::parse_freq(raw).map(|_| ()),
112            ParamKind::Ratio => units::parse_ratio(raw).map(|_| ()),
113            ParamKind::Percent => units::parse_percent(raw).map(|_| ()),
114            ParamKind::Grid => {
115                if raw == "auto" || raw == "off" {
116                    Ok(())
117                } else {
118                    Grid::parse(raw).map(|_| ())
119                }
120            }
121            ParamKind::Pitch => units::parse_cents(raw).map(|_| ()),
122            ParamKind::FreqRange => units::parse_num_range(raw, units::parse_freq).map(|_| ()),
123            ParamKind::Choice => {
124                if choices.is_empty() || choices.contains(&raw) {
125                    Ok(())
126                } else {
127                    Err(Error::usage(format!("'{raw}' is not one of: {}", choices.join(", "))))
128                }
129            }
130        }
131    }
132}
133
134/// Definition of one parameter.
135#[derive(Clone, Debug, Serialize)]
136pub struct ParamSpec {
137    /// kebab-case name; the CLI flag is `--<name>`, pipeline keys use underscores.
138    pub name: &'static str,
139    pub kind: ParamKind,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub default: Option<&'static str>,
142    pub help: &'static str,
143    #[serde(skip_serializing_if = "<[_]>::is_empty")]
144    pub choices: &'static [&'static str],
145    pub repeatable: bool,
146    pub required: bool,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub short: Option<char>,
149    /// Allowed numeric range for documentation, e.g. "8..64".
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub range: Option<&'static str>,
152    /// Hidden from the compact agent catalog (still documented in full reference).
153    #[serde(skip)]
154    pub advanced: bool,
155}
156
157/// Start a parameter definition: `param("threshold", ParamKind::Gain).default("-18dB").help("...")`.
158pub fn param(name: &'static str, kind: ParamKind) -> ParamSpec {
159    ParamSpec {
160        name,
161        kind,
162        default: None,
163        help: "",
164        choices: &[],
165        repeatable: false,
166        required: false,
167        short: None,
168        range: None,
169        advanced: false,
170    }
171}
172
173impl ParamSpec {
174    pub fn default(mut self, d: &'static str) -> Self {
175        self.default = Some(d);
176        self
177    }
178    pub fn help(mut self, h: &'static str) -> Self {
179        self.help = h;
180        self
181    }
182    pub fn choices(mut self, c: &'static [&'static str]) -> Self {
183        self.choices = c;
184        self
185    }
186    pub fn repeatable(mut self) -> Self {
187        self.repeatable = true;
188        self
189    }
190    pub fn required(mut self) -> Self {
191        self.required = true;
192        self
193    }
194    pub fn short(mut self, c: char) -> Self {
195        self.short = Some(c);
196        self
197    }
198    pub fn range(mut self, r: &'static str) -> Self {
199        self.range = Some(r);
200        self
201    }
202    pub fn advanced(mut self) -> Self {
203        self.advanced = true;
204        self
205    }
206    /// Pipeline/JSON key (`retune-speed` → `retune_speed`).
207    pub fn key(&self) -> String {
208        self.name.replace('-', "_")
209    }
210}
211
212/// Command categories (also the groups in the appendix command reference).
213#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, PartialOrd, Ord)]
214#[serde(rename_all = "snake_case")]
215pub enum Category {
216    Inspect,
217    Edit,
218    Dynamics,
219    Tone,
220    Space,
221    Restoration,
222    Panning,
223    Clean,
224    Separate,
225    Pitch,
226    Midi,
227    Score,
228    Chains,
229    Plugins,
230    Mastering,
231    Media,
232    Models,
233    Agents,
234    System,
235    Docs,
236}
237
238impl Category {
239    pub fn title(self) -> &'static str {
240        match self {
241            Category::Inspect => "Inspect",
242            Category::Edit => "Edit",
243            Category::Dynamics => "Dynamics",
244            Category::Tone => "Tone",
245            Category::Space => "Space and time",
246            Category::Restoration => "Restoration",
247            Category::Panning => "Panning",
248            Category::Clean => "Clean",
249            Category::Separate => "Separate",
250            Category::Pitch => "Pitch",
251            Category::Midi => "MIDI",
252            Category::Score => "Scores",
253            Category::Chains => "Chains",
254            Category::Plugins => "Plugins and sounds",
255            Category::Mastering => "Mastering",
256            Category::Media => "Media bridge",
257            Category::Models => "Models and system",
258            Category::Agents => "Agents",
259            Category::System => "System",
260            Category::Docs => "Docs",
261        }
262    }
263}
264
265/// How many positional inputs a command takes and of what kind.
266#[derive(Clone, Copy, Debug, Serialize)]
267pub struct Inputs {
268    pub min: usize,
269    /// `None` = unbounded.
270    pub max: Option<usize>,
271    pub kind: InputKind,
272}
273
274#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
275#[serde(rename_all = "snake_case")]
276pub enum InputKind {
277    Audio,
278    Midi,
279    Score,
280    Any,
281    None,
282}
283
284impl Inputs {
285    pub const NONE: Inputs = Inputs { min: 0, max: Some(0), kind: InputKind::None };
286    pub const ONE_AUDIO: Inputs = Inputs { min: 1, max: Some(1), kind: InputKind::Audio };
287    pub const MANY_AUDIO: Inputs = Inputs { min: 1, max: None, kind: InputKind::Audio };
288    pub const ONE_MIDI: Inputs = Inputs { min: 1, max: Some(1), kind: InputKind::Midi };
289    pub const MANY_MIDI: Inputs = Inputs { min: 1, max: None, kind: InputKind::Midi };
290    pub const ONE_ANY: Inputs = Inputs { min: 1, max: Some(1), kind: InputKind::Any };
291    pub const MANY_ANY: Inputs = Inputs { min: 1, max: None, kind: InputKind::Any };
292    pub const OPTIONAL_ANY: Inputs = Inputs { min: 0, max: None, kind: InputKind::Any };
293    pub fn exactly(n: usize, kind: InputKind) -> Inputs {
294        Inputs { min: n, max: Some(n), kind }
295    }
296}
297
298/// A worked example, used for docs and doc-tests.
299#[derive(Clone, Debug, Serialize)]
300pub struct Example {
301    pub cmd: &'static str,
302    pub explain: &'static str,
303}
304
305/// Single-input, single-output audio transform (usable as an `fx` stage).
306pub type TransformFn = fn(&Args, AudioBuffer, &mut OpCtx) -> Result<AudioBuffer>;
307/// General command: reads its own inputs via the context and records outputs.
308pub type CustomFn = fn(&Args, &mut OpCtx) -> Result<Value>;
309
310#[derive(Clone, Copy)]
311pub enum Runner {
312    /// One audio buffer in, one out. The executor handles decoding, `--mix`, `--range`
313    /// limiting is per-op, encoding and output naming. Usable inside `fx` and pipelines.
314    Transform(TransformFn),
315    /// Full control: the command reads inputs and writes outputs through `OpCtx`.
316    /// The returned JSON value becomes `result` in the envelope.
317    Custom(CustomFn),
318    /// The `fx` chain op, implemented by the executor itself.
319    Chain,
320}
321
322impl std::fmt::Debug for Runner {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        match self {
325            Runner::Transform(_) => write!(f, "Transform"),
326            Runner::Custom(_) => write!(f, "Custom"),
327            Runner::Chain => write!(f, "Chain"),
328        }
329    }
330}
331
332/// A named preset: a saved set of parameter values.
333#[derive(Clone, Debug, Serialize)]
334pub struct Preset {
335    pub name: &'static str,
336    pub help: &'static str,
337    pub values: &'static [(&'static str, &'static str)],
338}
339
340/// The definition of one command.
341#[derive(Clone, Debug, Serialize)]
342pub struct OpSpec {
343    /// Space-separated command path, e.g. `"denoise profile"`, `"midi quantize"`.
344    pub name: &'static str,
345    pub category: Category,
346    /// One line: what it does.
347    pub summary: &'static str,
348    /// Longer plain-language explanation for people.
349    pub about: &'static str,
350    pub params: Vec<ParamSpec>,
351    pub inputs: Inputs,
352    pub examples: Vec<Example>,
353    pub presets: Vec<Preset>,
354    /// The executor adds a generic `--mix` dry/wet when the op is a Transform that keeps
355    /// the length and channel count, unless the op declares its own `mix` param.
356    pub auto_mix: bool,
357    /// Best-effort quality tier (labelled in docs and results).
358    pub best_effort: bool,
359    /// Other names the command answers to.
360    pub aliases: Vec<&'static str>,
361    /// When set, positional words on the command line fill this parameter instead of
362    /// being input files (`ferraus schema separate`, `ferraus models pull <name>`).
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub positional: Option<&'static str>,
365    #[serde(skip)]
366    pub runner: Runner,
367}
368
369impl OpSpec {
370    pub fn new(name: &'static str, category: Category, summary: &'static str, runner: Runner) -> OpSpec {
371        let inputs = match runner {
372            Runner::Transform(_) | Runner::Chain => Inputs::ONE_AUDIO,
373            Runner::Custom(_) => Inputs::ONE_ANY,
374        };
375        OpSpec {
376            name,
377            category,
378            summary,
379            about: "",
380            params: Vec::new(),
381            inputs,
382            examples: Vec::new(),
383            presets: Vec::new(),
384            auto_mix: matches!(runner, Runner::Transform(_)),
385            best_effort: false,
386            aliases: Vec::new(),
387            positional: None,
388            runner,
389        }
390    }
391
392    pub fn transform(name: &'static str, category: Category, summary: &'static str, f: TransformFn) -> OpSpec {
393        OpSpec::new(name, category, summary, Runner::Transform(f))
394    }
395
396    pub fn custom(name: &'static str, category: Category, summary: &'static str, f: CustomFn) -> OpSpec {
397        OpSpec::new(name, category, summary, Runner::Custom(f))
398    }
399
400    pub fn about(mut self, a: &'static str) -> Self {
401        self.about = a;
402        self
403    }
404    pub fn param(mut self, p: ParamSpec) -> Self {
405        self.params.push(p);
406        self
407    }
408    pub fn params(mut self, ps: impl IntoIterator<Item = ParamSpec>) -> Self {
409        self.params.extend(ps);
410        self
411    }
412    pub fn inputs(mut self, i: Inputs) -> Self {
413        self.inputs = i;
414        self
415    }
416    pub fn example(mut self, cmd: &'static str, explain: &'static str) -> Self {
417        self.examples.push(Example { cmd, explain });
418        self
419    }
420    pub fn preset(
421        mut self,
422        name: &'static str,
423        help: &'static str,
424        values: &'static [(&'static str, &'static str)],
425    ) -> Self {
426        self.presets.push(Preset { name, help, values });
427        self
428    }
429    pub fn no_mix(mut self) -> Self {
430        self.auto_mix = false;
431        self
432    }
433    pub fn best_effort(mut self) -> Self {
434        self.best_effort = true;
435        self
436    }
437    /// Positional words fill parameter `name` (implies no input files).
438    pub fn positional(mut self, name: &'static str) -> Self {
439        self.positional = Some(name);
440        self.inputs = Inputs::NONE;
441        self
442    }
443    pub fn alias(mut self, a: &'static str) -> Self {
444        self.aliases.push(a);
445        self
446    }
447
448    /// Identifier used for MCP tool names and schema files: `denoise profile` → `denoise_profile`.
449    pub fn id(&self) -> String {
450        self.name.replace([' ', '-'], "_")
451    }
452
453    pub fn is_transform(&self) -> bool {
454        matches!(self.runner, Runner::Transform(_))
455    }
456
457    pub fn find_param(&self, name: &str) -> Option<&ParamSpec> {
458        let norm = name.replace('_', "-");
459        self.params.iter().find(|p| p.name == norm)
460    }
461
462    /// All params including the implicit ones the executor adds (`mix`, `preset`).
463    pub fn effective_params(&self) -> Vec<ParamSpec> {
464        let mut ps = self.params.clone();
465        if self.auto_mix && self.find_param("mix").is_none() {
466            ps.push(
467                param("mix", ParamKind::Percent)
468                    .default("100%")
469                    .help("Dry/wet balance: 0% returns the input unchanged, 100% is fully processed"),
470            );
471        }
472        if !self.presets.is_empty() && self.find_param("preset").is_none() {
473            let _names: Vec<&str> = self.presets.iter().map(|p| p.name).collect();
474            ps.push(
475                param("preset", ParamKind::Str)
476                    .help("Load a named preset (built-in or user); explicit flags override its values"),
477            );
478        }
479        ps
480    }
481}
482
483/// Parse booleans the way every surface accepts them.
484pub fn parse_bool(s: &str) -> Result<bool> {
485    match s.trim().to_ascii_lowercase().as_str() {
486        "" | "on" | "true" | "yes" | "1" => Ok(true),
487        "off" | "false" | "no" | "0" => Ok(false),
488        other => Err(Error::usage(format!("'{other}' is not on/off or true/false"))),
489    }
490}
491
492/// Resolved arguments for one invocation of one op: raw strings keyed by kebab-case
493/// parameter name, with defaults filled in. Typed accessors use the shared unit parser.
494#[derive(Clone, Debug, Default)]
495pub struct Args {
496    values: BTreeMap<String, Vec<String>>,
497    /// Names that were given explicitly (not defaults or presets).
498    explicit: Vec<String>,
499}
500
501impl Args {
502    pub fn new() -> Args {
503        Args::default()
504    }
505
506    /// Build args for `spec` from raw key/values (keys may use `_` or `-`), applying
507    /// a preset (if `preset` is set) and then defaults. Validates names and values.
508    pub fn build(
509        spec: &OpSpec,
510        raw: Vec<(String, String)>,
511        user_presets: &dyn Fn(&str, &str) -> Option<Vec<(String, String)>>,
512    ) -> Result<Args> {
513        let params = spec.effective_params();
514        let mut args = Args::new();
515        let mut explicit: BTreeMap<String, Vec<String>> = BTreeMap::new();
516        for (k, v) in raw {
517            let name = k.replace('_', "-");
518            let p = params.iter().find(|p| p.name == name).ok_or_else(|| {
519                let known: Vec<&str> = params.iter().map(|p| p.name).collect();
520                Error::usage(format!("'{}' does not accept '{}'. Accepted: {}", spec.name, name, known.join(", ")))
521                    .with_hint(format!("ferraus schema {}", spec.name))
522            })?;
523            p.kind.validate(&v, p.choices).map_err(|e| Error::usage(format!("--{}: {}", p.name, e.message)))?;
524            let entry = explicit.entry(name.clone()).or_default();
525            if p.repeatable {
526                entry.push(v);
527            } else if p.kind == ParamKind::List && !entry.is_empty() {
528                // a JSON/YAML array (pipeline files, MCP arguments) arrives as one pair per
529                // item; a list parameter keeps all of them, not just the last
530                let joined = format!("{},{}", entry[0], v);
531                *entry = vec![joined];
532            } else {
533                *entry = vec![v];
534            }
535        }
536        // presets: explicit flags override preset values. An op that declares its own
537        // `preset` parameter (e.g. a plugin preset name) handles it itself.
538        let own_preset = spec.params.iter().any(|p| p.name == "preset");
539        if let Some(pname) = explicit.get("preset").and_then(|v| v.first()).cloned().filter(|_| !own_preset) {
540            let values: Vec<(String, String)> = if let Some(p) = spec.presets.iter().find(|p| p.name == pname) {
541                p.values.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
542            } else if let Some(u) = user_presets(spec.name, &pname) {
543                u
544            } else {
545                let names: Vec<&str> = spec.presets.iter().map(|p| p.name).collect();
546                return Err(Error::usage(format!(
547                    "unknown preset '{pname}' for '{}'; built-in presets: {}",
548                    spec.name,
549                    names.join(", ")
550                ))
551                .with_hint(format!("ferraus presets list {}", spec.name)));
552            };
553            for (k, v) in values {
554                let name = k.replace('_', "-");
555                if !explicit.contains_key(&name) {
556                    if let Some(p) = params.iter().find(|p| p.name == name) {
557                        p.kind.validate(&v, p.choices)?;
558                        args.values.insert(name, vec![v]);
559                    }
560                }
561            }
562        }
563        for (k, v) in explicit {
564            args.explicit.push(k.clone());
565            args.values.insert(k, v);
566        }
567        for p in &params {
568            if !args.values.contains_key(p.name) {
569                if let Some(d) = p.default {
570                    args.values.insert(p.name.to_string(), vec![d.to_string()]);
571                } else if p.required {
572                    return Err(Error::usage(format!("'{}' requires --{}", spec.name, p.name))
573                        .with_hint(format!("ferraus docs {}", spec.name)));
574                }
575            }
576        }
577        Ok(args)
578    }
579
580    /// Set a raw value directly (used by ops composing other ops and by tests).
581    pub fn set(&mut self, name: &str, value: impl Into<String>) -> &mut Self {
582        self.values.insert(name.replace('_', "-"), vec![value.into()]);
583        self
584    }
585
586    pub fn with(mut self, name: &str, value: impl Into<String>) -> Self {
587        self.set(name, value);
588        self
589    }
590
591    pub fn remove(&mut self, name: &str) {
592        self.values.remove(&name.replace('_', "-"));
593    }
594
595    pub fn is_explicit(&self, name: &str) -> bool {
596        self.explicit.iter().any(|n| n == name)
597    }
598
599    pub fn has(&self, name: &str) -> bool {
600        self.values.contains_key(name)
601    }
602
603    pub fn raw(&self, name: &str) -> Option<&str> {
604        self.values.get(name).and_then(|v| v.last()).map(|s| s.as_str())
605    }
606
607    pub fn all(&self, name: &str) -> Vec<String> {
608        self.values.get(name).cloned().unwrap_or_default()
609    }
610
611    /// Every value of a repeatable param, also splitting comma lists.
612    pub fn list(&self, name: &str) -> Vec<String> {
613        self.all(name).iter().flat_map(|v| units::split_list(v)).collect()
614    }
615
616    pub fn str(&self, name: &str) -> Option<String> {
617        self.raw(name).map(|s| s.to_string())
618    }
619
620    pub fn str_or(&self, name: &str, d: &str) -> String {
621        self.raw(name).unwrap_or(d).to_string()
622    }
623
624    fn need<'a>(&'a self, name: &str) -> Result<&'a str> {
625        self.raw(name).ok_or_else(|| Error::usage(format!("missing --{name}")))
626    }
627
628    fn ctx<T>(&self, name: &str, r: Result<T>) -> Result<T> {
629        r.map_err(|e| Error::usage(format!("--{name}: {}", e.message)))
630    }
631
632    pub fn flag(&self, name: &str) -> bool {
633        self.raw(name).map(|v| parse_bool(v).unwrap_or(false)).unwrap_or(false)
634    }
635
636    /// `on`/`off` switch; missing means `default`.
637    pub fn switch(&self, name: &str, default: bool) -> bool {
638        self.raw(name).map(|v| parse_bool(v).unwrap_or(default)).unwrap_or(default)
639    }
640
641    pub fn f64(&self, name: &str) -> Result<f64> {
642        let v = self.need(name)?;
643        self.ctx(name, v.trim().parse::<f64>().map_err(|_| Error::usage(format!("'{v}' is not a number"))))
644    }
645
646    pub fn opt_f64(&self, name: &str) -> Result<Option<f64>> {
647        match self.raw(name) {
648            None | Some("auto") => Ok(None),
649            Some(_) => self.f64(name).map(Some),
650        }
651    }
652
653    pub fn int(&self, name: &str) -> Result<i64> {
654        let v = self.need(name)?;
655        self.ctx(name, v.trim().parse::<i64>().map_err(|_| Error::usage(format!("'{v}' is not an integer"))))
656    }
657
658    pub fn usize(&self, name: &str) -> Result<usize> {
659        let v = self.int(name)?;
660        if v < 0 {
661            return Err(Error::usage(format!("--{name} must not be negative")));
662        }
663        Ok(v as usize)
664    }
665
666    pub fn time(&self, name: &str) -> Result<Time> {
667        let v = self.need(name)?;
668        self.ctx(name, Time::parse(v))
669    }
670
671    pub fn opt_time(&self, name: &str) -> Result<Option<Time>> {
672        match self.raw(name) {
673            None => Ok(None),
674            Some(_) => self.time(name).map(Some),
675        }
676    }
677
678    /// Duration in seconds (no `end`-relative values).
679    pub fn seconds(&self, name: &str) -> Result<f64> {
680        let v = self.need(name)?;
681        self.ctx(name, units::parse_duration_s(v))
682    }
683
684    pub fn sync_time(&self, name: &str) -> Result<SyncTime> {
685        let v = self.need(name)?;
686        self.ctx(name, SyncTime::parse(v))
687    }
688
689    pub fn range(&self, name: &str) -> Result<Option<TimeRange>> {
690        match self.raw(name) {
691            None => Ok(None),
692            Some(v) => self.ctx(name, TimeRange::parse(v)).map(Some),
693        }
694    }
695
696    pub fn ranges(&self, name: &str) -> Result<Vec<TimeRange>> {
697        self.all(name).iter().map(|v| self.ctx(name, TimeRange::parse(v))).collect()
698    }
699
700    pub fn gain(&self, name: &str) -> Result<Gain> {
701        let v = self.need(name)?;
702        self.ctx(name, Gain::parse(v))
703    }
704
705    /// Decibels.
706    pub fn db(&self, name: &str) -> Result<f64> {
707        Ok(self.gain(name)?.db())
708    }
709
710    pub fn opt_db(&self, name: &str) -> Result<Option<f64>> {
711        match self.raw(name) {
712            None | Some("auto") => Ok(None),
713            Some(_) => self.db(name).map(Some),
714        }
715    }
716
717    pub fn freq(&self, name: &str) -> Result<f64> {
718        let v = self.need(name)?;
719        self.ctx(name, units::parse_freq(v))
720    }
721
722    pub fn opt_freq(&self, name: &str) -> Result<Option<f64>> {
723        match self.raw(name) {
724            None | Some("auto") | Some("off") => Ok(None),
725            Some(_) => self.freq(name).map(Some),
726        }
727    }
728
729    pub fn freq_range(&self, name: &str) -> Result<(f64, f64)> {
730        let v = self.need(name)?;
731        self.ctx(name, units::parse_num_range(v, units::parse_freq))
732    }
733
734    pub fn ratio(&self, name: &str) -> Result<f64> {
735        let v = self.need(name)?;
736        self.ctx(name, units::parse_ratio(v))
737    }
738
739    /// Fraction (80% → 0.8).
740    pub fn percent(&self, name: &str) -> Result<f64> {
741        let v = self.need(name)?;
742        self.ctx(name, units::parse_percent(v))
743    }
744
745    pub fn grid(&self, name: &str) -> Result<Option<Grid>> {
746        match self.raw(name) {
747            None | Some("auto") | Some("off") => Ok(None),
748            Some(v) => self.ctx(name, Grid::parse(v)).map(Some),
749        }
750    }
751
752    pub fn cents(&self, name: &str) -> Result<f64> {
753        let v = self.need(name)?;
754        self.ctx(name, units::parse_cents(v))
755    }
756
757    pub fn semitones(&self, name: &str) -> Result<f64> {
758        let v = self.need(name)?;
759        self.ctx(name, units::parse_semitones(v))
760    }
761
762    pub fn path(&self, name: &str) -> Option<PathBuf> {
763        self.raw(name).map(PathBuf::from)
764    }
765
766    pub fn choice(&self, name: &str) -> String {
767        self.raw(name).unwrap_or("").to_string()
768    }
769
770    /// Resolved parameters as JSON (for the envelope and sidecar).
771    pub fn to_json(&self) -> Map<String, Value> {
772        let mut m = Map::new();
773        for (k, v) in &self.values {
774            let key = k.replace('-', "_");
775            if v.len() == 1 {
776                m.insert(key, Value::String(v[0].clone()));
777            } else {
778                m.insert(key, Value::Array(v.iter().cloned().map(Value::String).collect()));
779            }
780        }
781        m
782    }
783
784    /// Raw key/value pairs (for re-building, replay, and pipelines).
785    pub fn pairs(&self) -> Vec<(String, String)> {
786        self.values.iter().flat_map(|(k, vs)| vs.iter().map(move |v| (k.clone(), v.clone()))).collect()
787    }
788}
789
790/// Convert a JSON object (MCP, pipeline YAML) into raw key/value pairs.
791pub fn json_to_pairs(v: &Value) -> Result<Vec<(String, String)>> {
792    let mut out = Vec::new();
793    let obj = match v {
794        Value::Object(o) => o,
795        Value::Null => return Ok(out),
796        _ => return Err(Error::usage("arguments must be a JSON object")),
797    };
798    for (k, val) in obj {
799        match val {
800            Value::Null => {}
801            Value::Array(items) => {
802                for it in items {
803                    out.push((k.clone(), scalar_to_string(it)?));
804                }
805            }
806            other => out.push((k.clone(), scalar_to_string(other)?)),
807        }
808    }
809    Ok(out)
810}
811
812fn scalar_to_string(v: &Value) -> Result<String> {
813    Ok(match v {
814        Value::String(s) => s.clone(),
815        Value::Bool(b) => {
816            if *b {
817                "true".into()
818            } else {
819                "false".into()
820            }
821        }
822        Value::Number(n) => n.to_string(),
823        Value::Null => String::new(),
824        Value::Array(a) => a.iter().map(scalar_to_string).collect::<Result<Vec<_>>>()?.join(","),
825        Value::Object(_) => serde_json::to_string(v)?,
826    })
827}
828
829/// Parse an fx stage string: `denoise:auto,reduction=12dB` or `fade:in=1s,out=2s`.
830pub fn parse_stage_string(s: &str) -> Result<(String, Vec<(String, String)>)> {
831    let (name, rest) = match s.split_once(':') {
832        Some((n, r)) => (n.trim(), r),
833        None => (s.trim(), ""),
834    };
835    if name.is_empty() {
836        return Err(Error::usage(format!("empty effect name in '{s}'")));
837    }
838    let mut pairs = Vec::new();
839    // split on commas that start a new key (key=...), so values may contain commas
840    let mut current: Option<(String, String)> = None;
841    for part in rest.split(',') {
842        let part = part.trim();
843        if part.is_empty() {
844            continue;
845        }
846        let looks_like_key = part
847            .split_once('=')
848            .map(|(k, _)| k.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'))
849            .unwrap_or(false)
850            || (!part.contains('=')
851                && part.starts_with(|c: char| c.is_ascii_alphabetic())
852                && part.chars().all(|c| c.is_ascii_alphabetic() || c == '-' || c == '_'));
853        if looks_like_key {
854            if let Some(kv) = current.take() {
855                pairs.push(kv);
856            }
857            match part.split_once('=') {
858                Some((k, v)) => current = Some((k.trim().to_string(), v.trim().to_string())),
859                None => current = Some((part.to_string(), "true".to_string())),
860            }
861        } else if let Some((_, v)) = current.as_mut() {
862            v.push(',');
863            v.push_str(part);
864        } else {
865            return Err(Error::usage(format!("cannot parse effect parameters '{rest}'")));
866        }
867    }
868    if let Some(kv) = current.take() {
869        pairs.push(kv);
870    }
871    Ok((name.to_string(), pairs))
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    fn dummy(_: &Args, b: AudioBuffer, _: &mut OpCtx) -> Result<AudioBuffer> {
879        Ok(b)
880    }
881
882    fn spec() -> OpSpec {
883        OpSpec::transform("compress", Category::Dynamics, "Compressor", dummy)
884            .param(param("threshold", ParamKind::Gain).default("-18dB"))
885            .param(param("ratio", ParamKind::Ratio).default("3:1"))
886            .param(param("detector", ParamKind::Choice).choices(&["rms", "peak"]).default("rms"))
887            .preset("vocal", "", &[("threshold", "-20dB"), ("ratio", "4:1")])
888    }
889
890    #[test]
891    fn defaults_and_explicit() {
892        let s = spec();
893        let a = Args::build(&s, vec![("ratio".into(), "4:1".into())], &|_, _| None).unwrap();
894        assert_eq!(a.ratio("ratio").unwrap(), 4.0);
895        assert_eq!(a.db("threshold").unwrap(), -18.0);
896        assert_eq!(a.percent("mix").unwrap(), 1.0);
897        assert!(a.is_explicit("ratio"));
898        assert!(!a.is_explicit("threshold"));
899    }
900
901    #[test]
902    fn preset_then_override() {
903        let s = spec();
904        let a = Args::build(&s, vec![("preset".into(), "vocal".into()), ("ratio".into(), "2:1".into())], &|_, _| None)
905            .unwrap();
906        assert_eq!(a.db("threshold").unwrap(), -20.0);
907        assert_eq!(a.ratio("ratio").unwrap(), 2.0);
908    }
909
910    #[test]
911    fn json_array_keeps_every_list_item() {
912        let s = OpSpec::transform("mix", Category::Edit, "Mix", dummy).param(param("gains", ParamKind::List));
913        let pairs = json_to_pairs(&serde_json::json!({"gains": ["0dB", "-6dB"]})).unwrap();
914        let a = Args::build(&s, pairs, &|_, _| None).unwrap();
915        assert_eq!(a.list("gains"), vec!["0dB".to_string(), "-6dB".to_string()]);
916    }
917
918    #[test]
919    fn rejects_unknown_and_bad_values() {
920        let s = spec();
921        assert!(Args::build(&s, vec![("nope".into(), "1".into())], &|_, _| None).is_err());
922        assert!(Args::build(&s, vec![("detector".into(), "lol".into())], &|_, _| None).is_err());
923        assert!(Args::build(&s, vec![("threshold".into(), "loud".into())], &|_, _| None).is_err());
924    }
925
926    #[test]
927    fn stage_strings() {
928        let (n, p) = parse_stage_string("denoise:auto").unwrap();
929        assert_eq!(n, "denoise");
930        assert_eq!(p, vec![("auto".to_string(), "true".to_string())]);
931        let (n, p) = parse_stage_string("fade:in=1s,out=2s").unwrap();
932        assert_eq!(n, "fade");
933        assert_eq!(p.len(), 2);
934        let (_, p) = parse_stage_string("eq:band=peak:1kHz:+3dB,band=highpass:80Hz:0dB").unwrap();
935        assert_eq!(p.len(), 2);
936        let (_, p) = parse_stage_string("mix:gains=0dB,-3dB").unwrap();
937        assert_eq!(p, vec![("gains".to_string(), "0dB,-3dB".to_string())]);
938        let (n, p) = parse_stage_string("reverse").unwrap();
939        assert_eq!(n, "reverse");
940        assert!(p.is_empty());
941    }
942}