From 6c7c4d13d70b640e188fa6f2058543f9c1ae52a7 Mon Sep 17 00:00:00 2001 From: claw1200 Date: Tue, 27 Oct 2026 18:47:53 +0000 Subject: [PATCH 1/2] Move ffmpeg encoding into an es-convert crate Presets, the ffmpeg command, encoding and the delay offset move out of es-engine so the USB exporter can encode without the library engine. Adds encode(source, target, preset) for writing anywhere and matches_format for a same-format check. The header reader (inspect) moves with them. No behaviour change; es-engine re-exports both. --- native/Cargo.lock | 14 +- native/Cargo.toml | 1 + native/crates/es-convert/Cargo.toml | 16 + .../{es-engine => es-convert}/src/inspect.rs | 0 native/crates/es-convert/src/lib.rs | 304 ++++++++++++++++++ native/crates/es-engine/Cargo.toml | 2 +- native/crates/es-engine/src/convert.rs | 228 +------------ native/crates/es-engine/src/lib.rs | 9 +- 8 files changed, 345 insertions(+), 229 deletions(-) create mode 100644 native/crates/es-convert/Cargo.toml rename native/crates/{es-engine => es-convert}/src/inspect.rs (100%) create mode 100644 native/crates/es-convert/src/lib.rs 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 100% rename from native/crates/es-engine/src/inspect.rs rename to native/crates/es-convert/src/inspect.rs diff --git a/native/crates/es-convert/src/lib.rs b/native/crates/es-convert/src/lib.rs new file mode 100644 index 0000000..54d9498 --- /dev/null +++ b/native/crates/es-convert/src/lib.rs @@ -0,0 +1,304 @@ +//! 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))); + } + + #[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) + } +} From c53399e44c38664c907c63d4049aed062ea9eda8 Mon Sep 17 00:00:00 2001 From: claw1200 Date: Tue, 27 Oct 2026 20:02:17 +0000 Subject: [PATCH 2/2] Label MP4 audio AAC only when its codec says so An MP4 whose sample entry isn't mp4a or alac (or wasn't found) read as AAC, so the format check would treat it as already AAC. It now reads as MP4 with an unknown encoding, which the compatibility check names as an unknown codec. --- app/src/renderer/src/lib/hardware/compatibility.ts | 4 +++- native/crates/es-convert/src/inspect.rs | 6 +++++- native/crates/es-convert/src/lib.rs | 3 +++ 3 files changed, 11 insertions(+), 2 deletions(-) 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/crates/es-convert/src/inspect.rs b/native/crates/es-convert/src/inspect.rs index a525c71..3971f00 100644 --- a/native/crates/es-convert/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 index 54d9498..9f71ea8 100644 --- a/native/crates/es-convert/src/lib.rs +++ b/native/crates/es-convert/src/lib.rs @@ -284,6 +284,9 @@ mod tests { 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]