use std::fs;
use std::io::BufReader;
use std::process::Command;
use hound::{SampleFormat, WavSpec, WavWriter};
use crate::converters::scad::which_tool;
use crate::{Error, Job, Result, extension_of};
pub fn midi_to_wav(job: &Job) -> Result<()> {
use std::sync::Arc;
use rustysynth::{
MidiFile, MidiFileSequencer, Synthesizer, SynthesizerSettings,
};
let (sound_font, sound_font_path) = load_soundfont()?;
let sound_font = Arc::new(sound_font);
let mut mid = fs::File::open(job.input)?;
let midi_file = Arc::new(
MidiFile::new(&mut mid)
.map_err(|e| Error::other(format!("load midi: {e}")))?,
);
let sample_rate: i32 = 44_100;
let channels: u16 = 2;
let bits_per_sample: u16 = 16;
eprintln!("SoundFont: {sound_font_path}");
eprintln!(
"Output: {sample_rate} Hz, {bits_per_sample}-bit PCM, {channels} channels",
);
let settings = SynthesizerSettings::new(sample_rate);
let synthesizer = Synthesizer::new(&sound_font, &settings)
.map_err(|e| Error::other(format!("synthesizer: {e}")))?;
let mut sequencer = MidiFileSequencer::new(synthesizer);
sequencer.play(&midi_file, false);
let sample_count =
(sample_rate as f64 * midi_file.get_length()).ceil() as usize;
let mut left = vec![0f32; sample_count];
let mut right = vec![0f32; sample_count];
sequencer.render(&mut left, &mut right);
let spec = WavSpec {
channels,
sample_rate: sample_rate as u32,
bits_per_sample,
sample_format: SampleFormat::Int,
};
let mut writer = WavWriter::create(job.output, spec)
.map_err(|e| Error::other(format!("wav create: {e}")))?;
for (l, r) in left.iter().zip(right.iter()) {
writer
.write_sample(f32_to_i16(*l))
.map_err(|e| Error::other(format!("wav write: {e}")))?;
writer
.write_sample(f32_to_i16(*r))
.map_err(|e| Error::other(format!("wav write: {e}")))?;
}
writer
.finalize()
.map_err(|e| Error::other(format!("wav finalize: {e}")))?;
Ok(())
}
fn f32_to_i16(s: f32) -> i16 {
(s.clamp(-1.0, 1.0) * 32767.0) as i16
}
fn load_soundfont() -> Result<(rustysynth::SoundFont, String)> {
let mut candidates: Vec<String> = Vec::new();
let mut env_override = false;
if let Ok(p) = std::env::var("UNIVERT_SOUNDFONT") {
env_override = true;
if !std::path::Path::new(&p).is_file() {
return Err(Error::other(format!(
"UNIVERT_SOUNDFONT points to {p}, which does not exist"
)));
}
candidates.push(p);
} else {
for p in [
"/usr/share/sounds/sf2/FluidR3_GM.sf2",
"/usr/share/sounds/sf2/FluidR3_GS.sf2",
"/usr/share/sounds/sf2/TimGM6mb.sf2",
"/usr/share/soundfonts/FluidR3_GM.sf2",
"/usr/share/soundfonts/default.sf2",
"/opt/homebrew/share/fluid-soundfont/FluidR3_GM.sf2",
"/usr/local/share/fluid-soundfont/FluidR3_GM.sf2",
"/opt/homebrew/share/fluid-synth/sf2/VintageDreamsWaves-v2.sf2",
"/usr/local/share/fluid-synth/sf2/VintageDreamsWaves-v2.sf2",
"/Library/Frameworks/CsoundLib64.framework/Resources/Manual/examples/sf_GMbank.sf2",
] {
if std::path::Path::new(p).is_file() {
candidates.push(p.to_string());
}
}
}
if candidates.is_empty() {
return Err(Error::other(
"No SoundFont found. Set UNIVERT_SOUNDFONT to a .sf2 file, or install \
one (macOS: `brew install fluid-synth`; Debian/Ubuntu: \
`apt install fluid-soundfont-gm`).",
));
}
let mut last_err: Option<String> = None;
for path in &candidates {
match fs::File::open(path)
.map_err(|e| format!("open {path}: {e}"))
.and_then(|mut f| {
rustysynth::SoundFont::new(&mut f)
.map_err(|e| format!("load {path}: {e}"))
}) {
Ok(sf) => return Ok((sf, path.clone())),
Err(e) => last_err = Some(e),
}
}
if env_override {
return Err(Error::other(format!(
"could not load UNIVERT_SOUNDFONT: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)));
}
Err(Error::other(format!(
"No usable SoundFont found ({} candidate(s) rejected by rustysynth). \
Last error: {}. Set UNIVERT_SOUNDFONT to a different .sf2 file, or \
install one (macOS: `brew install fluid-synth`; Debian/Ubuntu: \
`apt install fluid-soundfont-gm`).",
candidates.len(),
last_err.unwrap_or_else(|| "unknown error".to_string()),
)))
}
pub fn wav_to_aac(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", "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_ogg(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", "libvorbis"])
.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 flac_to_wav(job: &Job) -> Result<()> {
let mut reader = claxon::FlacReader::open(job.input)
.map_err(|e| Error::other(format!("flac open: {e}")))?;
let info = reader.streaminfo();
let spec = WavSpec {
channels: info.channels as u16,
sample_rate: info.sample_rate,
bits_per_sample: info.bits_per_sample as u16,
sample_format: SampleFormat::Int,
};
let mut writer = WavWriter::create(job.output, spec)
.map_err(|e| Error::other(format!("wav create: {e}")))?;
for sample in reader.samples() {
let s = sample.map_err(|e| Error::other(format!("flac decode: {e}")))?;
writer
.write_sample(s)
.map_err(|e| Error::other(format!("wav write: {e}")))?;
}
writer
.finalize()
.map_err(|e| Error::other(format!("wav finalize: {e}")))?;
Ok(())
}
pub fn wav_to_flac(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", "flac"])
.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 ogg_to_wav(job: &Job) -> Result<()> {
let file = fs::File::open(job.input)?;
let mut reader =
lewton::inside_ogg::OggStreamReader::new(BufReader::new(file))
.map_err(|e| Error::other(format!("ogg open: {e}")))?;
let spec = WavSpec {
channels: reader.ident_hdr.audio_channels as u16,
sample_rate: reader.ident_hdr.audio_sample_rate,
bits_per_sample: 16,
sample_format: SampleFormat::Int,
};
let channels = spec.channels as usize;
let mut writer = WavWriter::create(job.output, spec)
.map_err(|e| Error::other(format!("wav create: {e}")))?;
while let Some(packet) = reader
.read_dec_packet()
.map_err(|e| Error::other(format!("ogg decode: {e}")))?
{
if packet.is_empty() {
continue;
}
let frames = packet[0].len();
for frame in 0..frames {
for (ch, samples) in packet.iter().enumerate().take(channels) {
let sample = *samples.get(frame).unwrap_or(&0);
writer
.write_sample(sample)
.map_err(|e| Error::other(format!("wav write: {e}")))?;
let _ = ch;
}
}
}
writer
.finalize()
.map_err(|e| Error::other(format!("wav finalize: {e}")))?;
Ok(())
}
pub fn opus_to_wav(job: &Job) -> Result<()> {
if which_tool("ffmpeg") {
let status = Command::new("ffmpeg")
.args(["-y", "-hide_banner", "-loglevel", "error", "-i"])
.arg(job.input)
.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_wav(job: &Job) -> Result<()> {
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::errors::Error as SymError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
let src = fs::File::open(job.input)?;
let mss = MediaSourceStream::new(Box::new(src), Default::default());
let mut hint = Hint::new();
hint.with_extension("mp3");
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| Error::other(format!("mp3 probe: {e}")))?;
let mut format = probed.format;
let track = format
.default_track()
.ok_or_else(|| Error::other("mp3: no default track"))?;
let track_id = track.id;
let params = track.codec_params.clone();
let mut decoder = symphonia::default::get_codecs()
.make(¶ms, &DecoderOptions::default())
.map_err(|e| Error::other(format!("mp3 decoder: {e}")))?;
let sample_rate = params
.sample_rate
.ok_or_else(|| Error::other("mp3: unknown sample rate"))?;
let channels = params
.channels
.ok_or_else(|| Error::other("mp3: unknown channel layout"))?
.count() as u16;
let spec = WavSpec {
channels,
sample_rate,
bits_per_sample: 16,
sample_format: SampleFormat::Int,
};
let mut writer = WavWriter::create(job.output, spec)
.map_err(|e| Error::other(format!("wav create: {e}")))?;
loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(SymError::IoError(e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
break;
}
Err(SymError::ResetRequired) => break,
Err(e) => return Err(Error::other(format!("mp3 read: {e}"))),
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(SymError::IoError(_)) | Err(SymError::DecodeError(_)) => continue,
Err(e) => return Err(Error::other(format!("mp3 decode: {e}"))),
};
write_audio_buffer(&decoded, &mut writer, channels as usize)?;
}
writer
.finalize()
.map_err(|e| Error::other(format!("wav finalize: {e}")))?;
Ok(())
}
fn write_audio_buffer<W: std::io::Write + std::io::Seek>(
buf: &symphonia::core::audio::AudioBufferRef<'_>,
writer: &mut WavWriter<std::io::BufWriter<W>>,
channels: usize,
) -> Result<()> {
use symphonia::core::audio::{AudioBufferRef, Signal};
use symphonia::core::conv::IntoSample;
macro_rules! write_samples {
($buf:expr, $ty:ty) => {{
let frames = $buf.frames();
for f in 0..frames {
for ch in 0..channels {
let s = $buf.chan(ch)[f];
let i16_sample: i16 = <$ty as IntoSample<i16>>::into_sample(s);
writer
.write_sample(i16_sample)
.map_err(|e| Error::other(format!("wav write: {e}")))?;
}
}
}};
}
match buf {
AudioBufferRef::U8(b) => write_samples!(b, u8),
AudioBufferRef::U16(b) => write_samples!(b, u16),
AudioBufferRef::U24(b) => write_samples!(b, symphonia::core::sample::u24),
AudioBufferRef::U32(b) => write_samples!(b, u32),
AudioBufferRef::S8(b) => write_samples!(b, i8),
AudioBufferRef::S16(b) => write_samples!(b, i16),
AudioBufferRef::S24(b) => write_samples!(b, symphonia::core::sample::i24),
AudioBufferRef::S32(b) => write_samples!(b, i32),
AudioBufferRef::F32(b) => write_samples!(b, f32),
AudioBufferRef::F64(b) => write_samples!(b, f64),
}
Ok(())
}