Skip to main content

ferraus_core/
exec.rs

1//! The executor: runs one op with resolved args, handling batches, `--mix`, `fx` chains,
2//! dry runs, idempotent outputs, output encoding overrides, and sidecars. The CLI, the
3//! pipeline runner, and the MCP server all go through `Runtime::invoke`.
4
5use crate::buffer::AudioBuffer;
6use crate::ctx::{AudioIo, CancelToken, Globals, OpCtx, OutputRecord, Progress};
7use crate::envelope::{sha256_file, Envelope, HashedFile, HostInfo, Sidecar, FERRAUS_VERSION, SCHEMA_VERSION};
8use crate::error::{Error, Result};
9use crate::op::{parse_stage_string, Args, OpSpec, Runner};
10use crate::registry::Registry;
11use crate::resample::{resample, Quality};
12use serde_json::{json, Map, Value};
13use std::path::{Path, PathBuf};
14use std::time::Instant;
15
16/// Looks up user presets: (command, preset name) → values.
17pub type PresetLookup = dyn Fn(&str, &str) -> Option<Vec<(String, String)>> + Send + Sync;
18
19pub struct Runtime<'a> {
20    pub registry: &'a Registry,
21    pub io: &'a dyn AudioIo,
22    pub progress: &'a dyn Progress,
23    pub presets: &'a PresetLookup,
24    pub cancel: CancelToken,
25}
26
27/// One request to run a command.
28#[derive(Clone, Debug, Default)]
29pub struct Invocation {
30    pub command: String,
31    pub args: Vec<(String, String)>,
32    pub inputs: Vec<String>,
33    pub globals: Globals,
34    /// The original command line, recorded in sidecars.
35    pub argv: Vec<String>,
36    pub stdin: Option<Vec<u8>>,
37}
38
39/// What `invoke` returns: the envelope plus any bytes destined for stdout (`-o -`).
40pub struct Outcome {
41    pub envelope: Envelope,
42    pub stdout: Option<Vec<u8>>,
43}
44
45fn expand_inputs(inputs: &[String]) -> Result<Vec<PathBuf>> {
46    let mut out = Vec::new();
47    for i in inputs {
48        if i == "-" {
49            out.push(PathBuf::from("-"));
50            continue;
51        }
52        if i.contains(['*', '?', '[']) {
53            let mut matched: Vec<PathBuf> = glob::glob(i)
54                .map_err(|e| Error::usage(format!("bad glob '{i}': {e}")))?
55                .filter_map(|r| r.ok())
56                // provenance sidecars are never inputs
57                .filter(|p| !p.to_string_lossy().ends_with(".ferraus.json"))
58                .collect();
59            matched.sort();
60            if matched.is_empty() {
61                return Err(Error::input(format!("no files match '{i}'")));
62            }
63            out.extend(matched);
64        } else {
65            let p = PathBuf::from(i);
66            out.push(p);
67        }
68    }
69    Ok(out)
70}
71
72impl<'a> Runtime<'a> {
73    pub fn invoke(&self, inv: Invocation) -> Outcome {
74        let start = Instant::now();
75        match self.invoke_inner(inv.clone(), start) {
76            Ok(o) => o,
77            Err(e) => {
78                Outcome { envelope: Envelope::error(&inv.command, e, start.elapsed().as_millis() as u64), stdout: None }
79            }
80        }
81    }
82
83    fn invoke_inner(&self, inv: Invocation, start: Instant) -> Result<Outcome> {
84        let spec = self.registry.require(&inv.command)?;
85        let args = Args::build(spec, inv.args.clone(), self.presets)?;
86        let inputs = expand_inputs(&inv.inputs)?;
87        if inputs.len() < spec.inputs.min {
88            return Err(Error::usage(format!(
89                "'{}' needs {} input file(s), got {}",
90                spec.name,
91                spec.inputs.min,
92                inputs.len()
93            ))
94            .with_hint(format!("ferraus docs {}", spec.name)));
95        }
96        let batch = matches!(spec.inputs.max, Some(1)) && inputs.len() > 1;
97        if let Some(max) = spec.inputs.max {
98            if !batch && inputs.len() > max {
99                return Err(Error::usage(format!("'{}' takes at most {max} input(s)", spec.name)));
100            }
101        }
102        if batch && inv.globals.output.as_deref().map(|o| !o.contains('{')).unwrap_or(false) {
103            return Err(Error::usage("several inputs need --outdir or an -o template such as '{name}.out.wav'"));
104        }
105
106        let groups: Vec<Vec<PathBuf>> =
107            if batch { inputs.iter().map(|i| vec![i.clone()]).collect() } else { vec![inputs.clone()] };
108        let mut envelope = Envelope {
109            ok: true,
110            command: spec.name.to_string(),
111            ferraus_version: FERRAUS_VERSION.to_string(),
112            schema_version: SCHEMA_VERSION,
113            inputs: vec![],
114            outputs: vec![],
115            params: args.to_json(),
116            models: vec![],
117            metrics: Map::new(),
118            warnings: vec![],
119            result: Value::Null,
120            error: None,
121            timing_ms: 0,
122            dry_run: if inv.globals.dry_run { Some(true) } else { None },
123        };
124        let mut results = Vec::new();
125        let mut stdout: Option<Vec<u8>> = None;
126        let mut deterministic = !matches!(spec.runner, Runner::Custom(_)) || inv.globals.deterministic;
127        for (index, group) in groups.into_iter().enumerate() {
128            let item_start = Instant::now();
129            let mut ctx = OpCtx::new(spec.name, group, inv.globals.clone(), self.io, self.progress);
130            ctx.registry = Some(self.registry);
131            ctx.cancel = self.cancel.clone();
132            ctx.index = index;
133            ctx.stdin = inv.stdin.clone();
134            if batch {
135                self.progress.stage(spec.name, index + 1, inputs.len());
136            }
137            let res = self.run_one(spec, &args, &mut ctx);
138            match res {
139                Ok(v) => {
140                    if !inv.globals.dry_run && inv.globals.sidecar {
141                        self.write_sidecars(spec, &args, &ctx, &inv.argv, item_start.elapsed().as_millis() as u64)?;
142                    }
143                    if ctx.deterministic == Some(false) {
144                        deterministic = false;
145                    }
146                    envelope.inputs.append(&mut ctx.input_records);
147                    envelope.outputs.append(&mut ctx.outputs);
148                    envelope.warnings.append(&mut ctx.warnings);
149                    for m in ctx.models.drain(..) {
150                        if !envelope.models.contains(&m) {
151                            envelope.models.push(m);
152                        }
153                    }
154                    for (k, val) in ctx.notes {
155                        envelope.params.insert(k, val);
156                    }
157                    if batch {
158                        let mut item = Map::new();
159                        item.insert("input".into(), json!(inputs[index].display().to_string()));
160                        if !ctx.metrics.is_empty() {
161                            item.insert("metrics".into(), Value::Object(ctx.metrics));
162                        }
163                        if !v.is_null() {
164                            item.insert("result".into(), v);
165                        }
166                        results.push(Value::Object(item));
167                    } else {
168                        envelope.metrics = ctx.metrics;
169                        envelope.result = v;
170                    }
171                    if let Some(b) = ctx.stdout.take() {
172                        stdout.get_or_insert_with(Vec::new).extend(b);
173                    }
174                }
175                Err(e) => {
176                    for t in &ctx.temp_files {
177                        let _ = std::fs::remove_file(t);
178                    }
179                    // a failed command leaves no partial outputs behind (a failed verification
180                    // keeps its report: that output is the point of the run)
181                    if !inv.globals.dry_run && e.kind != crate::error::ErrorKind::Verification {
182                        for o in &ctx.outputs {
183                            if o.path != "-" {
184                                let _ = std::fs::remove_file(&o.path);
185                            }
186                        }
187                        ctx.outputs.clear();
188                    }
189                    if batch {
190                        results.push(json!({"input": inputs[index].display().to_string(), "ok": false, "error": e}));
191                        envelope.ok = false;
192                        if envelope.error.is_none() {
193                            envelope.error = Some(e);
194                        }
195                    } else {
196                        // keep what the op measured (e.g. the failed checks of a verify)
197                        let mut env = Envelope::error(spec.name, e, start.elapsed().as_millis() as u64);
198                        env.params = envelope.params.clone();
199                        env.inputs = std::mem::take(&mut ctx.input_records);
200                        env.metrics = std::mem::take(&mut ctx.metrics);
201                        env.warnings = std::mem::take(&mut ctx.warnings);
202                        return Ok(Outcome { envelope: env, stdout: None });
203                    }
204                }
205            }
206        }
207        if batch {
208            envelope.result = Value::Array(results);
209        }
210        let _ = deterministic;
211        if inv.globals.explain {
212            envelope.metrics.insert("explain".into(), explain(spec, &args));
213        }
214        envelope.timing_ms = start.elapsed().as_millis() as u64;
215        self.progress.log(&format!("done in {} ms", envelope.timing_ms));
216        Ok(Outcome { envelope, stdout })
217    }
218
219    /// Run a single (non-batch) item.
220    fn run_one(&self, spec: &OpSpec, args: &Args, ctx: &mut OpCtx) -> Result<Value> {
221        match spec.runner {
222            Runner::Transform(_) | Runner::Chain => {
223                let out_ext = default_ext(ctx);
224                let out_path = ctx.output_path(None, &out_ext)?;
225                if ctx.globals.dry_run {
226                    ctx.prepare_output(&out_path)?;
227                    ctx.outputs.push(OutputRecord {
228                        path: out_path.display().to_string(),
229                        role: None,
230                        duration_s: None,
231                        sample_rate: None,
232                        channels: None,
233                        extra: Map::new(),
234                    });
235                    let plan = if matches!(spec.runner, Runner::Chain) {
236                        chain_plan(self.registry, args, self.presets)?
237                    } else {
238                        Value::Null
239                    };
240                    return Ok(plan);
241                }
242                // idempotent outputs: same command, params, and inputs → reuse
243                if !ctx.globals.force && !ctx.globals.overwrite && out_path.as_os_str() != "-" && out_path.exists() {
244                    if let Some(v) = self.reuse_existing(spec, args, ctx, &out_path) {
245                        return Ok(v);
246                    }
247                }
248                ctx.prepare_output(&out_path)?;
249                let input = ctx.read_audio(0)?;
250                ctx.set_progress(0.0);
251                let out = match spec.runner {
252                    Runner::Transform(_) => apply_spec(spec, args, input, ctx)?,
253                    Runner::Chain => self.run_chain(args, input, ctx)?,
254                    Runner::Custom(_) => unreachable!(),
255                };
256                let out = conform_output(out, &ctx.globals);
257                let peak = out.peak();
258                if peak > 1.0 && ctx.encode_options(&out_path).bit_depth != Some(32) {
259                    ctx.warn(
260                        "output_clips",
261                        format!("the result peaks at {:+.2} dBFS and will clip in an integer format; lower the level (e.g. --fx \"limit\") or write 32-bit float (--bit-depth 32)", crate::buffer::lin_to_db(peak as f64)),
262                    );
263                }
264                ctx.write_audio(&out_path, &out, None)?;
265                ctx.set_progress(1.0);
266                Ok(ctx.result.take().unwrap_or(Value::Null))
267            }
268            Runner::Custom(f) => {
269                ctx.set_progress(0.0);
270                let v = f(args, ctx)?;
271                ctx.set_progress(1.0);
272                Ok(v)
273            }
274        }
275    }
276
277    fn reuse_existing(&self, spec: &OpSpec, args: &Args, ctx: &mut OpCtx, out: &Path) -> Option<Value> {
278        let sc_path = Sidecar::path_for(out);
279        let sc: Sidecar = serde_json::from_slice(&std::fs::read(&sc_path).ok()?).ok()?;
280        if sc.command != spec.name || sc.ferraus_version != FERRAUS_VERSION {
281            return None;
282        }
283        let params = args.to_json();
284        for (k, v) in &params {
285            if sc.params.get(k) != Some(v) {
286                return None;
287            }
288        }
289        if sc.inputs.len() != ctx.inputs.len() {
290            return None;
291        }
292        for (rec, path) in sc.inputs.iter().zip(&ctx.inputs) {
293            let h = sha256_file(path).ok()?;
294            if rec.sha256.as_deref() != Some(h.as_str()) {
295                return None;
296            }
297        }
298        for o in &sc.outputs {
299            let h = sha256_file(Path::new(&o.path)).ok()?;
300            if o.sha256.as_deref() != Some(h.as_str()) {
301                return None;
302            }
303        }
304        for o in &sc.outputs {
305            ctx.outputs.push(OutputRecord {
306                path: o.path.clone(),
307                role: o.role.clone(),
308                duration_s: o.duration_s,
309                sample_rate: None,
310                channels: None,
311                extra: Map::new(),
312            });
313        }
314        ctx.warn("reused_output", format!("{} already matches this command; pass --force to recompute", out.display()));
315        // keep the existing sidecar untouched
316        ctx.globals.sidecar = false;
317        Some(json!({"reused": true}))
318    }
319
320    fn run_chain(&self, args: &Args, mut buf: AudioBuffer, ctx: &mut OpCtx) -> Result<AudioBuffer> {
321        let stages = chain_stages(args)?;
322        let n = stages.len();
323        for (i, (name, pairs)) in stages.into_iter().enumerate() {
324            ctx.check_cancel()?;
325            self.progress.stage(&name, i + 1, n);
326            buf = run_transform_pairs(self.registry, self.presets, &name, pairs, buf, ctx)?;
327        }
328        Ok(buf)
329    }
330
331    fn write_sidecars(&self, spec: &OpSpec, args: &Args, ctx: &OpCtx, argv: &[String], timing_ms: u64) -> Result<()> {
332        if ctx.outputs.is_empty() || !ctx.globals.sidecar {
333            return Ok(());
334        }
335        let inputs: Vec<HashedFile> = ctx
336            .input_records
337            .iter()
338            .map(|r| HashedFile {
339                path: r.path.clone(),
340                sha256: if r.path == "-" { None } else { sha256_file(Path::new(&r.path)).ok() },
341                duration_s: r.duration_s,
342                role: None,
343            })
344            .collect();
345        let outputs: Vec<HashedFile> = ctx
346            .outputs
347            .iter()
348            .filter(|o| o.path != "-")
349            .map(|o| HashedFile {
350                path: o.path.clone(),
351                sha256: sha256_file(Path::new(&o.path)).ok(),
352                duration_s: o.duration_s,
353                role: o.role.clone(),
354            })
355            .collect();
356        let mut params = args.to_json();
357        for (k, v) in &ctx.notes {
358            params.insert(k.clone(), v.clone());
359        }
360        let deterministic = match ctx.deterministic {
361            Some(false) if !ctx.plugins.is_empty() => json!("unknown"),
362            Some(false) => json!(false),
363            _ => json!(true),
364        };
365        let sc = Sidecar {
366            ferraus_version: FERRAUS_VERSION.into(),
367            schema_version: SCHEMA_VERSION,
368            command: spec.name.into(),
369            argv: argv.to_vec(),
370            params,
371            inputs,
372            outputs: outputs.clone(),
373            models: ctx.models.iter().map(|m| serde_json::to_value(m).unwrap()).collect(),
374            plugins: ctx.plugins.clone(),
375            host: HostInfo::current(&ctx.globals.device, ctx.threads()),
376            deterministic,
377            warnings: ctx.warnings.iter().map(|w| serde_json::to_value(w).unwrap()).collect(),
378            timing_ms,
379        };
380        let text = serde_json::to_vec_pretty(&sc)?;
381        for o in &outputs {
382            let p = Sidecar::path_for(Path::new(&o.path));
383            // never write a sidecar for a sidecar
384            if o.path.ends_with(".ferraus.json") {
385                continue;
386            }
387            std::fs::write(p, &text)?;
388        }
389        Ok(())
390    }
391}
392
393/// Default output extension: same as the input when this build can encode it, else wav.
394pub fn default_ext(ctx: &OpCtx) -> String {
395    if let Some(f) = &ctx.globals.format {
396        return f.clone();
397    }
398    let ext = ctx
399        .inputs
400        .first()
401        .and_then(|p| p.extension())
402        .map(|e| e.to_string_lossy().to_lowercase())
403        .unwrap_or_else(|| "wav".into());
404    if ctx.io.can_encode(&ext) {
405        ext
406    } else {
407        "wav".into()
408    }
409}
410
411/// Apply `--sample-rate` and `--channels` overrides.
412pub fn conform_output(mut buf: AudioBuffer, g: &Globals) -> AudioBuffer {
413    if let Some(sr) = g.sample_rate {
414        if sr != buf.sample_rate {
415            buf = resample(&buf, sr, Quality::High);
416        }
417    }
418    if let Some(ch) = g.channels {
419        if ch != buf.channels() {
420            buf = buf.with_channels(ch);
421        }
422    }
423    buf
424}
425
426/// Run a Transform spec with generic dry/wet handling.
427pub fn apply_spec(spec: &OpSpec, args: &Args, input: AudioBuffer, ctx: &mut OpCtx) -> Result<AudioBuffer> {
428    let f = match spec.runner {
429        Runner::Transform(f) => f,
430        _ => return Err(Error::usage(format!("'{}' cannot be used as an effect stage", spec.name))),
431    };
432    let generic_mix = spec.auto_mix && spec.find_param("mix").is_none();
433    if spec.find_param("mix").is_some() || generic_mix {
434        let m = args.percent("mix").unwrap_or(1.0);
435        if !(0.0..=1.0).contains(&m) {
436            return Err(Error::usage(format!("--mix must be between 0% and 100%, got {}%", (m * 100.0).round())));
437        }
438    }
439    if generic_mix {
440        let m = args.percent("mix").unwrap_or(1.0);
441        if m <= 0.0 {
442            return Ok(input);
443        }
444        if m >= 1.0 {
445            return f(args, input, ctx);
446        }
447        let dry = input.clone();
448        let wet = f(args, input, ctx)?;
449        return Ok(blend(&dry, &wet, m as f32));
450    }
451    f(args, input, ctx)
452}
453
454/// `dry + m·(wet − dry)`, matching lengths and channels to the wet signal.
455pub fn blend(dry: &AudioBuffer, wet: &AudioBuffer, m: f32) -> AudioBuffer {
456    let dry = if dry.channels() != wet.channels() { dry.with_channels(wet.channels()) } else { dry.clone() };
457    let mut out = wet.clone();
458    for (oc, dc) in out.data.iter_mut().zip(&dry.data) {
459        for (i, o) in oc.iter_mut().enumerate() {
460            let d = dc.get(i).copied().unwrap_or(0.0);
461            *o = d + m * (*o - d);
462        }
463    }
464    out
465}
466
467/// Parse `fx` stages from `--fx` strings (and/or `chain` JSON from pipelines).
468pub fn chain_stages(args: &Args) -> Result<Vec<(String, Vec<(String, String)>)>> {
469    let mut stages = Vec::new();
470    for s in args.all("fx") {
471        stages.push(parse_stage_string(&s)?);
472    }
473    for c in args.all("chain") {
474        let v: Value = serde_json::from_str(&c).map_err(|e| Error::usage(format!("chain: {e}")))?;
475        let items = match v {
476            Value::Array(a) => a,
477            other => vec![other],
478        };
479        for it in items {
480            stages.push(stage_from_json(&it)?);
481        }
482    }
483    if stages.is_empty() {
484        return Err(Error::usage("fx needs at least one --fx stage, e.g. --fx \"fade:in=1s\""));
485    }
486    Ok(stages)
487}
488
489/// `{ "op": "compress", "preset": "vocal", "ratio": "4:1" }` or `{op, args: {...}}`.
490pub fn stage_from_json(v: &Value) -> Result<(String, Vec<(String, String)>)> {
491    match v {
492        Value::String(s) => parse_stage_string(s),
493        Value::Object(o) => {
494            let op =
495                o.get("op").and_then(|x| x.as_str()).ok_or_else(|| Error::usage("each chain stage needs an 'op'"))?;
496            let mut pairs = Vec::new();
497            for (k, val) in o {
498                if k == "op" || k == "id" {
499                    continue;
500                }
501                if k == "args" {
502                    pairs.extend(crate::op::json_to_pairs(val)?);
503                } else {
504                    pairs.extend(crate::op::json_to_pairs(&json!({ k: val }))?);
505                }
506            }
507            Ok((op.to_string(), pairs))
508        }
509        _ => Err(Error::usage("chain stages must be strings or objects")),
510    }
511}
512
513fn chain_plan(registry: &Registry, args: &Args, presets: &PresetLookup) -> Result<Value> {
514    let mut plan = Vec::new();
515    for (name, pairs) in chain_stages(args)? {
516        let spec = registry.require(&name)?;
517        if !spec.is_transform() {
518            return Err(Error::usage(format!(
519                "'{name}' cannot be used as an fx stage (it is not a single-input audio effect)"
520            )));
521        }
522        let a = Args::build(spec, pairs, presets)?;
523        plan.push(json!({"op": spec.name, "args": a.to_json()}));
524    }
525    Ok(json!({ "stages": plan }))
526}
527
528/// Run a named transform with raw pairs (used by fx chains, pipelines, and composite ops).
529pub fn run_transform_pairs(
530    registry: &Registry,
531    presets: &PresetLookup,
532    name: &str,
533    pairs: Vec<(String, String)>,
534    buf: AudioBuffer,
535    ctx: &mut OpCtx,
536) -> Result<AudioBuffer> {
537    let spec = registry.require(name)?;
538    if !spec.is_transform() {
539        return Err(Error::usage(format!(
540            "'{name}' cannot be used as an fx stage (it is not a single-input audio effect)"
541        )));
542    }
543    let a = Args::build(spec, pairs, presets)?;
544    apply_spec(spec, &a, buf, ctx)
545}
546
547/// Convenience for composite ops: `run_transform(ctx, "compress", &[("ratio", "1.5:1")], buf)`.
548pub fn run_transform(ctx: &mut OpCtx, name: &str, pairs: &[(&str, &str)], buf: AudioBuffer) -> Result<AudioBuffer> {
549    let registry = ctx.registry.ok_or_else(|| Error::internal("no registry in context"))?;
550    let pairs = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
551    run_transform_pairs(registry, &|_, _| None, name, pairs, buf, ctx)
552}
553
554/// Build Args for a named op from pairs (defaults applied), for composite ops.
555pub fn build_args(ctx: &OpCtx, name: &str, pairs: &[(&str, &str)]) -> Result<Args> {
556    let registry = ctx.registry.ok_or_else(|| Error::internal("no registry in context"))?;
557    let spec = registry.require(name)?;
558    Args::build(spec, pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), &|_, _| None)
559}
560
561/// Run any registered op as a nested step, with its own inputs and output path. Returns
562/// the op's result value and the outputs it wrote. Warnings and models are merged.
563pub fn run_nested(
564    ctx: &mut OpCtx,
565    name: &str,
566    pairs: &[(&str, &str)],
567    inputs: Vec<PathBuf>,
568    output: Option<&Path>,
569) -> Result<(Value, Vec<OutputRecord>)> {
570    let registry = ctx.registry.ok_or_else(|| Error::internal("no registry in context"))?;
571    let spec = registry.require(name)?;
572    let args = Args::build(spec, pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), &|_, _| None)?;
573    let mut child = ctx.child(spec.name, inputs);
574    child.globals.output = output.map(|p| p.display().to_string());
575    child.globals.outdir = None;
576    child.globals.in_place = false;
577    let v = match spec.runner {
578        Runner::Custom(f) => f(&args, &mut child)?,
579        Runner::Transform(_) | Runner::Chain => {
580            let out = child.output_path(None, "wav")?;
581            let input = child.read_audio(0)?;
582            let buf = apply_spec(spec, &args, input, &mut child)?;
583            child.write_audio(&out, &buf, None)?;
584            Value::Null
585        }
586    };
587    let outs = child.outputs.clone();
588    ctx.absorb(child);
589    Ok((v, outs))
590}
591
592fn explain(spec: &OpSpec, args: &Args) -> Value {
593    let mut lines = Vec::new();
594    for p in spec.effective_params() {
595        if let Some(v) = args.raw(p.name) {
596            let why = if args.is_explicit(p.name) {
597                "given on the command line"
598            } else if p.default == Some(v) {
599                "the default"
600            } else {
601                "from the preset"
602            };
603            lines.push(json!({"param": p.key(), "value": v, "meaning": p.help, "why": why}));
604        }
605    }
606    Value::Array(lines)
607}