1use 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
16pub 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#[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 pub argv: Vec<String>,
36 pub stdin: Option<Vec<u8>>,
37}
38
39pub 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 .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 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 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 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 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 ¶ms {
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 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 if o.path.ends_with(".ferraus.json") {
385 continue;
386 }
387 std::fs::write(p, &text)?;
388 }
389 Ok(())
390 }
391}
392
393pub 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
411pub 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
426pub 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
454pub 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
467pub 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
489pub 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
528pub 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
547pub 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
554pub 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
561pub 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}