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
1 change: 1 addition & 0 deletions Cargo.lock

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

24 changes: 24 additions & 0 deletions crates/claudius-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! | `GET /api/sounds` | `SoundList`: every sound name a script or the UI may use |
//! | `GET /api/audio/{version}.wav` / `.mp3` | the render of that state version (`RenderView.url` points here) |
//! | `GET /api/stems/{version}/{track}.wav` | one track of that render, when it was rendered with stems |
//! | `GET /api/export/{version}/{name}.{ext}?format=SPEC` | that render in another format (`ExportFormat.spec`), as a download named `{name}.{ext}` |
//! | `POST /mcp` | the MCP endpoint (Streamable HTTP) |
//! | `GET /` | the web UI |

Expand Down Expand Up @@ -79,6 +80,22 @@ pub struct State {
/// recent messages, oldest first (at most ~200)
pub log: Vec<String>,
pub genres: Vec<GenreInfo>,
/// the formats a render can be exported to (`/api/export`, `Command::Export`)
#[serde(default)]
pub exports: Vec<ExportFormat>,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct ExportFormat {
/// what to ask for: `mp3`, `flac:16`, `opus:96`
pub spec: String,
/// what to call it: `MP3, 320 kbps`
pub label: String,
pub extension: String,
pub lossless: bool,
/// why this machine cannot write it (AAC and Apple Lossless need Core Audio or ffmpeg); None when it can
#[serde(default)]
pub unavailable: Option<String>,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
Expand Down Expand Up @@ -276,6 +293,13 @@ pub enum Command {
#[serde(default)]
path: Option<String>,
},
/// write the latest render of the song as it is now in other formats (`ExportFormat.spec`), into `path` (default:
/// the song's folder, as `Save` makes it)
Export {
formats: Vec<String>,
#[serde(default)]
path: Option<String>,
},
SetTempo {
bpm: f64,
},
Expand Down
1 change: 1 addition & 0 deletions crates/claudius-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ tracing = "0.1.44"
ureq = { version = "3", features = ["json"] }

[dev-dependencies]
claudius-decode = { version = "0.2.0", path = "../claudius-decode" }
tokio = { version = "1.53.3", features = ["rt-multi-thread", "macros"] }
tower = { version = "0.5", features = ["util"] }

Expand Down
61 changes: 60 additions & 1 deletion crates/claudius-server/src/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use claudius::pattern::quote;
use claudius::rng::Rng;
use claudius::theory::rhythm::Hit;
use claudius::theory::scale::Key;
use claudius::{analysis, audio, config, genres, script, song};
use claudius::{analysis, audio, config, export, genres, script, song};
use claudius_api::*;

use super::{App, no, now_seed, ok};
Expand Down Expand Up @@ -141,6 +141,7 @@ impl App {
}
Command::Load { path } => self.load(Path::new(&path)),
Command::Save { path } => self.save(path.map(PathBuf::from)),
Command::Export { formats, path } => self.export(&formats, path.map(PathBuf::from)),
Command::SetTempo { bpm } => self.run_script(&format!("tempo({bpm})"), Some(format!("tempo {bpm:.0}"))),
Command::SetKey { key } => self.run_script(&format!("key({})", quote(&key)), Some(format!("key {key}"))),
Command::SetSwing { swing } => {
Expand Down Expand Up @@ -475,6 +476,64 @@ impl App {
}
}

/// the latest render, when it is of the song as it is now, as files in `formats`, in `path` or the song's folder
fn export(self: &Arc<Self>, formats: &[String], path: Option<PathBuf>) -> Reply {
let v = self.version();
if formats.is_empty() {
return no("export as what? give formats such as mp3, flac:16 or opus (State.exports lists them)", v);
}
let specs: Vec<export::Spec> = match formats.iter().map(|f| f.parse()).collect() {
Ok(s) => s,
Err(e) => return no(format!("{e}"), v),
};
if let Some(why) = specs.iter().find_map(export::Spec::unavailable) {
return no(why, v);
}
let Some(bp) = self.blueprint() else { return no("no song to export", v) };
let rendered = {
let s = self.lock();
s.render.as_ref().map(|r| r.view.version).filter(|rv| *rv >= s.song_version)
};
let Some(version) = rendered else {
return no("the song has changed since its last render: render it, then export", v);
};
let dir = path
.unwrap_or_else(|| config::songs_dir().join(format!("{}-{}-{}", bp.genre, song::slug(&bp.title), bp.seed)));
if !config::write_allowed(&dir, self.roots.as_deref()) {
return no(format!("{} is outside the folders this claudius may write", dir.display()), v);
}
let master = match audio::load_once(&self.cache.join(format!("{version}.wav"))) {
Ok(m) => m,
Err(e) => return no(format!("cannot read the render: {e:#}"), v),
};
let stem = match song::slug(&bp.title) {
s if s.is_empty() => "song".to_string(),
s => s,
};
let files = export::file_names(&dir, &stem, &specs);
let tags = export::tags(&bp.title);
let written: Vec<anyhow::Result<()>> = std::thread::scope(|scope| {
let jobs: Vec<_> = specs
.iter()
.zip(&files)
.map(|(spec, file)| {
let (master, tags) = (&master, &tags);
scope.spawn(move || export::write(master, spec, tags, file))
})
.collect();
jobs.into_iter().map(|j| j.join().unwrap_or_else(|_| Err(anyhow::anyhow!("the encoder stopped")))).collect()
});
for (file, w) in files.iter().zip(&written) {
if let Err(e) = w {
return no(format!("cannot export {}: {e:#}", file.display()), v);
}
}
let list: Vec<String> =
files.iter().zip(&specs).map(|(f, s)| format!("{} ({})", f.display(), s.label(config::SR))).collect();
self.update(|s| s.say(format!("exported {}", list.join(", "))));
ok(format!("exported {}", list.join(", ")), self.version())
}

fn save(self: &Arc<Self>, path: Option<PathBuf>) -> Reply {
let v = self.version();
let Some(bp) = self.blueprint() else { return no("no song to save", v) };
Expand Down
45 changes: 45 additions & 0 deletions crates/claudius-server/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ impl Session {
}
}

/// an encoded export: the render's version, the format, the file
type Export = (u64, export::Spec, Arc<Vec<u8>>);

pub struct App {
s: Mutex<Session>,
/// edits run one at a time (scripts may take a moment: sounds get cut from the library)
Expand All @@ -94,6 +97,8 @@ pub struct App {
/// the lock on `cache` that tells other sessions this one is running; the OS lets go of it when the process ends
_in_use: Option<std::fs::File>,
genres: Vec<GenreInfo>,
exports: Vec<ExportFormat>,
last_export: Mutex<Option<Export>>,
base: Mutex<String>,
roots: Option<Vec<PathBuf>>,
}
Expand Down Expand Up @@ -131,6 +136,8 @@ impl App {
cache,
_in_use: in_use,
genres: views::genre_infos(),
exports: export_formats(),
last_export: Mutex::new(None),
base: Mutex::new(format!("http://127.0.0.1:{DEFAULT_PORT}")),
roots: settings.file_roots,
});
Expand Down Expand Up @@ -275,6 +282,7 @@ impl App {
redo_label: s.redo.last().map(|r| r.1.clone()),
log: s.log.iter().cloned().collect(),
genres: self.genres.clone(),
exports: self.exports.clone(),
}
}

Expand Down Expand Up @@ -331,6 +339,29 @@ impl App {
}
}

