univert 0.2.0

Universal file converter (library and CLI)
Documentation
use std::process::Command;

use crate::converters::scad::which_tool;
use crate::{Error, Job, Result, extension_of};

pub fn midi_to_wav(job: &Job) -> Result<()> {
  // Prefer fluidsynth (widely packaged) when present. A pure-Rust path
  // via oxisynth could be added later; it requires bundling or locating
  // a SoundFont.
  if which_tool("fluidsynth") {
    let soundfont =
      std::env::var("UNIVERT_SOUNDFONT").unwrap_or_else(|_| default_sf2());
    let status = Command::new("fluidsynth")
      .args(["-ni", "-g", "0.8", "-F"])
      .arg(job.output)
      .arg(&soundfont)
      .arg(job.input)
      .status()
      .map_err(|e| Error::ToolFailed {
        tool: "fluidsynth",
        message: e.to_string(),
      })?;
    if !status.success() {
      return Err(Error::ToolFailed {
        tool: "fluidsynth",
        message: format!("fluidsynth exited with status {status}"),
      });
    }
    return Ok(());
  }
  Err(Error::MissingTool {
    from: extension_of(job.input).unwrap_or_default(),
    to: extension_of(job.output).unwrap_or_default(),
    tool: "fluidsynth",
  })
}

pub fn wav_to_aac(job: &Job) -> Result<()> {
  // Use ffmpeg when present; it is cross-platform, unlike macOS afconvert.
  if which_tool("ffmpeg") {
    let status = Command::new("ffmpeg")
      .args(["-y", "-hide_banner", "-loglevel", "error", "-i"])
      .arg(job.input)
      .args(["-c:a", "aac", "-f", "adts"])
      .arg(job.output)
      .status()
      .map_err(|e| Error::ToolFailed {
        tool: "ffmpeg",
        message: e.to_string(),
      })?;
    if !status.success() {
      return Err(Error::ToolFailed {
        tool: "ffmpeg",
        message: format!("ffmpeg exited with status {status}"),
      });
    }
    return Ok(());
  }
  Err(Error::MissingTool {
    from: extension_of(job.input).unwrap_or_default(),
    to: extension_of(job.output).unwrap_or_default(),
    tool: "ffmpeg",
  })
}

pub fn wav_to_mp3(job: &Job) -> Result<()> {
  if which_tool("ffmpeg") {
    let status = Command::new("ffmpeg")
      .args(["-y", "-hide_banner", "-loglevel", "error", "-i"])
      .arg(job.input)
      .args(["-c:a", "libmp3lame"])
      .arg(job.output)
      .status()
      .map_err(|e| Error::ToolFailed {
        tool: "ffmpeg",
        message: e.to_string(),
      })?;
    if !status.success() {
      return Err(Error::ToolFailed {
        tool: "ffmpeg",
        message: format!("ffmpeg exited with status {status}"),
      });
    }
    return Ok(());
  }
  Err(Error::MissingTool {
    from: extension_of(job.input).unwrap_or_default(),
    to: extension_of(job.output).unwrap_or_default(),
    tool: "ffmpeg",
  })
}

pub fn mp3_to_png(job: &Job) -> Result<()> {
  // Waveform visualization; not yet implemented natively.
  let from = extension_of(job.input).unwrap_or_default();
  let to = extension_of(job.output).unwrap_or_default();
  Err(Error::NotImplemented { from, to })
}

fn default_sf2() -> String {
  // Common install locations.
  for p in [
    "/usr/share/sounds/sf2/FluidR3_GM.sf2",
    "/usr/share/soundfonts/default.sf2",
    "/opt/homebrew/share/fluid-soundfont/FluidR3_GM.sf2",
    "/usr/local/share/fluid-soundfont/FluidR3_GM.sf2",
  ] {
    if std::path::Path::new(p).is_file() {
      return p.to_string();
    }
  }
  // Fall back to a placeholder that will make fluidsynth fail with a clear
  // message.
  "default.sf2".to_string()
}