From e96b0b2b930663431679a80401f1e249454ab160 Mon Sep 17 00:00:00 2001 From: claw1200 Date: Tue, 27 Oct 2026 19:12:04 +0000 Subject: [PATCH] Convert tracks with ffmpeg on the way to a USB A sync and its review can name a format. The USB job (schema 4) carries it, and es-usb hands the SDK an ffmpeg converter: tracks not already in that format are encoded straight onto the stick, their cues, beat grid and detail waveforms moved by the encoder delay. The library and its files are left as they are. The stick records each conversion, so syncing again with the same format re-encodes and re-copies nothing, and the review says so. Converting gets its own progress phase. The SDK submodule moves to the branch with the converter hook. --- app/src/main/audio/native.ts | 4 +- app/src/main/library.ts | 4 +- app/src/preload/engine.ts | 28 +++++++- es-rekordbox-sdk | 2 +- native/Cargo.lock | 3 + native/crates/es-engine/src/library.rs | 6 +- native/crates/es-engine/src/sync.rs | 39 ++++++++--- native/crates/es-engine/tests/sync.rs | 54 +++++++++++++-- native/crates/es-node/src/library.rs | 8 +-- native/crates/es-node/src/usb.rs | 19 +++-- native/crates/es-usb/Cargo.toml | 3 + native/crates/es-usb/src/convert.rs | 96 ++++++++++++++++++++++++++ native/crates/es-usb/src/main.rs | 34 ++++++++- 13 files changed, 264 insertions(+), 36 deletions(-) create mode 100644 native/crates/es-usb/src/convert.rs diff --git a/app/src/main/audio/native.ts b/app/src/main/audio/native.ts index 209a212..63a327e 100644 --- a/app/src/main/audio/native.ts +++ b/app/src/main/audio/native.ts @@ -128,8 +128,8 @@ export interface NativeLibrary { usbMedia(key: string): Promise usbImportPreview(volume: string, folder: string | null, policy: string | null): Promise usbImport(volume: string, folder: string | null, policy: string | null): Promise - usbSyncReview(volume: string, playlistIds: string[]): Promise - usbSync(volume: string, playlistIds: string[], review?: unknown): Promise + usbSyncReview(volume: string, playlistIds: string[], convert?: unknown): Promise + usbSync(volume: string, playlistIds: string[], review?: unknown, convert?: unknown): Promise usbRecoveryStatus(volume: string): Promise usbRecover(volume: string): Promise removeTracks(ids: string[]): Promise diff --git a/app/src/main/library.ts b/app/src/main/library.ts index 1d17dbd..1b9af1e 100644 --- a/app/src/main/library.ts +++ b/app/src/main/library.ts @@ -39,7 +39,7 @@ const READS: CommandHandlers = { 'usb.libraries': (lib, p) => lib.usbLibraries(text(p?.volumeId)), 'usb.selection': (lib, p) => lib.usbSelection(text(p?.volumeId)), 'usb.tracks': (lib, p) => lib.usbTracks(text(p?.volumeId), p?.playlistId == null ? null : text(p.playlistId)), - 'usb.syncReview': (lib, p) => lib.usbSyncReview(text(p?.volumeId), list(p?.playlistIds)), + 'usb.syncReview': (lib, p) => lib.usbSyncReview(text(p?.volumeId), list(p?.playlistIds), p?.convert), 'usb.recoveryStatus': (lib, p) => lib.usbRecoveryStatus(text(p?.volumeId)), 'usb.detail': (lib, p) => lib.usbDetail(text(p?.id)), 'usb.importPreview': (lib, p) => @@ -95,7 +95,7 @@ const WRITES: CommandHandlers = { 'track.relocate': (lib, p) => lib.trackRelocate(JSON.stringify(p?.moves ?? {})), 'usb.eject': (lib, p) => lib.usbEject(text(p?.volumeId)), 'usb.format': (lib, p) => lib.usbFormat(text(p?.volumeId)), - 'usb.sync': (lib, p) => lib.usbSync(text(p?.volumeId), list(p?.playlistIds), p?.review), + 'usb.sync': (lib, p) => lib.usbSync(text(p?.volumeId), list(p?.playlistIds), p?.review, p?.convert), 'usb.recover': (lib, p) => lib.usbRecover(text(p?.volumeId)), 'usb.import': (lib, p) => lib.usbImport( diff --git a/app/src/preload/engine.ts b/app/src/preload/engine.ts index 91a10ff..aa068ab 100644 --- a/app/src/preload/engine.ts +++ b/app/src/preload/engine.ts @@ -18,10 +18,32 @@ export interface SyncDiagnostic { allowedResolutions: SyncResolution[] } +export interface SyncPlan { + tracks: number + playlists: number + estimatedPeakBytes: number | null + approximate: boolean + /** Selected tracks copied as they are, converted, or left as the stick has them. */ + copy: number + convert: number + keep: number + /** Tracks the sync takes off the stick. */ + remove: number + /** The stick already holds exactly what the sync would write. */ + upToDate: boolean +} + export interface SyncInspection { token: string diagnostics: SyncDiagnostic[] - plan: { tracks: number; playlists: number; estimatedPeakBytes: number | null; approximate: boolean } | null + plan: SyncPlan | null +} + +export interface SyncConversion { + format: string + sampleRate?: number + bitDepth?: number + bitrate?: number } export interface SyncDecisions { @@ -118,8 +140,8 @@ export interface EngineCommandParams { 'track.relocate': { moves: Record } 'usb.eject': { volumeId: string } 'usb.format': { volumeId: string } - 'usb.syncReview': { volumeId: string; playlistIds: string[] } - 'usb.sync': { volumeId: string; playlistIds: string[]; review?: SyncDecisions } + 'usb.syncReview': { volumeId: string; playlistIds: string[]; convert?: SyncConversion } + 'usb.sync': { volumeId: string; playlistIds: string[]; review?: SyncDecisions; convert?: SyncConversion } 'usb.recoveryStatus': { volumeId: string } 'usb.recover': { volumeId: string } 'usb.import': { volumeId: string; folder?: string | null; policy?: PlaylistPolicy } diff --git a/es-rekordbox-sdk b/es-rekordbox-sdk index 62e2c64..c9efec1 160000 --- a/es-rekordbox-sdk +++ b/es-rekordbox-sdk @@ -1 +1 @@ -Subproject commit 62e2c6407d9e95e7d103f67af9f14933a6093506 +Subproject commit c9efec1973378df25efcc3d37380d316df2dc0ef diff --git a/native/Cargo.lock b/native/Cargo.lock index 2ba48a9..b99af06 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -682,6 +682,9 @@ dependencies = [ name = "es-usb" version = "1.9.0" dependencies = [ + "es-convert", + "es-tags", + "rb-anlz", "rb-model", "rb-usb", "serde", diff --git a/native/crates/es-engine/src/library.rs b/native/crates/es-engine/src/library.rs index 1325591..69da2d4 100644 --- a/native/crates/es-engine/src/library.rs +++ b/native/crates/es-engine/src/library.rs @@ -709,6 +709,7 @@ impl LibraryService { volume: &Path, playlist_ids: &[String], decisions: Option<&rb_model::conflict::Decisions>, + convert: Option<&crate::convert::Preset>, emit: &impl Fn(ServiceEvent), ) -> Result { let database = self @@ -716,7 +717,7 @@ impl LibraryService { .as_ref() .ok_or_else(|| crate::Error::Other("Library not open".into()))? .database(); - crate::sync::sync_volume(database, volume, playlist_ids, decisions, &|progress| { + crate::sync::sync_volume(database, volume, playlist_ids, decisions, convert, &|progress| { emit(ServiceEvent::UsbSyncProgress(progress)); }) } @@ -725,13 +726,14 @@ impl LibraryService { &self, volume: &Path, playlist_ids: &[String], + convert: Option<&crate::convert::Preset>, ) -> Result { let database = self .session .as_ref() .ok_or_else(|| crate::Error::Other("Library not open".into()))? .database(); - crate::sync::inspect_volume(database, volume, playlist_ids) + crate::sync::inspect_volume(database, volume, playlist_ids, convert) } pub fn usb_recovery_required(&self, volume: &Path) -> bool { diff --git a/native/crates/es-engine/src/sync.rs b/native/crates/es-engine/src/sync.rs index 85ae9e8..dd38cdc 100644 --- a/native/crates/es-engine/src/sync.rs +++ b/native/crates/es-engine/src/sync.rs @@ -10,11 +10,12 @@ use rb_library::Library; use rb_model::conflict::{Decisions, Inspection}; use serde_json::{json, Value}; +use crate::convert::Preset; use crate::{Error, Result}; /// What rekordbox's own analysis marks a fully analysed track with. const ANALYSED: i64 = 105; -const JOB_SCHEMA_VERSION: u32 = 3; +const JOB_SCHEMA_VERSION: u32 = 4; #[derive(Debug, Clone)] pub enum Progress { @@ -126,7 +127,7 @@ fn track_job(share: &Path, track: &ExportTrack) -> Value { }) } -fn job(share: &Path, volume: &Path, bundle: &Bundle, operation: &str, decisions: Option<&Decisions>) -> Result { +fn job(share: &Path, volume: &Path, bundle: &Bundle, operation: &str, decisions: Option<&Decisions>, convert: Option<&Preset>) -> Result { let tracks: Vec = bundle.tracks.iter().map(|t| track_job(share, t)).collect(); let known: std::collections::HashSet = bundle.tracks.iter().map(|t| num(&t.id)).collect(); let mut playlists = Vec::new(); @@ -154,6 +155,9 @@ fn job(share: &Path, volume: &Path, bundle: &Bundle, operation: &str, decisions: "settingsDir": find_settings().map(|p| p.to_string_lossy().into_owned()), "myTags": tags, "tracks": tracks, "playlists": playlists, "decisions": decisions, + "conversion": convert.map(|p| json!({ + "format": p.format, "bitDepth": p.bit_depth, "sampleRate": p.sample_rate, "bitrate": p.bitrate, + })), })) } @@ -198,21 +202,24 @@ impl UsbPublisher for CommandPublisher { } } -/// Copy the bundle's playlists onto `volume` and publish both device databases. +/// Copy the bundle's playlists onto `volume` and publish both device databases. With `convert`, tracks not +/// already in its format go onto the stick converted; the library and its files are left as they are. pub fn sync_volume( lib: &Library, volume: &Path, playlist_ids: &[String], decisions: Option<&Decisions>, + convert: Option<&Preset>, on_progress: &dyn Fn(Progress), ) -> Result { let publisher = CommandPublisher::discover()?; - sync_volume_with_publisher(lib, volume, playlist_ids, decisions, &publisher, on_progress) + sync_volume_with_publisher(lib, volume, playlist_ids, decisions, convert, &publisher, on_progress) } -pub fn inspect_volume(lib: &Library, volume: &Path, playlist_ids: &[String]) -> Result { +/// What syncing would change. `convert` must be what the sync will be given. +pub fn inspect_volume(lib: &Library, volume: &Path, playlist_ids: &[String], convert: Option<&Preset>) -> Result { let publisher = CommandPublisher::discover()?; - inspect_volume_with_publisher(lib, volume, playlist_ids, &publisher) + inspect_volume_with_publisher(lib, volume, playlist_ids, convert, &publisher) } pub fn recovery_required(volume: &Path) -> bool { @@ -227,13 +234,14 @@ pub fn inspect_volume_with_publisher( lib: &Library, volume: &Path, playlist_ids: &[String], + convert: Option<&Preset>, publisher: &impl UsbPublisher, ) -> Result { let volume = volume.canonicalize() .map_err(|_| Error::Other(format!("Volume is not mounted: {}", volume.display())))?; if !volume.is_dir() { return Err(Error::Other(format!("Volume is not mounted: {}", volume.display()))); } let bundle = lib.export_bundle(playlist_ids)?; - let body = job(&lib.share_dir(), &volume, &bundle, "inspect", None)?; + let body = job(&lib.share_dir(), &volume, &bundle, "inspect", None, convert)?; let result = publisher.publish(&body, &|_| {})?; serde_json::from_value(result.get("inspection").cloned() .ok_or_else(|| Error::Other("USB inspection returned no diagnostics".into()))?) @@ -246,6 +254,7 @@ pub fn sync_volume_with_publisher( volume: &Path, playlist_ids: &[String], decisions: Option<&Decisions>, + convert: Option<&Preset>, publisher: &impl UsbPublisher, on_progress: &dyn Fn(Progress), ) -> Result { @@ -256,7 +265,7 @@ pub fn sync_volume_with_publisher( return Err(Error::Other(format!("Volume is not mounted: {}", volume.display()))); } let bundle = lib.export_bundle(playlist_ids)?; - let body = job(&lib.share_dir(), &volume, &bundle, "export", decisions)?; + let body = job(&lib.share_dir(), &volume, &bundle, "export", decisions, convert)?; let result = publisher.publish(&body, on_progress)?; let tracks = result.get("tracks").and_then(Value::as_i64).unwrap_or(0); let playlists = result.get("playlists").and_then(Value::as_i64).unwrap_or(0); @@ -337,11 +346,20 @@ mod tests { #[test] fn usb_job_declares_its_schema_version() { let directory = tempfile::tempdir().unwrap(); - let body = job(directory.path(), directory.path(), &Bundle::default(), "export", None).unwrap(); + let body = job(directory.path(), directory.path(), &Bundle::default(), "export", None, None).unwrap(); assert_eq!(body["schemaVersion"], JOB_SCHEMA_VERSION); assert_eq!(body["operation"], "export"); assert!(body.get("overwrite").is_none()); + assert!(body["conversion"].is_null()); + } + + #[test] + fn a_conversion_travels_as_a_preset_es_usb_can_check_again() { + let directory = tempfile::tempdir().unwrap(); + let preset = crate::convert::clean_preset(&json!({ "format": "wav", "bitDepth": 24, "sampleRate": 44100 })).unwrap(); + let body = job(directory.path(), directory.path(), &Bundle::default(), "inspect", None, Some(&preset)).unwrap(); + assert_eq!(crate::convert::clean_preset(&body["conversion"]).unwrap(), preset); } #[test] @@ -361,6 +379,7 @@ mod tests { &volume, &["playlist-1".into()], None, + None, &FakePublisher, &|_| {}, ) @@ -380,7 +399,7 @@ mod tests { let library = Library::open_live(&database_path, Guard::Off).unwrap(); let volume = directory.path().join("STICK"); std::fs::create_dir_all(&volume).unwrap(); - let review = inspect_volume_with_publisher(&library, &volume, &["playlist-1".into()], &FakePublisher).unwrap(); + let review = inspect_volume_with_publisher(&library, &volume, &["playlist-1".into()], None, &FakePublisher).unwrap(); assert_eq!(review.token, "fixture"); assert!(review.diagnostics.is_empty()); } diff --git a/native/crates/es-engine/tests/sync.rs b/native/crates/es-engine/tests/sync.rs index d18b868..77cd277 100644 --- a/native/crates/es-engine/tests/sync.rs +++ b/native/crates/es-engine/tests/sync.rs @@ -49,7 +49,7 @@ fn a_playlist_synced_to_a_stick_reads_back_with_its_tracks() { let volume = dir.path().join("STICK"); std::fs::create_dir_all(&volume).unwrap(); let phases = RefCell::new(Vec::new()); - let result = sync_volume(&lib, &volume, &["10".to_owned()], None, &|p| { + let result = sync_volume(&lib, &volume, &["10".to_owned()], None, None, &|p| { if let Progress::Step { phase, .. } = p { phases.borrow_mut().push(phase); } @@ -62,7 +62,7 @@ fn a_playlist_synced_to_a_stick_reads_back_with_its_tracks() { assert_eq!(device.tracks.len(), 2); let mix = device.playlists.iter().find(|p| p.name == "Mix").unwrap(); assert_eq!(mix.track_ids.len(), 2); - let review = es_engine::sync::inspect_volume(&lib, &volume, &["10".to_owned()]).unwrap(); + let review = es_engine::sync::inspect_volume(&lib, &volume, &["10".to_owned()], None).unwrap(); assert!(review.diagnostics.is_empty()); assert_eq!(review.plan.as_ref().unwrap().tracks, 2); @@ -78,7 +78,7 @@ fn a_playlist_synced_to_a_stick_reads_back_with_its_tracks() { let foreign_path = volume.join("PIONEER/rbxport/manifest.json"); std::fs::create_dir_all(foreign_path.parent().unwrap()).unwrap(); std::fs::write(&foreign_path, b"foreign application state").unwrap(); - sync_volume(&lib, &volume, &["10".to_owned()], None, &|_| {}).unwrap(); + sync_volume(&lib, &volume, &["10".to_owned()], None, None, &|_| {}).unwrap(); let after: serde_json::Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); for (before, after) in tracks_before.as_array().unwrap().iter().zip(after["tracks"].as_array().unwrap()) { assert_eq!(before["export_id"], after["export_id"]); @@ -96,5 +96,51 @@ fn syncing_nothing_is_refused() { let lib = Library::open_live(&path, Guard::Off).unwrap(); let volume = dir.path().join("STICK"); std::fs::create_dir_all(&volume).unwrap(); - assert!(sync_volume(&lib, &volume, &[], None, &|_| {}).is_err()); + assert!(sync_volume(&lib, &volume, &[], None, None, &|_| {}).is_err()); +} + +#[test] +fn a_converted_sync_leaves_the_library_alone_and_the_next_changes_nothing() { + es_engine::sync::exporter().expect("Build es-usb and set EXPORTSTUDIO_USB_EXPORT before running sync tests"); + if es_engine::convert::ffmpeg_path().is_err() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let path = fixture::build(dir.path(), 5).unwrap(); + { + let conn = fixture::connect(&path).unwrap(); + Rows(&conn).playlist("10", "Mix", 0).unwrap(); + } + let mut lib = Library::open_live(&path, Guard::Off).unwrap(); + let share = lib.share_dir(); + let music = dir.path().join("music"); + std::fs::create_dir_all(&music).unwrap(); + let file = music.join("One.wav"); + wav(&file, 1); + let out = import_files(&mut lib, &share, &[file.to_string_lossy().into_owned()], &Options { artwork: false, playlist: Some("10".into()) }).unwrap(); + let volume = dir.path().join("STICK"); + std::fs::create_dir_all(&volume).unwrap(); + let mp3 = es_engine::convert::clean_preset(&serde_json::json!({ "format": "mp3", "bitrate": 128 })).unwrap(); + let playlists = ["10".to_owned()]; + + sync_volume(&lib, &volume, &playlists, None, Some(&mp3), &|_| {}).unwrap(); + let device = rb_device::read::read(&volume).unwrap().unwrap(); + assert!(device.tracks[0].path.ends_with("/One.mp3"), "{}", device.tracks[0].path); + assert_eq!(device.tracks[0].bitrate, 128); + assert_eq!(lib.track_files(&out.ids[0]).unwrap().unwrap().folder_path, file.to_string_lossy().replace('\\', "/")); + assert_eq!(std::fs::read_dir(&music).unwrap().count(), 1, "nothing is written beside the library's file"); + + let plan = es_engine::sync::inspect_volume(&lib, &volume, &playlists, Some(&mp3)).unwrap().plan.unwrap(); + assert_eq!((plan.copy, plan.convert, plan.keep), (0, 0, 1)); + let phases = RefCell::new(Vec::new()); + sync_volume(&lib, &volume, &playlists, None, Some(&mp3), &|p| { + if let Progress::Step { phase, .. } = p { + phases.borrow_mut().push(phase); + } + }) + .unwrap(); + assert!(!phases.borrow().iter().any(|p| p == "convert" || p == "copy"), "{:?}", phases.borrow()); + + let plan = es_engine::sync::inspect_volume(&lib, &volume, &playlists, None).unwrap().plan.unwrap(); + assert_eq!((plan.copy, plan.convert), (1, 0), "without conversion the original goes back"); } diff --git a/native/crates/es-node/src/library.rs b/native/crates/es-node/src/library.rs index 7591d00..41c11a5 100644 --- a/native/crates/es-node/src/library.rs +++ b/native/crates/es-node/src/library.rs @@ -813,8 +813,8 @@ impl RekordboxLibrary { } #[napi(ts_return_type = "Promise")] - pub fn usb_sync_review(&self, volume: String, playlist_ids: Vec) -> AsyncTask { - self.run(Op::Usb(UsbOp::SyncReview { volume, playlists: playlist_ids })) + pub fn usb_sync_review(&self, volume: String, playlist_ids: Vec, convert: Option) -> AsyncTask { + self.run(Op::Usb(UsbOp::SyncReview { volume, playlists: playlist_ids, convert })) } #[napi(ts_return_type = "Promise")] @@ -828,8 +828,8 @@ impl RekordboxLibrary { } #[napi(ts_return_type = "Promise")] - pub fn usb_sync(&self, volume: String, playlist_ids: Vec, decisions: Option) -> AsyncTask { - self.run(Op::Usb(UsbOp::Sync { volume, playlists: playlist_ids, decisions })) + pub fn usb_sync(&self, volume: String, playlist_ids: Vec, decisions: Option, convert: Option) -> AsyncTask { + self.run(Op::Usb(UsbOp::Sync { volume, playlists: playlist_ids, decisions, convert })) } } diff --git a/native/crates/es-node/src/usb.rs b/native/crates/es-node/src/usb.rs index c96d2d6..f82a081 100644 --- a/native/crates/es-node/src/usb.rs +++ b/native/crates/es-node/src/usb.rs @@ -24,8 +24,8 @@ pub(crate) enum UsbOp { Media(String), ImportPreview { volume: String, folder: Option, policy: Option }, Import { volume: String, folder: Option, policy: Option }, - SyncReview { volume: String, playlists: Vec }, - Sync { volume: String, playlists: Vec, decisions: Option }, + SyncReview { volume: String, playlists: Vec, convert: Option }, + Sync { volume: String, playlists: Vec, decisions: Option, convert: Option }, RecoveryStatus(String), Recover(String), } @@ -100,6 +100,11 @@ fn sync_review_value(review: &impl serde::Serialize) -> napi::Result) -> napi::Result> { + raw.filter(|v| !v.is_null()).map(es_engine::convert::clean_preset).transpose().map_err(failure) +} + fn volume_value(volume: Volume) -> serde_json::Value { json!({ "id": volume.id, @@ -236,9 +241,10 @@ pub(crate) fn perform(core: &Core, service: &mut LibraryService, op: &UsbOp) -> "failed": outcome.failed.iter().map(|(title, error)| json!({ "title": title, "error": error })).collect::>(), })) } - UsbOp::SyncReview { volume, playlists } => { + UsbOp::SyncReview { volume, playlists, convert } => { let volume = listed(volume)?; - let review = service.usb_sync_review(&volume, playlists).map_err(failure)?; + let convert = preset(convert.as_ref())?; + let review = service.usb_sync_review(&volume, playlists, convert.as_ref()).map_err(failure)?; sync_review_value(&review) } UsbOp::RecoveryStatus(volume) => { @@ -252,11 +258,12 @@ pub(crate) fn perform(core: &Core, service: &mut LibraryService, op: &UsbOp) -> publish_volumes(core); Ok(json!({ "recovered": true })) } - UsbOp::Sync { volume, playlists, decisions } => { + UsbOp::Sync { volume, playlists, decisions, convert } => { let volume = listed(volume)?; let emit = |event| core.emit_service_event(event); let decisions = decisions.as_ref().map(|value| serde_json::from_value(value.clone())).transpose().map_err(failure)?; - let result = service.usb_sync(&volume, playlists, decisions.as_ref(), &emit); + let convert = preset(convert.as_ref())?; + let result = service.usb_sync(&volume, playlists, decisions.as_ref(), convert.as_ref(), &emit); core.devices.forget(&volume); let result = result.map_err(failure)?; publish_volumes(core); diff --git a/native/crates/es-usb/Cargo.toml b/native/crates/es-usb/Cargo.toml index a34ddc6..9755185 100644 --- a/native/crates/es-usb/Cargo.toml +++ b/native/crates/es-usb/Cargo.toml @@ -10,6 +10,9 @@ description = "Export Studio command for USB sync." workspace = true [dependencies] +es-convert.workspace = true +es-tags.workspace = true +rb-anlz.workspace = true rb-model.workspace = true rb-usb.workspace = true serde = { workspace = true } diff --git a/native/crates/es-usb/src/convert.rs b/native/crates/es-usb/src/convert.rs new file mode 100644 index 0000000..95de2f0 --- /dev/null +++ b/native/crates/es-usb/src/convert.rs @@ -0,0 +1,96 @@ +//! Converting tracks with ffmpeg on their way to the stick. + +use std::io; +use std::path::Path; + +use es_convert::Preset; +use rb_usb::{AudioConverter, ConvertedAudio}; + +/// Raised when what an encode produces changes, so sticks converted before are converted again. +const ENCODER_VERSION: u32 = 1; + +pub struct Ffmpeg { + preset: Preset, + profile: String, +} + +impl Ffmpeg { + pub fn new(preset: Preset) -> Self { + let quality = match preset.bitrate { + Some(kbps) => format!("{kbps}k"), + None => format!("{}-{}", preset.bit_depth.unwrap_or(16), preset.sample_rate.unwrap_or(48_000)), + }; + Self { profile: format!("es-ffmpeg-v{ENCODER_VERSION}:{}-{quality}", preset.format), preset } + } +} + +impl AudioConverter for Ffmpeg { + fn profile(&self) -> &str { + &self.profile + } + + fn extension(&self) -> &str { + self.preset.extension.trim_start_matches('.') + } + + fn wants(&self, source: &Path) -> io::Result { + Ok(!es_convert::matches_format(source, &self.preset)) + } + + fn convert(&self, source: &Path, destination: &Path) -> io::Result { + es_convert::encode(source, destination, &self.preset).map_err(io::Error::other)?; + let info = es_tags::read(destination).map_err(io::Error::other)?; + let narrow = |value: i64| u32::try_from(value).unwrap_or(0); + Ok(ConvertedAudio { + offset_ms: es_convert::delay_offset_ms(source, destination).unwrap_or(0), + bitrate: self.preset.bitrate.map_or_else(|| narrow(info.bitrate), narrow), + sample_rate: narrow(info.sample_rate), + bit_depth: u16::try_from(info.bit_depth).unwrap_or(0), + }) + } + + fn shift_analysis(&self, _extension: &str, bytes: &[u8], offset_ms: i64) -> io::Result> { + rb_anlz::write::shift_timeline(bytes, offset_ms) + } + + fn estimate_bytes(&self, duration_sec: u64) -> u64 { + let seconds = duration_sec.saturating_add(1); + let bytes_per_second = match (self.preset.bitrate, self.preset.format) { + (Some(kbps), _) => u64::try_from(kbps).unwrap_or(320) * 1000 / 8, + (None, format) => { + let depth = u64::try_from(self.preset.bit_depth.unwrap_or(16)).unwrap_or(16); + let rate = u64::try_from(self.preset.sample_rate.unwrap_or(48_000)).unwrap_or(48_000); + let pcm = rate * depth / 8 * 2; + // Lossless compression roughly halves music; an estimate only. + if matches!(format, "flac" | "alac") { pcm * 3 / 5 } else { pcm } + } + }; + seconds.saturating_mul(bytes_per_second).saturating_add(64 * 1024) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn preset(raw: &serde_json::Value) -> Option { + es_convert::clean_preset(raw).ok() + } + + #[test] + fn profiles_name_the_format_and_its_quality() { + let mp3 = preset(&serde_json::json!({ "format": "mp3", "bitrate": 320 })).map(Ffmpeg::new); + assert_eq!(mp3.as_ref().map(|c| (c.profile(), c.extension())), Some(("es-ffmpeg-v1:mp3-320k", "mp3"))); + let alac = preset(&serde_json::json!({ "format": "alac", "bitDepth": 24, "sampleRate": 96000 })).map(Ffmpeg::new); + assert_eq!(alac.as_ref().map(|c| (c.profile(), c.extension())), Some(("es-ffmpeg-v1:alac-24-96000", "m4a"))); + } + + #[test] + fn estimates_follow_the_quality() { + let mp3 = preset(&serde_json::json!({ "format": "mp3", "bitrate": 320 })).map(Ffmpeg::new); + let wav = preset(&serde_json::json!({ "format": "wav", "bitDepth": 16, "sampleRate": 44100 })).map(Ffmpeg::new); + let size = |c: Option| c.map_or(0, |c| c.estimate_bytes(59)); + assert_eq!(size(mp3), 60 * 40_000 + 65_536); + assert_eq!(size(wav), 60 * 176_400 + 65_536); + } +} diff --git a/native/crates/es-usb/src/main.rs b/native/crates/es-usb/src/main.rs index 1c62818..cda4794 100644 --- a/native/crates/es-usb/src/main.rs +++ b/native/crates/es-usb/src/main.rs @@ -1,14 +1,17 @@ //! Versioned JSON transport for the application-independent USB export service. +mod convert; + use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; +use std::sync::Arc; use rb_model::source::{Playlist, Source, Tag, Track}; use serde::Deserialize; -const JOB_SCHEMA_VERSION: u32 = 3; +const JOB_SCHEMA_VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] enum JobError { @@ -39,6 +42,9 @@ struct Job { playlists: Vec, #[serde(default)] decisions: Option, + /// The format tracks not already in it are converted to on the stick. + #[serde(default)] + conversion: Option, } fn default_operation() -> String { "export".into() } @@ -52,7 +58,8 @@ fn emit(value: &serde_json::Value) -> io::Result<()> { fn phase(stage: &str) -> &'static str { match stage { - "copying" | "converting" => "copy", + "copying" => "copy", + "converting" => "convert", "database" => "writing", "verifying" => "verifying", "publishing" => "publishing", @@ -72,8 +79,18 @@ fn parse_job(bytes: &[u8]) -> Result { Ok(job) } +fn converter(job: &Job) -> Result>, JobError> { + let Some(raw) = job.conversion.as_ref().filter(|v| !v.is_null()) else { return Ok(None) }; + let preset = es_convert::clean_preset(raw).map_err(|error| JobError::Message(error.to_string()))?; + if job.operation == "export" { + es_convert::ffmpeg_path().map_err(|error| JobError::Message(error.to_string()))?; + } + Ok(Some(Arc::new(convert::Ffmpeg::new(preset)))) +} + fn run(path: &Path) -> Result<(), JobError> { let job = parse_job(&fs::read(path)?)?; + let converter = converter(&job)?; let source = Source { db_id: job.db_id, automatic: job.automatic, @@ -84,6 +101,7 @@ fn run(path: &Path) -> Result<(), JobError> { let options = rb_usb::Options { settings_dir: job.settings_dir, decisions: job.decisions, + converter, }; if job.operation == "inspect" { let inspection = rb_usb::inspect(&job.destination, &source, &options)?; @@ -155,6 +173,18 @@ mod tests { assert!(parse_job(job.to_string().as_bytes()).is_ok()); } + #[test] + fn a_conversion_is_checked_like_the_library_s() { + let job = |conversion: serde_json::Value| { + let raw = serde_json::json!({ "schemaVersion": JOB_SCHEMA_VERSION, "operation": "inspect", "destination": "/tmp", "tracks": [], "playlists": [], "conversion": conversion }); + parse_job(raw.to_string().as_bytes()).and_then(|job| converter(&job)) + }; + assert!(job(serde_json::Value::Null).is_ok_and(|c| c.is_none())); + let wav = job(serde_json::json!({ "format": "wav", "bitDepth": 16, "sampleRate": 44100 })).ok().flatten(); + assert_eq!(wav.map(|c| (c.extension().to_owned(), c.profile().to_owned())), Some(("wav".into(), "es-ffmpeg-v1:wav-16-44100".into()))); + assert!(job(serde_json::json!({ "format": "ogg" })).is_err()); + } + #[test] fn rejects_missing_or_unsupported_job_schema() { assert!(parse_job(br#"{"destination":"/tmp","tracks":[],"playlists":[]}"#).is_err());