/// The render of song version `version` as `spec` (`/api/export`), encoded on request. Only the latest export is
/// kept, in memory, so a second click on the same download does not encode it again.
pub fn export_bytes(&self, version: u64, spec: &export::Spec) -> Result<Arc<Vec<u8>>, String> {
let mut last = self.last_export.lock().unwrap_or_else(|e| e.into_inner());
if let Some((v, s, bytes)) = last.as_ref()
&& *v == version
&& s == spec
{
return Ok(bytes.clone());
}
let wav = self.cache.join(format!("{version}.wav"));
if !wav.exists() {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return Err(format!("no render of version {version}"));
}
let title = self.blueprint().map(|b| b.title).unwrap_or_default();
let bytes = audio::load_once(&wav)
.and_then(|m| export::encode(&m, spec, &export::tags(&title)))
.map(Arc::new)
.map_err(|e| format!("{e:#}"))?;
*last = Some((version, *spec, bytes.clone()));
Ok(bytes)
}

pub fn stem_path(&self, version: u64, track: &str) -> Option<PathBuf> {
let s = self.lock();
let r = s.render.as_ref().filter(|r| r.view.version == version)?;
Expand Down Expand Up @@ -386,6 +417,20 @@ fn clean_old_sessions(root: &Path, mine: &Path, grace: Duration, unlocked: Durat
}
}

