diff --git a/Cargo.lock b/Cargo.lock index aec155d..6b31791 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,16 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "aotuv_lancer_vorbis_sys" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc4fd1a61860d2f1198b60bedd30910eaffa978f1ee6214dfb24ac70d589225" +dependencies = [ + "cc", + "ogg_next_sys", +] + [[package]] name = "arboard" version = "3.7.0" @@ -792,6 +802,7 @@ dependencies = [ "anyhow", "blake3", "claudius-decode", + "claudius-encode", "claudius-packs", "criterion", "dirs", @@ -849,14 +860,28 @@ dependencies = [ "symphonia", ] +[[package]] +name = "claudius-encode" +version = "0.2.0" +dependencies = [ + "anyhow", + "claudius-decode", + "flacenc", + "hound", + "opus-pure", + "rusty_mp3", + "tempfile", + "vorbis_rs", +] + [[package]] name = "claudius-packs" version = "0.2.0" dependencies = [ "anyhow", "claudius-decode", + "claudius-encode", "dirs", - "flacenc", "hound", "rayon", "regex", @@ -3782,6 +3807,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "ogg_next_sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2d7a48e247c2bb07e633aefb65a38648ea58c7eedd4e4408a5861721ab049b" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3852,6 +3886,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "opus-pure" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de7c2ad44427a636b27bf0b03eb29e713775ba1b45ff31a0618c19ee5adb215" + [[package]] name = "orbclient" version = "0.3.55" @@ -4730,6 +4770,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "rusty_mp3" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f155ad4adb047b1a477c4b2788a20809e655e5bb7dce3061e34edf5fa92e1976" + [[package]] name = "ryu" version = "1.0.23" @@ -5570,6 +5616,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokenizers" version = "0.23.2" @@ -6006,6 +6058,20 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vorbis_rs" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49c5da94d280f7a27e8c937e9b73df2da3e23a2583f48471fd8fb4c72f9c1933" +dependencies = [ + "aotuv_lancer_vorbis_sys", + "errno", + "getrandom 0.4.3", + "ogg_next_sys", + "thiserror 2.0.21", + "tinyvec", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 69404d9..31faccb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/claudius", "crates/earshot", "crates/claudius-api", "crates/claudius-decode", "crates/claudius-ui", "crates/claudius-packs", "crates/claudius-server", "crates/claudius-cli", "xtask"] +members = ["crates/claudius", "crates/earshot", "crates/claudius-api", "crates/claudius-decode", "crates/claudius-encode", "crates/claudius-ui", "crates/claudius-packs", "crates/claudius-server", "crates/claudius-cli", "xtask"] [workspace.package] version = "0.2.0" diff --git a/_typos.toml b/_typos.toml index 400f096..d0ca7f8 100644 --- a/_typos.toml +++ b/_typos.toml @@ -16,6 +16,7 @@ seeked = "seeked" # the media element event stlye = "stlye" # a misspelled option in a test of the error it gives tak = "tak" # TAK, a lossless audio format, as ffmpeg names its demuxer thr = "thr" # threshold +tpe = "tpe" # TPE1, ID3's frame for the artist trak = "trak" # a misspelled function in an example of an error [files] diff --git a/crates/claudius-cli/src/args.rs b/crates/claudius-cli/src/args.rs index 0b09202..b271112 100644 --- a/crates/claudius-cli/src/args.rs +++ b/crates/claudius-cli/src/args.rs @@ -165,6 +165,27 @@ pub(crate) enum Cmd { #[arg(short, long)] out: Option, }, + /// Export a song as WAV, AIFF, FLAC, Apple Lossless, MP3, AAC, Opus or Ogg Vorbis (`--list` shows them all) + Export { + /// a song folder (its song.wav is the master) or any audio file + song: Option, + /// format[:quality][@rate], repeated or comma-separated: mp3, mp3:192, mp3:v0, wav:16, flac, aac, opus:96 + #[arg(short, long = "format", value_delimiter = ',')] + formats: Vec, + /// the file to write with one format, or the folder for several (default: the song's own folder) + #[arg(short, long)] + out: Option, + /// the title the files carry and are named after (default: the song's) + #[arg(long)] + title: Option, + #[arg(long)] + artist: Option, + #[arg(long)] + album: Option, + /// list the formats, and say which this machine cannot write + #[arg(long)] + list: bool, + }, /// Judge an audio file (with the arrangement and theory checks when its project.json sits next to it) Judge { file: PathBuf, diff --git a/crates/claudius-cli/src/doctor.rs b/crates/claudius-cli/src/doctor.rs index e669b2e..db40106 100644 --- a/crates/claudius-cli/src/doctor.rs +++ b/crates/claudius-cli/src/doctor.rs @@ -79,9 +79,14 @@ pub fn run(port: u16, web: Option) { Some(o) => { let text = String::from_utf8_lossy(&o.stdout); let v = text.split_whitespace().nth(2).unwrap_or("installed").to_string(); - r.line(true, "ffmpeg", format!("{v} (writes song.mp3)")); + let writes = if cfg!(target_os = "macos") { "" } else { "; writes AAC and Apple Lossless" }; + r.line(true, "ffmpeg", format!("{v} (reads the formats claudius cannot{writes})")); } - None => println!(" .. {:<15} not installed (optional: songs are saved as WAV only)", "ffmpeg"), + None => println!( + " .. {:<15} not installed (optional: reads the formats claudius cannot{})", + "ffmpeg", + if cfg!(target_os = "macos") { "" } else { ", and writes AAC and Apple Lossless" } + ), } let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); diff --git a/crates/claudius-cli/src/export.rs b/crates/claudius-cli/src/export.rs new file mode 100644 index 0000000..71fbd35 --- /dev/null +++ b/crates/claudius-cli/src/export.rs @@ -0,0 +1,99 @@ +//! `claudius export`: a song's master as files in other formats. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use claudius::config::SR; +use claudius::export::{self, Spec}; +use claudius::{audio, song}; + +pub struct Request { + pub song: Option, + pub formats: Vec, + pub out: Option, + pub title: Option, + pub artist: Option, + pub album: Option, + pub list: bool, +} + +fn list() { + println!("format[:quality][@rate], e.g. mp3:192, flac:16, wav@44100:\n"); + for p in export::presets() { + let note = p.unavailable().map(|_| " (needs ffmpeg here)").unwrap_or_default(); + println!(" {:<10} {}{note}", p.to_string(), p.label(SR)); + } + println!( + "\nalso: wav:32 (float), aiff:16, alac:16, mp3:128 to 320, mp3:v0 to v9, aac:64 to 320, opus:32 to 256, ogg:0 to 10" + ); +} + +/// the master and the title of `path`: a song folder's song.wav and blueprint title, or an audio file and its name +fn source(path: &Path) -> Result<(PathBuf, String, PathBuf)> { + if path.is_dir() { + let wav = path.join("song.wav"); + if !wav.is_file() { + bail!("{} has no song.wav: render the song first, or name an audio file", path.display()); + } + let title = song::read_blueprint(&path.join("blueprint.json")) + .map(|bp| bp.title) + .unwrap_or_else(|_| path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); + return Ok((wav, title, path.to_path_buf())); + } + if !path.is_file() { + bail!("no such song or file: {}", path.display()); + } + let title = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + Ok((path.to_path_buf(), title, path.parent().map(Path::to_path_buf).unwrap_or_default())) +} + +pub fn run(r: Request) -> Result<()> { + if r.list { + list(); + return Ok(()); + } + let Some(path) = r.song else { + bail!("export what? name a song folder or an audio file (claudius export --list shows the formats)") + }; + if r.formats.is_empty() { + bail!("export as what? --format mp3 (or flac, wav:16, aac, opus ...; claudius export --list shows them)"); + } + let specs: Vec = r.formats.iter().map(|f| f.parse()).collect::>()?; + if let Some(why) = specs.iter().find_map(Spec::unavailable) { + bail!(why); + } + let (master_path, title, folder) = source(&path)?; + let title = r.title.unwrap_or(title); + let mut tags = export::tags(&title); + tags.artist = r.artist.unwrap_or_default(); + tags.album = r.album.unwrap_or_default(); + let stem = match song::slug(&title) { + s if s.is_empty() => "song".to_string(), + s => s, + }; + let files = match r.out { + Some(o) if specs.len() == 1 && !o.is_dir() => vec![o], + Some(o) => export::file_names(&o, &stem, &specs), + None => export::file_names(&folder, &stem, &specs), + }; + let master = audio::load_once(&master_path).with_context(|| format!("cannot read {}", master_path.display()))?; + let t = std::time::Instant::now(); + let written: Vec> = std::thread::scope(|s| { + let jobs: Vec<_> = specs + .iter() + .zip(&files) + .map(|(spec, file)| { + let (master, tags) = (&master, &tags); + s.spawn(move || export::write(master, spec, tags, file).map(|_| (file.clone(), spec.label(SR)))) + }) + .collect(); + jobs.into_iter().map(|j| j.join().expect("export thread")).collect() + }); + for w in written { + let (file, label) = w?; + let mb = std::fs::metadata(&file).map(|m| m.len() as f64 / 1e6).unwrap_or(0.0); + println!("wrote {} ({label}, {mb:.1} MB)", file.display()); + } + eprintln!("exported in {:.1} s", t.elapsed().as_secs_f64()); + Ok(()) +} diff --git a/crates/claudius-cli/src/main.rs b/crates/claudius-cli/src/main.rs index 4a63173..aef062c 100644 --- a/crates/claudius-cli/src/main.rs +++ b/crates/claudius-cli/src/main.rs @@ -16,6 +16,7 @@ use claudius::{genres, song}; mod args; mod connect; mod doctor; +mod export; mod report; mod setup; mod skills; @@ -344,6 +345,9 @@ fn run(cli: Cli) -> Result<()> { let (_, r, w, v) = song::render_and_write(&bp, &dir, stems, Some(&f))?; print_summary(&bp, &r, &w, &v, t.elapsed().as_secs_f64()); } + Cmd::Export { song, formats, out, title, artist, album, list } => { + export::run(export::Request { song, formats, out, title, artist, album, list })?; + } Cmd::Judge { file, genre, ears, json } => { let listening = listen(&ears); let project_genre = file diff --git a/crates/claudius-cli/src/report.rs b/crates/claudius-cli/src/report.rs index a3f894c..58659f5 100644 --- a/crates/claudius-cli/src/report.rs +++ b/crates/claudius-cli/src/report.rs @@ -59,7 +59,7 @@ pub(crate) fn print_summary(bp: &Blueprint, r: &Rendered, w: &song::Written, v: print_ears(e); } println!(" verdict: {}", v.summary); - println!(" wrote {}", w.mp3.as_ref().unwrap_or(&w.wav).display()); + println!(" wrote {}", w.mp3.display()); } pub(crate) fn print_analysis(a: &claudius::analysis::song::Analysis) { diff --git a/crates/claudius-cli/tests/cli.rs b/crates/claudius-cli/tests/cli.rs index 62fbc0d..fbe5733 100644 --- a/crates/claudius-cli/tests/cli.rs +++ b/crates/claudius-cli/tests/cli.rs @@ -86,15 +86,62 @@ fn make_writes_a_song_folder() { .success() .stdout(predicate::str::contains("verdict")); let dir = std::fs::read_dir(&out).unwrap().flatten().next().expect("a song folder").path(); - for f in ["song.wav", "blueprint.json", "verdict.json", "report.json"] { + for f in ["song.wav", "song.mp3", "blueprint.json", "verdict.json", "report.json"] { assert!(dir.join(f).is_file(), "{f} missing in {}", dir.display()); } let verdict: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("verdict.json")).unwrap()).unwrap(); assert!(verdict["total"].as_f64().unwrap() > 50.0); + + // the song in three more formats, named after its title, next to it + let exported = temp_dir("export"); + claudius() + .arg("export") + .arg(&dir) + .args(["-f", "flac:16,mp3:192", "--format", "ogg", "--out"]) + .arg(&exported) + .assert() + .success() + .stdout(predicate::str::contains("FLAC, 16-bit, 44.1 kHz").and(predicate::str::contains("MP3, 192 kbps"))); + let files: Vec<_> = std::fs::read_dir(&exported).unwrap().flatten().map(|e| e.path()).collect(); + assert_eq!(files.len(), 3, "{files:?}"); + let master = claudius_decode::decode_native(&dir.join("song.wav")).unwrap(); + for f in &files { + let d = claudius_decode::decode_native(f).unwrap(); + let rate = if f.extension().is_some_and(|e| e == "flac") { 44_100 } else { 48_000 }; + assert_eq!((d.rate, d.channels), (rate, 2), "{}", f.display()); + let secs = |d: &claudius_decode::Decoded| d.frames() as f64 / d.rate as f64; + assert!((secs(&d) - secs(&master)).abs() < 0.1, "{}: {} s for {} s", f.display(), secs(&d), secs(&master)); + } + std::fs::remove_dir_all(&exported).ok(); std::fs::remove_dir_all(&out).ok(); } +#[test] +fn export_lists_its_formats_and_explains_bad_ones() { + claudius() + .args(["export", "--list"]) + .assert() + .success() + .stdout(predicate::str::contains("flac:16 FLAC, 16-bit, 44.1 kHz").and(predicate::str::contains("opus"))); + let dir = temp_dir("export-bad"); + claudius() + .arg("export") + .arg(&dir) + .args(["-f", "mp3:300"]) + .assert() + .failure() + .stderr(predicate::str::contains("MP3 takes 32, 40")); + claudius() + .arg("export") + .arg(&dir) + .args(["-f", "mp3"]) + .assert() + .failure() + .stderr(predicate::str::contains("has no song.wav: render the song first")); + std::fs::remove_dir_all(&dir).ok(); +} + fn free_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() } diff --git a/crates/claudius-decode/src/lib.rs b/crates/claudius-decode/src/lib.rs index 95d8d43..51f203e 100644 --- a/crates/claudius-decode/src/lib.rs +++ b/crates/claudius-decode/src/lib.rs @@ -109,11 +109,33 @@ pub fn decode_native(path: &Path) -> Result { decode_native_upto(path, MAX_SAMPLES) } +/// decode audio held in memory with symphonia; `ext` names its format as a file extension would (`flac`, `mp3`) +pub fn decode_bytes(bytes: Vec, ext: &str) -> Result { + let mss = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes)), MediaSourceStreamOptions::default()); + decode_stream(mss, Some(ext), MAX_SAMPLES, Path::new(&format!("audio.{ext}"))) +} + fn decode_native_upto(path: &Path, max_samples: usize) -> Result { let file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?; let mss = MediaSourceStream::new(Box::new(file), MediaSourceStreamOptions::default()); + let mut out = decode_stream(mss, path.extension().and_then(|e| e.to_str()), max_samples, path)?; + if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("caf")) + && let Some((priming, valid)) = caf_valid_frames(path) + { + // AAC in CAF starts with encoder priming and ends with padding; a loop must be exactly its bars + if let Some(b) = priming.checked_add(valid).and_then(|frames| frames.checked_mul(out.channels)) + && b <= out.samples.len() + { + out.samples = out.samples[priming * out.channels..b].to_vec(); + } + } + Ok(out) +} + +/// `name` is what a too-long error names +fn decode_stream(mss: MediaSourceStream, ext: Option<&str>, max_samples: usize, name: &Path) -> Result { let mut hint = Hint::new(); - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if let Some(ext) = ext { hint.with_extension(ext); } let mut format = @@ -150,7 +172,7 @@ fn decode_native_upto(path: &Path, max_samples: usize) -> Result { } audio.copy_to_vec_interleaved(&mut packet_samples); if out.samples.len() + packet_samples.len() > max_samples { - return Err(TooLong(path.to_path_buf()).into()); + return Err(TooLong(name.to_path_buf()).into()); } out.samples.extend_from_slice(&packet_samples); } @@ -162,16 +184,6 @@ fn decode_native_upto(path: &Path, max_samples: usize) -> Result { if out.rate == 0 || out.channels == 0 { bail!("cannot tell the sample rate or channel count"); } - if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("caf")) - && let Some((priming, valid)) = caf_valid_frames(path) - { - // AAC in CAF starts with encoder priming and ends with padding; a loop must be exactly its bars - if let Some(b) = priming.checked_add(valid).and_then(|frames| frames.checked_mul(out.channels)) - && b <= out.samples.len() - { - out.samples = out.samples[priming * out.channels..b].to_vec(); - } - } Ok(out) } diff --git a/crates/claudius-encode/Cargo.toml b/crates/claudius-encode/Cargo.toml new file mode 100644 index 0000000..1e6f72c --- /dev/null +++ b/crates/claudius-encode/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "claudius-encode" +description = "Audio file encoding for claudius: WAV, AIFF, FLAC, MP3, Opus and Ogg Vorbis written in process, AAC and Apple Lossless by the system's encoder" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[dependencies] +anyhow = "1" +claudius-decode = { version = "0.2.0", path = "../claudius-decode" } +flacenc = "0.5" +hound = "3" +opus-pure = "0.2" +rusty_mp3 = "1" +vorbis_rs = "0.5" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/claudius-encode/src/flac.rs b/crates/claudius-encode/src/flac.rs new file mode 100644 index 0000000..375617b --- /dev/null +++ b/crates/claudius-encode/src/flac.rs @@ -0,0 +1,226 @@ +//! FLAC through flacenc, each encoding read back before it is kept. flacenc 0.5 picks a subframe's predictor by an +//! estimate of its cost, which misjudges some 24-bit audio: a song's master shrinks to 97 % of its samples instead of +//! 82 %, and a harmonica note of 600 KB became 438 MB that no decoder reads back. Counting the bits instead fixes +//! both, and fails on other audio (a tone of 40 harmonics grows 1,300 times). So both are tried and the smaller +//! that reads back is kept; when neither does, the samples are stored verbatim, which FLAC can always hold. + +use std::io::Read; +use std::path::Path; + +use anyhow::{Result, anyhow, bail}; + +use crate::Tags; + +#[derive(Clone, Copy)] +enum Predict { + /// flacenc's default: the order by an estimate of its cost + Estimate, + /// the order by the bits it really takes + Count, + /// no prediction: the samples as they are + Verbatim, +} + +/// Lossless FLAC of `samples` (interleaved integers `bits` deep), checked by decoding it: the smaller of the +/// encoder's two ways of choosing predictors that is no larger than the samples and reads back exactly, or the +/// samples stored verbatim. +pub fn encode(samples: &[i32], channels: usize, bits: usize, rate: u32, tags: &Tags) -> Result> { + let limit = bound(samples.len(), bits) + comment_size(tags); + let mut best: Option> = None; + for predict in [Predict::Count, Predict::Estimate] { + let Ok(s) = stream(samples, channels, bits, rate, predict) else { continue }; + let s = with_tags(s, tags); + if s.len() <= limit && best.as_ref().is_none_or(|b| s.len() < b.len()) && reads_back(&s, samples, bits) { + best = Some(s); + } + } + if let Some(b) = best { + return Ok(b); + } + let verbatim = with_tags(stream(samples, channels, bits, rate, Predict::Verbatim)?, tags); + if !reads_back(&verbatim, samples, bits) { + bail!("the audio does not read back from FLAC, even stored verbatim"); + } + Ok(verbatim) +} + +/// The most a FLAC of `samples` interleaved samples `bits` deep should take: the samples themselves, plus frame +/// headers and metadata. Only a broken encoding goes past it. +pub fn bound(samples: usize, bits: usize) -> usize { + samples * bits / 8 + samples / 64 + 8192 +} + +/// A FLAC larger than the audio its header says it holds: one an earlier claudius wrote before conversions were +/// read back. Anything else (not a FLAC, missing, unreadable) is not. +pub fn bloated(path: &Path) -> bool { + let Ok(len) = std::fs::metadata(path).map(|m| m.len()) else { return false }; + let mut head = [0u8; 42]; + let read = std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut head)); + if read.is_err() || &head[..4] != b"fLaC" || head[4] & 0x7f != 0 { + return false; + } + // STREAMINFO: 20 bits of rate, 3 of channels - 1, 5 of bits - 1, 36 of samples per channel + let info = &head[8..]; + let channels = ((info[12] >> 1) & 7) as usize + 1; + let bits = ((((info[12] & 1) << 4) | (info[13] >> 4)) + 1) as usize; + let frames = + (((info[13] & 0x0f) as u64) << 32) | u32::from_be_bytes([info[14], info[15], info[16], info[17]]) as u64; + len as usize > bound(frames as usize * channels, bits) + 64 * 1024 +} + +fn stream(samples: &[i32], channels: usize, bits: usize, rate: u32, predict: Predict) -> Result> { + use flacenc::component::BitRepr; + use flacenc::error::Verify; + let mut config = flacenc::config::Encoder::default(); + match predict { + Predict::Estimate => {} + Predict::Count => config.subframe_coding.fixed.order_sel = flacenc::config::OrderSel::BitCount, + Predict::Verbatim => { + config.subframe_coding.use_fixed = false; + config.subframe_coding.use_lpc = false; + config.stereo_coding.use_leftside = false; + config.stereo_coding.use_rightside = false; + config.stereo_coding.use_midside = false; + } + } + let config = config.into_verified().map_err(|e| anyhow!("FLAC encoder settings: {e:?}"))?; + let source = flacenc::source::MemSource::from_samples(samples, channels, bits, rate as usize); + let stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size) + .map_err(|e| anyhow!("cannot encode FLAC: {e:?}"))?; + let mut sink = flacenc::bitsink::ByteSink::new(); + stream.write(&mut sink).map_err(|e| anyhow!("cannot write FLAC: {e:?}"))?; + let mut bytes = sink.as_slice().to_vec(); + // STREAMINFO's minimum block size excludes the last block (FLAC format, section 8.2); flacenc counts it, which + // makes a fixed-block-size stream look variable and some decoders (symphonia) refuse to open it + if bytes.len() > 12 && &bytes[..4] == b"fLaC" && bytes[4] & 0x7f == 0 { + let max = [bytes[10], bytes[11]]; + bytes[8..10].copy_from_slice(&max); + } + Ok(bytes) +} + +/// whether the FLAC `bytes` decode to exactly `samples` (interleaved, `bits` deep) +fn reads_back(bytes: &[u8], samples: &[i32], bits: usize) -> bool { + let Ok(d) = claudius_decode::decode_bytes(bytes.to_vec(), "flac") else { return false }; + let scale = (1i64 << (bits - 1)) as f32; + d.samples.len() == samples.len() && d.samples.iter().zip(samples).all(|(v, s)| (v * scale).round() as i32 == *s) +} + +/// the VORBIS_COMMENT block of `tags`, header included; empty when there are none +fn comment_block(tags: &Tags) -> Vec { + let fields = tags.fields(); + if fields.is_empty() { + return Vec::new(); + } + let vendor = b"claudius"; + let mut body = Vec::new(); + body.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + body.extend_from_slice(vendor); + body.extend_from_slice(&(fields.len() as u32).to_le_bytes()); + for (k, v) in fields { + let c = format!("{k}={v}"); + body.extend_from_slice(&(c.len() as u32).to_le_bytes()); + body.extend_from_slice(c.as_bytes()); + } + let mut block = vec![0x80 | 4]; + block.extend_from_slice(&(body.len() as u32).to_be_bytes()[1..]); + block.extend(body); + block +} + +fn comment_size(tags: &Tags) -> usize { + comment_block(tags).len() +} + +/// `bytes` with a VORBIS_COMMENT block after its last metadata block, which stops being the last +fn with_tags(mut bytes: Vec, tags: &Tags) -> Vec { + let block = comment_block(tags); + if block.is_empty() || bytes.len() < 8 || &bytes[..4] != b"fLaC" { + return bytes; + } + let mut at = 4; + while at + 4 <= bytes.len() { + let last = bytes[at] & 0x80 != 0; + let len = u32::from_be_bytes([0, bytes[at + 1], bytes[at + 2], bytes[at + 3]]) as usize; + if last { + bytes[at] &= 0x7f; + let end = at + 4 + len; + bytes.splice(end..end, block); + return bytes; + } + at += 4 + len; + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + /// loud 24-bit audio rich in harmonics, which flacenc's default settings encode into more bytes than its samples + fn harmonics() -> Vec { + (0..44_100) + .map(|i| { + let t = i as f64 / 44_100.0; + let sum: f64 = (1..=40).map(|h| (2.0 * std::f64::consts::PI * 784.0 * h as f64 * t).sin()).sum(); + (sum * 7_000_000.0 / 40.0) as i32 + }) + .collect() + } + + fn decoded(bytes: Vec, bits: u32) -> Vec { + let d = claudius_decode::decode_bytes(bytes, "flac").unwrap(); + d.samples.iter().map(|v| (v * (1u32 << (bits - 1)) as f32).round() as i32).collect() + } + + #[test] + fn no_flac_is_larger_than_its_samples() { + let samples = harmonics(); + let bytes = encode(&samples, 1, 24, 44_100, &Tags::default()).unwrap(); + assert!( + bytes.len() <= bound(samples.len(), 24), + "{} bytes for {} bytes of samples", + bytes.len(), + samples.len() * 3 + ); + assert_eq!(decoded(bytes, 24), samples, "lossless"); + } + + #[test] + fn tags_go_after_streaminfo_and_the_audio_stays_the_same() { + let samples: Vec = (0..20_000).map(|i| ((i as f64 * 0.01).sin() * 20_000.0) as i32).collect(); + let tags = Tags { + title: "Glass Garden".into(), + artist: "Ana".into(), + software: "claudius 0.3.0".into(), + ..Default::default() + }; + let bytes = encode(&samples, 2, 16, 48_000, &tags).unwrap(); + assert_eq!(bytes[4] & 0x80, 0, "STREAMINFO is no longer the last block"); + let text = String::from_utf8_lossy(&bytes[..512]).into_owned(); + assert!( + text.contains("TITLE=Glass Garden") + && text.contains("ARTIST=Ana") + && text.contains("ENCODER=claudius 0.3.0") + ); + assert!(!text.contains("ALBUM="), "empty fields are left out"); + assert_eq!(decoded(bytes, 16), samples); + } + + #[test] + fn a_damaged_conversion_is_recognised() { + let dir = tempfile::tempdir().unwrap(); + let flac = dir.path().join("out.flac"); + let samples: Vec = (0..20_000).map(|i| ((i as f64 * 0.01).sin() * 3_000_000.0) as i32).collect(); + std::fs::write(&flac, encode(&samples, 1, 24, 44_100, &Tags::default()).unwrap()).unwrap(); + assert!(!bloated(&flac)); + // what flacenc wrote for some 24-bit notes: frames far larger than the samples they hold + let mut bytes = std::fs::read(&flac).unwrap(); + bytes.extend(std::iter::repeat_n(0xffu8, 200_000)); + std::fs::write(&flac, &bytes).unwrap(); + assert!(bloated(&flac)); + std::fs::write(dir.path().join("x.wav"), b"RIFF....WAVE").unwrap(); + assert!(!bloated(&dir.path().join("x.wav")), "only FLAC files are judged"); + assert!(!bloated(&dir.path().join("missing.flac"))); + } +} diff --git a/crates/claudius-encode/src/lib.rs b/crates/claudius-encode/src/lib.rs new file mode 100644 index 0000000..330bc0f --- /dev/null +++ b/crates/claudius-encode/src/lib.rs @@ -0,0 +1,415 @@ +//! Audio files from samples. WAV, AIFF, FLAC, MP3 and Opus are written by Rust code in this process, and Ogg Vorbis by +//! libvorbis (aoTuV), built in. AAC and Apple Lossless come from the system's encoder: Core Audio's `afconvert` on +//! macOS, ffmpeg elsewhere when it is installed. A [`Spec`] names a format and its quality: `mp3:320`, `flac:16`. + +#![forbid(unsafe_code)] + +pub mod flac; +mod mp3; +mod opus; +mod pcm; +mod resample; +mod system; +mod vorbis; + +use std::fmt; +use std::str::FromStr; + +use anyhow::{Result, bail}; + +pub use resample::convert as resample; + +/// interleaved samples, nominally -1..1 +#[derive(Clone, Copy, Debug)] +pub struct Pcm<'a> { + pub samples: &'a [f32], + pub channels: usize, + pub rate: u32, +} + +/// what a file says about itself; empty fields are left out +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Tags { + pub title: String, + pub artist: String, + pub album: String, + /// a year, or a date as YYYY-MM-DD + pub date: String, + pub comment: String, + /// the program that made the file + pub software: String, +} + +impl Tags { + /// (Vorbis comment name, value) for every field that is set + fn fields(&self) -> Vec<(&'static str, &str)> { + [ + ("TITLE", &self.title), + ("ARTIST", &self.artist), + ("ALBUM", &self.album), + ("DATE", &self.date), + ("COMMENT", &self.comment), + ("ENCODER", &self.software), + ] + .into_iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| (k, v.as_str())) + .collect() + } +} + +/// how deep a lossless sample is +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Depth { + I16, + I24, + F32, +} + +impl Depth { + pub fn bits(self) -> u16 { + match self { + Depth::I16 => 16, + Depth::I24 => 24, + Depth::F32 => 32, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Wav(Depth), + Aiff(Depth), + Flac(Depth), + /// Apple Lossless in an .m4a + Alac(Depth), + /// constant bitrate, kbps + Mp3(u32), + /// variable bitrate at LAME's quality scale: V0 (best) to V9 + Mp3Vbr(u8), + /// kbps, in an .m4a + Aac(u32), + /// kbps, in an .opus (Ogg) + Opus(u32), + /// Vorbis quality 0 to 10, in an .ogg + Vorbis(u8), +} + +/// MPEG-1 Layer III bitrates +const MP3_KBPS: [u32; 14] = [32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]; +const RATES: [u32; 2] = [44_100, 48_000]; + +/// a format and its settings: what a file is to be +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Spec { + pub format: Format, + /// the file's sample rate; `None` takes the format's own (see [`Spec::rate_for`]) + pub rate: Option, +} + +impl Spec { + /// The file's sample rate for audio at `source`: 44.1 kHz for 16-bit lossless files (CD audio), 48 kHz for Opus, + /// which always runs at it, and the source's own otherwise. + pub fn rate_for(&self, source: u32) -> u32 { + if let Some(r) = self.rate { + return r; + } + match self.format { + Format::Opus(_) => 48_000, + Format::Wav(Depth::I16) + | Format::Aiff(Depth::I16) + | Format::Flac(Depth::I16) + | Format::Alac(Depth::I16) => 44_100, + _ => source, + } + } + + pub fn extension(&self) -> &'static str { + match self.format { + Format::Wav(_) => "wav", + Format::Aiff(_) => "aiff", + Format::Flac(_) => "flac", + Format::Alac(_) | Format::Aac(_) => "m4a", + Format::Mp3(_) | Format::Mp3Vbr(_) => "mp3", + Format::Opus(_) => "opus", + Format::Vorbis(_) => "ogg", + } + } + + /// the media type to serve the file as + pub fn mime(&self) -> &'static str { + match self.format { + Format::Wav(_) => "audio/wav", + Format::Aiff(_) => "audio/aiff", + Format::Flac(_) => "audio/flac", + Format::Alac(_) | Format::Aac(_) => "audio/mp4", + Format::Mp3(_) | Format::Mp3Vbr(_) => "audio/mpeg", + Format::Opus(_) | Format::Vorbis(_) => "audio/ogg", + } + } + + pub fn lossless(&self) -> bool { + matches!(self.format, Format::Wav(_) | Format::Aiff(_) | Format::Flac(_) | Format::Alac(_)) + } + + /// what a menu calls it, for audio at `source` Hz: `MP3, 320 kbps`, `FLAC, 16-bit, 44.1 kHz` + pub fn label(&self, source: u32) -> String { + let khz = |r: u32| { + if r.is_multiple_of(1000) { format!("{} kHz", r / 1000) } else { format!("{:.1} kHz", r as f64 / 1000.0) } + }; + let rate = khz(self.rate_for(source)); + let depth = |d: Depth| match d { + Depth::F32 => "32-bit float".to_string(), + d => format!("{}-bit", d.bits()), + }; + let rated = |s: String| if self.rate.is_some() { format!("{s}, {rate}") } else { s }; + match self.format { + Format::Wav(d) => format!("WAV, {}, {rate}", depth(d)), + Format::Aiff(d) => format!("AIFF, {}, {rate}", depth(d)), + Format::Flac(d) => format!("FLAC, {}, {rate}", depth(d)), + Format::Alac(d) => format!("Apple Lossless, {}, {rate}", depth(d)), + Format::Mp3(k) => rated(format!("MP3, {k} kbps")), + Format::Mp3Vbr(q) => rated(format!("MP3, VBR V{q}")), + Format::Aac(k) => rated(format!("AAC, {k} kbps")), + Format::Opus(k) => format!("Opus, {k} kbps"), + Format::Vorbis(q) => rated(format!("Ogg Vorbis, quality {q}")), + } + } + + /// why this machine cannot write it, when it cannot: AAC and Apple Lossless need a system encoder + pub fn unavailable(&self) -> Option { + match self.format { + Format::Aac(_) | Format::Alac(_) if system::encoder().is_none() => Some(format!( + "{} needs Core Audio (macOS) or ffmpeg, and this machine has neither: install ffmpeg, or export MP3 or Opus", + self.label(48_000).split(',').next().unwrap_or_default() + )), + _ => None, + } + } +} + +impl fmt::Display for Spec { + /// the canonical spec: `mp3`, `flac:16`, `opus:96`, `wav@44100` + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let depth = |d: Depth| match d { + Depth::I24 => String::new(), + d => format!(":{}", d.bits()), + }; + match self.format { + Format::Wav(d) => write!(f, "wav{}", depth(d))?, + Format::Aiff(d) => write!(f, "aiff{}", depth(d))?, + Format::Flac(d) => write!(f, "flac{}", depth(d))?, + Format::Alac(d) => write!(f, "alac{}", depth(d))?, + Format::Mp3(320) => write!(f, "mp3")?, + Format::Mp3(k) => write!(f, "mp3:{k}")?, + Format::Mp3Vbr(q) => write!(f, "mp3:v{q}")?, + Format::Aac(256) => write!(f, "aac")?, + Format::Aac(k) => write!(f, "aac:{k}")?, + Format::Opus(160) => write!(f, "opus")?, + Format::Opus(k) => write!(f, "opus:{k}")?, + Format::Vorbis(7) => write!(f, "ogg")?, + Format::Vorbis(q) => write!(f, "ogg:{q}")?, + } + if let Some(r) = self.rate { + write!(f, "@{r}")?; + } + Ok(()) + } +} + +fn number(what: &str, s: &str) -> Result { + s.parse::().map_err(|_| anyhow::anyhow!("'{s}' is not a {what}")) +} + +impl FromStr for Spec { + type Err = anyhow::Error; + + /// `format[:quality][@rate]`: `wav`, `wav:16`, `wav:32` (float), `aiff`, `flac:16`, `alac`, `mp3` (320 kbps), + /// `mp3:192`, `mp3:v0`, `aac:192`, `opus:96`, `ogg:6`, and a rate after `@` (`flac@44100`, `mp3@44.1`) + fn from_str(s: &str) -> Result { + let s = s.trim().to_ascii_lowercase(); + let (body, rate) = match s.split_once('@') { + Some((b, r)) => { + let r = r.trim_end_matches("khz").trim_end_matches('k'); + let hz = match r { + "44.1" => 44_100, + "48" => 48_000, + r => number::("sample rate", r)?, + }; + if !RATES.contains(&hz) { + bail!("a file is written at 44100 or 48000 Hz, not {hz}"); + } + (b, Some(hz)) + } + None => (s.as_str(), None), + }; + let (name, q) = match body.split_once(':') { + Some((n, q)) => (n, Some(q)), + None => (body, None), + }; + let depth = |float: bool| -> Result { + match q { + None | Some("24") => Ok(Depth::I24), + Some("16") => Ok(Depth::I16), + Some("32") | Some("32f") | Some("float") if float => Ok(Depth::F32), + Some(q) => bail!("{name} is 16 or 24 bits{}, not '{q}'", if float { " (or 32, float)" } else { "" }), + } + }; + let kbps = |default: u32, lo: u32, hi: u32| -> Result { + let k = match q { + None => default, + Some(q) => number::("bitrate in kbps", q.trim_end_matches("kbps").trim_end_matches('k'))?, + }; + if !(lo..=hi).contains(&k) { + bail!("{name} runs from {lo} to {hi} kbps, not {k}"); + } + Ok(k) + }; + let format = match name { + "wav" | "wave" => Format::Wav(depth(true)?), + "aif" | "aiff" => Format::Aiff(depth(false)?), + "flac" => Format::Flac(depth(false)?), + "alac" => Format::Alac(depth(false)?), + "mp3" => match q { + Some(v) if v.starts_with('v') => { + let q = number::("VBR quality (V0 to V9)", &v[1..])?; + if q > 9 { + bail!("MP3 VBR runs from V0 (best) to V9, not V{q}"); + } + Format::Mp3Vbr(q) + } + _ => { + let k = kbps(320, 32, 320)?; + if !MP3_KBPS.contains(&k) { + bail!("MP3 takes {} kbps, not {k}", MP3_KBPS.map(|k| k.to_string()).join(", ")); + } + Format::Mp3(k) + } + }, + "aac" | "m4a" => Format::Aac(kbps(256, 64, 320)?), + "opus" => Format::Opus(kbps(160, 32, 256)?), + "ogg" | "vorbis" => { + let q = match q { + None => 7, + Some(q) => number::("Vorbis quality (0 to 10)", q.trim_start_matches('q'))?, + }; + if q > 10 { + bail!("Vorbis quality runs from 0 to 10, not {q}"); + } + Format::Vorbis(q) + } + other => bail!("unknown format '{other}': wav, aiff, flac, alac, mp3, aac, opus or ogg"), + }; + if matches!(format, Format::Opus(_)) && rate.is_some_and(|r| r != 48_000) { + bail!("Opus is always 48 kHz"); + } + Ok(Spec { format, rate }) + } +} + +/// the formats a menu offers, most used first within lossless and lossy +pub fn presets() -> Vec { + ["wav", "wav:16", "aiff", "flac", "flac:16", "alac", "mp3", "mp3:v0", "mp3:192", "aac", "opus", "opus:96", "ogg"] + .iter() + .map(|s| s.parse().expect("preset")) + .collect() +} + +/// Encode `pcm` as `spec`, resampled to the file's rate first when it differs. +pub fn encode(spec: &Spec, pcm: Pcm, tags: &Tags) -> Result> { + if !(1..=2).contains(&pcm.channels) { + bail!("claudius writes mono and stereo files, not {} channels", pcm.channels); + } + if let Some(why) = spec.unavailable() { + bail!(why); + } + let rate = spec.rate_for(pcm.rate); + let resampled; + let pcm = if rate == pcm.rate { + pcm + } else { + resampled = resample::convert(pcm.samples, pcm.channels, pcm.rate, rate); + Pcm { samples: &resampled, channels: pcm.channels, rate } + }; + match spec.format { + Format::Wav(d) => pcm::wav(&pcm, d), + Format::Aiff(d) => pcm::aiff(&pcm, d), + Format::Flac(d) => { + flac::encode(&pcm::quantize(pcm.samples, d), pcm.channels, d.bits() as usize, pcm.rate, tags) + } + Format::Alac(d) => system::alac(&pcm, d, tags), + Format::Mp3(k) => mp3::encode(&pcm, k, None, tags), + Format::Mp3Vbr(q) => mp3::encode(&pcm, 320, Some(q), tags), + Format::Aac(k) => system::aac(&pcm, k, tags), + Format::Opus(k) => opus::encode(&pcm, k, tags), + Format::Vorbis(q) => vorbis::encode(&pcm, q, tags), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn specs_read_and_print_the_same() { + for (s, want) in [ + ("wav", "wav"), + ("WAV:24", "wav"), + ("wav:16", "wav:16"), + ("wav:32", "wav:32"), + ("aif", "aiff"), + ("flac:16@48000", "flac:16@48000"), + ("alac", "alac"), + ("mp3", "mp3"), + ("mp3:320", "mp3"), + ("mp3:192k", "mp3:192"), + ("mp3:V0", "mp3:v0"), + ("m4a", "aac"), + ("aac:128kbps", "aac:128"), + ("opus:96", "opus:96"), + ("vorbis", "ogg"), + ("ogg:q5", "ogg:5"), + ("mp3@44.1", "mp3@44100"), + ("wav@48k", "wav@48000"), + ] { + let spec: Spec = s.parse().unwrap(); + assert_eq!(spec.to_string(), want, "{s}"); + assert_eq!(want.parse::().unwrap(), spec, "{want}"); + } + } + + #[test] + fn bad_specs_say_what_is_allowed() { + for (s, says) in [ + ("mp4", "unknown format 'mp4'"), + ("mp3:300", "MP3 takes 32, 40"), + ("mp3:v12", "V0 (best) to V9"), + ("flac:32", "flac is 16 or 24 bits, not '32'"), + ("opus@44100", "Opus is always 48 kHz"), + ("wav@96000", "44100 or 48000 Hz"), + ("ogg:11", "0 to 10"), + ("aac:500", "64 to 320 kbps"), + ] { + let e = s.parse::().unwrap_err().to_string(); + assert!(e.contains(says), "{s}: {e}"); + } + } + + #[test] + fn rates_and_labels() { + let rate = |s: &str| s.parse::().unwrap().rate_for(48_000); + assert_eq!( + (rate("wav"), rate("wav:16"), rate("flac:16@48000"), rate("mp3"), rate("opus")), + (48_000, 44_100, 48_000, 48_000, 48_000) + ); + let label = |s: &str| s.parse::().unwrap().label(48_000); + assert_eq!(label("wav:16"), "WAV, 16-bit, 44.1 kHz"); + assert_eq!(label("flac"), "FLAC, 24-bit, 48 kHz"); + assert_eq!(label("wav:32"), "WAV, 32-bit float, 48 kHz"); + assert_eq!(label("mp3"), "MP3, 320 kbps"); + assert_eq!(label("mp3:v0@44100"), "MP3, VBR V0, 44.1 kHz"); + assert_eq!(label("opus:96"), "Opus, 96 kbps"); + assert_eq!(label("alac"), "Apple Lossless, 24-bit, 48 kHz"); + assert!(presets().iter().all(|p| p.to_string().parse::().unwrap() == *p)); + } +} diff --git a/crates/claudius-encode/src/mp3.rs b/crates/claudius-encode/src/mp3.rs new file mode 100644 index 0000000..a33ea42 --- /dev/null +++ b/crates/claudius-encode/src/mp3.rs @@ -0,0 +1,129 @@ +//! MP3 through rusty_mp3 (pure Rust; within about 0.1 PEAQ ODG of LAME at 160 kbps and above), with an ID3v2.4 tag. + +use anyhow::{Result, bail}; + +use crate::{Pcm, Tags}; + +/// constant bitrate `kbps`, or LAME's VBR quality `vbr` (0 = best) when it is given +pub(crate) fn encode(pcm: &Pcm, kbps: u32, vbr: Option, tags: &Tags) -> Result> { + if ![32_000, 44_100, 48_000].contains(&pcm.rate) { + bail!("MP3 is written at 32, 44.1 or 48 kHz, not {} Hz", pcm.rate); + } + let mut e = rusty_mp3::Mp3Encoder::new(rusty_mp3::Mp3EncoderConfig { + bitrate_kbps: kbps, + vbr_quality: vbr.map(|q| rusty_mp3::vbr_quality_index(q as f32)), + }); + for chunk in pcm.samples.chunks(pcm.channels * 1152 * 16) { + e.push_pcm_f32(chunk, pcm.channels as u16, pcm.rate)?; + } + // the Xing/Info frame, which players read the length and seek table from, is prepended by finish(): drain after + e.finish(); + let mut out = id3(tags); + loop { + match e.next_packet() { + Ok(p) => out.extend(p), + Err(rusty_mp3::Error::Eof) | Err(rusty_mp3::Error::Again) => break, + Err(err) => return Err(err.into()), + } + } + Ok(out) +} + +/// a 28-bit size as ID3v2 writes it: seven bits a byte +fn synchsafe(n: usize) -> [u8; 4] { + [((n >> 21) & 0x7f) as u8, ((n >> 14) & 0x7f) as u8, ((n >> 7) & 0x7f) as u8, (n & 0x7f) as u8] +} + +/// An ID3v2.4 tag of UTF-8 text frames; nothing when no field is set. +fn id3(tags: &Tags) -> Vec { + let mut frames = Vec::new(); + let mut frame = |id: &[u8; 4], body: Vec| { + frames.extend_from_slice(id); + frames.extend_from_slice(&synchsafe(body.len())); + frames.extend_from_slice(&[0, 0]); + frames.extend(body); + }; + for (id, v) in [ + (b"TIT2", &tags.title), + (b"TPE1", &tags.artist), + (b"TALB", &tags.album), + (b"TDRC", &tags.date), + (b"TSSE", &tags.software), + ] { + if !v.is_empty() { + let mut body = vec![3u8]; + body.extend_from_slice(v.as_bytes()); + frame(id, body); + } + } + if !tags.comment.is_empty() { + // encoding, language, an empty description, the text + let mut body = vec![3u8, b'e', b'n', b'g', 0]; + body.extend_from_slice(tags.comment.as_bytes()); + frame(b"COMM", body); + } + if frames.is_empty() { + return frames; + } + let mut out = b"ID3\x04\x00\x00".to_vec(); + out.extend_from_slice(&synchsafe(frames.len())); + out.extend(frames); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mp3_decodes_to_the_same_music() { + let rate = 48_000; + let n = rate as usize * 2; + let x: Vec = (0..n) + .flat_map(|i| { + let t = i as f32 / rate as f32; + let v = 0.3 * (2.0 * std::f32::consts::PI * 440.0 * t).sin() + + 0.2 * (2.0 * std::f32::consts::PI * 660.0 * t).sin(); + [v, 0.8 * v] + }) + .collect(); + let tags = Tags { title: "Night Bus".into(), software: "claudius".into(), ..Default::default() }; + for (kbps, vbr) in [(320, None), (192, None), (320, Some(0))] { + let bytes = encode(&Pcm { samples: &x, channels: 2, rate }, kbps, vbr, &tags).unwrap(); + assert_eq!(&bytes[..3], b"ID3"); + assert!(bytes.windows(9).any(|w| w == b"Night Bus")); + let tag = 10 + u32::from_be_bytes([bytes[6], bytes[7], bytes[8], bytes[9]]) as usize; + let info = &bytes[tag..tag + 400]; + assert!( + info.windows(4).any(|w| w == b"Info" || w == b"Xing"), + "{kbps} {vbr:?}: the Info frame comes first" + ); + let d = claudius_decode::decode_bytes(bytes.clone(), "mp3").unwrap(); + assert_eq!((d.rate, d.channels), (rate, 2)); + let frames = d.samples.len() / 2; + assert!(frames >= n && frames < n + 3 * 1152, "{frames} frames for {n}"); + // the decoder's delay: the shift that lines the two up best, then how close they are + let corr = |lag: usize| -> f64 { + (rate as usize..rate as usize + 9600).map(|i| x[2 * i] as f64 * d.samples[2 * (i + lag)] as f64).sum() + }; + let lag = (0..2400).max_by(|a, b| corr(*a).partial_cmp(&corr(*b)).unwrap()).unwrap(); + let (mut sig, mut err) = (0.0f64, 0.0f64); + for i in rate as usize..rate as usize + 24_000 { + for c in 0..2 { + let a = x[2 * i + c] as f64; + sig += a * a; + err += (a - d.samples[2 * (i + lag) + c] as f64).powi(2); + } + } + let snr = 10.0 * (sig / err).log10(); + assert!(snr > 20.0, "{kbps} kbps {vbr:?}: {snr:.1} dB"); + } + } + + #[test] + fn no_tags_no_id3() { + assert!(id3(&Tags::default()).is_empty()); + assert_eq!(synchsafe(0x0fff_ffff), [0x7f; 4]); + assert_eq!(synchsafe(257), [0, 0, 2, 1]); + } +} diff --git a/crates/claudius-encode/src/opus.rs b/crates/claudius-encode/src/opus.rs new file mode 100644 index 0000000..b430038 --- /dev/null +++ b/crates/claudius-encode/src/opus.rs @@ -0,0 +1,92 @@ +//! Opus through opus-pure (pure Rust, byte-exact with libopus 1.6 where the format allows), in Ogg: an .opus file. + +use anyhow::{Result, bail}; +use opus_pure::{Application, MAX_PACKET_BYTES, OggOpusWriter, OpusEncoder, OpusHead, OpusTags}; + +use crate::{Pcm, Tags}; + +/// Gapless: the encoder's lookahead is fed and its pre-skip recorded, and the last packet's duration ends the file +/// on the last real sample, so the file decodes to exactly the samples given. +pub(crate) fn encode(pcm: &Pcm, kbps: u32, tags: &Tags) -> Result> { + if pcm.rate != 48_000 { + bail!("Opus is written at 48 kHz, not {} Hz", pcm.rate); + } + let rate = pcm.rate as i32; + let channels = pcm.channels; + let frame = (rate / 50) as usize; + let mut encoder = OpusEncoder::new(rate, channels, Application::Audio)?; + encoder.bitrate_bps = kbps as i32 * 1000; + let head = OpusHead::for_encoder(&encoder, pcm.rate); + let mut comments = OpusTags::new(); + for (k, v) in tags.fields() { + comments.push(k, v)?; + } + let total = pcm.samples.len() / channels; + let frames = (total + head.pre_skip as usize).div_ceil(frame); + let final_granule = u64::from(head.pre_skip) + total as u64; + let mut w = OggOpusWriter::with_tags(Vec::new(), head, comments)?; + let mut packet = vec![0u8; MAX_PACKET_BYTES]; + let per = frame * channels; + let mut block = vec![0.0f32; per]; + for i in 0..frames { + let s = (i * per).min(pcm.samples.len()); + let e = (s + per).min(pcm.samples.len()); + block[..e - s].copy_from_slice(&pcm.samples[s..e]); + block[e - s..].fill(0.0); + let n = encoder.encode(&block, frame, &mut packet)?; + if i + 1 == frames { + let rest = final_granule - w.granule() as u64; + w.write_packet_with_duration(&packet[..n], rest as u32)?; + } else { + w.write_packet(&packet[..n])?; + } + } + Ok(w.finish()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// what opus-pure's decode example does: decode every packet, keep what the head and the granules say is audio + fn decode(bytes: Vec) -> Vec { + use opus_pure::{MAX_PACKET_SAMPLES, OggOpusReader, Trim}; + let mut reader = OggOpusReader::new(std::io::Cursor::new(bytes)).unwrap(); + let head = reader.head().clone(); + let channels = head.channel_count as usize; + let mut decoder = head.decoder(48_000).unwrap(); + let mut trim = Trim::new(&head, 48_000, channels).unwrap(); + let mut block = vec![0.0f32; MAX_PACKET_SAMPLES * channels]; + let mut out = Vec::new(); + for packet in reader.packets() { + let packet = packet.unwrap(); + let n = decoder.decode(&packet.data, MAX_PACKET_SAMPLES, &mut block).unwrap(); + out.extend_from_slice(trim.keep(&packet, &block[..n * channels])); + } + out + } + + #[test] + fn opus_decodes_to_exactly_as_many_samples() { + let n = 48_000 + 123; + let x: Vec = (0..n) + .flat_map(|i| { + let v = 0.4 * (i as f32 * 2.0 * std::f32::consts::PI * 330.0 / 48_000.0).sin(); + [v, -v] + }) + .collect(); + let tags = Tags { title: "Low Tide".into(), ..Default::default() }; + let bytes = encode(&Pcm { samples: &x, channels: 2, rate: 48_000 }, 160, &tags).unwrap(); + assert_eq!(&bytes[..4], b"OggS"); + assert!(bytes.windows(14).any(|w| w == b"TITLE=Low Tide")); + let pcm = decode(bytes); + assert_eq!(pcm.len(), x.len(), "gapless"); + let (mut sig, mut err) = (0.0f64, 0.0f64); + for i in 4800..pcm.len() - 4800 { + sig += (x[i] as f64).powi(2); + err += (x[i] as f64 - pcm[i] as f64).powi(2); + } + let snr = 10.0 * (sig / err).log10(); + assert!(snr > 15.0, "{snr:.1} dB"); + } +} diff --git a/crates/claudius-encode/src/pcm.rs b/crates/claudius-encode/src/pcm.rs new file mode 100644 index 0000000..1a5156d --- /dev/null +++ b/crates/claudius-encode/src/pcm.rs @@ -0,0 +1,151 @@ +//! Uncompressed files: WAV through hound, AIFF written here, and the integer samples both (and FLAC) store. + +use std::io::Cursor; + +use anyhow::Result; + +use crate::{Depth, Pcm}; + +/// Samples as integers `depth` deep, full scale clipped: 24-bit rounded, 16-bit with triangular (TPDF) dither of +/// one LSB, from a fixed seed, so the same audio always becomes the same file. +pub(crate) fn quantize(x: &[f32], depth: Depth) -> Vec { + match depth { + Depth::I24 | Depth::F32 => x.iter().map(|v| (v.clamp(-1.0, 1.0) * 8_388_607.0).round() as i32).collect(), + Depth::I16 => { + let mut state = 0x9E37_79B9_7F4A_7C15u64; + let mut uniform = move || { + // xorshift64*: the top 24 bits, in [0, 1) + state ^= state >> 12; + state ^= state << 25; + state ^= state >> 27; + (state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32 / (1u32 << 24) as f32 + }; + x.iter() + .map(|v| { + let dither = uniform() - uniform(); + (v * 32_767.0 + dither).round().clamp(-32_768.0, 32_767.0) as i32 + }) + .collect() + } + } +} + +pub(crate) fn wav(pcm: &Pcm, depth: Depth) -> Result> { + let spec = hound::WavSpec { + channels: pcm.channels as u16, + sample_rate: pcm.rate, + bits_per_sample: depth.bits(), + sample_format: if depth == Depth::F32 { hound::SampleFormat::Float } else { hound::SampleFormat::Int }, + }; + let mut out = Cursor::new(Vec::new()); + let mut w = hound::WavWriter::new(&mut out, spec)?; + match depth { + Depth::F32 => { + for v in pcm.samples { + w.write_sample(*v)?; + } + } + Depth::I16 => { + for v in quantize(pcm.samples, depth) { + w.write_sample(v as i16)?; + } + } + Depth::I24 => { + for v in quantize(pcm.samples, depth) { + w.write_sample(v)?; + } + } + } + w.finalize()?; + Ok(out.into_inner()) +} + +/// a sample rate as the 80-bit IEEE 754 extended float AIFF stores in its COMM chunk +fn extended(rate: u32) -> [u8; 10] { + let mut out = [0u8; 10]; + if rate == 0 { + return out; + } + let shift = 31 - rate.leading_zeros(); + out[..2].copy_from_slice(&(16_383 + shift as u16).to_be_bytes()); + out[2..].copy_from_slice(&((rate as u64) << (63 - shift)).to_be_bytes()); + out +} + +/// AIFF: a FORM of a COMM chunk (channels, frames, bits, rate) and an SSND chunk of big-endian samples +pub(crate) fn aiff(pcm: &Pcm, depth: Depth) -> Result> { + let bytes = depth.bits() as usize / 8; + let samples = quantize(pcm.samples, depth); + let data = samples.len() * bytes; + let ssnd = 8 + data; + let pad = ssnd % 2; + let form = 4 + (8 + 18) + (8 + ssnd + pad); + let mut out = Vec::with_capacity(8 + form); + out.extend_from_slice(b"FORM"); + out.extend_from_slice(&(form as u32).to_be_bytes()); + out.extend_from_slice(b"AIFF"); + out.extend_from_slice(b"COMM"); + out.extend_from_slice(&18u32.to_be_bytes()); + out.extend_from_slice(&(pcm.channels as u16).to_be_bytes()); + out.extend_from_slice(&((samples.len() / pcm.channels) as u32).to_be_bytes()); + out.extend_from_slice(&depth.bits().to_be_bytes()); + out.extend_from_slice(&extended(pcm.rate)); + out.extend_from_slice(b"SSND"); + out.extend_from_slice(&(ssnd as u32).to_be_bytes()); + out.extend_from_slice(&[0u8; 8]); + for v in samples { + out.extend_from_slice(&v.to_be_bytes()[4 - bytes..]); + } + if pad == 1 { + out.push(0); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tone(n: usize, channels: usize) -> Vec { + (0..n * channels) + .map(|i| ((i / channels) as f32 * 0.031).sin() * 0.7 * if i % channels == 0 { 1.0 } else { -0.5 }) + .collect() + } + + #[test] + fn aiff_sample_rates_are_the_extended_floats_apple_writes() { + assert_eq!(extended(44_100), [0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]); + assert_eq!(extended(48_000), [0x40, 0x0E, 0xBB, 0x80, 0, 0, 0, 0, 0, 0]); + } + + #[test] + fn wav_and_aiff_read_back_as_written() { + for channels in [1usize, 2] { + let x = tone(4801, channels); + let pcm = Pcm { samples: &x, channels, rate: 48_000 }; + for (depth, bits) in [(Depth::I16, 16u32), (Depth::I24, 24)] { + let ints = quantize(&x, depth); + for (name, bytes) in [("wav", wav(&pcm, depth).unwrap()), ("aiff", aiff(&pcm, depth).unwrap())] { + let d = claudius_decode::decode_bytes(bytes, name).unwrap(); + assert_eq!((d.rate, d.channels), (48_000, channels), "{name} {bits}"); + let back: Vec = + d.samples.iter().map(|v| (v * (1u32 << (bits - 1)) as f32).round() as i32).collect(); + assert_eq!(back, ints, "{name} {bits}-bit, {channels} channels: lossless"); + } + } + let d = claudius_decode::decode_bytes(wav(&pcm, Depth::F32).unwrap(), "wav").unwrap(); + assert_eq!(d.samples, x, "float WAV keeps every sample"); + } + } + + #[test] + fn sixteen_bits_are_dithered_the_same_way_every_time() { + let x = vec![0.25f32 / 32_767.0; 10_000]; + let a = quantize(&x, Depth::I16); + assert_eq!(a, quantize(&x, Depth::I16)); + // a quarter of an LSB survives as the average of the dithered samples, instead of rounding to silence + let mean = a.iter().map(|v| *v as f64).sum::() / a.len() as f64; + assert!((mean - 0.25).abs() < 0.05, "{mean}"); + assert!(a.iter().all(|v| v.abs() <= 1)); + } +} diff --git a/crates/claudius-encode/src/resample.rs b/crates/claudius-encode/src/resample.rs new file mode 100644 index 0000000..eabf5e8 --- /dev/null +++ b/crates/claudius-encode/src/resample.rs @@ -0,0 +1,132 @@ +//! Sample-rate conversion for a file at another rate than its audio: a polyphase windowed sinc (Kaiser, β 9.5, about +//! 95 dB of stopband) reaching 80 input samples either side, cutting off at 95 % of the lower rate's Nyquist +//! frequency (21 kHz from 48 to 44.1 kHz). + +const HALF: usize = 80; +const BETA: f64 = 9.5; + +fn gcd(a: u32, b: u32) -> u32 { + if b == 0 { a } else { gcd(b, a % b) } +} + +/// the modified Bessel function I0, by its series +fn bessel_i0(x: f64) -> f64 { + let (mut sum, mut term, mut k) = (1.0, 1.0, 1.0); + while term > 1e-12 * sum { + term *= (x / (2.0 * k)).powi(2); + sum += term; + k += 1.0; + } + sum +} + +/// interleaved `x` at `from` Hz, at `to` Hz instead +pub fn convert(x: &[f32], channels: usize, from: u32, to: u32) -> Vec { + if from == to || x.is_empty() || channels == 0 { + return x.to_vec(); + } + let g = gcd(from, to); + let (up, down) = ((to / g) as usize, (from / g) as usize); + let frames = x.len() / channels; + let out_frames = (frames as u64 * to as u64 / from as u64) as usize; + // the cutoff in cycles per input sample + let fc = 0.5 * 0.95 * (to.min(from) as f64 / from as f64); + let taps = 2 * HALF; + let i0b = bessel_i0(BETA); + // table[phase][j]: the weight of input sample (base + 1 - HALF + j) for an output that sits phase/up past base + let table: Vec> = (0..up) + .map(|phase| { + let mut w: Vec = (0..taps) + .map(|j| { + let t = phase as f64 / up as f64 + (HALF - 1) as f64 - j as f64; + let r = t / HALF as f64; + if r.abs() >= 1.0 { + return 0.0; + } + let a = 2.0 * std::f64::consts::PI * fc * t; + let sinc = if a.abs() < 1e-12 { 1.0 } else { a.sin() / a }; + sinc * bessel_i0(BETA * (1.0 - r * r).sqrt()) / i0b + }) + .collect(); + let sum: f64 = w.iter().sum(); + w.iter_mut().for_each(|v| *v /= sum); + w + }) + .collect(); + let one = |c: usize| -> Vec { + (0..out_frames) + .map(|n| { + let pos = n * down; + let (base, phase) = (pos / up, pos % up); + let first = base as isize + 1 - HALF as isize; + let w = &table[phase]; + let mut acc = 0.0f64; + for (j, wj) in w.iter().enumerate() { + let k = first + j as isize; + if k >= 0 && (k as usize) < frames { + acc += x[k as usize * channels + c] as f64 * wj; + } + } + acc as f32 + }) + .collect() + }; + let planes: Vec> = std::thread::scope(|s| { + let handles: Vec<_> = (0..channels).map(|c| s.spawn(move || one(c))).collect(); + handles.into_iter().map(|h| h.join().expect("resampler thread")).collect() + }); + let mut out = Vec::with_capacity(out_frames * channels); + for n in 0..out_frames { + for p in &planes { + out.push(p[n]); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sine(f: f64, rate: u32, n: usize) -> Vec { + (0..n).map(|i| (2.0 * std::f64::consts::PI * f * i as f64 / rate as f64).sin() as f32 * 0.5).collect() + } + + fn rms(x: &[f32]) -> f64 { + (x.iter().map(|v| (*v as f64).powi(2)).sum::() / x.len() as f64).sqrt() + } + + #[test] + fn a_tone_keeps_its_pitch_and_level() { + let x = sine(1000.0, 48_000, 48_000); + let y = convert(&x, 1, 48_000, 44_100); + assert_eq!(y.len(), 44_100); + let want = sine(1000.0, 44_100, 44_100); + // the filter is symmetric, so the output lines up with the input: compare away from the edges + let err: Vec = y[400..44_000].iter().zip(&want[400..44_000]).map(|(a, b)| a - b).collect(); + let snr = 20.0 * (rms(&want[400..44_000]) / rms(&err)).log10(); + assert!(snr > 90.0, "{snr:.1} dB"); + } + + #[test] + fn what_cannot_be_kept_is_filtered_out() { + // 23 kHz exists at 48 kHz and not at 44.1 kHz: it must go, not fold down to 21.1 kHz + let x = sine(23_000.0, 48_000, 48_000); + let y = convert(&x, 1, 48_000, 44_100); + let level = 20.0 * (rms(&y[400..44_000]) / rms(&x)).log10(); + assert!(level < -80.0, "{level:.1} dB left"); + // 20 kHz passes + let x = sine(20_000.0, 48_000, 48_000); + let level = 20.0 * (rms(&convert(&x, 1, 48_000, 44_100)[400..44_000]) / rms(&x)).log10(); + assert!(level.abs() < 0.2, "{level:.2} dB"); + } + + #[test] + fn channels_stay_apart() { + let (l, r) = (sine(500.0, 48_000, 4800), sine(700.0, 48_000, 4800)); + let x: Vec = l.iter().zip(&r).flat_map(|(a, b)| [*a, *b]).collect(); + let y = convert(&x, 2, 48_000, 44_100); + let yl: Vec = y.iter().step_by(2).copied().collect(); + assert_eq!(yl, convert(&l, 1, 48_000, 44_100)); + } +} diff --git a/crates/claudius-encode/src/system.rs b/crates/claudius-encode/src/system.rs new file mode 100644 index 0000000..708363b --- /dev/null +++ b/crates/claudius-encode/src/system.rs @@ -0,0 +1,120 @@ +//! AAC and Apple Lossless by the system's encoder, in an .m4a: Core Audio's `afconvert` on macOS (Apple's AAC +//! encoder, the best there is), ffmpeg elsewhere when it is installed. The Rust AAC encoders are not fast enough yet: +//! the best one takes longer than the song to encode it. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use anyhow::{Context, Result, bail}; + +use crate::{Depth, Pcm, Tags, pcm}; + +pub(crate) enum Encoder { + Afconvert(PathBuf), + Ffmpeg(PathBuf), +} + +/// the encoder this machine has: afconvert on macOS, otherwise ffmpeg when it is installed +pub(crate) fn encoder() -> Option { + let afconvert = Path::new("/usr/bin/afconvert"); + if cfg!(target_os = "macos") && afconvert.is_file() { + return Some(Encoder::Afconvert(afconvert.to_path_buf())); + } + claudius_decode::ffmpeg().map(Encoder::Ffmpeg) +} + +pub(crate) fn aac(pcm: &Pcm, kbps: u32, tags: &Tags) -> Result> { + if ![44_100, 48_000].contains(&pcm.rate) { + bail!("AAC is written at 44.1 or 48 kHz, not {} Hz", pcm.rate); + } + // constrained VBR at the highest quality, as iTunes Plus is made + let bps = (kbps * 1000).to_string(); + run( + pcm, + Depth::I24, + tags, + &["-f", "m4af", "-d", "aac", "-b", &bps, "-s", "2", "-q", "127"], + &["-c:a", "aac", "-b:a", &format!("{kbps}k")], + ) +} + +pub(crate) fn alac(pcm: &Pcm, depth: Depth, tags: &Tags) -> Result> { + run(pcm, depth, tags, &["-f", "m4af", "-d", "alac"], &["-c:a", "alac"]) +} + +static SCRATCH: AtomicUsize = AtomicUsize::new(0); + +/// Write `pcm` as a WAV `depth` deep into a scratch folder, have the encoder turn it into an .m4a, and return that. +/// ffmpeg also writes the tags; afconvert cannot. +fn run(pcm: &Pcm, depth: Depth, tags: &Tags, afconvert: &[&str], ffmpeg: &[&str]) -> Result> { + let Some(encoder) = encoder() else { + bail!("no AAC encoder here: Core Audio is on macOS only, and ffmpeg is not installed") + }; + let dir = std::env::temp_dir().join(format!( + "claudius-encode-{}-{}", + std::process::id(), + SCRATCH.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", dir.display()))?; + let result = (|| -> Result> { + let (input, output) = (dir.join("in.wav"), dir.join("out.m4a")); + std::fs::write(&input, pcm::wav(pcm, depth)?)?; + let mut cmd; + match &encoder { + Encoder::Afconvert(p) => { + cmd = Command::new(p); + cmd.args(afconvert).arg(&input).arg(&output); + } + Encoder::Ffmpeg(p) => { + cmd = Command::new(p); + cmd.args(["-v", "error", "-nostdin", "-y"]).args(claudius_decode::ffmpeg_input(&input)).args(ffmpeg); + for (k, v) in tags.fields() { + let key = if k == "ENCODER" { "encoder".to_string() } else { k.to_ascii_lowercase() }; + cmd.arg("-metadata").arg(format!("{key}={v}")); + } + cmd.arg(&output); + } + } + let out = cmd.output().context("cannot run the system's encoder")?; + if !out.status.success() { + bail!("the system's encoder failed: {}", String::from_utf8_lossy(&out.stderr).trim()); + } + Ok(std::fs::read(&output)?) + })(); + let _ = std::fs::remove_dir_all(&dir); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aac_and_apple_lossless_where_there_is_an_encoder() { + if encoder().is_none() { + #[cfg(target_os = "macos")] + panic!("every Mac has afconvert"); + #[cfg(not(target_os = "macos"))] + return; + } + let n = 48_000; + let x: Vec = (0..n) + .flat_map(|i| { + let v = 0.4 * (i as f32 * 2.0 * std::f32::consts::PI * 440.0 / 48_000.0).sin(); + [v, -v] + }) + .collect(); + let pcm = Pcm { samples: &x, channels: 2, rate: 48_000 }; + let aac = aac(&pcm, 256, &Tags::default()).unwrap(); + assert_eq!(&aac[4..8], b"ftyp"); + let d = claudius_decode::decode_bytes(aac, "m4a").unwrap(); + assert_eq!((d.rate, d.channels), (48_000, 2)); + assert!(d.samples.len() >= x.len(), "{} for {}", d.samples.len(), x.len()); + let alac = alac(&pcm, Depth::I24, &Tags::default()).unwrap(); + let d = claudius_decode::decode_bytes(alac, "m4a").unwrap(); + let ints = pcm::quantize(&x, Depth::I24); + let back: Vec = d.samples.iter().map(|v| (v * 8_388_608.0).round() as i32).collect(); + assert_eq!(back, ints, "Apple Lossless is lossless"); + } +} diff --git a/crates/claudius-encode/src/vorbis.rs b/crates/claudius-encode/src/vorbis.rs new file mode 100644 index 0000000..63a0053 --- /dev/null +++ b/crates/claudius-encode/src/vorbis.rs @@ -0,0 +1,63 @@ +//! Ogg Vorbis through vorbis_rs: libvorbis with the aoTuV tuning, compiled in. + +use std::num::{NonZeroU8, NonZeroU32}; + +use anyhow::{Result, anyhow}; +use vorbis_rs::{VorbisBitrateManagementStrategy, VorbisEncoderBuilder}; + +use crate::{Pcm, Tags}; + +/// the Ogg stream serial: fixed, so the same audio always makes the same file +const SERIAL: i32 = 0x636c_6175; + +/// quality `q` on Vorbis' 0..10 scale (7 is about 224 kbps) +pub(crate) fn encode(pcm: &Pcm, q: u8, tags: &Tags) -> Result> { + let rate = NonZeroU32::new(pcm.rate).ok_or_else(|| anyhow!("no sample rate"))?; + let channels = NonZeroU8::new(pcm.channels as u8).ok_or_else(|| anyhow!("no channels"))?; + let mut builder = VorbisEncoderBuilder::new(rate, channels, Vec::new())?; + builder + .stream_serial(SERIAL) + .bitrate_management_strategy(VorbisBitrateManagementStrategy::QualityVbr { target_quality: q as f32 / 10.0 }); + builder.comment_tags(tags.fields())?; + let mut e = builder.build()?; + let planar: Vec> = + (0..pcm.channels).map(|c| pcm.samples.iter().skip(c).step_by(pcm.channels).copied().collect()).collect(); + let n = planar[0].len(); + for start in (0..n).step_by(4096) { + let end = (start + 4096).min(n); + e.encode_audio_block(planar.iter().map(|ch| &ch[start..end]).collect::>())?; + } + Ok(e.finish()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vorbis_decodes_to_the_same_music_and_the_same_bytes_twice() { + let n = 48_000; + let x: Vec = (0..n) + .flat_map(|i| { + let v = 0.4 * (i as f32 * 2.0 * std::f32::consts::PI * 523.25 / 48_000.0).sin(); + [v, v * 0.5] + }) + .collect(); + let tags = Tags { title: "Paper Moon".into(), ..Default::default() }; + let pcm = Pcm { samples: &x, channels: 2, rate: 48_000 }; + let bytes = encode(&pcm, 7, &tags).unwrap(); + assert_eq!(bytes, encode(&pcm, 7, &tags).unwrap(), "deterministic"); + assert!(bytes.windows(16).any(|w| w == b"TITLE=Paper Moon")); + let d = claudius_decode::decode_bytes(bytes, "ogg").unwrap(); + assert_eq!((d.rate, d.channels), (48_000, 2)); + assert!((d.samples.len() as i64 - x.len() as i64).abs() <= 2 * 2048, "{} for {}", d.samples.len(), x.len()); + let n = x.len().min(d.samples.len()) - 2 * 9600; + let (mut sig, mut err) = (0.0f64, 0.0f64); + for (a, b) in x.iter().zip(&d.samples).skip(9600).take(n) { + sig += (*a as f64).powi(2); + err += (*a as f64 - *b as f64).powi(2); + } + let snr = 10.0 * (sig / err).log10(); + assert!(snr > 20.0, "{snr:.1} dB"); + } +} diff --git a/crates/claudius-packs/Cargo.toml b/crates/claudius-packs/Cargo.toml index 6938e96..09b5f71 100644 --- a/crates/claudius-packs/Cargo.toml +++ b/crates/claudius-packs/Cargo.toml @@ -21,8 +21,8 @@ path = "src/bin/claudius-packs.rs" [dependencies] anyhow = "1" claudius-decode = { version = "0.2.0", path = "../claudius-decode" } +claudius-encode = { version = "0.2.0", path = "../claudius-encode" } dirs = "7" -flacenc = "0.5" hound = "3" rayon = "1" regex = "1" diff --git a/crates/claudius-packs/src/fetch.rs b/crates/claudius-packs/src/fetch.rs index 862d75b..dfaf48d 100644 --- a/crates/claudius-packs/src/fetch.rs +++ b/crates/claudius-packs/src/fetch.rs @@ -263,10 +263,8 @@ fn pcm_fmt_trimmed(bytes: &[u8]) -> Option> { trimmed.then_some(out) } -/// lossless WAV -> FLAC (keeps rate, channels and bit depth; float WAV becomes 24-bit). Each file is read back and -/// compared with the samples it holds: on some 24-bit audio flacenc 0.5 misjudges what a subframe will cost and -/// writes frames of megabytes that no decoder reads back (a harmonica note of 600 KB became 438 MB). Such a file is -/// written again verbatim, which FLAC can always hold. +/// lossless WAV -> FLAC (keeps rate, channels and bit depth; float WAV becomes 24-bit), read back before it is kept +/// ([`claudius_encode::flac::encode`]) pub fn transcode_flac(src: &Path, dst: &Path) -> Result<()> { let raw = std::fs::read(src).with_context(|| format!("cannot read {}", src.display()))?; let bytes = match hound::WavReader::new(std::io::Cursor::new(&raw[..])) { @@ -288,79 +286,15 @@ pub fn transcode_flac(src: &Path, dst: &Path) -> Result<()> { 24, ), }; - let (channels, rate) = (spec.channels as usize, spec.sample_rate as usize); - let flac = flac_bytes(&samples, channels, bits, rate, false) - .with_context(|| format!("cannot encode {} as FLAC", src.display()))?; - let write = |bytes: &[u8]| std::fs::write(dst, bytes).with_context(|| format!("cannot write {}", dst.display())); - if flac.len() <= flac_bound(samples.len(), bits) { - write(&flac)?; - if reads_back(dst, &samples, bits) { - return Ok(()); - } - } - write(&flac_bytes(&samples, channels, bits, rate, true)?)?; - if !reads_back(dst, &samples, bits) { - bail!("{} does not read back from FLAC, even stored verbatim", src.display()); - } - Ok(()) -} - -/// The most a FLAC of these samples should take: the samples themselves, plus frame headers and metadata. Only a -/// broken encoding goes past it. -fn flac_bound(samples: usize, bits: usize) -> usize { - samples * bits / 8 + samples / 64 + 8192 -} - -fn flac_bytes(samples: &[i32], channels: usize, bits: usize, rate: usize, verbatim: bool) -> Result> { - use flacenc::component::BitRepr; - use flacenc::error::Verify; - let mut config = flacenc::config::Encoder::default(); - if verbatim { - config.subframe_coding.use_fixed = false; - config.subframe_coding.use_lpc = false; - config.stereo_coding.use_leftside = false; - config.stereo_coding.use_rightside = false; - config.stereo_coding.use_midside = false; - } - let config = config.into_verified().map_err(|e| anyhow!("FLAC encoder settings: {e:?}"))?; - let source = flacenc::source::MemSource::from_samples(samples, channels, bits, rate); - let stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size) - .map_err(|e| anyhow!("cannot encode FLAC: {e:?}"))?; - let mut sink = flacenc::bitsink::ByteSink::new(); - stream.write(&mut sink).map_err(|e| anyhow!("cannot write FLAC: {e:?}"))?; - let mut bytes = sink.as_slice().to_vec(); - // STREAMINFO's minimum block size excludes the last block (FLAC format, section 8.2); flacenc counts it, which - // makes a fixed-block-size stream look variable and some decoders (symphonia) refuse to open it - if bytes.len() > 12 && &bytes[..4] == b"fLaC" && bytes[4] & 0x7f == 0 { - let max = [bytes[10], bytes[11]]; - bytes[8..10].copy_from_slice(&max); - } - Ok(bytes) -} - -/// whether the FLAC at `path` decodes to exactly `samples` (interleaved, `bits` deep) -fn reads_back(path: &Path, samples: &[i32], bits: usize) -> bool { - let Ok(d) = claudius_decode::decode_native(path) else { return false }; - let scale = (1i64 << (bits - 1)) as f32; - d.samples.len() == samples.len() && d.samples.iter().zip(samples).all(|(v, s)| (v * scale).round() as i32 == *s) -} - -/// A FLAC that claudius converted and that is larger than the audio it holds: written by an earlier version, before -/// conversions were read back. Installing the pack again converts it anew. -pub fn bloated_flac(path: &Path) -> bool { - let Ok(len) = std::fs::metadata(path).map(|m| m.len()) else { return false }; - let mut head = [0u8; 42]; - let read = std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut head)); - if read.is_err() || &head[..4] != b"fLaC" || head[4] & 0x7f != 0 { - return false; - } - // STREAMINFO: 20 bits of rate, 3 of channels - 1, 5 of bits - 1, 36 of samples per channel - let info = &head[8..]; - let channels = ((info[12] >> 1) & 7) as usize + 1; - let bits = ((((info[12] & 1) << 4) | (info[13] >> 4)) + 1) as usize; - let frames = - (((info[13] & 0x0f) as u64) << 32) | u32::from_be_bytes([info[14], info[15], info[16], info[17]]) as u64; - len as usize > flac_bound(frames as usize * channels, bits) + let flac = claudius_encode::flac::encode( + &samples, + spec.channels as usize, + bits, + spec.sample_rate, + &claudius_encode::Tags::default(), + ) + .with_context(|| format!("cannot encode {} as FLAC", src.display()))?; + std::fs::write(dst, flac).with_context(|| format!("cannot write {}", dst.display())) } #[derive(Clone, Debug)] @@ -536,66 +470,6 @@ pub fn walk(dir: &Path) -> Vec<(String, u64)> { #[cfg(test)] mod flac_tests { - fn write_wav(path: &std::path::Path, bits: u16, samples: &[i32]) { - let spec = hound::WavSpec { - channels: 1, - sample_rate: 44_100, - bits_per_sample: bits, - sample_format: hound::SampleFormat::Int, - }; - let mut w = hound::WavWriter::create(path, spec).unwrap(); - for s in samples { - w.write_sample(*s).unwrap(); - } - w.finalize().unwrap(); - } - - fn decoded(path: &std::path::Path, bits: u32) -> Vec { - let d = claudius_decode::decode_native(path).unwrap(); - d.samples.iter().map(|v| (v * (1u32 << (bits - 1)) as f32).round() as i32).collect() - } - - /// loud 24-bit audio rich in harmonics, which flacenc's default settings encode into more bytes than its samples - fn harmonics() -> Vec { - (0..44_100) - .map(|i| { - let t = i as f64 / 44_100.0; - let sum: f64 = (1..=40).map(|h| (2.0 * std::f64::consts::PI * 784.0 * h as f64 * t).sin()).sum(); - (sum * 7_000_000.0 / 40.0) as i32 - }) - .collect() - } - - #[test] - fn no_flac_is_larger_than_its_samples() { - let dir = tempfile::tempdir().unwrap(); - let (wav, flac) = (dir.path().join("in.wav"), dir.path().join("out.flac")); - let samples = harmonics(); - write_wav(&wav, 24, &samples); - super::transcode_flac(&wav, &flac).unwrap(); - let len = std::fs::metadata(&flac).unwrap().len() as usize; - assert!(len <= super::flac_bound(samples.len(), 24), "{len} bytes for {} bytes of samples", samples.len() * 3); - assert_eq!(decoded(&flac, 24), samples, "lossless"); - assert!(!super::bloated_flac(&flac)); - } - - #[test] - fn a_damaged_conversion_is_recognised() { - let dir = tempfile::tempdir().unwrap(); - let (wav, flac) = (dir.path().join("in.wav"), dir.path().join("out.flac")); - let samples: Vec = (0..20_000).map(|i| ((i as f64 * 0.01).sin() * 3_000_000.0) as i32).collect(); - write_wav(&wav, 24, &samples); - super::transcode_flac(&wav, &flac).unwrap(); - assert!(!super::bloated_flac(&flac)); - // what flacenc wrote for some 24-bit notes: frames far larger than the samples they hold - let mut bytes = std::fs::read(&flac).unwrap(); - bytes.extend(std::iter::repeat_n(0xffu8, 200_000)); - std::fs::write(&flac, &bytes).unwrap(); - assert!(super::bloated_flac(&flac)); - assert!(!super::bloated_flac(&wav), "only FLAC files are judged"); - assert!(!super::bloated_flac(&dir.path().join("missing.flac"))); - } - #[test] fn a_wav_with_a_long_fmt_chunk_still_becomes_flac() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/claudius-packs/src/install.rs b/crates/claudius-packs/src/install.rs index 1ba7544..d225f67 100644 --- a/crates/claudius-packs/src/install.rs +++ b/crates/claudius-packs/src/install.rs @@ -423,7 +423,9 @@ pub fn install_spec(pack: &PackSpec, opts: &InstallOptions, progress: &dyn Fn(f3 .files .iter() .filter(|f| { - f.flac && fetch::bloated_flac(&dir.join(&f.path)) && std::fs::remove_file(dir.join(&f.path)).is_ok() + f.flac + && claudius_encode::flac::bloated(&dir.join(&f.path)) + && std::fs::remove_file(dir.join(&f.path)).is_ok() }) .count(); if damaged > 0 { diff --git a/crates/claudius-server/src/app/mod.rs b/crates/claudius-server/src/app/mod.rs index 1ef8e03..f55caea 100644 --- a/crates/claudius-server/src/app/mod.rs +++ b/crates/claudius-server/src/app/mod.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; use claudius::ears::Ears; use claudius::library::Library; use claudius::model::*; -use claudius::{audio, config, song}; +use claudius::{audio, config, export, song}; use claudius_api::*; use crate::views; @@ -318,10 +318,10 @@ impl App { "mp3" => { let mp3 = self.cache.join(format!("{version}.mp3")); if !mp3.exists() { - let tmp = self.cache.join(format!("{version}.part.mp3")); - // without ffmpeg there is no MP3: browsers play the WAV just as well - if audio::wav_to_mp3(&wav, &tmp).is_err() || std::fs::rename(&tmp, &mp3).is_err() { - let _ = std::fs::remove_file(&tmp); + let spec: export::Spec = export::FOLDER_MP3.parse().ok()?; + let made = audio::load_once(&wav).and_then(|m| export::write(&m, &spec, &export::tags(""), &mp3)); + // a browser plays the WAV just as well + if made.is_err() { return Some(wav); } } diff --git a/crates/claudius/Cargo.toml b/crates/claudius/Cargo.toml index 8e15001..6d8717c 100644 --- a/crates/claudius/Cargo.toml +++ b/crates/claudius/Cargo.toml @@ -30,6 +30,7 @@ earshot = { path = "../earshot", optional = true } claudius-packs = { path = "../claudius-packs" } rhai = "1.26" claudius-decode = { version = "0.2.0", path = "../claudius-decode" } +claudius-encode = { version = "0.2.0", path = "../claudius-encode" } [dev-dependencies] criterion = "0.8" diff --git a/crates/claudius/src/audio.rs b/crates/claudius/src/audio.rs index 8ea3f54..82397b9 100644 --- a/crates/claudius/src/audio.rs +++ b/crates/claudius/src/audio.rs @@ -1,10 +1,9 @@ -//! Planar stereo buffers, decoding (wav, aiff, flac, mp3, aac / m4a, alac, caf / Apple Loops, ogg), WAV export -//! and the sample-level helpers every module shares. +//! Planar stereo buffers, decoding (wav, aiff, flac, mp3, aac / m4a, alac, caf / Apple Loops, ogg), the song WAV +//! and the sample-level helpers every module shares; [`crate::export`] writes the other formats. use std::path::Path; -use std::process::Command; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result}; use crate::config::{self, SR}; @@ -228,6 +227,11 @@ fn decode_file(path: &Path) -> Result> { Ok(l.iter().zip(&r).flat_map(|(a, b)| [*a, *b]).collect()) } +/// decode any audio file to 48 kHz stereo without keeping a decoded copy: for files read once, like a render's WAV +pub fn load_once(path: &Path) -> Result { + Ok(Stereo::from_interleaved(&decode_file(path)?)) +} + /// decode any audio file to 48 kHz stereo (cached as raw f32 under ~/.cache/claudius/decoded) pub fn load(path: &Path) -> Result { let key = cache_key(path)?; @@ -270,21 +274,6 @@ pub fn save_wav(path: &Path, x: &Stereo, float: bool) -> Result<()> { Ok(()) } -/// MP3 copy of a WAV for quick listening; needs ffmpeg (with libmp3lame), and is skipped without it -pub fn wav_to_mp3(wav: &Path, mp3: &Path) -> Result<()> { - let ffmpeg = claudius_decode::ffmpeg().ok_or_else(|| anyhow::anyhow!("ffmpeg is not installed: no MP3 copy"))?; - let st = Command::new(ffmpeg) - .args(["-v", "error", "-nostdin", "-y"]) - .args(claudius_decode::ffmpeg_input(wav)) - .args(["-codec:a", "libmp3lame", "-b:a", "256k"]) - .arg(mp3) - .status()?; - if !st.success() { - bail!("ffmpeg could not write {}", mp3.display()); - } - Ok(()) -} - const SINC_HALF: i64 = 16; /// read `x` at fractional positions start + i * step (windowed-sinc, anti-aliased when step > 1): the core of diff --git a/crates/claudius/src/export.rs b/crates/claudius/src/export.rs new file mode 100644 index 0000000..7785b3e --- /dev/null +++ b/crates/claudius/src/export.rs @@ -0,0 +1,89 @@ +//! Songs as files: the 48 kHz master in any format claudius writes ([`claudius_encode`]), resampled when the file is +//! at 44.1 kHz, and tagged with the song's title. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +pub use claudius_encode::{Format, Spec, Tags, presets}; + +use crate::audio::Stereo; +use crate::config::SR; + +/// The MP3 every song folder has, for listening and sharing. +pub const FOLDER_MP3: &str = "mp3:256"; + +/// the tags of a song called `title`, made by this claudius +pub fn tags(title: &str) -> Tags { + Tags { title: title.to_string(), software: format!("claudius {}", env!("CARGO_PKG_VERSION")), ..Default::default() } +} + +pub fn encode(master: &Stereo, spec: &Spec, tags: &Tags) -> Result> { + let x = master.interleaved(); + claudius_encode::encode(spec, claudius_encode::Pcm { samples: &x, channels: 2, rate: SR }, tags) +} + +/// Write `master` as `spec` to `path`, which appears only once it is complete. +pub fn write(master: &Stereo, spec: &Spec, tags: &Tags, path: &Path) -> Result<()> { + let bytes = encode(master, spec, tags).with_context(|| format!("cannot write {}", spec.label(SR)))?; + if let Some(d) = path.parent() { + std::fs::create_dir_all(d)?; + } + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let tmp = path.with_file_name(format!(".{name}.{}.part", std::process::id())); + std::fs::write(&tmp, bytes).with_context(|| format!("cannot write {}", tmp.display()))?; + std::fs::rename(&tmp, path).with_context(|| format!("cannot write {}", path.display())) +} + +/// The files to export `specs` to, named after `stem` in `dir`: `stem.mp3`, and when several specs share an +/// extension, the later ones after their spec as well (`stem-mp3-192.mp3`). +pub fn file_names(dir: &Path, stem: &str, specs: &[Spec]) -> Vec { + let mut seen: Vec<&str> = Vec::new(); + specs + .iter() + .map(|s| { + let ext = s.extension(); + let name = if seen.contains(&ext) { + format!("{stem}-{}.{ext}", s.to_string().replace([':', '@'], "-")) + } else { + format!("{stem}.{ext}") + }; + seen.push(ext); + dir.join(name) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_stay_apart() { + let specs: Vec = ["mp3", "flac", "mp3:192", "wav:16", "wav"].iter().map(|s| s.parse().unwrap()).collect(); + let files = file_names(Path::new("x"), "glass-garden", &specs); + assert!(files.iter().all(|f| f.parent() == Some(Path::new("x")))); + let names: Vec = files.iter().map(|f| f.file_name().unwrap().to_string_lossy().into_owned()).collect(); + assert_eq!( + names, + [ + "glass-garden.mp3", + "glass-garden.flac", + "glass-garden-mp3-192.mp3", + "glass-garden.wav", + "glass-garden-wav.wav" + ] + ); + } + + #[test] + fn a_master_becomes_a_cd_file_at_44_1_khz() { + let n = SR as usize / 2; + let m = Stereo { + l: (0..n).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(), + r: (0..n).map(|i| (i as f32 * 0.07).sin() * 0.5).collect(), + }; + let bytes = encode(&m, &"wav:16".parse().unwrap(), &tags("Glass Garden")).unwrap(); + let d = claudius_decode::decode_bytes(bytes, "wav").unwrap(); + assert_eq!((d.rate, d.channels, d.samples.len() / 2), (44_100, 2, n * 44_100 / SR as usize)); + } +} diff --git a/crates/claudius/src/lib.rs b/crates/claudius/src/lib.rs index 0e22970..1ab0005 100644 --- a/crates/claudius/src/lib.rs +++ b/crates/claudius/src/lib.rs @@ -22,6 +22,7 @@ pub mod config; pub mod dsp; pub mod ears; pub mod evolve; +pub mod export; pub mod fx; pub mod genres; pub mod judge; diff --git a/crates/claudius/src/song.rs b/crates/claudius/src/song.rs index f5c774f..114d133 100644 --- a/crates/claudius/src/song.rs +++ b/crates/claudius/src/song.rs @@ -74,7 +74,7 @@ pub fn slug(s: &str) -> String { pub struct Written { pub dir: PathBuf, pub wav: PathBuf, - pub mp3: Option, + pub mp3: PathBuf, } /// a song from its blueprint.json, checked: its size, and that its pattern tracks still read @@ -86,7 +86,7 @@ pub fn read_blueprint(path: &Path) -> Result { Ok(bp) } -/// render a blueprint and write song.wav (+ .mp3), blueprint.json, song.rhai, project.json, report.json, +/// render a blueprint and write song.wav, song.mp3, blueprint.json, song.rhai, project.json, report.json, /// verdict.json, credits.txt when sampled instruments ask for credit (and stems/) pub fn render_and_write( bp: &Blueprint, @@ -98,7 +98,7 @@ pub fn render_and_write( let r = render::render(&project, &render::Options { stems, ..Default::default() })?; // the MP3 is encoded while the critic listens: both take seconds, and neither needs the other let (audio, e) = std::thread::scope(|s| { - let audio = s.spawn(|| write_audio(dir, &r)); + let audio = s.spawn(|| write_audio(dir, bp, &r)); let e = ears.and_then(|f| f(&project, &r)); (audio.join(), e) }); @@ -108,22 +108,23 @@ pub fn render_and_write( Ok((project, r, Written { dir: dir.to_path_buf(), wav, mp3 }, verdict)) } -/// write a rendered song's folder: song.wav (+ .mp3 when ffmpeg is there), blueprint.json, song.rhai, project.json, +/// write a rendered song's folder: song.wav, song.mp3, blueprint.json, song.rhai, project.json, /// report.json, verdict.json, credits.txt when sampled instruments ask for credit, and stems/ when they were rendered /// (32-bit float, since a track can peak above full scale before the master's limiter) pub fn write_folder(dir: &Path, bp: &Blueprint, project: &Project, r: &Rendered, verdict: &Verdict) -> Result { - let (wav, mp3) = write_audio(dir, r)?; + let (wav, mp3) = write_audio(dir, bp, r)?; write_documents(dir, bp, project, r, verdict)?; Ok(Written { dir: dir.to_path_buf(), wav, mp3 }) } -/// song.wav, and song.mp3 when ffmpeg is there -fn write_audio(dir: &Path, r: &Rendered) -> Result<(PathBuf, Option)> { +/// song.wav (24-bit) and song.mp3 +fn write_audio(dir: &Path, bp: &Blueprint, r: &Rendered) -> Result<(PathBuf, PathBuf)> { std::fs::create_dir_all(dir)?; let wav = dir.join("song.wav"); audio::save_wav(&wav, &r.master, false)?; let mp3 = dir.join("song.mp3"); - let mp3 = audio::wav_to_mp3(&wav, &mp3).ok().map(|_| mp3); + let spec: crate::export::Spec = crate::export::FOLDER_MP3.parse()?; + crate::export::write(&r.master, &spec, &crate::export::tags(&bp.title), &mp3)?; Ok((wav, mp3)) } diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index f2312e3..b95d814 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -60,6 +60,7 @@ - [Command line](guide/cli.md) - [MCP tools](guide/mcp.md) - [HTTP API](guide/http-api.md) +- [Exporting](guide/export.md) - [Sample packs](guide/packs.md) - [Apple Loops and your own samples](guide/samples.md) - [Files and settings](guide/files.md) diff --git a/docs/src/engineering/server.md b/docs/src/engineering/server.md index 9757c00..305a3f6 100644 --- a/docs/src/engineering/server.md +++ b/docs/src/engineering/server.md @@ -156,8 +156,8 @@ request ─▶ CORS ─▶ security headers ─▶ guard ─▶ routes: /api/... `Secure` when `X-Forwarded-Proto` says https), comparing in constant time. - **Commands** must be `application/json` and at most 1 MiB, and run on `spawn_blocking`, since an edit can take seconds. -- **Audio and stems** are served with `tower-http`'s `ServeFile`, which handles `Range`; an MP3 is encoded with ffmpeg - on first request, and the WAV is served in its place without ffmpeg. +- **Audio and stems** are served with `tower-http`'s `ServeFile`, which handles `Range`; an MP3 is encoded + (`claudius-encode`) on the first request and kept. - **The web studio** is served from its `dist` folder with `index.html` as the fallback for any other path; without one, a short page says how to build it. diff --git a/docs/src/getting-started/first-song.md b/docs/src/getting-started/first-song.md index a477521..113d04e 100644 --- a/docs/src/getting-started/first-song.md +++ b/docs/src/getting-started/first-song.md @@ -15,7 +15,7 @@ claudius 0.1.0 (macos aarch64) ok sample packs 22 default packs (1.5 GB) in ~/Library/Application Support/claudius/packs ok ears models in ~/.cache/earshot-rs ok Apple Loops 14096 loops and samples - ok ffmpeg 9.0.2 (writes song.mp3) + ok ffmpeg 9.0.2 (reads the formats claudius cannot) ok port 7878 free .. Claude Desktop not connected: `claudius connect claude-desktop` .. Cursor not connected: `claudius connect cursor` @@ -27,7 +27,7 @@ Everything claudius needs is here. Two things matter for this page: the **sample packs** (recorded instruments and drum machines) and the **ears** (the listening models the critic uses). If either is missing, `claudius setup` downloads them: about 3.1 GB of packs (1.5 GB once unpacked) and 0.8 GB of models, once. Apple Loops are optional (claudius uses them when GarageBand or Logic -content is installed), and without ffmpeg you get `song.wav` but no `song.mp3`. +content is installed), and so is ffmpeg, which reads the few audio formats claudius cannot read itself. ## Make a song diff --git a/docs/src/getting-started/install.md b/docs/src/getting-started/install.md index b0a0cc8..361f215 100644 --- a/docs/src/getting-started/install.md +++ b/docs/src/getting-started/install.md @@ -105,8 +105,9 @@ first time it starts. Each model file is checked against a pinned SHA-256 hash. synthesizes every sound; without the models, the verdict uses meters and music theory only. `claudius setup --help` lists the options, and `claudius-packs list` shows every pack with its licence. -claudius uses Apple Loops automatically when GarageBand or Logic content is installed. ffmpeg is optional: with it, -songs are also saved as MP3. +claudius uses Apple Loops automatically when GarageBand or Logic content is installed. ffmpeg is optional: it reads +the few audio formats claudius cannot read itself, and on Windows and Linux it writes AAC and Apple Lossless when you +[export](../guide/export.md) them. ## Check the installation @@ -120,7 +121,7 @@ claudius 0.1.0 (macos aarch64) ok sample packs 22 default packs (1.5 GB) in ~/Library/Application Support/claudius/packs ok ears models in ~/.cache/earshot-rs ok Apple Loops 14096 loops and samples - ok ffmpeg 9.0.2 (writes song.mp3) + ok ffmpeg 9.0.2 (reads the formats claudius cannot) ok port 7878 free .. Claude Desktop not connected: `claudius connect claude-desktop` .. statistics off (`claudius telemetry` explains them) diff --git a/docs/src/guide/cli.md b/docs/src/guide/cli.md index 9a21b0f..6104fb2 100644 --- a/docs/src/guide/cli.md +++ b/docs/src/guide/cli.md @@ -59,7 +59,7 @@ The song folder is `/--<seed>`, for example `out/deep_house-g | file | what it is | |---|---| | `song.wav` | the master, 48 kHz, 24-bit | -| `song.mp3` | a 256 kbps copy, when ffmpeg is installed | +| `song.mp3` | a 256 kbps copy; `claudius export` writes other formats | | `blueprint.json` | the song's genome: open it, edit it, render it again | | `song.rhai` | the song as a script, for `claudius script` or the studio's script editor | | `project.json` | the arranged session the blueprint expanded into | @@ -192,6 +192,24 @@ and bass style, with the genre's sounds and mix. It prints the analysis (as `ana ## Listen and judge +### `claudius export` + +Writes a song's master in other formats: WAV, AIFF, FLAC, Apple Lossless, MP3, AAC, Opus and Ogg Vorbis. +[Exporting](export.md) has every format and what each is for. + +```sh +claudius export out/deep_house-liquid-avenue-21 -f mp3,flac,wav:16 +claudius export ~/Music/mix.wav -f opus:96 -o ~/Desktop/mix.opus +``` + +| flag | default | what it does | +|---|---|---| +| `<song>` | | a song folder (its `song.wav`) or any audio file | +| `-f`, `--format` | | `format[:quality][@rate]`, repeated or comma-separated: `mp3`, `mp3:v0`, `wav:16`, `flac`, `aac`, `opus:96` | +| `-o`, `--out` | the song's folder | the file to write for one format, or the folder for several | +| `--title`, `--artist`, `--album` | the song's title | the tags the files carry; the files are named after the title | +| `--list` | | lists the formats, and any this machine cannot write | + ### `claudius judge` Gives the critic's verdict on any audio file. When the file has a `project.json` next to it (as `make` writes), the @@ -448,7 +466,7 @@ claudius 0.1.0 (macos aarch64) ok sample packs 22 default packs (1.5 GB) in ~/Library/Application Support/claudius/packs ok ears models in ~/.cache/earshot-rs ok Apple Loops 14096 loops and samples - ok ffmpeg 9.0.2 (writes song.mp3) + ok ffmpeg 9.0.2 (reads the formats claudius cannot) ok port 7878 free .. Claude Desktop not connected: `claudius connect claude-desktop` .. Cursor not connected: `claudius connect cursor` diff --git a/docs/src/guide/export.md b/docs/src/guide/export.md new file mode 100644 index 0000000..3f9bfc5 --- /dev/null +++ b/docs/src/guide/export.md @@ -0,0 +1,71 @@ +# Exporting + +A song's master is `song.wav` in its folder, 24-bit at 48 kHz, and every song folder has a 256 kbps `song.mp3` beside +it. `claudius export` writes the master in any other format: + +```sh +claudius export out/deep_house-liquid-avenue-21 --format flac # liquid-avenue.flac in the song's folder +claudius export out/deep_house-liquid-avenue-21 -f mp3,wav:16,opus # three files at once +claudius export out/deep_house-liquid-avenue-21 -f mp3 -o ~/Desktop/liquid-avenue.mp3 +claudius export ~/Music/mix.wav -f aac --title "Night Bus" --artist "Ana" +claudius export --list # every format, and any this machine cannot write +``` + +```text +wrote out/deep_house-liquid-avenue-21/liquid-avenue.mp3 (MP3, 320 kbps, 8.4 MB) +wrote out/deep_house-liquid-avenue-21/liquid-avenue.wav (WAV, 16-bit, 44.1 kHz, 36.9 MB) +wrote out/deep_house-liquid-avenue-21/liquid-avenue.opus (Opus, 160 kbps, 4.3 MB) +``` + +The files are named after the song's title; when two share an extension, the later ones also carry their format +(`liquid-avenue-mp3-192.mp3`). `--out` names the file for one format, or the folder for several. + +## Formats + +| format | what it writes | for | +|---|---|---| +| `wav` | WAV, 24-bit, 48 kHz | the master as it is: mastering engineers, other DAWs | +| `wav:16` | WAV, 16-bit, 44.1 kHz | CD, and distributors that ask for CD quality | +| `wav:32` | WAV, 32-bit float, 48 kHz | more processing elsewhere | +| `aiff`, `aiff:16` | AIFF, 24 or 16-bit | Logic, GarageBand and Apple's other apps | +| `flac`, `flac:16` | FLAC, 24 or 16-bit | lossless at about four fifths of the size or less: Bandcamp, archives | +| `alac`, `alac:16` | Apple Lossless in an `.m4a` | lossless in Apple Music and on an iPhone | +| `mp3` | MP3, 320 kbps | sharing, and every player there is | +| `mp3:v0` | MP3, variable bitrate (about 256 kbps) | the same quality in smaller files | +| `mp3:192`, `mp3:128` | MP3 | smaller files still | +| `aac` | AAC in an `.m4a`, 256 kbps | Apple Music, the iPhone, the web | +| `opus`, `opus:96` | Opus, 160 or 96 kbps | the web: the smallest files at a given quality | +| `ogg` | Ogg Vorbis, quality 7 | games, and players of Ogg | + +A format is written `format[:quality][@rate]`: + +- the quality is the bits of a lossless file (16 or 24, or 32 for a float WAV), the kbps of MP3 (32 to 320), AAC (64 to + 320) or Opus (32 to 256), `v0` to `v9` for MP3's variable bitrate, and 0 to 10 for Vorbis; +- the rate is `@44100` or `@48000`. A 16-bit file is 44.1 kHz unless you say otherwise; everything else keeps the + master's 48 kHz, and Opus is always 48 kHz. + +To 44.1 kHz the master is resampled with a filter flat to 20 kHz that keeps what cannot exist at the lower rate +95 dB down, and 16-bit files are dithered, so quiet endings fade instead of breaking up. + +## For the web + +Every current browser plays MP3, AAC, Opus, Ogg Vorbis, FLAC and WAV; Safari plays Ogg Opus and Ogg Vorbis from 18.4 +(macOS 15.4, iOS 18.4). Opus makes the smallest files for streaming, and MP3 or AAC reach the oldest devices. AIFF and +Apple Lossless are for Apple's apps: Chrome and the browsers built on it play neither. + +## Tags + +Files carry the song's title (or `--title`), `--artist`, `--album`, and the name and version of the claudius that +made them: ID3v2 in MP3, Vorbis comments in FLAC, Opus and Ogg. AAC and Apple Lossless carry them when ffmpeg writes +them; Core Audio writes none. + +## How each format is written + +WAV, AIFF, FLAC, MP3 and Opus are written by claudius itself, in Rust, and Ogg Vorbis by libvorbis (aoTuV), which is +built in: none needs another program. Every FLAC is decoded again before it is kept, and stored verbatim if it does +not come back exactly. AAC and Apple Lossless come from the system's encoder: Core Audio on macOS, which is Apple's own +AAC encoder, and ffmpeg on Windows and Linux. Where neither is there, those two formats are unavailable, `--list` says +so, and exporting them fails with `AAC needs Core Audio (macOS) or ffmpeg, and this machine has neither`. + +Measured on a song against the usual encoders at the same bitrate (the spectrum's difference from the master, in +third-octave bands), claudius' MP3 matches LAME's at 192 and 320 kbps, and its Opus matches libopus at 160 kbps. diff --git a/docs/src/guide/files.md b/docs/src/guide/files.md index 835a452..e2a86be 100644 --- a/docs/src/guide/files.md +++ b/docs/src/guide/files.md @@ -32,7 +32,7 @@ MCP made it, so they hold the same files: | file | what it is | |---|---| | `song.wav` | the master, 48 kHz, 24-bit | -| `song.mp3` | a 256 kbps copy, when ffmpeg is installed | +| `song.mp3` | a 256 kbps copy ([Exporting](export.md) writes other formats) | | `blueprint.json` | the song's genome | | `song.rhai` | the song as a script | | `project.json` | the arranged session | @@ -123,7 +123,7 @@ Without one, `/` shows a page that says how to build it, and the API and MCP wor | `CLAUDIUS_PORT` | `7878` | `--port` of `studio`, `serve`, `mcp`, `doctor` and `connect` | | `CLAUDIUS_TOKEN` | none | `--token` of `serve` and `mcp` | | `CLAUDIUS_WEB` | found as above | the web studio folder | -| `CLAUDIUS_FFMPEG` | `ffmpeg` on `PATH`, then `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin` | the ffmpeg claudius uses for formats it cannot decode and for MP3; `none` turns it off | +| `CLAUDIUS_FFMPEG` | `ffmpeg` on `PATH`, then `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin` | the ffmpeg claudius uses for formats it cannot decode, and to export AAC and Apple Lossless off macOS; `none` turns it off | | `CLAUDIUS_DEBUG` | unset | when set, renders print their timings and the beat tracker its cues to stderr | | `DO_NOT_TRACK`, `CLAUDIUS_TELEMETRY` | unset | `DO_NOT_TRACK=1` or `CLAUDIUS_TELEMETRY=0` keeps [usage statistics](privacy.md) off whatever the answer | | `CLAUDIUS_TELEMETRY_URL` | the Worker in `telemetry/` | where usage statistics go, to test an endpoint (the only place a debug build sends them) | diff --git a/docs/src/guide/http-api.md b/docs/src/guide/http-api.md index 1a1f3bb..30c0a34 100644 --- a/docs/src/guide/http-api.md +++ b/docs/src/guide/http-api.md @@ -282,8 +282,8 @@ content-range: bytes 0-99/72955106 content-length: 100 ``` -The MP3 is encoded by ffmpeg on the first request (a few seconds for a song) and kept. Without ffmpeg, the `.mp3` -address serves the WAV, which browsers play too. A session keeps its last ten renders on disk, so older versions +The MP3 is encoded on the first request (a second or two for a song) and kept. A session keeps its last ten renders +on disk, so older versions return 404. Stems exist only for a render made with `{"cmd": "render", "stems": true}` or by `save`, and only the latest render's stems are served, as 24-bit WAV; the copies a `save` writes into the song folder are 32-bit float. diff --git a/docs/src/guide/troubleshooting.md b/docs/src/guide/troubleshooting.md index fcaeba6..516351d 100644 --- a/docs/src/guide/troubleshooting.md +++ b/docs/src/guide/troubleshooting.md @@ -152,13 +152,13 @@ system's `tar` could not unpack the `tr909` zip or the organ's `.tar.xz`; instal claudius does not need ffmpeg to work. Without it: -- songs have `song.wav` and no `song.mp3`, without an error; -- the web studio is sent the WAV where it asks for MP3, which browsers play as well; +- on Windows and Linux, exporting AAC or Apple Lossless fails with `AAC needs Core Audio (macOS) or ffmpeg, and this + machine has neither: install ffmpeg, or export MP3 or Opus`; - files in formats claudius cannot decode itself fail with `cannot decode <file>: <reason> (installing ffmpeg adds more formats)`. It reads WAV, AIFF, FLAC, MP3, AAC and ALAC, CAF and Ogg Vorbis on its own. -`claudius doctor` shows `.. ffmpeg not installed (optional: songs are saved as WAV only)`. claudius looks for it on +`claudius doctor` shows `.. ffmpeg not installed (optional: reads the formats claudius cannot)`. claudius looks for it on `PATH` and then in `/opt/homebrew/bin`, `/usr/local/bin` and `/usr/bin`, since apps started from the Dock do not get your shell's `PATH`. `CLAUDIUS_FFMPEG=/path/to/ffmpeg` names one, `CLAUDIUS_FFMPEG=none` turns it off. @@ -184,7 +184,7 @@ toolbar shows the job in progress), and with `--no-auto-render` nothing renders volume slider at the bottom right too. In the browser, the first playback has to start from a click (browsers block sound a page starts on its own), and -the page plays the MP3, or the WAV without ffmpeg. +the page plays the MP3. If the window shows `Cannot reach the claudius server at http://127.0.0.1:7878`, the server it was opened for is gone; start `claudius studio` again. `Use the demo` switches to a built-in song that needs no server. diff --git a/skills/claudius-setup/SKILL.md b/skills/claudius-setup/SKILL.md index e2bde04..a45da37 100644 --- a/skills/claudius-setup/SKILL.md +++ b/skills/claudius-setup/SKILL.md @@ -108,6 +108,6 @@ Run `claudius doctor` first; it names most problems. The common ones: | the assistant and the studio show different songs | `claudius mcp` started before the studio, so it has a session of its own: restart the assistant once the studio runs | | `“Claudius” Not Opened` (macOS) | the app came from a disk image downloaded in a browser, and the releases are not notarized yet: install with the `curl` command above instead, or System Settings, Privacy & Security, Open Anyway | | `busy (rendering)` | a job is running: wait and call `status`; `stop` ends a mutation or an evolution early, but a render always finishes | -| no `song.mp3` | ffmpeg is not installed: `song.wav` is the same song; install ffmpeg for MP3s | +| `AAC needs Core Audio (macOS) or ffmpeg` | Windows or Linux without ffmpeg: install it, or export MP3 or Opus | The full guide, with every message, is in the claudius docs under Troubleshooting. diff --git a/skills/claudius-songs/SKILL.md b/skills/claudius-songs/SKILL.md index 2d0a67c..ecae81f 100644 --- a/skills/claudius-songs/SKILL.md +++ b/skills/claudius-songs/SKILL.md @@ -132,7 +132,7 @@ Afterwards, tell the person what changed (the log says) and offer to undo it. ## Saving and sharing -`save` (or `claudius script ... --out DIR`) writes `song.wav`, `song.mp3` when ffmpeg is installed, `song.rhai`, +`save` (or `claudius script ... --out DIR`) writes `song.wav`, `song.mp3`, `song.rhai`, `blueprint.json`, `verdict.json`, and `credits.txt` when the song uses sample packs that ask for credit; `save` also writes `stems/`, one file per track, as does `claudius render --stems`. Tell the person about `credits.txt` before they publish: CC BY packs must be credited. Paths always mean the diff --git a/skills/claudius-songs/references/tools.md b/skills/claudius-songs/references/tools.md index c3c68de..95e850d 100644 --- a/skills/claudius-songs/references/tools.md +++ b/skills/claudius-songs/references/tools.md @@ -37,7 +37,7 @@ Notes: - There is no undo on the command line, and none is needed: every step writes a new folder, and the one before is the undo. Give each step its own `--out`. Writing an edit into the folder it reads from replaces the song you are editing, because the folder name stays the same. -- A command-line song folder holds `song.wav` (and `song.mp3` with ffmpeg), `blueprint.json` (the whole song), +- A command-line song folder holds `song.wav`, `song.mp3`, `blueprint.json` (the whole song), `song.rhai` (the song as a script), `project.json`, `verdict.json`, `report.json`, `stems/` when asked for, and `credits.txt` when sampled instruments need credit. `claudius judge SONG/song.wav` judges it again, with the arrangement checks because `project.json` sits next to it. diff --git a/telemetry/src/index.js b/telemetry/src/index.js index efacd7f..0863b3c 100644 --- a/telemetry/src/index.js +++ b/telemetry/src/index.js @@ -4,7 +4,7 @@ // data for three months. docs/src/guide/privacy.md describes it for the people who send them. const COMMANDS = new Set([ - "make", "evolve", "mutate", "analyze", "remake", "chop", "render", "judge", "genres", "sounds", "script", + "make", "evolve", "mutate", "analyze", "remake", "chop", "render", "export", "judge", "genres", "sounds", "script", "reference", "setup", "doctor", "connect", "skills", "studio", "serve", "mcp", "update", ]); const VERSION = /^\d{1,3}\.\d{1,3}\.\d{1,3}(-[0-9A-Za-z.]{1,20})?$/;