1use std::path::{Path, PathBuf};
29
30use crate::AsrError;
31
32#[derive(Debug, Clone)]
38pub struct DownloadProgress {
39 pub file: String,
41 pub file_index: usize,
45 pub file_count: usize,
47 pub bytes_done: u64,
49 pub bytes_total: Option<u64>,
53}
54
55pub fn download_files_with_progress<F>(
71 repo_id: &str,
72 files: &[&str],
73 dest_dir: &Path,
74 mut on_progress: F,
75) -> Result<(), AsrError>
76where
77 F: FnMut(DownloadProgress),
78{
79 use hf_hub::api::sync::Api;
80
81 std::fs::create_dir_all(dest_dir).map_err(|e| {
82 AsrError::Backend(format!(
83 "creating model directory {}: {e}",
84 dest_dir.display()
85 ))
86 })?;
87
88 let file_count = files.len();
89
90 let api = Api::new().map_err(|e| AsrError::Backend(format!("hf-hub init failed: {e}")))?;
91 let repo = api.model(repo_id.to_string());
92
93 for (idx, name) in files.iter().enumerate() {
94 let file_index = idx + 1;
95
96 let adapter = CallbackProgress {
100 file: (*name).to_string(),
101 file_index,
102 file_count,
103 bytes_done: 0,
104 bytes_total: None,
105 on_progress: &mut on_progress,
106 };
107
108 tracing::debug!(
109 repo_id,
110 file = name,
111 file_index,
112 file_count,
113 "fetching from HuggingFace with progress"
114 );
115
116 let src = repo
117 .download_with_progress(name, adapter)
118 .map_err(|e| AsrError::Backend(format!("hf-hub download of {name} failed: {e}")))?;
119
120 let dest = dest_dir.join(name);
121 std::fs::copy(&src, &dest).map_err(|e| {
125 AsrError::Backend(format!(
126 "copying {} to {}: {e}",
127 src.display(),
128 dest.display()
129 ))
130 })?;
131 }
132
133 Ok(())
134}
135
136struct CallbackProgress<'a, F: FnMut(DownloadProgress)> {
140 file: String,
141 file_index: usize,
142 file_count: usize,
143 bytes_done: u64,
144 bytes_total: Option<u64>,
145 on_progress: &'a mut F,
146}
147
148impl<F: FnMut(DownloadProgress)> CallbackProgress<'_, F> {
149 fn emit(&mut self) {
150 (self.on_progress)(DownloadProgress {
151 file: self.file.clone(),
152 file_index: self.file_index,
153 file_count: self.file_count,
154 bytes_done: self.bytes_done,
155 bytes_total: self.bytes_total,
156 });
157 }
158}
159
160impl<F: FnMut(DownloadProgress)> hf_hub::api::Progress for CallbackProgress<'_, F> {
161 fn init(&mut self, size: usize, _filename: &str) {
162 self.bytes_total = Some(size as u64);
163 self.bytes_done = 0;
164 self.emit();
165 }
166
167 fn update(&mut self, size: usize) {
168 self.bytes_done = self.bytes_done.saturating_add(size as u64);
170 self.emit();
171 }
172
173 fn finish(&mut self) {
174 if let Some(total) = self.bytes_total {
177 if self.bytes_done < total {
178 self.bytes_done = total;
179 self.emit();
180 }
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub struct PinnedFile {
195 pub name: &'static str,
197 pub sha256: &'static str,
199 pub size: u64,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct DownloadSource {
211 base_url: String,
212}
213
214impl DownloadSource {
215 pub const HUGGING_FACE_URL: &'static str = "https://huggingface.co";
217
218 pub fn hugging_face() -> Self {
220 Self::mirror(Self::HUGGING_FACE_URL)
221 }
222
223 pub fn mirror(base_url: impl Into<String>) -> Self {
226 let base_url = base_url.into().trim_end_matches('/').to_string();
227 Self { base_url }
228 }
229
230 pub fn base_url(&self) -> &str {
232 &self.base_url
233 }
234
235 pub fn file_url(&self, repo_id: &str, revision: &str, file: &str) -> String {
237 format!("{}/{repo_id}/resolve/{revision}/{file}", self.base_url)
238 }
239}
240
241const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
245const RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
247
248pub fn download_pinned_to_cache<F>(
268 repo_id: &str,
269 revision: &str,
270 files: &[PinnedFile],
271 sources: &[DownloadSource],
272 on_progress: F,
273) -> Result<PathBuf, AsrError>
274where
275 F: FnMut(DownloadProgress),
276{
277 let cache_root = hf_hub_cache_root()
278 .ok_or_else(|| AsrError::Backend("cannot locate the HuggingFace cache directory".into()))?;
279 download_pinned_into(&cache_root, repo_id, revision, files, sources, on_progress)
280}
281
282fn download_pinned_into<F>(
285 cache_root: &Path,
286 repo_id: &str,
287 revision: &str,
288 files: &[PinnedFile],
289 sources: &[DownloadSource],
290 mut on_progress: F,
291) -> Result<PathBuf, AsrError>
292where
293 F: FnMut(DownloadProgress),
294{
295 let repo_dir = cache_root.join(repo_folder_name(repo_id));
296 let snapshot = repo_dir.join("snapshots").join(revision);
297 let file_count = files.len();
298 let agent = http_agent();
299 let mut source_idx = 0;
300 let mut failures: Vec<String> = Vec::new();
301
302 for (idx, file) in files.iter().enumerate() {
303 let dest = snapshot.join(file.name);
304 let emit = |on_progress: &mut F, bytes_done: u64| {
305 on_progress(DownloadProgress {
306 file: file.name.to_string(),
307 file_index: idx + 1,
308 file_count,
309 bytes_done,
310 bytes_total: Some(file.size),
311 })
312 };
313
314 if std::fs::metadata(&dest).is_ok_and(|m| m.is_file() && m.len() == file.size) {
315 emit(&mut on_progress, file.size);
316 continue;
317 }
318
319 loop {
320 let Some(source) = sources.get(source_idx) else {
321 return Err(AsrError::Backend(if failures.is_empty() {
322 "no download source configured".to_string()
323 } else {
324 format!(
325 "download of {} failed from every source: {}",
326 file.name,
327 failures.join("; ")
328 )
329 }));
330 };
331 let url = source.file_url(repo_id, revision, file.name);
332 tracing::debug!(
333 url,
334 file_index = idx + 1,
335 file_count,
336 "fetching pinned file"
337 );
338 match fetch_verified(&agent, &url, &dest, file, |done| {
339 emit(&mut on_progress, done)
340 }) {
341 Ok(()) => break,
342 Err(FetchError::Local(err)) => return Err(err),
343 Err(FetchError::Remote(msg)) => {
344 tracing::warn!(url, error = %msg, "download source failed, trying the next one");
345 failures.push(format!("{}: {msg}", source.base_url()));
346 source_idx += 1;
347 }
348 }
349 }
350 }
351
352 let refs = repo_dir.join("refs");
353 std::fs::create_dir_all(&refs)?;
354 std::fs::write(refs.join("main"), revision)?;
355 Ok(snapshot)
356}
357
358#[derive(Debug)]
361enum FetchError {
362 Remote(String),
363 Local(AsrError),
364}
365
366impl From<std::io::Error> for FetchError {
367 fn from(err: std::io::Error) -> Self {
368 FetchError::Local(AsrError::Io(err))
369 }
370}
371
372fn fetch_verified(
375 agent: &ureq::Agent,
376 url: &str,
377 dest: &Path,
378 file: &PinnedFile,
379 mut on_bytes: impl FnMut(u64),
380) -> Result<(), FetchError> {
381 use sha2::{Digest, Sha256};
382 use std::io::{Read, Write};
383
384 if let Some(parent) = dest.parent() {
385 std::fs::create_dir_all(parent)?;
386 }
387 let part = dest.with_file_name(format!(
388 "{}.part",
389 dest.file_name()
390 .and_then(|n| n.to_str())
391 .unwrap_or("download")
392 ));
393
394 on_bytes(0);
395 let mut response = agent
396 .get(url)
397 .call()
398 .map_err(|e| FetchError::Remote(e.to_string()))?;
399 let mut reader = response.body_mut().as_reader();
400
401 let result = (|| {
402 let mut out = std::fs::File::create(&part)?;
403 let mut hasher = Sha256::new();
404 let mut buf = vec![0u8; 64 * 1024];
405 let mut done: u64 = 0;
406 loop {
407 let n = reader
408 .read(&mut buf)
409 .map_err(|e| FetchError::Remote(e.to_string()))?;
410 if n == 0 {
411 break;
412 }
413 done += n as u64;
414 if done > file.size {
415 return Err(FetchError::Remote(format!(
416 "{} is larger than expected ({} bytes)",
417 file.name, file.size
418 )));
419 }
420 hasher.update(&buf[..n]);
421 out.write_all(&buf[..n])?;
422 on_bytes(done);
423 }
424 out.flush()?;
425 drop(out);
426 if done != file.size {
427 return Err(FetchError::Remote(format!(
428 "{} is incomplete ({done} of {} bytes)",
429 file.name, file.size
430 )));
431 }
432 let digest = to_hex(&hasher.finalize());
433 if !digest.eq_ignore_ascii_case(file.sha256) {
434 return Err(FetchError::Remote(format!(
435 "{} failed its checksum (got {digest})",
436 file.name
437 )));
438 }
439 match std::fs::remove_file(dest) {
442 Ok(()) => {}
443 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
444 Err(e) => return Err(e.into()),
445 }
446 std::fs::rename(&part, dest)?;
447 Ok(())
448 })();
449 if result.is_err() {
450 let _ = std::fs::remove_file(&part);
451 }
452 result
453}
454
455fn http_agent() -> ureq::Agent {
456 use ureq::tls::{TlsConfig, TlsProvider};
457 ureq::Agent::config_builder()
458 .tls_config(
459 TlsConfig::builder()
460 .provider(TlsProvider::NativeTls)
461 .build(),
462 )
463 .timeout_connect(Some(CONNECT_TIMEOUT))
464 .timeout_recv_response(Some(RESPONSE_TIMEOUT))
465 .build()
466 .into()
467}
468
469fn to_hex(bytes: &[u8]) -> String {
470 use std::fmt::Write;
471 bytes
472 .iter()
473 .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
474 let _ = write!(s, "{b:02x}");
475 s
476 })
477}
478
479fn repo_folder_name(repo_id: &str) -> String {
481 format!("models--{}", repo_id.replace('/', "--"))
482}
483
484pub fn is_repo_cached(repo_id: &str, files: &[&str]) -> bool {
510 let Some(cache_root) = hf_hub_cache_root() else {
511 return false;
512 };
513 snapshot_satisfies_files(&cache_root, repo_id, files)
514}
515
516fn hf_hub_cache_root() -> Option<PathBuf> {
522 if let Some(home) = std::env::var_os("HF_HOME") {
523 let mut p = PathBuf::from(home);
524 p.push("hub");
525 return Some(p);
526 }
527 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
528 let mut p = PathBuf::from(home);
529 p.extend([".cache", "huggingface", "hub"]);
530 Some(p)
531}
532
533fn snapshot_satisfies_files(cache_root: &Path, repo_id: &str, files: &[&str]) -> bool {
539 let repo_dir = cache_root.join(repo_folder_name(repo_id));
540 let snapshots = repo_dir.join("snapshots");
541 let Ok(entries) = std::fs::read_dir(&snapshots) else {
542 return false;
543 };
544 for entry in entries.flatten() {
545 let snap = entry.path();
546 if !snap.is_dir() {
547 continue;
548 }
549 if files.iter().all(|f| snap.join(f).exists()) {
550 return true;
551 }
552 }
553 false
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
563 fn callback_progress_reports_init_update_and_finish() {
564 use hf_hub::api::Progress;
565
566 let events = std::cell::RefCell::new(Vec::<DownloadProgress>::new());
567 let mut on_progress = |p: DownloadProgress| events.borrow_mut().push(p);
568
569 let mut adapter = CallbackProgress {
570 file: "encoder.onnx".to_string(),
571 file_index: 1,
572 file_count: 4,
573 bytes_done: 0,
574 bytes_total: None,
575 on_progress: &mut on_progress,
576 };
577
578 adapter.init(1000, "encoder.onnx");
579 adapter.update(400);
580 adapter.update(600);
581 adapter.finish();
582
583 let got = events.into_inner();
584 assert_eq!(got.len(), 3);
587
588 assert_eq!(got[0].bytes_done, 0);
589 assert_eq!(got[0].bytes_total, Some(1000));
590 assert_eq!(got[0].file_index, 1);
591 assert_eq!(got[0].file_count, 4);
592 assert_eq!(got[0].file, "encoder.onnx");
593
594 assert_eq!(got[1].bytes_done, 400);
595 assert_eq!(got[2].bytes_done, 1000);
596 }
597
598 #[test]
602 fn callback_progress_finish_synthesizes_completion() {
603 use hf_hub::api::Progress;
604
605 let events = std::cell::RefCell::new(Vec::<DownloadProgress>::new());
606 let mut on_progress = |p: DownloadProgress| events.borrow_mut().push(p);
607
608 let mut adapter = CallbackProgress {
609 file: "tokens.txt".to_string(),
610 file_index: 4,
611 file_count: 4,
612 bytes_done: 0,
613 bytes_total: None,
614 on_progress: &mut on_progress,
615 };
616
617 adapter.init(500, "tokens.txt");
618 adapter.update(400); adapter.finish(); let got = events.into_inner();
622 assert_eq!(got.len(), 3);
623 assert_eq!(got.last().unwrap().bytes_done, 500);
624 assert_eq!(got.last().unwrap().bytes_total, Some(500));
625 }
626
627 #[test]
634 fn snapshot_probe_matches_hf_hub_layout() {
635 let root = unique_temp_dir("snapshot-probe");
636 let _cleanup = scopeguard_remove(root.clone());
637 std::fs::create_dir_all(&root).unwrap();
638
639 let repo_id = "csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20";
640 let files = ["encoder.onnx", "decoder.onnx", "tokens.txt"];
641
642 assert!(!snapshot_satisfies_files(&root, repo_id, &files));
643
644 let repo_dir = root.join(format!("models--{}", repo_id.replace('/', "--")));
645 let snap_a = repo_dir.join("snapshots").join("abc123");
646 std::fs::create_dir_all(&snap_a).unwrap();
647 assert!(!snapshot_satisfies_files(&root, repo_id, &files));
648
649 std::fs::write(snap_a.join("encoder.onnx"), b"").unwrap();
650 std::fs::write(snap_a.join("decoder.onnx"), b"").unwrap();
651 assert!(!snapshot_satisfies_files(&root, repo_id, &files));
652
653 std::fs::write(snap_a.join("tokens.txt"), b"").unwrap();
654 assert!(snapshot_satisfies_files(&root, repo_id, &files));
655
656 let snap_b = repo_dir.join("snapshots").join("def456");
659 std::fs::create_dir_all(&snap_b).unwrap();
660 std::fs::write(snap_b.join("encoder.onnx"), b"").unwrap();
661 assert!(snapshot_satisfies_files(&root, repo_id, &files));
662 }
663
664 #[test]
668 fn snapshot_probe_flattens_slashes_in_repo_id() {
669 let root = unique_temp_dir("slash-flatten");
670 let _cleanup = scopeguard_remove(root.clone());
671 let repo_id = "owner/repo";
672 let snap = root
673 .join("models--owner--repo")
674 .join("snapshots")
675 .join("c0");
676 std::fs::create_dir_all(&snap).unwrap();
677 std::fs::write(snap.join("model.onnx"), b"").unwrap();
678 assert!(snapshot_satisfies_files(&root, repo_id, &["model.onnx"]));
679 }
680
681 fn serve(
687 routes: Vec<(&'static str, u16, Vec<u8>)>,
688 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
689 use std::io::{BufRead, BufReader, Write};
690 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
691 let base = format!("http://{}", listener.local_addr().unwrap());
692 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
693 let log_t = log.clone();
694 std::thread::spawn(move || {
695 for stream in listener.incoming() {
696 let Ok(mut stream) = stream else { return };
697 let mut reader = BufReader::new(stream.try_clone().unwrap());
698 let mut line = String::new();
699 reader.read_line(&mut line).unwrap();
700 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
701 loop {
702 let mut h = String::new();
703 if reader.read_line(&mut h).unwrap() <= 2 {
704 break;
705 }
706 }
707 log_t.lock().unwrap().push(path.clone());
708 let (status, body) = routes
709 .iter()
710 .find(|(p, _, _)| *p == path)
711 .map(|(_, s, b)| (*s, b.clone()))
712 .unwrap_or((404, b"not found".to_vec()));
713 let head = format!(
714 "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
715 body.len()
716 );
717 let _ = stream.write_all(head.as_bytes());
718 let _ = stream.write_all(&body);
719 }
720 });
721 (base, log)
722 }
723
724 fn refused_base() -> String {
727 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
728 let addr = listener.local_addr().unwrap();
729 drop(listener);
730 format!("http://{addr}")
731 }
732
733 const REPO: &str = "owner/model";
734 const REV: &str = "0123456789abcdef0123456789abcdef01234567";
735
736 fn pinned(name: &'static str, body: &[u8]) -> PinnedFile {
737 use sha2::{Digest, Sha256};
738 let sha = to_hex(&Sha256::digest(body));
739 PinnedFile {
740 name,
741 sha256: Box::leak(sha.into_boxed_str()),
742 size: body.len() as u64,
743 }
744 }
745
746 fn route(name: &str) -> &'static str {
747 Box::leak(format!("/{REPO}/resolve/{REV}/{name}").into_boxed_str())
748 }
749
750 #[test]
751 fn source_file_url_uses_resolve_layout_and_trims_slash() {
752 let src = DownloadSource::mirror("https://example.com/models/");
753 assert_eq!(src.base_url(), "https://example.com/models");
754 assert_eq!(
755 src.file_url("owner/model", "abc", "encoder.onnx"),
756 "https://example.com/models/owner/model/resolve/abc/encoder.onnx"
757 );
758 assert_eq!(
759 DownloadSource::hugging_face().file_url("a/b", "main", "t.txt"),
760 "https://huggingface.co/a/b/resolve/main/t.txt"
761 );
762 }
763
764 #[test]
768 fn falls_back_to_next_source_and_populates_hf_cache() {
769 let root = unique_temp_dir("pinned-fallback");
770 let _cleanup = scopeguard_remove(root.clone());
771 let files = [
772 pinned("encoder.onnx", b"enc-bytes"),
773 pinned("tokens.txt", b"a 0\nb 1\n"),
774 ];
775 let (mirror, log) = serve(vec![
776 (route("encoder.onnx"), 200, b"enc-bytes".to_vec()),
777 (route("tokens.txt"), 200, b"a 0\nb 1\n".to_vec()),
778 ]);
779 let sources = [
780 DownloadSource::mirror(refused_base()),
781 DownloadSource::mirror(mirror),
782 ];
783
784 let mut events = Vec::new();
785 let snap =
786 download_pinned_into(&root, REPO, REV, &files, &sources, |p| events.push(p)).unwrap();
787
788 assert_eq!(snap, root.join("models--owner--model/snapshots").join(REV));
789 assert_eq!(
790 std::fs::read(snap.join("encoder.onnx")).unwrap(),
791 b"enc-bytes"
792 );
793 assert_eq!(
794 std::fs::read_to_string(root.join("models--owner--model/refs/main")).unwrap(),
795 REV
796 );
797 assert!(snapshot_satisfies_files(
798 &root,
799 REPO,
800 &["encoder.onnx", "tokens.txt"]
801 ));
802 assert!(!snap.join("encoder.onnx.part").exists());
803 assert_eq!(log.lock().unwrap().len(), 2);
805 let last = events.last().unwrap();
806 assert_eq!((last.file_index, last.file_count), (2, 2));
807 assert_eq!(last.bytes_done, 8);
808 assert_eq!(last.bytes_total, Some(8));
809 }
810
811 #[test]
814 fn checksum_mismatch_falls_through_to_next_source() {
815 let root = unique_temp_dir("pinned-mismatch");
816 let _cleanup = scopeguard_remove(root.clone());
817 let files = [pinned("model.onnx", b"genuine!")];
818 let (bad, _) = serve(vec![(route("model.onnx"), 200, b"tampered".to_vec())]);
819 let (good, _) = serve(vec![(route("model.onnx"), 200, b"genuine!".to_vec())]);
820 let sources = [DownloadSource::mirror(bad), DownloadSource::mirror(good)];
821
822 let snap = download_pinned_into(&root, REPO, REV, &files, &sources, |_| {}).unwrap();
823 assert_eq!(std::fs::read(snap.join("model.onnx")).unwrap(), b"genuine!");
824 }
825
826 #[test]
829 fn fails_when_every_source_fails_and_leaves_no_file() {
830 let root = unique_temp_dir("pinned-allfail");
831 let _cleanup = scopeguard_remove(root.clone());
832 let files = [pinned("model.onnx", b"genuine!")];
833 let (bad, _) = serve(vec![(route("model.onnx"), 200, b"tampered".to_vec())]);
834 let (missing, _) = serve(vec![]);
835 let sources = [DownloadSource::mirror(bad), DownloadSource::mirror(missing)];
836
837 let err = download_pinned_into(&root, REPO, REV, &files, &sources, |_| {}).unwrap_err();
838 let msg = err.to_string();
839 assert!(msg.contains("checksum"), "{msg}");
840 assert!(msg.contains("404"), "{msg}");
841 let snap = root.join("models--owner--model/snapshots").join(REV);
842 assert!(!snap.join("model.onnx").exists());
843 assert!(!snap.join("model.onnx.part").exists());
844 assert!(!root.join("models--owner--model/refs/main").exists());
845 }
846
847 #[test]
848 fn truncated_body_is_rejected() {
849 let root = unique_temp_dir("pinned-short");
850 let _cleanup = scopeguard_remove(root.clone());
851 let mut file = pinned("model.onnx", b"full body");
852 file.size = 100;
853 let (host, _) = serve(vec![(route("model.onnx"), 200, b"full body".to_vec())]);
854 let err = download_pinned_into(
855 &root,
856 REPO,
857 REV,
858 &[file],
859 &[DownloadSource::mirror(host)],
860 |_| {},
861 )
862 .unwrap_err();
863 assert!(err.to_string().contains("incomplete"), "{err}");
864 }
865
866 #[test]
869 fn skips_files_already_present_at_pinned_size() {
870 let root = unique_temp_dir("pinned-resume");
871 let _cleanup = scopeguard_remove(root.clone());
872 let files = [pinned("a.onnx", b"aaaa"), pinned("b.txt", b"bb")];
873 let snap = root.join("models--owner--model/snapshots").join(REV);
874 std::fs::create_dir_all(&snap).unwrap();
875 std::fs::write(snap.join("a.onnx"), b"aaaa").unwrap();
876 let (host, log) = serve(vec![(route("b.txt"), 200, b"bb".to_vec())]);
877
878 download_pinned_into(
879 &root,
880 REPO,
881 REV,
882 &files,
883 &[DownloadSource::mirror(host)],
884 |_| {},
885 )
886 .unwrap();
887 assert_eq!(*log.lock().unwrap(), vec![route("b.txt").to_string()]);
888 }
889
890 #[test]
891 fn no_sources_is_an_error() {
892 let root = unique_temp_dir("pinned-nosource");
893 let _cleanup = scopeguard_remove(root.clone());
894 let err =
895 download_pinned_into(&root, REPO, REV, &[pinned("x", b"x")], &[], |_| {}).unwrap_err();
896 assert!(err.to_string().contains("no download source"), "{err}");
897 }
898
899 fn unique_temp_dir(label: &str) -> std::path::PathBuf {
905 use std::time::{SystemTime, UNIX_EPOCH};
906 let nanos = SystemTime::now()
907 .duration_since(UNIX_EPOCH)
908 .map(|d| d.as_nanos())
909 .unwrap_or(0);
910 let pid = std::process::id();
911 std::env::temp_dir().join(format!("wavekat-asr-{label}-{pid}-{nanos}"))
912 }
913
914 fn scopeguard_remove(path: std::path::PathBuf) -> impl Drop {
915 struct Guard(std::path::PathBuf);
916 impl Drop for Guard {
917 fn drop(&mut self) {
918 let _ = std::fs::remove_dir_all(&self.0);
919 }
920 }
921 Guard(path)
922 }
923}