Skip to main content

xil_web/
audio.rs

1//! A bounded local copy of workspace audio, so playback of a NAS workspace
2//! reads the network once, and the "play all" concatenation preview.
3
4use std::fs;
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use sha2::{Digest, Sha256};
11use xil_core::fsutil::basename;
12
13use crate::episodes::load_stems;
14
15/// `_AUDIO_CACHE_MAX_BYTES`.
16pub const AUDIO_CACHE_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
17const COPY_CHUNK: usize = 4 * 1024 * 1024;
18
19/// `_audio_cache_dir()`: `$XDG_CACHE_HOME` (or `~/.cache`)`/xil-gui/audio`.
20pub fn audio_cache_dir() -> PathBuf {
21    let base = std::env::var("XDG_CACHE_HOME")
22        .ok()
23        .filter(|v| !v.is_empty())
24        .map(PathBuf::from)
25        .unwrap_or_else(|| {
26            std::env::var("HOME")
27                .map(PathBuf::from)
28                .unwrap_or_else(|_| PathBuf::from("."))
29                .join(".cache")
30        });
31    let d = base.join("xil-gui").join("audio");
32    let _ = fs::create_dir_all(&d);
33    d
34}
35
36fn mtime_ns(m: &fs::Metadata) -> u128 {
37    m.modified()
38        .ok()
39        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
40        .map(|d| d.as_nanos())
41        .unwrap_or(0)
42}
43
44fn hex(digest: &[u8]) -> String {
45    digest.iter().map(|b| format!("{b:02x}")).collect()
46}
47
48fn touch(path: &Path) {
49    let _ = filetime::set_file_mtime(path, filetime::FileTime::now());
50}
51
52/// `_evict_audio_cache(cache_dir, keep)`: delete oldest files until the cache
53/// is under [`AUDIO_CACHE_MAX_BYTES`].
54pub fn evict_audio_cache(cache_dir: &Path, keep: &Path) {
55    let Ok(rd) = fs::read_dir(cache_dir) else {
56        return;
57    };
58    let mut entries: Vec<(SystemTime, u64, PathBuf)> = rd
59        .filter_map(Result::ok)
60        .filter_map(|e| {
61            let p = e.path();
62            let m = e.metadata().ok()?;
63            (m.is_file() && p != keep).then(|| (m.modified().unwrap_or(UNIX_EPOCH), m.len(), p))
64        })
65        .collect();
66    let mut total: u64 = entries.iter().map(|e| e.1).sum();
67    total += fs::metadata(keep).map(|m| m.len()).unwrap_or(0);
68    entries.sort();
69    for (_, size, path) in entries {
70        if total <= AUDIO_CACHE_MAX_BYTES {
71            break;
72        }
73        if fs::remove_file(&path).is_ok() {
74            total -= size;
75        }
76    }
77}
78
79/// `_cached_audio_path(src)`: copy `src` into the cache (keyed by path, size
80/// and mtime) and return the copy. Any I/O failure returns `src` unchanged.
81pub fn cached_audio_path(src: &Path) -> PathBuf {
82    let attempt = || -> std::io::Result<PathBuf> {
83        let m = fs::metadata(src)?;
84        let dir = audio_cache_dir();
85        let abs = std::path::absolute(src)?;
86        let key = hex(&Sha256::digest(
87            format!("{}|{}|{}", abs.display(), m.len(), mtime_ns(&m)).as_bytes(),
88        ));
89        let ext = src
90            .extension()
91            .map(|e| format!(".{}", e.to_string_lossy()))
92            .unwrap_or_default();
93        let dest = dir.join(format!("{key}{ext}"));
94        if dest.exists() {
95            touch(&dest);
96            return Ok(dest);
97        }
98        let part = dir.join(format!("{key}{ext}.part"));
99        {
100            let mut fin = fs::File::open(src)?;
101            let mut fout = fs::File::create(&part)?;
102            let mut buf = vec![0u8; COPY_CHUNK];
103            loop {
104                let n = fin.read(&mut buf)?;
105                if n == 0 {
106                    break;
107                }
108                fout.write_all(&buf[..n])?;
109            }
110        }
111        fs::rename(&part, &dest)?;
112        evict_audio_cache(&dir, &dest);
113        Ok(dest)
114    };
115    attempt().unwrap_or_else(|_| src.to_path_buf())
116}
117
118/// `_concatenate_stems(slug, tag, filter)`: every matching stem joined into
119/// one cached MP3, keyed by the ordered `(path, size, mtime)` of its inputs so
120/// a repeat click is instant and a re-produced stem rolls the key over.
121///
122/// Decoding goes through ffmpeg rather than pydub; this is a listening
123/// preview, not a pipeline artifact, so it only has to sound the same.
124pub fn concatenate_stems(slug: &str, tag: &str, filter: &str) -> Option<PathBuf> {
125    let stems = load_stems(slug, tag, filter);
126    if stems.is_empty() {
127        return None;
128    }
129    let mut sig = vec![filter.to_string()];
130    for (_, path) in &stems {
131        let m = fs::metadata(path).ok()?;
132        let abs = std::path::absolute(path).ok()?;
133        sig.push(format!("{}|{}|{}", abs.display(), m.len(), mtime_ns(&m)));
134    }
135    let key = hex(&Sha256::digest(sig.join("\n").as_bytes()));
136    let dir = audio_cache_dir();
137    let out = dir.join(format!("concat_{key}.mp3"));
138    if out.exists() {
139        touch(&out);
140        return Some(out);
141    }
142    let part = dir.join(format!("concat_{key}.part.mp3"));
143    let mut cmd = Command::new("ffmpeg");
144    cmd.args(["-hide_banner", "-loglevel", "error", "-y"]);
145    let mut graph = String::new();
146    for (i, (_, path)) in stems.iter().enumerate() {
147        cmd.arg("-i").arg(cached_audio_path(path));
148        graph.push_str(&format!(
149            "[{i}:a]aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[a{i}];"
150        ));
151    }
152    for i in 0..stems.len() {
153        graph.push_str(&format!("[a{i}]"));
154    }
155    graph.push_str(&format!("concat=n={}:v=0:a=1[out]", stems.len()));
156    cmd.args(["-filter_complex", &graph, "-map", "[out]", "-b:a", "128k"])
157        .arg(&part);
158    let ok = cmd.output().map(|o| o.status.success()).unwrap_or(false);
159    if !ok {
160        let _ = fs::remove_file(&part);
161        return None;
162    }
163    fs::rename(&part, &out).ok()?;
164    evict_audio_cache(&dir, &out);
165    Some(out)
166}
167
168/// The `/cache/...` URL for a file inside the audio cache, or `None` when the
169/// path is elsewhere (a failed copy falls back to the workspace original).
170pub fn cache_url(path: &Path) -> Option<String> {
171    let dir = audio_cache_dir();
172    path.parent()
173        .filter(|p| *p == dir)
174        .map(|_| format!("/cache/{}", basename(path)))
175}