diff --git a/es-rekordbox-sdk b/es-rekordbox-sdk index 3aee501..0d0d493 160000 --- a/es-rekordbox-sdk +++ b/es-rekordbox-sdk @@ -1 +1 @@ -Subproject commit 3aee501c1406e83be53a62824439aabac07f0d5e +Subproject commit 0d0d493604c5d95349fe211477c8af2ca373559b diff --git a/native/Cargo.lock b/native/Cargo.lock index c26be73..095fe64 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -593,6 +593,8 @@ dependencies = [ name = "es-decode" version = "0.1.0" dependencies = [ + "mp3lame-encoder", + "rb-audio", "rubato", "symphonia", "thiserror 2.0.21", @@ -607,6 +609,7 @@ dependencies = [ "es-tags", "image", "rb-anlz", + "rb-audio", "rb-device", "rb-library", "rb-model", @@ -1497,6 +1500,13 @@ dependencies = [ name = "rb-anlz" version = "0.1.0" +[[package]] +name = "rb-audio" +version = "0.1.0" +dependencies = [ + "rbl-core", +] + [[package]] name = "rb-device" version = "0.1.0" diff --git a/native/Cargo.toml b/native/Cargo.toml index 871ee5c..77f0eb0 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -19,6 +19,7 @@ es-tags = { path = "crates/es-tags" } es-engine = { path = "crates/es-engine" } es-backup = { path = "crates/es-backup" } 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" } rb-library = { path = "../es-rekordbox-sdk/crates/rb-library" } rb-model = { path = "../es-rekordbox-sdk/crates/rb-model" } diff --git a/native/crates/es-decode/Cargo.toml b/native/crates/es-decode/Cargo.toml index 3f0149f..8bfcef2 100644 --- a/native/crates/es-decode/Cargo.toml +++ b/native/crates/es-decode/Cargo.toml @@ -5,6 +5,10 @@ version.workspace = true publish.workspace = true [dependencies] +rb-audio.workspace = true symphonia.workspace = true rubato.workspace = true thiserror.workspace = true + +[dev-dependencies] +mp3lame-encoder = "0.2.5" diff --git a/native/crates/es-decode/src/lib.rs b/native/crates/es-decode/src/lib.rs index 85d074f..f423c6a 100644 --- a/native/crates/es-decode/src/lib.rs +++ b/native/crates/es-decode/src/lib.rs @@ -35,8 +35,6 @@ impl From for DecodeError { } } -mod mp3; - pub type Result = std::result::Result; /// Frames the resampler consumes per pass. @@ -54,8 +52,9 @@ pub struct Streamer { source: Vec, /// Source frames at the front of `source` still to be dropped, after a seek landed early. skip: u64, - /// Source frames of silence before the file's audio, to start where rekordbox does. - lead: u64, + /// Source frames rekordbox's timeline runs ahead of the decode: silence to play first when + /// positive, decoded frames to drop when negative. + lead: i64, /// Lead-in frames still to be played. lead_left: u64, resampler: Option>, @@ -107,13 +106,13 @@ impl Streamer { output_rate }; let lead = if params.codec == CODEC_TYPE_MP3 { - mp3::lead_in(path) + rb_audio::rekordbox_lead_frames(path) } else { 0 }; let total = params .n_frames - .map(|n| scale(n + lead, source_rate, output_rate)) + .map(|n| scale(n.saturating_add_signed(lead), source_rate, output_rate)) .unwrap_or(0); let resampler = if source_rate == output_rate { None @@ -128,9 +127,9 @@ impl Streamer { source_rate, output_rate, source: Vec::new(), - skip: 0, + skip: lead.unsigned_abs() * u64::from(lead < 0), lead, - lead_left: lead, + lead_left: u64::try_from(lead).unwrap_or(0), resampler, ready: Vec::new(), ready_at: 0, @@ -210,8 +209,8 @@ impl Streamer { if let Some(resampler) = self.resampler.as_mut() { resampler.reset(); } - self.lead_left = self.lead.saturating_sub(source_frame); - let source_frame = source_frame.saturating_sub(self.lead); + self.lead_left = u64::try_from(self.lead).unwrap_or(0).saturating_sub(source_frame); + let source_frame = source_frame.saturating_add_signed(-self.lead); let seconds = source_frame as f64 / f64::from(self.source_rate); let landed = self.format.seek( SeekMode::Accurate, @@ -553,4 +552,158 @@ mod tests { "frame {k}: {got} vs {expected}" ); } + + const MP3_FRAME: usize = 1152; + + /// A rising tone, so no two stretches of it look alike and a misplaced frame shows. + fn sweep(frame: u32, start: f64, rise: f64) -> f32 { + let t = f64::from(frame); + (t * start + t * t * rise).sin() as f32 + } + + /// A second of stereo audio as CBR 128 kbit/s MP3. `tag` adds a LAME Info frame in front. + fn encode(mode: mp3lame_encoder::Mode, tag: bool) -> Vec { + use mp3lame_encoder::{Bitrate, Builder, DualPcm, FlushNoGap}; + let mut encoder = Builder::new() + .unwrap() + .with_num_channels(2) + .unwrap() + .with_sample_rate(44_100) + .unwrap() + .with_brate(Bitrate::Kbps128) + .unwrap() + .with_mode(mode) + .unwrap() + .with_to_write_vbr_tag(tag) + .unwrap() + .build() + .unwrap(); + let left: Vec = (0..44_100).map(|i| sweep(i, 0.01, 3e-6) * 0.5).collect(); + let right: Vec = (0..44_100).map(|i| sweep(i, 0.02, 2e-6) * 0.4).collect(); + let mut bytes = Vec::with_capacity(mp3lame_encoder::max_required_buffer_size(left.len())); + encoder.encode_to_vec(DualPcm { left: &left, right: &right }, &mut bytes).unwrap(); + bytes.reserve(7200); + encoder.flush_to_vec::(&mut bytes).unwrap(); + if tag { + let mut info = Vec::with_capacity(encoder.lame_tag_size()); + encoder.lame_tag_encode_to_vec(&mut info).unwrap(); + bytes[..info.len()].copy_from_slice(&info); + } + bytes + } + + /// Marks every frame after the first as joint stereo with no joint coding, which decodes exactly as + /// stereo does, so rekordbox's frame map restarts at the second frame. + fn joint_after_first(mut bytes: Vec) -> Vec { + let mut at = 0; + let mut first = true; + while at + 4 <= bytes.len() { + let padding = usize::from((bytes[at + 2] >> 1) & 1); + if !first { + bytes[at + 3] = (bytes[at + 3] & 0x3F) | 0x40; + } + first = false; + at += 144 * 128_000 / 44_100 + padding; + } + bytes + } + + fn mp3(name: &str, bytes: &[u8]) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("es-decode-{}-{name}.mp3", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + path + } + + fn read_all(path: &Path, rate: u32) -> (u64, Vec) { + let mut s = Streamer::open(path, rate).unwrap(); + let total = s.total_frames(); + let mut out = vec![0.0; 2 * 4096]; + let mut all = Vec::new(); + loop { + let n = s.fill(&mut out).unwrap(); + if n == 0 { + return (total, all); + } + all.extend_from_slice(&out[..n * 2]); + } + } + + fn left(samples: &[f32]) -> Vec { + samples.chunks_exact(2).map(|f| f[0]).collect() + } + + fn close(a: &[f32], b: &[f32], tolerance: f32, what: &str) { + assert_eq!(a.len(), b.len(), "{what}: length"); + let worst = a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(worst <= tolerance, "{what}: differs by {worst}"); + } + + #[test] + fn a_negative_lead_drops_the_first_frame_and_keeps_the_rest_in_place() { + use mp3lame_encoder::Mode; + let plain = mp3("plain", &encode(Mode::Stereo, false)); + let negative = mp3("negative", &joint_after_first(encode(Mode::Stereo, false))); + assert_eq!(rb_audio::rekordbox_lead_frames(&plain), 0); + assert_eq!(rb_audio::rekordbox_lead_frames(&negative), -(MP3_FRAME as i64)); + + let (_, whole) = read_all(&plain, 44_100); + let (_, trimmed) = read_all(&negative, 44_100); + assert_eq!(trimmed.len(), whole.len() - 2 * MP3_FRAME); + assert_eq!(trimmed, whole[2 * MP3_FRAME..]); + + let mut s = Streamer::open(&negative, 44_100).unwrap(); + let mut reference = Streamer::open(&plain, 44_100).unwrap(); + for target in [0usize, 12_345, 30_000] { + assert_eq!(s.seek(target as u64).unwrap(), target as u64); + let mut out = vec![0.0; 2 * 256]; + assert_eq!(s.fill(&mut out).unwrap(), 256); + assert_eq!(s.position(), target as u64 + 256); + // Seeking an MP3 with no index is inexact, so the check is that the same decoded frame is reached. + reference.seek((target + MP3_FRAME) as u64).unwrap(); + let mut expected = vec![0.0; 2 * 256]; + reference.fill(&mut expected).unwrap(); + assert_eq!(out, expected, "seek to {target}"); + } + s.seek(0).unwrap(); + let mut start = vec![0.0; 2 * 256]; + s.fill(&mut start).unwrap(); + assert_eq!(start, trimmed[..2 * 256]); + } + + #[test] + fn a_negative_lead_stays_aligned_when_resampled() { + use mp3lame_encoder::Mode; + let plain = mp3("plain-48", &encode(Mode::Stereo, false)); + let negative = mp3("negative-48", &joint_after_first(encode(Mode::Stereo, false))); + let (_, whole) = read_all(&plain, 48_000); + let (_, trimmed) = read_all(&negative, 48_000); + // 1,152 frames at 44.1 kHz is 1,254.04 at 48 kHz. + let shift = 1_254; + let (whole, trimmed) = (left(&whole), left(&trimmed)); + assert!((whole.len() as i64 - shift as i64 - trimmed.len() as i64).abs() <= 2); + close(&trimmed[2_000..20_000], &whole[2_000 + shift..20_000 + shift], 0.01, "resampled"); + + let mut s = Streamer::open(&negative, 48_000).unwrap(); + let mut reference = Streamer::open(&plain, 48_000).unwrap(); + for target in [0usize, 24_000] { + s.seek(target as u64).unwrap(); + let mut out = vec![0.0; 2 * 512]; + assert_eq!(s.fill(&mut out).unwrap(), 512); + // The same source frame in the unshifted file, which sits `shift` output frames later. + reference.seek((target + shift) as u64).unwrap(); + let mut expected = vec![0.0; 2 * 512]; + reference.fill(&mut expected).unwrap(); + assert_eq!(out, expected, "seek to {target}"); + } + } + + #[test] + fn a_positive_lead_reports_the_decoded_length() { + use mp3lame_encoder::Mode; + let path = mp3("tagged", &encode(Mode::JointStereo, true)); + assert_eq!(rb_audio::rekordbox_lead_frames(&path), MP3_FRAME as i64); + let (total, all) = read_all(&path, 44_100); + assert_eq!(total as usize, all.len() / 2); + assert!(all[..2 * MP3_FRAME].iter().all(|&s| s == 0.0)); + } } diff --git a/native/crates/es-decode/src/mp3.rs b/native/crates/es-decode/src/mp3.rs deleted file mode 100644 index fb505aa..0000000 --- a/native/crates/es-decode/src/mp3.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! Where rekordbox's timeline starts for an MP3. -//! -//! Rekordbox places LAME-encoded MP3s one frame later than the raw decode: its beatgrid, cues and -//! waveforms all sit on that timeline. Measured against its own analysis waveforms, LAME files -//! are one frame late and files without a LAME tag (none at all, or ffmpeg's `Lavc`) line up. -//! Encoder delay is not trimmed either way. - -use std::fs::File; -use std::io::{Read, Seek, SeekFrom}; -use std::path::Path; - -/// Bytes read from the first frame, enough to cover its Xing/Info and LAME tags. -const HEAD_BYTES: usize = 4096; - -/// Source frames of silence to play before the file's audio so it lands where rekordbox has it. -pub fn lead_in(path: &Path) -> u64 { - read_head(path).map_or(0, |head| lead_in_from(&head)) -} - -fn read_head(path: &Path) -> Option> { - let mut file = File::open(path).ok()?; - let mut id3 = [0u8; 10]; - file.read_exact(&mut id3).ok()?; - let start = if &id3[..3] == b"ID3" { - let size = id3[6..10] - .iter() - .fold(0u64, |acc, &b| (acc << 7) | u64::from(b & 0x7f)); - let footer = if id3[5] & 0x10 != 0 { 10 } else { 0 }; - 10 + size + footer - } else { - 0 - }; - file.seek(SeekFrom::Start(start)).ok()?; - let mut head = Vec::with_capacity(HEAD_BYTES); - file.take(HEAD_BYTES as u64).read_to_end(&mut head).ok()?; - Some(head) -} - -/// The lead-in for an MP3 whose first bytes (after any ID3v2 tag) are `head`. -fn lead_in_from(head: &[u8]) -> u64 { - let Some(sync) = head - .windows(2) - .position(|w| w[0] == 0xff && w[1] & 0xe0 == 0xe0) - else { - return 0; - }; - let header = &head[sync..]; - if header.len() < 4 { - return 0; - } - let version = (header[1] >> 3) & 0b11; - let layer = (header[1] >> 1) & 0b11; - // Layer III only: 1152 samples a frame for MPEG-1, 576 for MPEG-2 and 2.5. - if layer != 0b01 { - return 0; - } - let samples = if version == 0b11 { 1152 } else { 576 }; - let frame = &header[..header.len().min(2048)]; - let tagged = ["Xing", "Info"] - .iter() - .find_map(|tag| find(frame, tag.as_bytes())); - match tagged { - Some(at) if find(&frame[at..], b"LAME").is_some() => samples, - _ => 0, - } -} - -fn find(haystack: &[u8], needle: &[u8]) -> Option { - haystack.windows(needle.len()).position(|w| w == needle) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn frame(version_bits: u8, tag: &[u8], encoder: &[u8]) -> Vec { - let mut head = vec![0xff, 0xe2 | (version_bits << 3), 0x90, 0x44]; - head.extend_from_slice(&[0; 32]); - head.extend_from_slice(tag); - head.extend_from_slice(&[0; 116]); - head.extend_from_slice(encoder); - head.resize(1024, 0); - head - } - - #[test] - fn a_lame_tagged_mpeg1_file_starts_a_frame_late() { - assert_eq!(lead_in_from(&frame(0b11, b"Info", b"LAME3.100")), 1152); - assert_eq!(lead_in_from(&frame(0b11, b"Xing", b"LAME3.99r")), 1152); - } - - #[test] - fn mpeg2_frames_are_half_as_long() { - assert_eq!(lead_in_from(&frame(0b10, b"Xing", b"LAME3.100")), 576); - } - - #[test] - fn untagged_and_ffmpeg_files_start_on_time() { - assert_eq!(lead_in_from(&frame(0b11, b"\0\0\0\0", b"\0\0\0\0")), 0); - assert_eq!(lead_in_from(&frame(0b11, b"Info", b"Lavc61.22")), 0); - } -} diff --git a/native/crates/es-engine/Cargo.toml b/native/crates/es-engine/Cargo.toml index 5c73f50..66036a7 100644 --- a/native/crates/es-engine/Cargo.toml +++ b/native/crates/es-engine/Cargo.toml @@ -12,6 +12,7 @@ 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 3b900bd..7fc0bb1 100644 --- a/native/crates/es-engine/src/convert.rs +++ b/native/crates/es-engine/src/convert.rs @@ -16,8 +16,6 @@ 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]; -const MP3_SCAN_BYTES: usize = 1 << 16; -const MP3_DECODER_DELAY: i64 = 529; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { @@ -212,96 +210,11 @@ fn run_ffmpeg(command: &[String], partial: &Path) -> Result<()> { Ok(()) } -/// Samples an MP3 decoder that ignores gapless data adds at the start, and the sample rate, read from the -/// encoder delay in the LAME/Xing Info tag. `None` when the file has no such tag. -fn mp3_delay(path: &Path) -> Option<(i64, i64)> { - let mut file = std::fs::File::open(path).ok()?; - let mut data = vec![0u8; MP3_SCAN_BYTES]; - let mut filled = 0; - while filled < data.len() { - let n = file.read(&mut data[filled..]).ok()?; - if n == 0 { - break; - } - filled += n; - } - data.truncate(filled); - let mut start = 0usize; - if data.starts_with(b"ID3") && data.len() >= 10 { - let size = (usize::from(data[6]) << 21) | (usize::from(data[7]) << 14) | (usize::from(data[8]) << 7) | usize::from(data[9]); - start = 10 + size + if data[5] & 0x10 != 0 { 10 } else { 0 }; - if start >= data.len() { - use std::io::{Seek, SeekFrom}; - file.seek(SeekFrom::Start(start as u64)).ok()?; - data.clear(); - data.resize(MP3_SCAN_BYTES, 0); - let mut got = 0; - while got < data.len() { - let n = file.read(&mut data[got..]).ok()?; - if n == 0 { - break; - } - got += n; - } - data.truncate(got); - start = 0; - } - } - let last = data.len().saturating_sub(4).min(start + 4096); - let (pos, (side, rate, crc)) = (start..last).find_map(|p| frame_header(&data, p).map(|h| (p, h)))?; - let tag = pos + 4 + if crc { 2 } else { 0 } + side; - if !matches!(data.get(tag..tag + 4)?, b"Xing" | b"Info") { - return None; - } - let flags = u32::from_be_bytes(data.get(tag + 4..tag + 8)?.try_into().ok()?); - let lame = tag + 8 + if flags & 1 != 0 { 4 } else { 0 } + if flags & 2 != 0 { 4 } else { 0 } + if flags & 4 != 0 { 100 } else { 0 } + if flags & 8 != 0 { 4 } else { 0 }; - if !matches!(data.get(lame..lame + 4)?, b"LAME" | b"Lavc" | b"Lavf") || lame + 24 > data.len() { - return None; - } - let packed = (i64::from(data[lame + 21]) << 16) | (i64::from(data[lame + 22]) << 8) | i64::from(data[lame + 23]); - let (delay, padding) = (packed >> 12, packed & 0xFFF); - if delay == 0 && padding == 0 { - return None; - } - Some((delay + MP3_DECODER_DELAY, i64::from(rate))) -} - -/// Side info size, sample rate and whether a CRC follows, of the MPEG audio frame header at `pos`. -fn frame_header(data: &[u8], pos: usize) -> Option<(usize, u32, bool)> { - let h = data.get(pos..pos + 4)?; - if h[0] != 0xFF || h[1] & 0xE0 != 0xE0 { - return None; - } - let version = (h[1] >> 3) & 3; - let layer = (h[1] >> 1) & 3; - let rate_index = usize::from((h[2] >> 2) & 3); - if version == 1 || layer != 1 || rate_index == 3 { - return None; - } - let base = [44100u32, 48000, 32000][rate_index]; - let rate = base / if version == 3 { 1 } else if version == 2 { 2 } else { 4 }; - let mono = (h[3] >> 6) == 3; - let side = match (version == 3, mono) { - (true, true) => 17, - (true, false) => 32, - (false, true) => 9, - (false, false) => 17, - }; - Some((side, rate, h[1] & 1 == 0)) -} - -fn mp3_delay_ms(path: &Path) -> i64 { - if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("mp3")) { - return 0; - } - mp3_delay(path).map_or(0, |(samples, rate)| (samples as f64 * 1000.0 / rate as f64).round_ties_even() as i64) -} - -/// Milliseconds that cues and the beat grid move after converting `source` to `target`: a decoder that ignores -/// the LAME tag plays an MP3 later by its encoder delay plus the decoder delay, which ffmpeg trims. +/// Milliseconds that cues and the beat grid 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) -> i64 { - mp3_delay_ms(target) - mp3_delay_ms(source) +pub fn delay_offset_ms(source: &Path, target: &Path) -> Option { + rb_audio::conversion_shift_ms(source, target) } #[derive(Debug, Clone, Default)] @@ -339,7 +252,7 @@ fn convert_one(lib: &mut Library, share: &Path, id: &str, preset: &Preset, mode: let target = convert_file(&source, preset)?; let done = (|| -> Result { let info = es_tags::read(&target).map_err(|e| Error::Other(e.to_string()))?; - let offset = delay_offset_ms(&source, &target); + let offset = delay_offset_ms(&source, &target).unwrap_or(0); let nonzero = |v: i64| (v != 0).then_some(v); let old_files: Vec = files.analysis_path.as_deref().map(|rel| rb_anlz::files(share, rel)).unwrap_or_default().into_iter().filter(|p| p.is_file()).collect(); let carries = old_files.iter().any(|p| p.extension().is_some_and(|e| e == "DAT")); @@ -454,21 +367,4 @@ mod tests { assert_eq!(output_path(&source, ".flac"), dir.path().join("a (1).flac")); assert_eq!(output_path(&source, ".wav"), dir.path().join("a (1).wav")); } - - #[test] - fn an_info_tag_gives_the_encoder_delay() { - let mut frame = vec![0xFF, 0xFB, 0x90, 0x00]; - frame.extend(vec![0u8; 32]); - frame.extend(b"Info"); - frame.extend(0u32.to_be_bytes()); - frame.extend(b"LAME"); - frame.extend(vec![0u8; 17]); - frame.extend([0x00, 0x24, 0x00]); - frame.extend(vec![0u8; 64]); - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("a.mp3"); - std::fs::write(&path, &frame).unwrap(); - assert_eq!(mp3_delay(&path), Some((2 + MP3_DECODER_DELAY, 44100))); - assert_eq!(delay_offset_ms(&dir.path().join("x.wav"), &path), (((2 + MP3_DECODER_DELAY) as f64) * 1000.0 / 44100.0).round() as i64); - } }