development
rs 361 lines 13.1 KB
Raw
1 //! `list_separations`: the SplitFire app's own separation history, read from disk.
2 //!
3 //! The app stores each song as a folder of WAV stems plus a `splitfire.json` sidecar, under
4 //! either its iCloud Drive Documents container or the pre-iCloud app-data `separated/` tree.
5 //! It rebuilds its history by scanning exactly that layout, so this tool is read-only by
6 //! design: writing new stems belongs to `separate_stems` and deleting belongs to the app.
7
8 use std::path::{Path, PathBuf};
9
10 use anyhow::Result;
11 use serde::Deserialize;
12 use serde_json::{Value, json};
13
14 use super::{ToolCtx, ToolOutcome, function_def};
15
16 pub const NAME: &str = "list_separations";
17
18 /// The app's sidecar, as written by `stems_storage::SeparationSidecar`.
19 #[derive(Deserialize)]
20 struct Sidecar {
21 model_id: String,
22 /// Unix millis at separation time on the originating device.
23 separated_at_ms: u64,
24 /// Original input file name, with extension.
25 source_file: String,
26 /// Stem ids written, e.g. "vocals", "drums".
27 stems: Vec<String>,
28 }
29
30 /// One discovered song folder.
31 struct Separation {
32 dir: PathBuf,
33 /// Which root it came from, in the app's vocabulary.
34 backend: &'static str,
35 sidecar: Sidecar,
36 /// Stem files actually present, paired with their size in bytes.
37 files: Vec<(PathBuf, u64)>,
38 }
39
40 pub fn definition() -> Value {
41 function_def(
42 NAME,
43 "List the songs the SplitFire app has already separated, with the model used, when it ran, \
44 and the stem files on disk. Read-only: it reports the app's existing library and never \
45 changes it. Use it to answer questions about past separations or to find a stem to analyze.",
46 json!({
47 "type": "object",
48 "properties": {
49 "song": {
50 "type": "string",
51 "description": "Only report songs whose folder or source file name contains this text (case-insensitive)"
52 }
53 }
54 }),
55 )
56 }
57
58 #[derive(Deserialize)]
59 struct Args {
60 song: Option<String>,
61 }
62
63 pub async fn execute(ctx: &ToolCtx, args: Value) -> Result<ToolOutcome> {
64 let a: Args = serde_json::from_value(args)?;
65 let roots = stem_roots();
66 let filter = a.song.as_deref().map(str::to_lowercase);
67
68 let found = {
69 let roots = roots.clone();
70 let filter = filter.clone();
71 tokio::task::spawn_blocking(move || collect(&roots, filter.as_deref()))
72 };
73 let mut found = tokio::select! {
74 r = found => r?,
75 () = ctx.cancel.cancelled() => return Ok(ToolOutcome::err("Listing cancelled.")),
76 };
77 // Newest first; the app presents history the same way.
78 found.sort_by_key(|f| std::cmp::Reverse(f.sidecar.separated_at_ms));
79
80 let mut out = ToolOutcome::ok(report(&roots, &found, filter.as_deref()));
81 out.locations = found.iter().map(|s| s.dir.clone()).collect();
82 Ok(out)
83 }
84
85 /// Where the app keeps stems, most-specific first. `SPLITFIRE_STEMS_DIR` replaces the
86 /// defaults outright: discovery is platform- and sandbox-specific, so it is the escape hatch
87 /// when the real roots live somewhere this code would not guess.
88 fn stem_roots() -> Vec<(PathBuf, &'static str)> {
89 if let Some(raw) = std::env::var_os("SPLITFIRE_STEMS_DIR")
90 && !raw.is_empty()
91 {
92 return std::env::split_paths(&raw)
93 .filter(|p| !p.as_os_str().is_empty())
94 .map(|p| (p, "configured"))
95 .collect();
96 }
97 let Some(dirs) = directories::BaseDirs::new() else {
98 return Vec::new();
99 };
100 let home = dirs.home_dir();
101 let mut roots = Vec::new();
102 // Apple platforms: the app's iCloud Drive Documents container.
103 if cfg!(target_os = "macos") {
104 roots.push((
105 home.join("Library/Mobile Documents/iCloud~ai~splitfire~SplitfireAI/Documents"),
106 "icloud",
107 ));
108 }
109 // Everywhere else, and for pre-iCloud separations: `<app data>/separated`.
110 // Both the release and the debug bundle identifier, since either may have run here.
111 for id in ["ai.splitfire.SplitfireAI", "ai.splitfire.SplitfireAI-Debug"] {
112 roots.push((dirs.data_dir().join(id).join("separated"), "local"));
113 }
114 roots
115 }
116
117 /// Scan each root's immediate children for a sidecar. Best-effort throughout: a missing or
118 /// unreadable root, or a folder without a readable sidecar, is simply not reported.
119 fn collect(roots: &[(PathBuf, &'static str)], filter: Option<&str>) -> Vec<Separation> {
120 let mut out = Vec::new();
121 for (root, backend) in roots {
122 let Ok(entries) = std::fs::read_dir(root) else {
123 continue;
124 };
125 for entry in entries.flatten() {
126 let dir = entry.path();
127 if !dir.is_dir() {
128 continue;
129 }
130 let Ok(bytes) = std::fs::read(dir.join("splitfire.json")) else {
131 continue;
132 };
133 let Ok(sidecar) = serde_json::from_slice::<Sidecar>(&bytes) else {
134 continue;
135 };
136 if let Some(needle) = filter {
137 let name = dir
138 .file_name()
139 .map(|n| n.to_string_lossy().to_lowercase())
140 .unwrap_or_default();
141 if !name.contains(needle) && !sidecar.source_file.to_lowercase().contains(needle) {
142 continue;
143 }
144 }
145 let files = stem_files(&dir, &sidecar);
146 out.push(Separation {
147 dir,
148 backend,
149 sidecar,
150 files,
151 });
152 }
153 }
154 out
155 }
156
157 /// The stem files for a song, in the sidecar's order. A stem listed but absent is reported as
158 /// missing rather than silently dropped: on iCloud it may simply not be downloaded yet.
159 fn stem_files(dir: &Path, sidecar: &Sidecar) -> Vec<(PathBuf, u64)> {
160 sidecar
161 .stems
162 .iter()
163 .map(|stem| {
164 let path = dir.join(format!("{stem}.wav"));
165 let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
166 (path, size)
167 })
168 .collect()
169 }
170
171 fn report(roots: &[(PathBuf, &'static str)], found: &[Separation], filter: Option<&str>) -> String {
172 if found.is_empty() {
173 let mut s = match filter {
174 Some(f) => format!("No separated songs matching `{f}`."),
175 None => "No separated songs found.".to_string(),
176 };
177 s.push_str("\n\nSearched:");
178 for (root, _) in roots {
179 s.push_str(&format!("\n- {}", root.display()));
180 }
181 if roots.is_empty() {
182 s.push_str("\n- (no stem roots could be resolved)");
183 }
184 s.push_str(
185 "\n\nIf the app stores stems somewhere else, set SPLITFIRE_STEMS_DIR to that directory.",
186 );
187 return s;
188 }
189
190 let plural = if found.len() == 1 { "song" } else { "songs" };
191 let mut s = format!("{} separated {plural}:\n", found.len());
192 for sep in found {
193 let name = sep
194 .dir
195 .file_name()
196 .map(|n| n.to_string_lossy().to_string())
197 .unwrap_or_else(|| sep.dir.display().to_string());
198 s.push_str(&format!(
199 "\n{name}\n model: {}\n separated: {} ({})\n source: {}\n folder: {}\n",
200 sep.sidecar.model_id,
201 iso8601_ms(sep.sidecar.separated_at_ms),
202 sep.backend,
203 sep.sidecar.source_file,
204 sep.dir.display(),
205 ));
206 for (path, size) in &sep.files {
207 let stem = path
208 .file_stem()
209 .map(|n| n.to_string_lossy().to_string())
210 .unwrap_or_default();
211 if *size == 0 {
212 s.push_str(&format!(
213 " - {stem}: not on this device ({})\n",
214 path.display()
215 ));
216 } else {
217 s.push_str(&format!(
218 " - {stem}: {} ({})\n",
219 human_bytes(*size),
220 path.display()
221 ));
222 }
223 }
224 }
225 s
226 }
227
228 fn human_bytes(n: u64) -> String {
229 const MIB: f64 = (1 << 20) as f64;
230 if n >= (1 << 20) {
231 format!("{:.1} MiB", n as f64 / MIB)
232 } else {
233 format!("{:.0} KiB", (n as f64 / 1024.0).max(1.0))
234 }
235 }
236
237 /// Unix millis as a UTC timestamp, via the same civil-date maths as `crate::agent::iso8601`.
238 fn iso8601_ms(ms: u64) -> String {
239 let secs = (ms / 1000) as i64;
240 let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
241 // Days from 1970-01-01 to a civil date (Howard Hinnant's algorithm).
242 let z = days + 719_468;
243 let era = z.div_euclid(146_097);
244 let doe = z.rem_euclid(146_097);
245 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
246 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
247 let mp = (5 * doy + 2) / 153;
248 let d = doy - (153 * mp + 2) / 5 + 1;
249 let m = if mp < 10 { mp + 3 } else { mp - 9 };
250 let y = era * 400 + yoe + i64::from(m <= 2);
251 format!(
252 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
253 rem / 3600,
254 (rem % 3600) / 60,
255 rem % 60
256 )
257 }
258
259 #[cfg(test)]
260 mod tests {
261 use super::*;
262
263 fn write_song(root: &Path, name: &str, sidecar: &str, stems: &[&str]) {
264 let dir = root.join(name);
265 std::fs::create_dir_all(&dir).unwrap();
266 std::fs::write(dir.join("splitfire.json"), sidecar).unwrap();
267 for stem in stems {
268 std::fs::write(dir.join(format!("{stem}.wav")), vec![0u8; 2048]).unwrap();
269 }
270 }
271
272 /// The real sidecar the app writes, copied from a separation on disk.
273 const REAL_SIDECAR: &str = r#"{
274 "model_id": "htdemucs",
275 "separated_at_ms": 1783395299798,
276 "source_file": "splitfire-test-song.wav",
277 "stems": ["drums", "bass", "other", "vocals"]
278 }"#;
279
280 #[test]
281 fn reads_a_real_shaped_sidecar_tree() {
282 let tmp = std::env::temp_dir().join(format!("sf-lib-{}", uuid::Uuid::new_v4()));
283 let root = tmp.join("Documents");
284 write_song(
285 &root,
286 "splitfire-test-song",
287 REAL_SIDECAR,
288 &["drums", "bass", "other", "vocals"],
289 );
290 // A folder with no sidecar, and one with a corrupt sidecar: both skipped, not fatal.
291 std::fs::create_dir_all(root.join("not-a-separation")).unwrap();
292 write_song(&root, "corrupt", "{ not json", &[]);
293
294 let roots = vec![(root.clone(), "icloud")];
295 let found = collect(&roots, None);
296 assert_eq!(found.len(), 1, "only the valid sidecar is reported");
297 let sep = &found[0];
298 assert_eq!(sep.sidecar.model_id, "htdemucs");
299 assert_eq!(sep.sidecar.source_file, "splitfire-test-song.wav");
300 assert_eq!(sep.sidecar.stems.len(), 4);
301 assert_eq!(sep.files.len(), 4);
302 assert!(sep.files.iter().all(|(_, size)| *size == 2048));
303
304 let text = report(&roots, &found, None);
305 assert!(text.contains("1 separated song"), "{text}");
306 assert!(text.contains("htdemucs"), "{text}");
307 assert!(text.contains("2026-"), "timestamp rendered: {text}");
308 assert!(text.contains("vocals"), "{text}");
309
310 // A filter that matches the source file but not the folder name still hits.
311 assert_eq!(collect(&roots, Some("test-song")).len(), 1);
312 assert_eq!(collect(&roots, Some("nothing")).len(), 0);
313 std::fs::remove_dir_all(&tmp).ok();
314 }
315
316 #[test]
317 fn missing_roots_are_not_an_error() {
318 let roots = vec![(PathBuf::from("/nonexistent/splitfire/stems"), "local")];
319 let found = collect(&roots, None);
320 assert!(found.is_empty());
321 let text = report(&roots, &found, None);
322 assert!(text.contains("No separated songs found"), "{text}");
323 assert!(text.contains("SPLITFIRE_STEMS_DIR"), "{text}");
324 }
325
326 #[test]
327 fn a_stem_absent_from_disk_is_reported_as_not_downloaded() {
328 let tmp = std::env::temp_dir().join(format!("sf-lib-{}", uuid::Uuid::new_v4()));
329 let root = tmp.join("Documents");
330 // Sidecar lists four stems; only two were downloaded from iCloud.
331 write_song(&root, "half-synced", REAL_SIDECAR, &["drums", "bass"]);
332 let roots = vec![(root, "icloud")];
333 let found = collect(&roots, None);
334 let text = report(&roots, &found, None);
335 assert!(text.contains("vocals: not on this device"), "{text}");
336 assert!(text.contains("drums: 2 KiB"), "{text}");
337 std::fs::remove_dir_all(&tmp).ok();
338 }
339
340 #[test]
341 fn stems_dir_env_var_replaces_the_defaults() {
342 // Safety: single-threaded assertion on process env, restored before returning.
343 let key = "SPLITFIRE_STEMS_DIR";
344 let prev = std::env::var_os(key);
345 unsafe { std::env::set_var(key, "/tmp/sf-one") };
346 let roots = stem_roots();
347 assert_eq!(roots.len(), 1);
348 assert_eq!(roots[0].0, PathBuf::from("/tmp/sf-one"));
349 assert_eq!(roots[0].1, "configured");
350 match prev {
351 Some(v) => unsafe { std::env::set_var(key, v) },
352 None => unsafe { std::env::remove_var(key) },
353 }
354 }
355
356 #[test]
357 fn epoch_millis_render_as_utc() {
358 assert_eq!(iso8601_ms(0), "1970-01-01T00:00:00Z");
359 assert_eq!(iso8601_ms(1_783_395_299_798), "2026-07-07T03:34:59Z");
360 }
361 }