/// what `State.exports` lists: every preset, with why this machine cannot write it when it cannot
fn export_formats() -> Vec<ExportFormat> {
export::presets()
.iter()
.map(|p| ExportFormat {
spec: p.to_string(),
label: p.label(config::SR),
extension: p.extension().to_string(),
lossless: p.lossless(),
unavailable: p.unavailable(),
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
42 changes: 42 additions & 0 deletions crates/claudius-server/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub fn router(app: Arc<App>, o: &Options) -> Router {
.route("/api/sounds", get(sounds))
.route("/api/audio/{file}", get(audio))
.route("/api/stems/{version}/{file}", get(stem))
.route("/api/export/{version}/{file}", get(export_file))
.with_state(ctx);
let mut r = api.nest_service("/mcp", crate::mcp::http_service(app, o.public));
r = match &o.web {
Expand Down Expand Up @@ -289,6 +290,47 @@ async fn stem(State(c): State<Ctx>, Path((version, file)): Path<(u64, String)>,
}
}

#[derive(Deserialize)]
struct ExportQuery {
format: Option<String>,
}

/// a render in another format, as a download named after the URL's file: `/api/export/12/glass-garden.mp3?format=mp3:192`
async fn export_file(
State(c): State<Ctx>,
Path((version, file)): Path<(String, String)>,
Query(q): Query<ExportQuery>,
) -> Response {
let (Ok(version), Some((name, ext))) = (version.parse::<u64>(), file.rsplit_once('.')) else {
return (StatusCode::NOT_FOUND, "export is /api/export/{version}/{name}.{ext}?format=SPEC").into_response();
};
let spec: claudius::export::Spec = match q.format.as_deref().unwrap_or(ext).parse() {
Ok(s) => s,
Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
};
if !spec.extension().eq_ignore_ascii_case(ext) {
return (StatusCode::BAD_REQUEST, format!("{spec} makes .{} files, not .{ext}", spec.extension()))
.into_response();
}
let download: String =
name.chars().map(|ch| if ch.is_ascii_alphanumeric() || "-_. ".contains(ch) { ch } else { '-' }).collect();
let app = c.app.clone();
match tokio::task::spawn_blocking(move || app.export_bytes(version, &spec)).await {
Ok(Ok(bytes)) => {
let mut r = Body::from(bytes.as_ref().clone()).into_response();
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(spec.mime()));
if let Ok(v) = HeaderValue::from_str(&format!("attachment; filename=\"{download}.{}\"", spec.extension())) {
h.insert(header::CONTENT_DISPOSITION, v);
}
r
}
Ok(Err(e)) if e.starts_with("no render") => (StatusCode::NOT_FOUND, e).into_response(),
Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

async fn no_ui() -> Html<&'static str> {
Html(
"<!doctype html><meta charset=utf-8><title>claudius</title><body style='font:15px system-ui;max-width:42em;margin:3em auto'>\
Expand Down
21 changes: 21 additions & 0 deletions crates/claudius-server/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,15 @@ pub struct SaveArgs {
pub path: Option<String>,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExportArgs {
/// the formats to write, each format[:quality][@rate]: "mp3", "mp3:v0", "wav:16", "flac", "aac", "opus:96"
pub formats: Vec<String>,
/// folder to write into (default: the song's folder, Music/claudius/<genre>-<title>-<seed>)
#[serde(default)]
pub path: Option<String>,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RemakeArgs {
/// the audio file to learn from
Expand Down Expand Up @@ -475,6 +484,18 @@ impl Studio {
self.job(Command::Save { path: a.path }, 240).await
}

#[tool(
description = "Export the song as audio files: wav (24-bit, 48 kHz), wav:16 (CD: 16-bit, 44.1 kHz), wav:32 (float), aiff, flac, flac:16, alac (Apple Lossless), mp3 (320 kbps), mp3:v0, mp3:192, mp3:128, aac (256 kbps .m4a), opus (160 kbps, the smallest for the web), opus:96, ogg (Vorbis). Each is format[:quality][@rate]. Writes them into the song's folder (or path), named after the song, rendering first when the song changed since its last render."
)]
async fn export(&self, Parameters(a): Parameters<ExportArgs>) -> Result<String, String> {
let st = self.b.state().await?;
if st.render.as_ref().is_none_or(|r| r.version < st.song_version) {
self.heard(false).await?;
}
let r = self.b.command(Command::Export { formats: a.formats, path: a.path }).await?;
if r.ok { Ok(r.message) } else { Err(r.message) }
}

#[tool(description = "Open a saved song: a folder or blueprint.json written by save, or a .rhai script.")]
async fn open(&self, Parameters(a): Parameters<PathArgs>) -> Result<String, String> {
self.edit(Command::Load { path: a.path }, true).await
Expand Down
54 changes: 53 additions & 1 deletion crates/claudius-server/tests/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,18 @@ async fn assistants_work_over_mcp() {
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect();
for t in ["new_song", "get_song", "run_script", "listen", "mutate", "evolve", "undo", "script_reference"] {
for t in [
"new_song",
"get_song",
"run_script",
"listen",
"mutate",
"evolve",
"undo",
"script_reference",
"save",
"export",
] {
assert!(tools.iter().any(|x| x == t), "{t} missing from {tools:?}");
}

Expand Down Expand Up @@ -305,6 +316,47 @@ async fn saves_during_a_render_wait_for_it_and_write_everything() {
std::fs::remove_dir_all(&base).unwrap();
}

#[tokio::test(flavor = "multi_thread")]
async fn a_render_exports_by_command_and_as_a_download() {
let app = app();
let new = Command::NewSong { genre: "techno".into(), seed: Some(5), bpm: None, key: None, loops: false };
assert!(app.command(new).ok);
let st = app.settled(Duration::from_secs(240)).await;
let v = st.render.as_ref().expect("a render").version;
assert!(st.exports.iter().any(|f| f.spec == "flac:16" && f.label == "FLAC, 16-bit, 44.1 kHz" && f.lossless));

// by command, into a folder
let dir = std::env::temp_dir().join(format!("claudius-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let r = app.command(Command::Export {
formats: vec!["mp3:192".into(), "opus".into()],
path: Some(dir.display().to_string()),
});
assert!(r.ok, "{}", r.message);
let mut files: Vec<String> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
files.sort();
assert!(files.len() == 2 && files[0].ends_with(".mp3") && files[1].ends_with(".opus"), "{files:?}");
let r = app.command(Command::Export { formats: vec!["mp3:300".into()], path: Some(dir.display().to_string()) });
assert!(!r.ok && r.message.contains("MP3 takes"), "{}", r.message);

// as a download, named after the address
let router = http::router(app.clone(), &Options::default());
let get = |path: String| Request::get(path).header(header::HOST, "localhost").body(Body::empty()).unwrap();
let res = router.clone().oneshot(get(format!("/api/export/{v}/night-bus.flac?format=flac:16"))).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.headers()[header::CONTENT_TYPE], "audio/flac");
assert_eq!(res.headers()[header::CONTENT_DISPOSITION], "attachment; filename=\"night-bus.flac\"");
let body = axum::body::to_bytes(res.into_body(), 1 << 28).await.unwrap();
let d = claudius_decode::decode_bytes(body.to_vec(), "flac").unwrap();
assert_eq!((d.rate, d.channels), (44_100, 2));
let (s, _, b) = call(&router, get(format!("/api/export/{v}/x.mp3?format=flac"))).await;
assert!(s == StatusCode::BAD_REQUEST && b.contains("makes .flac files, not .mp3"), "{s} {b}");
let (s, _, _) = call(&router, get(format!("/api/export/{}/x.mp3", v + 1000))).await;
assert_eq!(s, StatusCode::NOT_FOUND);
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn the_studio_reads_and_writes_steps_as_scripts_do() {
use claudius::theory::rhythm::Hit;
Expand Down
1 change: 1 addition & 0 deletions crates/claudius-ui/src/mock/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ impl Mock {
self.log(msg.clone());
Ok(msg)
}
Command::Export { .. } => Err("the demo cannot export: start `claudius serve` for that".into()),
Command::Analyze { path } => match audio_file(path) {
Ok(()) => {
let msg = self.fake_analysis(path);
Expand Down
Loading
Loading