Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 1 addition & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
21 changes: 21 additions & 0 deletions crates/claudius-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,27 @@ pub(crate) enum Cmd {
#[arg(short, long)]
out: Option<PathBuf>,
},
/// 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<PathBuf>,
/// 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<String>,
/// the file to write with one format, or the folder for several (default: the song's own folder)
#[arg(short, long)]
out: Option<PathBuf>,
/// the title the files carry and are named after (default: the song's)
#[arg(long)]
title: Option<String>,
#[arg(long)]
artist: Option<String>,
#[arg(long)]
album: Option<String>,
/// 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,
Expand Down
9 changes: 7 additions & 2 deletions crates/claudius-cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,14 @@ pub fn run(port: u16, web: Option<std::path::PathBuf>) {
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));
Expand Down
99 changes: 99 additions & 0 deletions crates/claudius-cli/src/export.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf>,
pub formats: Vec<String>,
pub out: Option<PathBuf>,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
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<Spec> = r.formats.iter().map(|f| f.parse()).collect::<Result<_>>()?;
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<Result<(PathBuf, String)>> = 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(())
}
4 changes: 4 additions & 0 deletions crates/claudius-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use claudius::{genres, song};
mod args;
mod connect;
mod doctor;
mod export;
mod report;
mod setup;
mod skills;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/claudius-cli/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
49 changes: 48 additions & 1 deletion crates/claudius-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Loading