diff --git a/app/src/renderer/src/lib/hardware/compatibility.ts b/app/src/renderer/src/lib/hardware/compatibility.ts index a2852bb..ba422d5 100644 --- a/app/src/renderer/src/lib/hardware/compatibility.ts +++ b/app/src/renderer/src/lib/hardware/compatibility.ts @@ -53,7 +53,9 @@ function failure(player: Player, facts: AudioFacts): { rank: number; text: strin if (facts.encoding === 'float') { return { rank: 1, text: facts.bitDepth ? `${facts.bitDepth}-bit float` : '32-bit float' } } - if (facts.encoding === 'other') return { rank: 2, text: 'Not integer PCM' } + if (facts.encoding === 'other') { + return { rank: 2, text: facts.kind?.toUpperCase() === 'MP4' ? 'Unknown audio codec' : 'Not integer PCM' } + } if (facts.channels != null && facts.channels > 0 && facts.channels !== 1 && facts.channels !== 2) { return { rank: 3, text: `${facts.channels} channels` } } diff --git a/native/Cargo.lock b/native/Cargo.lock index 095fe64..2ba48a9 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -578,6 +578,18 @@ dependencies = [ "zip", ] +[[package]] +name = "es-convert" +version = "0.1.0" +dependencies = [ + "rb-audio", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.21", + "uuid", +] + [[package]] name = "es-deck" version = "0.1.0" @@ -605,11 +617,11 @@ name = "es-engine" version = "0.1.0" dependencies = [ "es-backup", + "es-convert", "es-index", "es-tags", "image", "rb-anlz", - "rb-audio", "rb-device", "rb-library", "rb-model", diff --git a/native/Cargo.toml b/native/Cargo.toml index 77f0eb0..b04d603 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -18,6 +18,7 @@ es-index = { path = "crates/es-index" } es-tags = { path = "crates/es-tags" } es-engine = { path = "crates/es-engine" } es-backup = { path = "crates/es-backup" } +es-convert = { path = "crates/es-convert" } rb-anlz = { path = "../es-rekordbox-sdk/crates/rb-anlz" } rb-audio = { path = "../es-rekordbox-sdk/crates/rb-audio" } rb-device = { path = "../es-rekordbox-sdk/crates/rb-device" } diff --git a/native/crates/es-convert/Cargo.toml b/native/crates/es-convert/Cargo.toml new file mode 100644 index 0000000..9487738 --- /dev/null +++ b/native/crates/es-convert/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "es-convert" +description = "Encoding audio files to a chosen format with ffmpeg, and what a conversion does to the timeline." +edition.workspace = true +version.workspace = true +publish.workspace = true + +[dependencies] +rb-audio.workspace = true +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror.workspace = true +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tempfile = "3" diff --git a/native/crates/es-engine/src/inspect.rs b/native/crates/es-convert/src/inspect.rs similarity index 99% rename from native/crates/es-engine/src/inspect.rs rename to native/crates/es-convert/src/inspect.rs index a525c71..3971f00 100644 --- a/native/crates/es-engine/src/inspect.rs +++ b/native/crates/es-convert/src/inspect.rs @@ -299,7 +299,11 @@ fn mp4_walk(file: &mut File, end: u64, depth: u32, size: u64, out: &mut Mp4) -> fn mp4(file: &mut File, size: u64) -> Audio { let mut out = Mp4::default(); let _ = file.seek(SeekFrom::Start(0)).and_then(|_| mp4_walk(file, size, 0, size, &mut out)); - let (kind, encoding) = if &out.codec == b"alac" { ("ALAC", "alac") } else { ("AAC", "aac") }; + let (kind, encoding) = match &out.codec { + b"alac" => ("ALAC", "alac"), + b"mp4a" => ("AAC", "aac"), + _ => ("MP4", "other"), + }; Audio { kind, sample_rate: out.rate, bit_depth: out.bits, channels: out.channels, bitrate: 0, encoding } } diff --git a/native/crates/es-convert/src/lib.rs b/native/crates/es-convert/src/lib.rs new file mode 100644 index 0000000..9f71ea8 --- /dev/null +++ b/native/crates/es-convert/src/lib.rs @@ -0,0 +1,307 @@ +//! Encoding an audio file to a chosen format with ffmpeg, and how far the result moves rekordbox's timeline. + +pub mod inspect; + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const TIMEOUT: Duration = Duration::from_secs(60 * 30); +const BIT_DEPTHS: [i64; 2] = [16, 24]; +const BITRATES: [i64; 7] = [96, 128, 160, 192, 224, 256, 320]; +const SAMPLE_RATES: [i64; 4] = [44100, 48000, 88200, 96000]; + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct Error(pub String); + +pub type Result = std::result::Result; + +fn fail(message: impl Into) -> Error { + Error(message.into()) +} + +/// A validated target format: lossless formats carry a bit depth and sample rate, lossy ones a bitrate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Preset { + pub format: &'static str, + pub extension: &'static str, + pub bit_depth: Option, + pub sample_rate: Option, + pub bitrate: Option, +} + +fn whole(value: &serde_json::Value, key: &str, default: i64) -> i64 { + match value.get(key) { + Some(v) => v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|n| *n != 0).unwrap_or(default), + None => default, + } +} + +pub fn clean_preset(raw: &serde_json::Value) -> Result { + let format = raw.get("format").and_then(serde_json::Value::as_str).unwrap_or("").to_lowercase(); + let (name, extension, lossless): (&'static str, &'static str, bool) = match format.as_str() { + "wav" => ("wav", ".wav", true), + "aiff" => ("aiff", ".aiff", true), + "flac" => ("flac", ".flac", true), + "alac" => ("alac", ".m4a", true), + "mp3" => ("mp3", ".mp3", false), + "aac" => ("aac", ".m4a", false), + other => return Err(fail(format!("Unsupported format: {}", if other.is_empty() { "none" } else { other }))), + }; + if lossless { + let depth = whole(raw, "bitDepth", 16); + let rate = whole(raw, "sampleRate", 48000); + if !BIT_DEPTHS.contains(&depth) { + return Err(fail(format!("Bit depth must be one of {BIT_DEPTHS:?}"))); + } + if !SAMPLE_RATES.contains(&rate) { + return Err(fail(format!("Sample rate must be one of {SAMPLE_RATES:?}"))); + } + Ok(Preset { format: name, extension, bit_depth: Some(depth), sample_rate: Some(rate), bitrate: None }) + } else { + let bitrate = whole(raw, "bitrate", 320); + if !BITRATES.contains(&bitrate) { + return Err(fail(format!("Bitrate must be one of {BITRATES:?}"))); + } + Ok(Preset { format: name, extension, bit_depth: None, sample_rate: None, bitrate: Some(bitrate) }) + } +} + +/// Whether `path` already holds `preset`'s format, whatever its quality. Unreadable files don't. +#[must_use] +pub fn matches_format(path: &Path, preset: &Preset) -> bool { + let Some(audio) = inspect::inspect_file(path).audio else { return false }; + let kind = match preset.format { + "wav" => "WAV", + "aiff" => "AIFF", + "flac" => "FLAC", + "alac" => "ALAC", + "mp3" => "MP3", + _ => "AAC", + }; + // A WAVE or AIFF container can hold compressed audio, which isn't the format asked for. + audio.kind == kind && audio.encoding != "other" +} + +/// The ffmpeg named by the environment (the bundled one when packaged), else the one on `PATH`. +pub fn ffmpeg_path() -> Result { + let mut candidates: Vec = Vec::new(); + if let Some(env) = std::env::var_os("EXPORTSTUDIO_FFMPEG") { + candidates.push(PathBuf::from(env)); + } + if let Some(path) = std::env::var_os("PATH") { + let name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }; + candidates.extend(std::env::split_paths(&path).map(|dir| dir.join(name))); + } + candidates.into_iter().find(|p| p.is_file()).ok_or_else(|| fail("ffmpeg was not found")) +} + +fn codec_args(preset: &Preset) -> Vec { + let depth = preset.bit_depth.unwrap_or(16); + let raw = |extra: bool| if extra { vec!["-bits_per_raw_sample".to_owned(), "24".to_owned()] } else { Vec::new() }; + let strings = |items: &[&str]| items.iter().map(|s| (*s).to_owned()).collect::>(); + match preset.format { + "wav" => vec!["-c:a".into(), format!("pcm_s{depth}le")], + "aiff" => vec!["-c:a".into(), format!("pcm_s{depth}be")], + "flac" => [strings(&["-c:a", "flac", "-sample_fmt", if depth == 16 { "s16" } else { "s32" }]), raw(depth != 16)].concat(), + "alac" => [strings(&["-c:a", "alac", "-sample_fmt", if depth == 16 { "s16p" } else { "s32p" }]), raw(depth != 16)].concat(), + "mp3" => vec!["-c:a".into(), "libmp3lame".into(), "-b:a".into(), format!("{}k", preset.bitrate.unwrap_or(320))], + _ => vec!["-c:a".into(), "aac".into(), "-b:a".into(), format!("{}k", preset.bitrate.unwrap_or(320))], + } +} + +#[must_use] +pub fn build_command(ffmpeg: &Path, source: &Path, target: &Path, preset: &Preset) -> Vec { + let mut args: Vec = ["-nostdin", "-v", "error", "-y", "-i"].iter().map(|s| (*s).to_owned()).collect(); + args.push(source.to_string_lossy().into_owned()); + args.extend(["-map", "0:a:0", "-map_metadata", "0"].map(str::to_owned)); + if matches!(preset.format, "mp3" | "flac" | "alac" | "aac") { + args.extend(["-map", "0:v:0?", "-c:v", "copy", "-disposition:v", "attached_pic"].map(str::to_owned)); + } else { + args.push("-vn".into()); + } + if let Some(rate) = preset.sample_rate { + args.extend(["-ar".to_owned(), rate.to_string()]); + } + args.extend(codec_args(preset)); + if preset.format == "mp3" { + args.extend(["-id3v2_version", "3"].map(str::to_owned)); + } + match preset.format { + "alac" | "aac" => args.extend(["-f", "ipod"].map(str::to_owned)), + "wav" => args.extend(["-f", "wav"].map(str::to_owned)), + "aiff" => args.extend(["-f", "aiff"].map(str::to_owned)), + _ => {} + } + args.push(target.to_string_lossy().into_owned()); + let mut out = vec![ffmpeg.to_string_lossy().into_owned()]; + out.extend(args); + out +} + +/// Next to the source, same name, with a numeric suffix when the name is taken. +#[must_use] +pub fn output_path(source: &Path, extension: &str) -> PathBuf { + let stem = source.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = |s: &str| source.with_file_name(format!("{s}{extension}")); + let mut target = name(&stem); + let mut number = 1; + while target.exists() || target == source { + target = name(&format!("{stem} ({number})")); + number += 1; + } + target +} + +/// Encode `source` to a new file beside it. Nothing is left behind on failure. +pub fn convert_file(source: &Path, preset: &Preset) -> Result { + if !source.is_file() { + return Err(fail("The audio file is missing")); + } + let target = output_path(source, preset.extension); + encode(source, &target, preset)?; + Ok(target) +} + +/// Encode `source` to `target`, replacing it. The file appears only once it is complete. +pub fn encode(source: &Path, target: &Path, preset: &Preset) -> Result<()> { + let folder = target.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf); + let partial = folder.join(format!(".convert-{}{}", uuid::Uuid::new_v4().simple(), preset.extension)); + let result = run_ffmpeg(&build_command(&ffmpeg_path()?, source, &partial, preset), &partial) + .and_then(|()| std::fs::rename(&partial, target).map_err(|e| fail(e.to_string()))); + if result.is_err() { + let _ = std::fs::remove_file(&partial); + } + result +} + +fn run_ffmpeg(command: &[String], partial: &Path) -> Result<()> { + let mut ffmpeg = Command::new(&command[0]); + ffmpeg.args(&command[1..]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped()); + // CREATE_NO_WINDOW: ffmpeg is a console program and the app has no console to share. + #[cfg(windows)] + std::os::windows::process::CommandExt::creation_flags(&mut ffmpeg, 0x0800_0000); + let mut child = ffmpeg + .spawn() + .map_err(|e| fail(format!("Could not start ffmpeg: {e}")))?; + let mut stderr = child.stderr.take(); + let reader = std::thread::spawn(move || { + let mut text = String::new(); + if let Some(s) = stderr.as_mut() { + let _ = s.read_to_string(&mut text); + } + text + }); + let started = Instant::now(); + let status = loop { + match child.try_wait().map_err(|e| fail(e.to_string()))? { + Some(status) => break status, + None if started.elapsed() > TIMEOUT => { + let _ = child.kill(); + let _ = child.wait(); + return Err(fail("Conversion timed out")); + } + None => std::thread::sleep(Duration::from_millis(50)), + } + }; + let text = reader.join().unwrap_or_default(); + let size = partial.metadata().map_or(0, |m| m.len()); + if !status.success() || size == 0 { + return Err(fail(text.trim().lines().last().unwrap_or("ffmpeg failed"))); + } + Ok(()) +} + +/// Milliseconds that cues, the beat grid and the detail waveforms move after converting `source` to `target`, from +/// what each file says about its own delay. `None` when either does not say, and nothing is moved. +#[must_use] +pub fn delay_offset_ms(source: &Path, target: &Path) -> Option { + rb_audio::conversion_shift_ms(source, target) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn presets_are_checked_and_filled() { + let wav = clean_preset(&json!({"format": "WAV"})).unwrap(); + assert_eq!((wav.bit_depth, wav.sample_rate, wav.bitrate), (Some(16), Some(48000), None)); + let mp3 = clean_preset(&json!({"format": "mp3", "bitrate": 192})).unwrap(); + assert_eq!((mp3.bitrate, mp3.bit_depth), (Some(192), None)); + assert!(clean_preset(&json!({"format": "ogg"})).is_err()); + assert!(clean_preset(&json!({"format": "flac", "bitDepth": 20})).is_err()); + assert!(clean_preset(&json!({"format": "mp3", "bitrate": 100})).is_err()); + } + + #[test] + fn commands_match_the_formats() { + let flac = clean_preset(&json!({"format": "flac", "bitDepth": 24, "sampleRate": 44100})).unwrap(); + let args = build_command(Path::new("ffmpeg"), Path::new("a.wav"), Path::new("b.flac"), &flac).join(" "); + assert!(args.contains("-c:a flac -sample_fmt s32 -bits_per_raw_sample 24"), "{args}"); + assert!(args.contains("-ar 44100") && args.contains("-map 0:v:0?"), "{args}"); + let wav = clean_preset(&json!({"format": "wav"})).unwrap(); + let args = build_command(Path::new("ffmpeg"), Path::new("a.flac"), Path::new("b.wav"), &wav).join(" "); + assert!(args.contains("pcm_s16le") && args.contains("-vn") && args.ends_with("-f wav b.wav"), "{args}"); + } + + #[test] + fn outputs_do_not_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("a.wav"); + std::fs::write(&source, b"x").unwrap(); + assert_eq!(output_path(&source, ".flac"), dir.path().join("a.flac")); + std::fs::write(dir.path().join("a.flac"), b"x").unwrap(); + assert_eq!(output_path(&source, ".flac"), dir.path().join("a (1).flac")); + assert_eq!(output_path(&source, ".wav"), dir.path().join("a (1).wav")); + } + + fn pcm_wav(path: &Path) { + let mut bytes = b"RIFF".to_vec(); + bytes.extend(40u32.to_le_bytes()); + bytes.extend(b"WAVEfmt "); + bytes.extend(16u32.to_le_bytes()); + bytes.extend([1, 0, 2, 0]); + bytes.extend(44100u32.to_le_bytes()); + bytes.extend((44100u32 * 4).to_le_bytes()); + bytes.extend([4, 0, 16, 0]); + bytes.extend(b"data"); + bytes.extend(4u32.to_le_bytes()); + bytes.extend([0; 4]); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn a_format_matches_whatever_its_quality() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("a.wav"); + pcm_wav(&file); + let wav = |depth: i64, rate: i64| clean_preset(&json!({"format": "wav", "bitDepth": depth, "sampleRate": rate})).unwrap(); + assert!(matches_format(&file, &wav(16, 44100))); + assert!(matches_format(&file, &wav(24, 96000))); + assert!(!matches_format(&file, &clean_preset(&json!({"format": "aiff"})).unwrap())); + assert!(!matches_format(&dir.path().join("missing.wav"), &wav(16, 44100))); + let bare_mp4 = dir.path().join("a.m4a"); + std::fs::write(&bare_mp4, [&[0, 0, 0, 16][..], b"ftypM4A ", &[0; 4]].concat()).unwrap(); + assert!(!matches_format(&bare_mp4, &clean_preset(&json!({"format": "aac"})).unwrap()), "no codec is not AAC"); + } + + #[test] + fn encoding_writes_the_target_and_nothing_else() { + if ffmpeg_path().is_err() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("a.wav"); + pcm_wav(&source); + let out = dir.path().join("elsewhere"); + std::fs::create_dir(&out).unwrap(); + let target = out.join("b.flac"); + encode(&source, &target, &clean_preset(&json!({"format": "flac"})).unwrap()).unwrap(); + assert!(matches_format(&target, &clean_preset(&json!({"format": "flac"})).unwrap())); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1); + } +} diff --git a/native/crates/es-engine/Cargo.toml b/native/crates/es-engine/Cargo.toml index 66036a7..eaf67d2 100644 --- a/native/crates/es-engine/Cargo.toml +++ b/native/crates/es-engine/Cargo.toml @@ -8,11 +8,11 @@ publish.workspace = true [dependencies] rb-library.workspace = true es-backup.workspace = true +es-convert.workspace = true es-index.workspace = true es-tags.workspace = true rb-device.workspace = true rb-anlz.workspace = true -rb-audio.workspace = true unicode-normalization = "0.1" image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } thiserror.workspace = true diff --git a/native/crates/es-engine/src/convert.rs b/native/crates/es-engine/src/convert.rs index f594269..3c75995 100644 --- a/native/crates/es-engine/src/convert.rs +++ b/native/crates/es-engine/src/convert.rs @@ -1,22 +1,16 @@ //! Converting tracks' audio files with ffmpeg and pointing the library at the results. -use std::io::Read; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; use rb_anlz::decode::Tags; use rb_anlz::write::{patch_tags, path_tag, timeline_patches}; use rb_library::{ConversionTarget, Library, Stream}; +pub use es_convert::{build_command, clean_preset, convert_file, delay_offset_ms, encode, ffmpeg_path, matches_format, output_path, Preset}; + use crate::import::file_type_code; use crate::{Error, Result}; -const TIMEOUT: Duration = Duration::from_secs(60 * 30); -const BIT_DEPTHS: [i64; 2] = [16, 24]; -const BITRATES: [i64; 7] = [96, 128, 160, 192, 224, 256, 320]; -const SAMPLE_RATES: [i64; 4] = [44100, 48000, 88200, 96000]; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { /// A new track beside the original. @@ -38,185 +32,6 @@ impl Mode { } } -/// A validated target format: lossless formats carry a bit depth and sample rate, lossy ones a bitrate. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Preset { - pub format: &'static str, - pub extension: &'static str, - pub bit_depth: Option, - pub sample_rate: Option, - pub bitrate: Option, -} - -fn whole(value: &serde_json::Value, key: &str, default: i64) -> i64 { - match value.get(key) { - Some(v) => v.as_i64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|n| *n != 0).unwrap_or(default), - None => default, - } -} - -pub fn clean_preset(raw: &serde_json::Value) -> Result { - let format = raw.get("format").and_then(serde_json::Value::as_str).unwrap_or("").to_lowercase(); - let (name, extension, lossless): (&'static str, &'static str, bool) = match format.as_str() { - "wav" => ("wav", ".wav", true), - "aiff" => ("aiff", ".aiff", true), - "flac" => ("flac", ".flac", true), - "alac" => ("alac", ".m4a", true), - "mp3" => ("mp3", ".mp3", false), - "aac" => ("aac", ".m4a", false), - other => return Err(Error::Other(format!("Unsupported format: {}", if other.is_empty() { "none" } else { other }))), - }; - if lossless { - let depth = whole(raw, "bitDepth", 16); - let rate = whole(raw, "sampleRate", 48000); - if !BIT_DEPTHS.contains(&depth) { - return Err(Error::Other(format!("Bit depth must be one of {BIT_DEPTHS:?}"))); - } - if !SAMPLE_RATES.contains(&rate) { - return Err(Error::Other(format!("Sample rate must be one of {SAMPLE_RATES:?}"))); - } - Ok(Preset { format: name, extension, bit_depth: Some(depth), sample_rate: Some(rate), bitrate: None }) - } else { - let bitrate = whole(raw, "bitrate", 320); - if !BITRATES.contains(&bitrate) { - return Err(Error::Other(format!("Bitrate must be one of {BITRATES:?}"))); - } - Ok(Preset { format: name, extension, bit_depth: None, sample_rate: None, bitrate: Some(bitrate) }) - } -} - -/// The ffmpeg named by the environment (the bundled one when packaged), else the one on `PATH`. -pub fn ffmpeg_path() -> Result { - let mut candidates: Vec = Vec::new(); - if let Some(env) = std::env::var_os("EXPORTSTUDIO_FFMPEG") { - candidates.push(PathBuf::from(env)); - } - if let Some(path) = std::env::var_os("PATH") { - let name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }; - candidates.extend(std::env::split_paths(&path).map(|dir| dir.join(name))); - } - candidates.into_iter().find(|p| p.is_file()).ok_or_else(|| Error::Other("ffmpeg was not found".into())) -} - -fn codec_args(preset: &Preset) -> Vec { - let depth = preset.bit_depth.unwrap_or(16); - let raw = |extra: bool| if extra { vec!["-bits_per_raw_sample".to_owned(), "24".to_owned()] } else { Vec::new() }; - let strings = |items: &[&str]| items.iter().map(|s| (*s).to_owned()).collect::>(); - match preset.format { - "wav" => vec!["-c:a".into(), format!("pcm_s{depth}le")], - "aiff" => vec!["-c:a".into(), format!("pcm_s{depth}be")], - "flac" => [strings(&["-c:a", "flac", "-sample_fmt", if depth == 16 { "s16" } else { "s32" }]), raw(depth != 16)].concat(), - "alac" => [strings(&["-c:a", "alac", "-sample_fmt", if depth == 16 { "s16p" } else { "s32p" }]), raw(depth != 16)].concat(), - "mp3" => vec!["-c:a".into(), "libmp3lame".into(), "-b:a".into(), format!("{}k", preset.bitrate.unwrap_or(320))], - _ => vec!["-c:a".into(), "aac".into(), "-b:a".into(), format!("{}k", preset.bitrate.unwrap_or(320))], - } -} - -#[must_use] -pub fn build_command(ffmpeg: &Path, source: &Path, target: &Path, preset: &Preset) -> Vec { - let mut args: Vec = ["-nostdin", "-v", "error", "-y", "-i"].iter().map(|s| (*s).to_owned()).collect(); - args.push(source.to_string_lossy().into_owned()); - args.extend(["-map", "0:a:0", "-map_metadata", "0"].map(str::to_owned)); - if matches!(preset.format, "mp3" | "flac" | "alac" | "aac") { - args.extend(["-map", "0:v:0?", "-c:v", "copy", "-disposition:v", "attached_pic"].map(str::to_owned)); - } else { - args.push("-vn".into()); - } - if let Some(rate) = preset.sample_rate { - args.extend(["-ar".to_owned(), rate.to_string()]); - } - args.extend(codec_args(preset)); - if preset.format == "mp3" { - args.extend(["-id3v2_version", "3"].map(str::to_owned)); - } - match preset.format { - "alac" | "aac" => args.extend(["-f", "ipod"].map(str::to_owned)), - "wav" => args.extend(["-f", "wav"].map(str::to_owned)), - "aiff" => args.extend(["-f", "aiff"].map(str::to_owned)), - _ => {} - } - args.push(target.to_string_lossy().into_owned()); - let mut out = vec![ffmpeg.to_string_lossy().into_owned()]; - out.extend(args); - out -} - -/// Next to the source, same name, with a numeric suffix when the name is taken. -#[must_use] -pub fn output_path(source: &Path, extension: &str) -> PathBuf { - let stem = source.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); - let name = |s: &str| source.with_file_name(format!("{s}{extension}")); - let mut target = name(&stem); - let mut number = 1; - while target.exists() || target == source { - target = name(&format!("{stem} ({number})")); - number += 1; - } - target -} - -/// Encode `source` to a new file beside it. Nothing is left behind on failure. -pub fn convert_file(source: &Path, preset: &Preset) -> Result { - if !source.is_file() { - return Err(Error::Other("The audio file is missing".into())); - } - let target = output_path(source, preset.extension); - let folder = target.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf); - let partial = folder.join(format!(".convert-{}{}", uuid::Uuid::new_v4().simple(), preset.extension)); - let result = run_ffmpeg(&build_command(&ffmpeg_path()?, source, &partial, preset), &partial); - match result { - Ok(()) => std::fs::rename(&partial, &target).map_err(|e| Error::Other(e.to_string())).map(|()| target), - Err(e) => { - let _ = std::fs::remove_file(&partial); - Err(e) - } - } -} - -fn run_ffmpeg(command: &[String], partial: &Path) -> Result<()> { - let mut ffmpeg = Command::new(&command[0]); - ffmpeg.args(&command[1..]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped()); - // CREATE_NO_WINDOW: ffmpeg is a console program and the app has no console to share. - #[cfg(windows)] - std::os::windows::process::CommandExt::creation_flags(&mut ffmpeg, 0x0800_0000); - let mut child = ffmpeg - .spawn() - .map_err(|e| Error::Other(format!("Could not start ffmpeg: {e}")))?; - let mut stderr = child.stderr.take(); - let reader = std::thread::spawn(move || { - let mut text = String::new(); - if let Some(s) = stderr.as_mut() { - let _ = s.read_to_string(&mut text); - } - text - }); - let started = Instant::now(); - let status = loop { - match child.try_wait().map_err(|e| Error::Other(e.to_string()))? { - Some(status) => break status, - None if started.elapsed() > TIMEOUT => { - let _ = child.kill(); - let _ = child.wait(); - return Err(Error::Other("Conversion timed out".into())); - } - None => std::thread::sleep(Duration::from_millis(50)), - } - }; - let text = reader.join().unwrap_or_default(); - let size = partial.metadata().map_or(0, |m| m.len()); - if !status.success() || size == 0 { - return Err(Error::Other(text.trim().lines().last().unwrap_or("ffmpeg failed").to_owned())); - } - Ok(()) -} - -/// Milliseconds that cues, the beat grid and the detail waveforms move after converting `source` to `target`, from -/// what each file says about its own delay. `None` when either does not say, and nothing is moved. -#[must_use] -pub fn delay_offset_ms(source: &Path, target: &Path) -> Option { - rb_audio::conversion_shift_ms(source, target) -} - #[derive(Debug, Clone, Default)] pub struct Outcome { pub ids: Vec, @@ -313,42 +128,3 @@ pub fn convert_tracks(lib: &mut Library, share: &Path, ids: &[String], preset: & progress(ordered.len(), ordered.len(), None); outcome } - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn presets_are_checked_and_filled() { - let wav = clean_preset(&json!({"format": "WAV"})).unwrap(); - assert_eq!((wav.bit_depth, wav.sample_rate, wav.bitrate), (Some(16), Some(48000), None)); - let mp3 = clean_preset(&json!({"format": "mp3", "bitrate": 192})).unwrap(); - assert_eq!((mp3.bitrate, mp3.bit_depth), (Some(192), None)); - assert!(clean_preset(&json!({"format": "ogg"})).is_err()); - assert!(clean_preset(&json!({"format": "flac", "bitDepth": 20})).is_err()); - assert!(clean_preset(&json!({"format": "mp3", "bitrate": 100})).is_err()); - } - - #[test] - fn commands_match_the_formats() { - let flac = clean_preset(&json!({"format": "flac", "bitDepth": 24, "sampleRate": 44100})).unwrap(); - let args = build_command(Path::new("ffmpeg"), Path::new("a.wav"), Path::new("b.flac"), &flac).join(" "); - assert!(args.contains("-c:a flac -sample_fmt s32 -bits_per_raw_sample 24"), "{args}"); - assert!(args.contains("-ar 44100") && args.contains("-map 0:v:0?"), "{args}"); - let wav = clean_preset(&json!({"format": "wav"})).unwrap(); - let args = build_command(Path::new("ffmpeg"), Path::new("a.flac"), Path::new("b.wav"), &wav).join(" "); - assert!(args.contains("pcm_s16le") && args.contains("-vn") && args.ends_with("-f wav b.wav"), "{args}"); - } - - #[test] - fn outputs_do_not_overwrite() { - let dir = tempfile::tempdir().unwrap(); - let source = dir.path().join("a.wav"); - std::fs::write(&source, b"x").unwrap(); - assert_eq!(output_path(&source, ".flac"), dir.path().join("a.flac")); - std::fs::write(dir.path().join("a.flac"), b"x").unwrap(); - assert_eq!(output_path(&source, ".flac"), dir.path().join("a (1).flac")); - assert_eq!(output_path(&source, ".wav"), dir.path().join("a (1).wav")); - } -} diff --git a/native/crates/es-engine/src/lib.rs b/native/crates/es-engine/src/lib.rs index 46672ab..98340a1 100644 --- a/native/crates/es-engine/src/lib.rs +++ b/native/crates/es-engine/src/lib.rs @@ -6,12 +6,13 @@ pub mod browse; pub mod convert; pub mod device_import; pub mod import; -pub mod inspect; pub mod library; pub mod relocate; pub mod place; pub mod sync; +pub use es_convert::inspect; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] @@ -36,3 +37,9 @@ pub enum Error { } pub type Result = std::result::Result; + +impl From for Error { + fn from(error: es_convert::Error) -> Self { + Self::Other(error.0) + } +}