diff --git a/.gitignore b/.gitignore
index d016fd0..6b4d0d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ benchmarks/audio/
.codex-plan-loop/
*.tsanalysis.json
*.tspeaks
+*.tsa
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58eacf4..7fa2a9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,23 @@
## Unreleased
+### Breaking changes
+
+- `timestretch-cli analyze` writes the binary `.tsa` analysis container
+ by default (` .tsa`) instead of ` .tsanalysis.json`; an
+ explicit `-o out.json` keeps the legacy JSON format. `--pre-analysis`
+ accepts either format.
+- Desktop: tracks now converge to a single `.tsa` sidecar. Valid legacy
+ `.tsanalysis.json` artifacts are absorbed into the container on load
+ and both legacy sidecars (`.tsanalysis.json`, `.tspeaks`) are deleted
+ once the on-disk container supersedes them.
+
+### Deprecated
+
+- `read_preanalysis_json` / `write_preanalysis_json`: use the `.tsa`
+ analysis container (`io::tsa`) instead. The JSON pair keeps working
+ while downstream consumers migrate.
+
### Added
- `EngineProfile::WideKeylock`: wide-range Master Tempo deck profile
@@ -25,6 +42,29 @@
- Desktop: a Range selector (Standard | Wide) with seek-priced engine
rebuild that preserves the playhead, and a live pipeline-latency
readout next to it.
+- `.tsa` analysis container (`io::tsa`): one content-bound file per
+ track holding the pre-analysis artifact and the 3-band waveform peaks
+ as versioned chunks (unknown chunks skip forward-compatibly; readers
+ reject-don't-panic on hostile input). Two API layers: bytes
+ (`AnalysisFile::to_bytes`/`from_bytes`/`from_bytes_validated`, for
+ apps that store analysis blobs in their own database keyed by
+ `content_hash`) and sidecar file wrappers with atomic writes
+ (`read_analysis_file`, `read_analysis_file_validated`,
+ `write_analysis_file`, `analysis_file_path` — `.tsa`).
+ `timestretch-cli analyze` writes both chunks, making it a complete
+ offline pre-analysis tool.
+- `analysis::waveform`: the desktop app's 3-band waveform peaks pyramid
+ moved into the library (`BandPeaks`, `PeakLevel`, `NUM_BANDS`) so any
+ frontend gets display peaks without reimplementing the analyzer.
+- `PreAnalysisArtifact::matches_identity`: `matches_source` semantics
+ for callers that already hold the (rate, length, hash) identity;
+ `matches_source` now delegates to it.
+
+### Removed
+
+- Desktop `.tspeaks` sidecar format (introduced on an unreleased
+ branch): superseded by the `.tsa` container's PEAK chunk; existing
+ files are deleted after migration, peaks recompute in milliseconds.
## 0.10.0
diff --git a/README.md b/README.md
index 1dfdcae..7e3f87d 100644
--- a/README.md
+++ b/README.md
@@ -190,26 +190,34 @@ let output = timestretch::stretch_to_bpm(&input, ¶ms, 126.0, 128.0).unwrap()
```rust
use timestretch::{
- analyze_for_dj, read_preanalysis_json, stretch, write_preanalysis_json,
- StretchParams,
+ analyze_for_dj, read_analysis_file, write_analysis_file, stretch,
+ AnalysisFile, BandPeaks, StretchParams,
};
use std::path::Path;
-// Build a reusable analysis artifact once (offline)
-let artifact = analyze_for_dj(&input, 44100);
-write_preanalysis_json(Path::new("track.preanalysis.json"), &artifact).unwrap();
+// Build the `.tsa` analysis container once (offline): the beat/onset
+// artifact plus the 3-band waveform peaks a player UI needs at load.
+let mut analysis = AnalysisFile::for_source(&input, 44100);
+analysis.artifact = Some(analyze_for_dj(&input, 44100));
+analysis.peaks = Some(BandPeaks::compute(&input, 1, 44100));
+write_analysis_file(Path::new("track.wav.tsa"), &analysis).unwrap();
-// Load artifact at runtime and attach it to params
-let loaded = read_preanalysis_json(Path::new("track.preanalysis.json")).unwrap();
+// Load it at runtime and attach the artifact to params
+let loaded = read_analysis_file(Path::new("track.wav.tsa")).unwrap();
let params = StretchParams::new(126.0 / 128.0)
.with_sample_rate(44100)
- .with_pre_analysis(loaded)
+ .with_pre_analysis(loaded.artifact.unwrap())
.with_beat_snap_confidence_threshold(0.35)
.with_beat_snap_tolerance_ms(5.0);
let output = stretch(&input, ¶ms).unwrap();
```
+Apps that keep analysis in their own database rather than sidecar files
+can use the bytes layer directly — `AnalysisFile::to_bytes` /
+`from_bytes` / `from_bytes_validated` — and key blobs by
+`AnalysisFile::content_hash`.
+
### WAV File I/O
```rust
@@ -356,10 +364,13 @@ See `benchmarks/README.md` for corpus setup and manifest/checksum requirements.
- `detect_beat_grid_buffer(&AudioBuffer)` — detect beat grid from an `AudioBuffer`
- `bpm_ratio(source_bpm, target_bpm)` — compute stretch ratio for BPM change
-**Pre-analysis artifact pipeline:**
+**Pre-analysis pipeline (`.tsa` analysis container):**
- `analyze_for_dj(&[f32], sample_rate)` — generate offline beat/onset artifact
-- `write_preanalysis_json(path, &PreAnalysisArtifact)` — write artifact JSON
-- `read_preanalysis_json(path)` — read artifact JSON
+- `AnalysisFile` — one container per track: identity header + artifact + waveform peaks; `to_bytes`/`from_bytes`/`from_bytes_validated` for database-backed storage
+- `write_analysis_file(path, &AnalysisFile)` / `read_analysis_file(path)` / `read_analysis_file_validated(path, rate, len, hash)` — sidecar I/O (atomic writes)
+- `analysis_file_path(audio_path)` — sidecar convention: `.tsa`
+- `BandPeaks::compute(&[f32], channels, sample_rate)` — 3-band waveform peaks pyramid
+- `write_preanalysis_json` / `read_preanalysis_json` — deprecated: legacy JSON sidecars (use the `.tsa` container)
**WAV file convenience:**
- `stretch_wav_file(input, output, &StretchParams)` — read, stretch, and write a WAV file
diff --git a/ROADMAP.md b/ROADMAP.md
index e4a0060..f464006 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1126,30 +1126,30 @@ Automation: auto
### Why
The RT contract is machine-verified, but the crate's *input* surface has
-never been hardened: the artifact JSON loader (`src/core/preanalysis.rs`),
-the WAV reader (`src/io/wav.rs`), the desktop app's binary peaks-cache
-loader (`desktop/src/waveform/cache.rs`), and the public batch API have no
-fuzz coverage, and there is no enforced policy that arbitrary input
-produces `Err`, never a panic. For a library embedded in a shipping app — and a
+never been hardened: the `.tsa` analysis-container loader
+(`src/io/tsa.rs`), the deprecated artifact JSON loader
+(`src/core/preanalysis.rs`), the WAV reader (`src/io/wav.rs`), and the
+public batch API have no fuzz coverage, and there is no enforced policy
+that arbitrary input produces `Err`, never a panic. For a library embedded in a shipping app — and a
prerequisite for any 1.0 — "does not panic on hostile or degenerate input"
must be a tested property, not an intention. This stage touches no DSP.
### Primary Files
- New: `fuzz/` (cargo-fuzz targets), a soak harness in `qa/`
-- Audited in place: `src/core/preanalysis.rs` (JSON load path),
- `src/io/wav.rs`, `desktop/src/waveform/cache.rs` (`.tspeaks` binary
- load path), `src/lib.rs` (param validation), `src/error.rs`,
+- Audited in place: `src/io/tsa.rs` (`.tsa` container load path),
+ `src/core/preanalysis.rs` (deprecated JSON load path),
+ `src/io/wav.rs`, `src/lib.rs` (param validation), `src/error.rs`,
engine constructors in `src/engine/`
- CI: `.github/workflows/ci.yml` (bounded fuzz on PRs, longer cron run)
### Work
-- Fuzz targets: artifact JSON from arbitrary bytes; WAV parsing from
- arbitrary bytes; the `.tspeaks` binary peaks cache from arbitrary bytes
- (its reader is already written to reject-not-panic, with a unit-test
+- Fuzz targets: the `.tsa` analysis container from arbitrary bytes (its
+ reader is already written to reject-not-panic, with a unit-test
corruption matrix — the fuzzer's job is to prove that property holds);
- the batch `stretch()` API driven by arbitrary params ×
+ the deprecated artifact JSON from arbitrary bytes; WAV parsing from
+ arbitrary bytes; the batch `stretch()` API driven by arbitrary params ×
degenerate audio (NaN/Inf/denormal samples, zero-length, one sample,
extreme rates and sample rates).
- No-panic policy: every public entry point returns `Err` on invalid
diff --git a/desktop/src/app.rs b/desktop/src/app.rs
index d915a81..d33a83c 100644
--- a/desktop/src/app.rs
+++ b/desktop/src/app.rs
@@ -1431,17 +1431,29 @@ impl TimeStretchApp {
}
}
-/// Sidecar artifact path for a loaded audio file: `.tsanalysis.json`.
-fn sidecar_path(audio_path: &std::path::Path) -> PathBuf {
+/// Legacy JSON artifact sidecar (`.tsanalysis.json`): read only to
+/// migrate old analyses into the `.tsa` container, deleted once superseded.
+fn legacy_json_path(audio_path: &std::path::Path) -> PathBuf {
let mut os = audio_path.as_os_str().to_os_string();
os.push(".tsanalysis.json");
PathBuf::from(os)
}
-/// The background load: decode, peaks (with `.tspeaks` sidecar cache),
-/// then pre-analysis (with `.tsanalysis.json` sidecar cache) — one decode,
-/// one downmix, one hash, shared by everything. Sidecar writes are
-/// best-effort: a read-only volume must not break loading.
+/// Legacy peaks sidecar (`.tspeaks`): never read anymore — peaks
+/// recompute in milliseconds — deleted once superseded by the container.
+fn legacy_peaks_path(audio_path: &std::path::Path) -> PathBuf {
+ let mut os = audio_path.as_os_str().to_os_string();
+ os.push(".tspeaks");
+ PathBuf::from(os)
+}
+
+/// The background load: decode, then the single `.tsa` analysis container
+/// — peaks ship to the UI immediately, pre-analysis follows. One decode,
+/// one downmix, one hash, shared by everything. Container writes are
+/// best-effort: a read-only volume must not break loading. A valid legacy
+/// `.tsanalysis.json` artifact is absorbed into the container (skipping
+/// the slow re-analysis), and both legacy sidecars are deleted once the
+/// on-disk container supersedes them.
///
/// Results are dropped if another file was loaded in the meantime (the
/// receiver is gone and the `generation` guard rejects the artifact).
@@ -1472,45 +1484,76 @@ fn run_load_worker(req: LoadRequest) {
let num_frames = decoded.num_frames;
let num_channels = (decoded.channels as usize).max(1);
- // One mono downmix + one hash serve the peaks cache key, the peaks
- // computation, and the pre-analysis below.
+ // One mono downmix + one hash: the container identity, the peaks
+ // input, and the pre-analysis input.
let mono = timestretch::downmix_to_mid(&decoded.samples, num_channels);
let content_hash = timestretch::hash_samples(&mono);
- let cache_path = waveform::cache::peaks_cache_path(&path);
- let peaks =
- match waveform::cache::read_validated(&cache_path, sample_rate, mono.len(), content_hash) {
- Some(cached) => {
- log::info!("Peaks: using cached sidecar {}", cache_path.display());
- cached
- }
- None => {
- let start = std::time::Instant::now();
- // Computed from the hashed mono signal (not the interleaved
- // stereo): identical bucket count, per-quantization-identical
- // values, half the filter work — and the persisted peaks
- // derive from exactly the signal that keys them.
- let fresh = BandPeaks::compute(&mono, 1, sample_rate);
- log::info!("Peaks: computed in {:.2}s", start.elapsed().as_secs_f64());
- if let Err(e) = waveform::cache::write(
- &cache_path,
- &fresh,
- sample_rate,
- mono.len(),
- content_hash,
- ) {
- log::warn!("Peaks: could not cache {}: {e}", cache_path.display());
- }
- fresh
+ let tsa_path = timestretch::analysis_file_path(&path);
+ let on_disk =
+ timestretch::read_analysis_file_validated(&tsa_path, sample_rate, mono.len(), content_hash);
+ // Whether the container on disk already supersedes both legacy
+ // sidecars; kept true across best-effort rewrites of the same content.
+ let mut persisted_complete = on_disk
+ .as_ref()
+ .is_some_and(|af| af.artifact.is_some() && af.peaks.is_some());
+ let mut analysis = match on_disk {
+ Some(af) => {
+ log::info!("Analysis: using cached container {}", tsa_path.display());
+ af
+ }
+ None => timestretch::AnalysisFile::for_source(&mono, sample_rate),
+ };
+ let mut dirty = false;
+
+ // Legacy migration: absorb a still-valid JSON artifact so the slow
+ // re-analysis is skipped. The legacy `.tspeaks` cache is deliberately
+ // NOT read — recomputing peaks costs milliseconds, keeping its parser
+ // alive costs a hundred lines.
+ if analysis.artifact.is_none() {
+ #[allow(deprecated)]
+ let legacy = timestretch::read_preanalysis_json(&legacy_json_path(&path));
+ if let Ok(legacy) = legacy
+ && legacy.matches_source(&mono, sample_rate)
+ {
+ log::info!(
+ "Pre-analysis: migrating legacy JSON sidecar into {}",
+ tsa_path.display()
+ );
+ analysis.artifact = Some(legacy);
+ dirty = true;
+ }
+ }
+
+ if analysis.peaks.is_none() {
+ let start = std::time::Instant::now();
+ // Computed from the hashed mono signal (not the interleaved
+ // stereo): identical bucket count, per-quantization-identical
+ // values, half the filter work — and the persisted peaks derive
+ // from exactly the signal that keys them.
+ analysis.peaks = Some(BandPeaks::compute(&mono, 1, sample_rate));
+ log::info!("Peaks: computed in {:.2}s", start.elapsed().as_secs_f64());
+ dirty = true;
+ }
+
+ // Write #1, before the Track send: peaks (and any migrated artifact)
+ // survive a crash during the slow analysis below.
+ if dirty {
+ match timestretch::write_analysis_file(&tsa_path, &analysis) {
+ Ok(()) => {
+ persisted_complete = analysis.artifact.is_some();
+ dirty = false;
}
- };
+ Err(e) => log::warn!("Analysis: could not write {}: {e}", tsa_path.display()),
+ }
+ }
let track = LoadedTrack {
path: path.clone(),
samples: Arc::new(decoded.samples),
sample_rate,
num_frames,
- peaks,
+ peaks: analysis.peaks.clone().expect("peaks were just ensured"),
};
if tx.send(LoadMsg::Track(Ok(track))).is_err() {
// A newer load replaced this one; skip the expensive analysis too.
@@ -1523,13 +1566,9 @@ fn run_load_worker(req: LoadRequest) {
if state.lock().unwrap().analysis_generation != generation {
return;
}
- let sidecar = sidecar_path(&path);
- let artifact = match timestretch::read_preanalysis_json(&sidecar) {
- Ok(cached) if cached.matches_source(&mono, sample_rate) => {
- log::info!("Pre-analysis: using cached sidecar {}", sidecar.display());
- cached
- }
- _ => {
+ let artifact = match analysis.artifact.clone() {
+ Some(cached) => cached,
+ None => {
let start = std::time::Instant::now();
let fresh = timestretch::analyze_for_dj(&mono, sample_rate);
log::info!(
@@ -1540,16 +1579,35 @@ fn run_load_worker(req: LoadRequest) {
fresh.transient_onsets.len(),
start.elapsed().as_secs_f64()
);
- if let Err(e) = timestretch::write_preanalysis_json(&sidecar, &fresh) {
- log::warn!(
- "Pre-analysis: could not cache sidecar {}: {e}",
- sidecar.display()
- );
- }
+ analysis.artifact = Some(fresh.clone());
+ dirty = true;
fresh
}
};
+ // Write #2: the container now carries both chunks. No read-modify-
+ // write — this worker holds the whole file.
+ if dirty {
+ match timestretch::write_analysis_file(&tsa_path, &analysis) {
+ Ok(()) => persisted_complete = true,
+ Err(e) => log::warn!("Analysis: could not write {}: {e}", tsa_path.display()),
+ }
+ }
+
+ // Once the on-disk container supersedes both legacy sidecars — it
+ // holds a valid artifact AND peaks for exactly this audio — delete
+ // them so tracks converge to the single `.tsa` file. Only these two
+ // sibling paths are ever touched (Halo's `.halo.*` variants are not).
+ if persisted_complete {
+ for legacy in [legacy_json_path(&path), legacy_peaks_path(&path)] {
+ match std::fs::remove_file(&legacy) {
+ Ok(()) => log::info!("Removed superseded legacy sidecar {}", legacy.display()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => log::warn!("Could not remove legacy sidecar {}: {e}", legacy.display()),
+ }
+ }
+ }
+
let artifact = Arc::new(artifact);
{
// Store worker-side (not only via the channel) so `start_playback`
@@ -1696,7 +1754,7 @@ mod tests {
}
#[test]
- fn load_worker_sends_track_then_analysis_and_caches_peaks() {
+ fn load_worker_writes_single_tsa() {
let dir = temp_dir("worker");
let wav = dir.join("track.wav");
write_test_wav(&wav, 4.0);
@@ -1717,11 +1775,12 @@ mod tests {
};
assert!(artifact.bpm > 0.0, "pulse train should yield a BPM");
assert!(state.lock().unwrap().pre_analysis.is_some());
- // Suffix-append convention: track.wav.tspeaks.
- assert!(dir.join("track.wav.tspeaks").exists());
- assert!(dir.join("track.wav.tsanalysis.json").exists());
+ // One sidecar, suffix-append convention — and no legacy files.
+ assert!(dir.join("track.wav.tsa").exists());
+ assert!(!dir.join("track.wav.tspeaks").exists());
+ assert!(!dir.join("track.wav.tsanalysis.json").exists());
- // Second run against the same file: both sidecars hit; results
+ // Second run against the same file: pure container hit; results
// equivalent.
let (req2, rx2) = worker_request(&wav, &state, 7);
run_load_worker(req2);
@@ -1742,6 +1801,115 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ /// Run one worker and drain its channel; panics on decode errors.
+ fn run_worker_to_completion(wav: &std::path::Path, state: &SharedStateHandle, generation: u64) {
+ let (req, rx) = worker_request(wav, state, generation);
+ run_load_worker(req);
+ while let Ok(msg) = rx.recv() {
+ if let LoadMsg::Track(Err(e)) = msg {
+ panic!("worker failed: {e}");
+ }
+ }
+ }
+
+ /// Analyze the test WAV's mono signal the same way the worker does.
+ fn analyzed_artifact(wav: &std::path::Path) -> timestretch::PreAnalysisArtifact {
+ let decoded = decoder::decode_file(wav).unwrap();
+ let mono =
+ timestretch::downmix_to_mid(&decoded.samples, (decoded.channels as usize).max(1));
+ timestretch::analyze_for_dj(&mono, decoded.sample_rate)
+ }
+
+ #[test]
+ fn load_worker_migrates_legacy_json() {
+ let dir = temp_dir("migrate");
+ let wav = dir.join("track.wav");
+ write_test_wav(&wav, 4.0);
+ // Pre-seed a valid legacy JSON artifact and a garbage .tspeaks
+ // (its contents are never read — only superseded and deleted). The
+ // distinctive confidence value marks this exact artifact: a fresh
+ // re-analysis would never reproduce it, so its presence in the
+ // container proves absorption. (Exact float equality is off the
+ // table — serde_json's default f64 parsing can be one ulp off.)
+ let mut real = analyzed_artifact(&wav);
+ real.confidence = 0.4242;
+ #[allow(deprecated)]
+ timestretch::write_preanalysis_json(&legacy_json_path(&wav), &real).unwrap();
+ std::fs::write(legacy_peaks_path(&wav), b"garbage bytes").unwrap();
+
+ let state: SharedStateHandle = Arc::new(Mutex::new(SharedState::new()));
+ run_worker_to_completion(&wav, &state, 0);
+
+ // The marked artifact was absorbed (not re-analyzed), the container
+ // is complete, the legacy files are gone.
+ let tsa = timestretch::read_analysis_file(&dir.join("track.wav.tsa")).unwrap();
+ let migrated = tsa.artifact.expect("artifact chunk present");
+ assert!(
+ (migrated.confidence - 0.4242).abs() < 1e-6,
+ "marker confidence proves absorption, got {}",
+ migrated.confidence
+ );
+ assert!((migrated.bpm - real.bpm).abs() < 1e-9);
+ assert_eq!(migrated.beat_positions, real.beat_positions);
+ assert!(tsa.peaks.is_some(), "peaks chunk present");
+ assert!(!legacy_json_path(&wav).exists(), "legacy JSON deleted");
+ assert!(!legacy_peaks_path(&wav).exists(), "legacy .tspeaks deleted");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn load_worker_deletes_partial_legacy_files() {
+ // Each legacy file alone: absorbed/superseded, then deleted.
+ for (tag, json, tspeaks) in [("json_only", true, false), ("tspeaks_only", false, true)] {
+ let dir = temp_dir(tag);
+ let wav = dir.join("track.wav");
+ write_test_wav(&wav, 2.0);
+ if json {
+ let real = analyzed_artifact(&wav);
+ #[allow(deprecated)]
+ timestretch::write_preanalysis_json(&legacy_json_path(&wav), &real).unwrap();
+ }
+ if tspeaks {
+ std::fs::write(legacy_peaks_path(&wav), b"garbage").unwrap();
+ }
+ let state: SharedStateHandle = Arc::new(Mutex::new(SharedState::new()));
+ run_worker_to_completion(&wav, &state, 0);
+ assert!(dir.join("track.wav.tsa").exists(), "{tag}: .tsa written");
+ assert!(!legacy_json_path(&wav).exists(), "{tag}: JSON gone");
+ assert!(!legacy_peaks_path(&wav).exists(), "{tag}: .tspeaks gone");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+ }
+
+ #[test]
+ fn load_worker_stale_legacy_json_not_absorbed() {
+ let dir = temp_dir("stale_json");
+ let wav = dir.join("track.wav");
+ write_test_wav(&wav, 2.0);
+ // A legacy artifact whose content hash mismatches this audio.
+ let stale = timestretch::PreAnalysisArtifact {
+ version: timestretch::PREANALYSIS_VERSION,
+ sample_rate: 44_100,
+ bpm: 99.9,
+ source_len_samples: 12_345,
+ content_hash: 0xDEAD_BEEF,
+ ..Default::default()
+ };
+ #[allow(deprecated)]
+ timestretch::write_preanalysis_json(&legacy_json_path(&wav), &stale).unwrap();
+
+ let state: SharedStateHandle = Arc::new(Mutex::new(SharedState::new()));
+ run_worker_to_completion(&wav, &state, 0);
+
+ // Fresh analysis ran (not the stale 99.9 BPM), and the stale JSON
+ // was still deleted — superseded by the fresh ARTF chunk.
+ let tsa = timestretch::read_analysis_file(&dir.join("track.wav.tsa")).unwrap();
+ let artifact = tsa.artifact.expect("fresh artifact present");
+ assert_ne!(artifact.bpm, 99.9, "stale artifact must not be absorbed");
+ assert!(!legacy_json_path(&wav).exists(), "stale JSON deleted");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn load_worker_decode_error_sends_err() {
let dir = temp_dir("decode_err");
@@ -1780,6 +1948,11 @@ mod tests {
"stale worker must skip analysis entirely"
);
assert!(state.lock().unwrap().pre_analysis.is_none());
+ // Write #1 still persisted the peaks — but no artifact was
+ // computed for the stale generation.
+ let tsa = timestretch::read_analysis_file(&dir.join("track.wav.tsa")).unwrap();
+ assert!(tsa.peaks.is_some(), "peaks persisted before the bail");
+ assert!(tsa.artifact.is_none(), "no artifact for a stale worker");
let _ = std::fs::remove_dir_all(&dir);
}
diff --git a/desktop/src/waveform/cache.rs b/desktop/src/waveform/cache.rs
deleted file mode 100644
index ff447a5..0000000
--- a/desktop/src/waveform/cache.rs
+++ /dev/null
@@ -1,336 +0,0 @@
-//! Persisted waveform-peaks sidecar.
-//!
-//! A track's base pyramid level is written next to the audio file as
-//! `.tspeaks`: a fixed little-endian header followed by six planes
-//! of u8-quantized per-band peaks. Only the base level is stored; the
-//! upper levels rebuild in microseconds via the halving pyramid. The file
-//! is keyed by the decoded mono signal's identity (length + FNV hash, the
-//! same scheme as the `.tsanalysis.json` sidecar), so renamed or retagged
-//! files keep their analysis. Any mismatch, truncation, or corruption
-//! reads as a miss — never an error the UI has to handle.
-
-use std::path::{Path, PathBuf};
-
-use super::peaks::{
- BASE_BUCKETS_PER_SEC, BandPeaks, CROSSOVER_HIGH_HZ, CROSSOVER_LOW_HZ, NUM_BANDS, PeakLevel,
- base_num_buckets,
-};
-
-const MAGIC: [u8; 4] = *b"TSPK";
-const FORMAT_VERSION: u32 = 1;
-/// Header size in bytes; the six N-byte planes follow immediately.
-const HEADER_LEN: usize = 60;
-
-/// Sidecar peaks-cache path: `.tspeaks` (suffix-append, like the
-/// `.tsanalysis.json` sidecar, so distinct extensions never collide).
-pub fn peaks_cache_path(audio_path: &Path) -> PathBuf {
- let mut os = audio_path.as_os_str().to_os_string();
- os.push(".tspeaks");
- PathBuf::from(os)
-}
-
-/// `v` in `[0, 1]` to a u8 step; out-of-range clamps.
-fn quantize_unit(v: f32) -> u8 {
- (v.clamp(0.0, 1.0) * 255.0).round() as u8
-}
-
-fn dequantize_unit(q: u8) -> f32 {
- q as f32 / 255.0
-}
-
-/// Read and validate a cached pyramid for a mono source of
-/// `source_len_samples` frames hashing to `content_hash`. Returns `None`
-/// on any mismatch, truncation, or corruption — the caller recomputes.
-pub fn read_validated(
- path: &Path,
- sample_rate: u32,
- source_len_samples: usize,
- content_hash: u64,
-) -> Option {
- let bytes = std::fs::read(path).ok()?;
- if bytes.len() < HEADER_LEN {
- return None;
- }
- let u32_at = |off: usize| u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
- let u64_at = |off: usize| u64::from_le_bytes(bytes[off..off + 8].try_into().unwrap());
-
- if bytes[0..4] != MAGIC || u32_at(4) != FORMAT_VERSION || u32_at(8) != sample_rate {
- return None;
- }
- // Analysis parameters compare by bit pattern: a cache built with
- // different buckets-per-sec or crossovers renders differently and
- // must be rebuilt.
- if u64_at(12) != BASE_BUCKETS_PER_SEC.to_bits()
- || u64_at(20) != CROSSOVER_LOW_HZ.to_bits()
- || u64_at(28) != CROSSOVER_HIGH_HZ.to_bits()
- {
- return None;
- }
- let num_buckets = u64_at(36) as usize;
- // The bucket count must match what the source length implies — this
- // also bounds the allocation below to a value derived from the
- // caller's trusted source length, not from file contents.
- if num_buckets != base_num_buckets(source_len_samples, sample_rate)
- || u64_at(44) != source_len_samples as u64
- || u64_at(52) != content_hash
- {
- return None;
- }
- // Exact length: rejects truncation and trailing garbage alike.
- if bytes.len() != HEADER_LEN + 6 * num_buckets {
- return None;
- }
-
- let plane = |idx: usize| {
- let start = HEADER_LEN + idx * num_buckets;
- &bytes[start..start + num_buckets]
- };
- let pos: [Vec; NUM_BANDS] =
- std::array::from_fn(|band| plane(band).iter().map(|&q| dequantize_unit(q)).collect());
- let neg: [Vec; NUM_BANDS] = std::array::from_fn(|band| {
- plane(NUM_BANDS + band)
- .iter()
- .map(|&q| -dequantize_unit(q))
- .collect()
- });
- Some(BandPeaks::from_base_level(PeakLevel {
- buckets_per_sec: BASE_BUCKETS_PER_SEC,
- pos,
- neg,
- }))
-}
-
-/// Write the base level of `peaks` atomically (temp sibling + rename), so
-/// a crash mid-write can't leave a truncated file that shadows the real
-/// cache until the next hash change.
-pub fn write(
- path: &Path,
- peaks: &BandPeaks,
- sample_rate: u32,
- source_len_samples: usize,
- content_hash: u64,
-) -> std::io::Result<()> {
- let base = peaks.level(0);
- let num_buckets = base.num_buckets();
-
- let mut bytes = Vec::with_capacity(HEADER_LEN + 6 * num_buckets);
- bytes.extend_from_slice(&MAGIC);
- bytes.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
- bytes.extend_from_slice(&sample_rate.to_le_bytes());
- bytes.extend_from_slice(&BASE_BUCKETS_PER_SEC.to_le_bytes());
- bytes.extend_from_slice(&CROSSOVER_LOW_HZ.to_le_bytes());
- bytes.extend_from_slice(&CROSSOVER_HIGH_HZ.to_le_bytes());
- bytes.extend_from_slice(&(num_buckets as u64).to_le_bytes());
- bytes.extend_from_slice(&(source_len_samples as u64).to_le_bytes());
- bytes.extend_from_slice(&content_hash.to_le_bytes());
- debug_assert_eq!(bytes.len(), HEADER_LEN);
- for band in 0..NUM_BANDS {
- bytes.extend(base.pos[band].iter().map(|&v| quantize_unit(v)));
- }
- for band in 0..NUM_BANDS {
- bytes.extend(base.neg[band].iter().map(|&v| quantize_unit(-v)));
- }
-
- // Pid-suffixed temp name so two app instances can't collide.
- let mut temp_os = path.as_os_str().to_os_string();
- temp_os.push(format!(".tmp{}", std::process::id()));
- let temp = PathBuf::from(temp_os);
- std::fs::write(&temp, &bytes).inspect_err(|_| {
- let _ = std::fs::remove_file(&temp);
- })?;
- std::fs::rename(&temp, path).inspect_err(|_| {
- let _ = std::fs::remove_file(&temp);
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- const SR: u32 = 44_100;
-
- /// Unique temp dir per test (parallel test threads share the process).
- fn temp_dir(tag: &str) -> PathBuf {
- let dir = std::env::temp_dir().join(format!("tspeaks_test_{}_{tag}", std::process::id()));
- let _ = std::fs::remove_dir_all(&dir);
- std::fs::create_dir_all(&dir).unwrap();
- dir
- }
-
- /// A second of mixed low+high tone, mono, plus its identity.
- fn test_source() -> (Vec, u64) {
- let n = SR as usize;
- let mono: Vec = (0..n)
- .map(|i| {
- let t = i as f64 / SR as f64;
- (0.7 * (std::f64::consts::TAU * 60.0 * t).sin()
- + 0.2 * (std::f64::consts::TAU * 8_000.0 * t).sin()) as f32
- })
- .collect();
- let hash = timestretch::hash_samples(&mono);
- (mono, hash)
- }
-
- /// Write a valid cache file for the test source; returns
- /// (cache path, original peaks, mono length, hash).
- fn valid_file(dir: &Path) -> (PathBuf, BandPeaks, usize, u64) {
- let (mono, hash) = test_source();
- let peaks = BandPeaks::compute(&mono, 1, SR);
- let path = dir.join("track.wav.tspeaks");
- write(&path, &peaks, SR, mono.len(), hash).unwrap();
- (path, peaks, mono.len(), hash)
- }
-
- #[test]
- fn roundtrip_within_quantization_error() {
- let dir = temp_dir("roundtrip");
- let (path, original, len, hash) = valid_file(&dir);
- let loaded = read_validated(&path, SR, len, hash).expect("valid file must load");
- let (a, b) = (loaded.level(0), original.level(0));
- assert_eq!(a.num_buckets(), b.num_buckets());
- for band in 0..NUM_BANDS {
- for (x, y) in a.pos[band].iter().zip(&b.pos[band]) {
- assert!((x - y).abs() <= 0.5 / 255.0 + f32::EPSILON, "{x} vs {y}");
- }
- for (x, y) in a.neg[band].iter().zip(&b.neg[band]) {
- assert!((x - y).abs() <= 0.5 / 255.0 + f32::EPSILON, "{x} vs {y}");
- }
- }
- // The rebuilt pyramid has the same shape as a computed one.
- assert_eq!(
- loaded.level_index_for(1.0),
- original.level_index_for(1.0),
- "coarsest level should match"
- );
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn quantize_clamps_out_of_range() {
- assert_eq!(quantize_unit(1.5), 255);
- assert_eq!(quantize_unit(-0.1), 0);
- assert_eq!(quantize_unit(0.0), 0);
- assert_eq!(quantize_unit(1.0), 255);
- // Negative peaks are stored as magnitudes: a hot -1.5 clamps full.
- assert_eq!(quantize_unit(-(-1.5f32)), 255);
- assert_eq!(quantize_unit(-(0.1f32)), 0);
- }
-
- #[test]
- fn single_bucket_empty_track_roundtrips() {
- let dir = temp_dir("empty");
- let peaks = BandPeaks::compute(&[], 1, SR);
- let hash = timestretch::hash_samples(&[]);
- let path = dir.join("empty.wav.tspeaks");
- write(&path, &peaks, SR, 0, hash).unwrap();
- let loaded = read_validated(&path, SR, 0, hash).expect("empty track must roundtrip");
- assert_eq!(loaded.level(0).num_buckets(), 1);
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn missing_file_returns_none() {
- assert!(read_validated(Path::new("/nonexistent/x.tspeaks"), SR, 100, 1).is_none());
- }
-
- /// Each corruption writes a valid file, applies one mutation, and
- /// asserts rejection.
- fn corrupt_and_check(tag: &str, mutate: impl FnOnce(&mut Vec)) {
- let dir = temp_dir(tag);
- let (path, _, len, hash) = valid_file(&dir);
- let mut bytes = std::fs::read(&path).unwrap();
- mutate(&mut bytes);
- std::fs::write(&path, &bytes).unwrap();
- assert!(
- read_validated(&path, SR, len, hash).is_none(),
- "corrupted file ({tag}) must be rejected"
- );
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn wrong_magic_rejected() {
- corrupt_and_check("magic", |b| b[0] = b'X');
- }
-
- #[test]
- fn wrong_version_rejected() {
- corrupt_and_check("version", |b| b[4..8].copy_from_slice(&99u32.to_le_bytes()));
- }
-
- #[test]
- fn crossover_mismatch_rejected() {
- corrupt_and_check("crossover", |b| {
- b[20..28].copy_from_slice(&250.0f64.to_le_bytes())
- });
- }
-
- #[test]
- fn truncated_header_rejected() {
- corrupt_and_check("hdr_trunc", |b| b.truncate(30));
- }
-
- #[test]
- fn truncated_payload_rejected() {
- corrupt_and_check("payload_trunc", |b| {
- let n = b.len();
- b.truncate(n - 7);
- });
- }
-
- #[test]
- fn trailing_garbage_rejected() {
- corrupt_and_check("trailing", |b| b.extend_from_slice(&[0u8; 3]));
- }
-
- #[test]
- fn sample_rate_mismatch_rejected() {
- let dir = temp_dir("sr");
- let (path, _, len, hash) = valid_file(&dir);
- assert!(read_validated(&path, 48_000, len, hash).is_none());
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn hash_mismatch_rejected() {
- let dir = temp_dir("hash");
- let (path, _, len, hash) = valid_file(&dir);
- assert!(read_validated(&path, SR, len, hash ^ 1).is_none());
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn source_len_mismatch_rejected() {
- let dir = temp_dir("len");
- let (path, _, len, hash) = valid_file(&dir);
- assert!(read_validated(&path, SR, len + 1, hash).is_none());
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn write_leaves_no_temp_file() {
- let dir = temp_dir("no_temp");
- let (path, ..) = valid_file(&dir);
- let entries: Vec<_> = std::fs::read_dir(&dir)
- .unwrap()
- .map(|e| e.unwrap().file_name())
- .collect();
- assert_eq!(entries.len(), 1, "only the final file: {entries:?}");
- assert_eq!(entries[0], path.file_name().unwrap());
-
- // Failed write (parent doesn't exist): Err, and no stray temp in
- // any existing directory.
- let peaks = BandPeaks::compute(&[], 1, SR);
- let bad = dir.join("missing_subdir").join("x.tspeaks");
- assert!(write(&bad, &peaks, SR, 0, 0).is_err());
- let _ = std::fs::remove_dir_all(&dir);
- }
-
- #[test]
- fn peaks_cache_path_appends_suffix() {
- assert_eq!(
- peaks_cache_path(Path::new("/a/b.mp3")),
- PathBuf::from("/a/b.mp3.tspeaks")
- );
- }
-}
diff --git a/desktop/src/waveform/mod.rs b/desktop/src/waveform/mod.rs
index 032305d..9aa3a79 100644
--- a/desktop/src/waveform/mod.rs
+++ b/desktop/src/waveform/mod.rs
@@ -3,12 +3,14 @@
//! bar.beat counter. Shared grid/palette machinery lives here; the painters
//! live in the submodules.
-pub mod cache;
mod counter;
mod overview;
-mod peaks;
mod zoomed;
+/// The peaks pyramid lives in the library now (`.tsa` PEAK chunk); the
+/// alias keeps the painters' `peaks::` paths reading naturally.
+use timestretch::analysis::waveform as peaks;
+
pub use counter::paint_beat_counter;
pub use overview::{OverviewParams, OverviewTexture, paint_overview};
pub use peaks::BandPeaks;
diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs
index f111bc1..3b29747 100644
--- a/src/analysis/mod.rs
+++ b/src/analysis/mod.rs
@@ -10,6 +10,7 @@ pub mod preanalysis;
pub mod rigid_grid;
pub mod tempogram;
pub mod transient;
+pub mod waveform;
pub use beat::*;
pub use comparison::*;
@@ -20,3 +21,4 @@ pub use preanalysis::*;
pub use rigid_grid::*;
pub use tempogram::*;
pub use transient::*;
+pub use waveform::*;
diff --git a/desktop/src/waveform/peaks.rs b/src/analysis/waveform.rs
similarity index 99%
rename from desktop/src/waveform/peaks.rs
rename to src/analysis/waveform.rs
index c0ec50b..91351bf 100644
--- a/desktop/src/waveform/peaks.rs
+++ b/src/analysis/waveform.rs
@@ -22,6 +22,7 @@ pub(crate) const CROSSOVER_LOW_HZ: f64 = 200.0;
pub(crate) const CROSSOVER_HIGH_HZ: f64 = 2_000.0;
/// One resolution level: per-band positive/negative peaks per bucket.
+#[derive(Clone)]
pub struct PeakLevel {
/// Buckets per second of audio at this level.
pub buckets_per_sec: f64,
@@ -39,6 +40,7 @@ impl PeakLevel {
/// The full pyramid: `levels[0]` is the finest (150 buckets/s), each
/// following level halves the bucket count.
+#[derive(Clone)]
pub struct BandPeaks {
levels: Vec,
}
diff --git a/src/cli.rs b/src/cli.rs
index cb68aca..199f67a 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -142,7 +142,7 @@ fn main() {
// mismatched sidecar must never abort a render: warn and fall back to
// online analysis instead.
let pre_analysis: Option = pre_analysis_path.as_ref().and_then(|path| {
- let artifact = match timestretch::read_preanalysis_json(Path::new(path)) {
+ let artifact = match read_artifact_any_format(Path::new(path)) {
Ok(artifact) => artifact,
Err(e) => {
eprintln!(
@@ -266,10 +266,12 @@ fn main() {
/// `timestretch-cli analyze [-o artifact.json]`
///
/// Runs offline pre-analysis once and writes the reusable artifact as JSON
-/// (default: a `.tsanalysis.json` sidecar next to the input file).
+/// (default: a `.tsa` analysis-container sidecar next to the input file).
fn run_analyze(args: &[String]) {
if args.len() < 3 {
- eprintln!("Usage: timestretch-cli analyze [-o ] [--verbose]");
+ eprintln!(
+ "Usage: timestretch-cli analyze [-o ] [--verbose]"
+ );
std::process::exit(1);
}
let input_path = &args[2];
@@ -397,35 +399,83 @@ fn run_analyze(args: &[String]) {
}
}
- if let Err(e) = timestretch::write_preanalysis_json(Path::new(&output_path), &artifact) {
+ let write_result = if wants_legacy_json(&output_path) {
+ eprintln!(
+ "NOTE: JSON artifact sidecars are deprecated; omit -o (or use a .tsa path) \
+ to write the .tsa analysis container instead"
+ );
+ #[allow(deprecated)]
+ timestretch::write_preanalysis_json(Path::new(&output_path), &artifact)
+ } else {
+ // The full analysis container: artifact plus waveform peaks, so
+ // one `analyze` run pre-computes everything an app needs at load.
+ let mut analysis =
+ timestretch::AnalysisFile::for_source(&analysis_signal, buffer.sample_rate);
+ analysis.peaks = Some(timestretch::BandPeaks::compute(
+ &analysis_signal,
+ 1,
+ buffer.sample_rate,
+ ));
+ analysis.artifact = Some(artifact);
+ timestretch::write_analysis_file(Path::new(&output_path), &analysis)
+ };
+ if let Err(e) = write_result {
eprintln!("ERROR: Failed to write {}: {}", output_path, e);
std::process::exit(1);
}
eprintln!("Written to {}", output_path);
}
-/// Default artifact path: ` .tsanalysis.json` next to the audio file.
+/// Default artifact path: ` .tsa` next to the audio file.
fn default_sidecar_path(input_path: &str) -> String {
- format!("{}.tsanalysis.json", input_path)
+ format!("{}.tsa", input_path)
+}
+
+/// Whether an explicit `-o` path asks for the deprecated JSON format.
+fn wants_legacy_json(output_path: &str) -> bool {
+ Path::new(output_path)
+ .extension()
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
+}
+
+/// Reads an artifact from either format: `.tsa` containers are detected by
+/// their magic bytes; anything else parses as the legacy JSON sidecar.
+fn read_artifact_any_format(path: &Path) -> Result {
+ let is_tsa = std::fs::read(path)
+ .map(|bytes| bytes.starts_with(b"TSAF"))
+ .unwrap_or(false);
+ if is_tsa {
+ timestretch::read_analysis_file(path)?
+ .artifact
+ .ok_or_else(|| {
+ timestretch::StretchError::InvalidFormat(
+ "analysis container has no artifact chunk".to_string(),
+ )
+ })
+ } else {
+ #[allow(deprecated)]
+ timestretch::read_preanalysis_json(path)
+ }
}
fn print_usage() {
eprintln!("Usage: timestretch-cli [options]");
- eprintln!(" timestretch-cli analyze [-o ]");
+ eprintln!(" timestretch-cli analyze [-o ]");
eprintln!();
eprintln!("Modes:");
eprintln!(" --ratio Stretch ratio (1.5 = 50% slower)");
eprintln!(" --from-bpm --to-bpm BPM matching");
eprintln!(" --auto-bpm --to-bpm Auto-detect source BPM, match to target");
eprintln!(" --pitch Pitch shift (2.0 = up one octave)");
- eprintln!(" analyze Write a reusable pre-analysis artifact");
+ eprintln!(" analyze Write a reusable .tsa analysis container");
eprintln!();
eprintln!("Options:");
eprintln!(" --envelope Formant/envelope profile for --pitch:");
eprintln!(" off, balanced (default), vocal");
eprintln!(" --window hann (default), blackman-harris, kaiser:");
- eprintln!(" --pre-analysis Use a pre-analysis artifact (from `analyze`);");
- eprintln!(" validated against the input, ignored if stale");
+ eprintln!(" --pre-analysis Use an analysis file (from `analyze`; .tsa or");
+ eprintln!(" legacy .json), validated against the input,");
+ eprintln!(" ignored if stale");
eprintln!(" --normalize, -n Match output RMS to input (default: on)");
eprintln!(" --no-normalize Disable RMS matching");
eprintln!(" --24bit Write 24-bit PCM output (default: 16-bit)");
@@ -440,9 +490,7 @@ fn print_usage() {
eprintln!(" timestretch-cli in.wav out.wav --ratio 2.0 --window blackman-harris --normalize");
eprintln!(" timestretch-cli in.wav out.wav 1.5");
eprintln!(" timestretch-cli analyze in.wav");
- eprintln!(
- " timestretch-cli in.wav out.wav --ratio 1.05 --pre-analysis in.wav.tsanalysis.json"
- );
+ eprintln!(" timestretch-cli in.wav out.wav --ratio 1.05 --pre-analysis in.wav.tsa");
}
fn parse_f64(args: &[String], idx: usize, name: &str) -> f64 {
diff --git a/src/core/preanalysis.rs b/src/core/preanalysis.rs
index 004d5d3..1885f4c 100644
--- a/src/core/preanalysis.rs
+++ b/src/core/preanalysis.rs
@@ -253,16 +253,36 @@ impl PreAnalysisArtifact {
/// rejected outright: their positions carry the pre-v4 window-start
/// bias, so a cached sidecar must be regenerated, not reused.
pub fn matches_source(&self, samples: &[f32], sample_rate: u32) -> bool {
+ // Hash only when the artifact actually binds one (matches_identity
+ // skips the comparison when the artifact's hash is 0 either way).
+ let hash = if self.content_hash != 0 {
+ hash_samples(samples)
+ } else {
+ 0
+ };
+ self.matches_identity(sample_rate, samples.len(), hash)
+ }
+
+ /// [`Self::matches_source`] without the samples in hand: checks the
+ /// same schema-version gate and the precomputed length/hash identity,
+ /// skipping each binding the artifact predates. For callers that
+ /// already hold the identity (e.g. the `.tsa` container's file header).
+ pub fn matches_identity(
+ &self,
+ sample_rate: u32,
+ source_len_samples: usize,
+ content_hash: u64,
+ ) -> bool {
if self.version < MIN_COMPATIBLE_VERSION {
return false;
}
if self.sample_rate != sample_rate {
return false;
}
- if self.source_len_samples != 0 && self.source_len_samples != samples.len() {
+ if self.source_len_samples != 0 && self.source_len_samples != source_len_samples {
return false;
}
- if self.content_hash != 0 && self.content_hash != hash_samples(samples) {
+ if self.content_hash != 0 && self.content_hash != content_hash {
return false;
}
true
@@ -327,6 +347,10 @@ pub fn hash_samples(samples: &[f32]) -> u64 {
}
/// Writes a pre-analysis artifact as JSON.
+#[deprecated(
+ since = "0.11.0",
+ note = "use `write_analysis_file` with the `.tsa` container (`crate::io::tsa`), which also carries waveform peaks"
+)]
pub fn write_preanalysis_json(
path: &Path,
artifact: &PreAnalysisArtifact,
@@ -339,6 +363,10 @@ pub fn write_preanalysis_json(
}
/// Reads a pre-analysis artifact from JSON.
+#[deprecated(
+ since = "0.11.0",
+ note = "use `read_analysis_file` / `read_analysis_file_validated` on the `.tsa` container (`crate::io::tsa`)"
+)]
pub fn read_preanalysis_json(path: &Path) -> Result {
let data = std::fs::read_to_string(path)?;
serde_json::from_str(&data).map_err(|e| {
diff --git a/src/io/mod.rs b/src/io/mod.rs
index 5a6c0cc..8306df4 100644
--- a/src/io/mod.rs
+++ b/src/io/mod.rs
@@ -1,5 +1,7 @@
-//! Audio file I/O (WAV format).
+//! File I/O: WAV audio and the `.tsa` analysis container.
+pub mod tsa;
pub mod wav;
+pub use tsa::*;
pub use wav::*;
diff --git a/src/io/tsa.rs b/src/io/tsa.rs
new file mode 100644
index 0000000..f46553a
--- /dev/null
+++ b/src/io/tsa.rs
@@ -0,0 +1,694 @@
+//! `.tsa` analysis container: one file per track holding every persisted
+//! analysis product.
+//!
+//! The container consolidates what used to be two sidecars — the
+//! pre-analysis artifact (`.tsanalysis.json`) and the waveform-peaks cache
+//! (`.tspeaks`) — behind a single content-bound identity: sample rate,
+//! mono-signal length, and FNV-1a content hash ([`hash_samples`]), so a
+//! renamed or retagged file keeps its analysis.
+//!
+//! Two API layers:
+//! - **Bytes** ([`AnalysisFile::to_bytes`], [`AnalysisFile::from_bytes`],
+//! [`AnalysisFile::from_bytes_validated`]) — no filesystem coupling, for
+//! consumers that store the blob elsewhere (e.g. a library database
+//! keyed by content hash).
+//! - **Files** ([`read_analysis_file`], [`read_analysis_file_validated`],
+//! [`write_analysis_file`], [`analysis_file_path`]) — the sidecar
+//! convention (`.tsa`), thin wrappers over the bytes layer with
+//! an atomic (temp + rename) writer.
+//!
+//! Layout (little-endian throughout): a 28-byte file header — magic
+//! `TSAF`, container version, `sample_rate` u32, `source_len_samples`
+//! u64, `content_hash` u64 — followed by sequential chunks, each tagged
+//! `[u8; 4]` + chunk version u32 + payload length u64. Unknown tags and
+//! unknown versions of known tags are skipped (forward compatibility);
+//! duplicate known chunks, truncation, and trailing bytes are structural
+//! errors. Readers never panic on hostile input.
+
+use std::path::{Path, PathBuf};
+
+use crate::analysis::waveform::{
+ BASE_BUCKETS_PER_SEC, BandPeaks, CROSSOVER_HIGH_HZ, CROSSOVER_LOW_HZ, NUM_BANDS, PeakLevel,
+ base_num_buckets,
+};
+use crate::core::preanalysis::{PreAnalysisArtifact, hash_samples};
+use crate::error::StretchError;
+
+/// Current `.tsa` container format version.
+pub const TSA_CONTAINER_VERSION: u32 = 1;
+
+const MAGIC: [u8; 4] = *b"TSAF";
+const FILE_HEADER_LEN: usize = 28;
+const CHUNK_HEADER_LEN: usize = 16;
+/// Pre-analysis artifact chunk: serde-JSON bytes of [`PreAnalysisArtifact`]
+/// (the artifact's own schema versioning applies inside the payload).
+const TAG_ARTIFACT: [u8; 4] = *b"ARTF";
+/// Waveform-peaks chunk: analyzer parameters + the base pyramid level.
+const TAG_PEAKS: [u8; 4] = *b"PEAK";
+const ARTIFACT_CHUNK_VERSION: u32 = 1;
+const PEAKS_CHUNK_VERSION: u32 = 1;
+/// PEAK payload prefix: buckets/s, two crossovers (f64 bits), bucket count.
+const PEAKS_PARAMS_LEN: usize = 32;
+
+/// Consolidated per-track analysis: one content-bound identity, optional
+/// payload chunks. Missing chunks are simply absent analysis, not errors.
+#[derive(Clone)]
+pub struct AnalysisFile {
+ /// Sample rate the analysis ran at.
+ pub sample_rate: u32,
+ /// Length in frames of the mono analysis signal.
+ pub source_len_samples: usize,
+ /// FNV-1a 64 hash of the mono analysis signal ([`hash_samples`]).
+ pub content_hash: u64,
+ /// Pre-analysis artifact (beat grid, onsets, key, loudness).
+ pub artifact: Option,
+ /// 3-band waveform peaks pyramid.
+ pub peaks: Option,
+}
+
+impl AnalysisFile {
+ /// Empty container bound to a mono analysis signal's identity.
+ pub fn for_source(mono: &[f32], sample_rate: u32) -> Self {
+ Self {
+ sample_rate,
+ source_len_samples: mono.len(),
+ content_hash: hash_samples(mono),
+ artifact: None,
+ peaks: None,
+ }
+ }
+
+ /// Encode the container. Chunks are emitted for present fields only;
+ /// a header-only container (no analysis yet) is valid.
+ pub fn to_bytes(&self) -> Vec {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(&MAGIC);
+ bytes.extend_from_slice(&TSA_CONTAINER_VERSION.to_le_bytes());
+ bytes.extend_from_slice(&self.sample_rate.to_le_bytes());
+ bytes.extend_from_slice(&(self.source_len_samples as u64).to_le_bytes());
+ bytes.extend_from_slice(&self.content_hash.to_le_bytes());
+ debug_assert_eq!(bytes.len(), FILE_HEADER_LEN);
+
+ if let Some(artifact) = &self.artifact {
+ let payload = serde_json::to_vec(artifact)
+ .expect("PreAnalysisArtifact JSON serialization cannot fail");
+ push_chunk_header(
+ &mut bytes,
+ TAG_ARTIFACT,
+ ARTIFACT_CHUNK_VERSION,
+ payload.len(),
+ );
+ bytes.extend_from_slice(&payload);
+ }
+
+ if let Some(peaks) = &self.peaks {
+ let base = peaks.level(0);
+ let num_buckets = base.num_buckets();
+ push_chunk_header(
+ &mut bytes,
+ TAG_PEAKS,
+ PEAKS_CHUNK_VERSION,
+ PEAKS_PARAMS_LEN + 6 * num_buckets,
+ );
+ bytes.extend_from_slice(&base.buckets_per_sec.to_le_bytes());
+ bytes.extend_from_slice(&CROSSOVER_LOW_HZ.to_le_bytes());
+ bytes.extend_from_slice(&CROSSOVER_HIGH_HZ.to_le_bytes());
+ bytes.extend_from_slice(&(num_buckets as u64).to_le_bytes());
+ for band in 0..NUM_BANDS {
+ bytes.extend(base.pos[band].iter().map(|&v| quantize_unit(v)));
+ }
+ for band in 0..NUM_BANDS {
+ bytes.extend(base.neg[band].iter().map(|&v| quantize_unit(-v)));
+ }
+ }
+
+ bytes
+ }
+
+ /// Structural decode: envelope errors (bad magic/version, truncation,
+ /// duplicate or overlong chunks, trailing bytes) are `Err`; a chunk
+ /// whose *payload* fails to decode (e.g. corrupt artifact JSON)
+ /// degrades to the field being `None`. Identity is returned, not
+ /// checked against anything.
+ pub fn from_bytes(bytes: &[u8]) -> Result {
+ parse(bytes, None)
+ }
+
+ /// Load-boundary decode: `None` unless the container's identity equals
+ /// the given one. Surviving chunks are additionally gated — an
+ /// artifact that fails [`PreAnalysisArtifact::matches_identity`] or a
+ /// peaks chunk built with different analyzer parameters (buckets/s,
+ /// crossovers) or an unexpected bucket count is dropped to `None`
+ /// while the rest of the container stays usable.
+ pub fn from_bytes_validated(
+ bytes: &[u8],
+ sample_rate: u32,
+ source_len_samples: usize,
+ content_hash: u64,
+ ) -> Option {
+ parse(bytes, Some((sample_rate, source_len_samples, content_hash))).ok()
+ }
+}
+
+fn push_chunk_header(bytes: &mut Vec, tag: [u8; 4], version: u32, payload_len: usize) {
+ bytes.extend_from_slice(&tag);
+ bytes.extend_from_slice(&version.to_le_bytes());
+ bytes.extend_from_slice(&(payload_len as u64).to_le_bytes());
+}
+
+fn invalid(msg: &str) -> StretchError {
+ StretchError::InvalidFormat(format!(".tsa container: {msg}"))
+}
+
+/// `v` in `[0, 1]` to a u8 step; out-of-range clamps.
+fn quantize_unit(v: f32) -> u8 {
+ (v.clamp(0.0, 1.0) * 255.0).round() as u8
+}
+
+fn dequantize_unit(q: u8) -> f32 {
+ q as f32 / 255.0
+}
+
+/// Shared decoder. With `expected` identity, the header must match it
+/// exactly and chunk payloads are gated per-chunk (mismatches degrade to
+/// `None` fields); without, chunks decode as stored.
+fn parse(bytes: &[u8], expected: Option<(u32, usize, u64)>) -> Result {
+ if bytes.len() < FILE_HEADER_LEN {
+ return Err(invalid("shorter than the file header"));
+ }
+ let u32_at = |off: usize| u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
+ let u64_at = |off: usize| u64::from_le_bytes(bytes[off..off + 8].try_into().unwrap());
+
+ if bytes[0..4] != MAGIC {
+ return Err(invalid("bad magic"));
+ }
+ if u32_at(4) != TSA_CONTAINER_VERSION {
+ return Err(invalid("unsupported container version"));
+ }
+ let sample_rate = u32_at(8);
+ let source_len_samples = usize::try_from(u64_at(12))
+ .map_err(|_| invalid("source length exceeds addressable memory"))?;
+ let content_hash = u64_at(20);
+
+ if let Some((want_sr, want_len, want_hash)) = expected
+ && (sample_rate != want_sr || source_len_samples != want_len || content_hash != want_hash)
+ {
+ return Err(invalid("identity mismatch"));
+ }
+
+ let mut file = AnalysisFile {
+ sample_rate,
+ source_len_samples,
+ content_hash,
+ artifact: None,
+ peaks: None,
+ };
+ let mut artifact_seen = false;
+ let mut peaks_seen = false;
+
+ let mut cursor = FILE_HEADER_LEN;
+ while cursor < bytes.len() {
+ if bytes.len() - cursor < CHUNK_HEADER_LEN {
+ return Err(invalid("truncated chunk header"));
+ }
+ let tag: [u8; 4] = bytes[cursor..cursor + 4].try_into().unwrap();
+ let chunk_version = u32_at(cursor + 4);
+ let payload_len = usize::try_from(u64_at(cursor + 8))
+ .map_err(|_| invalid("chunk length exceeds addressable memory"))?;
+ cursor += CHUNK_HEADER_LEN;
+ // Bounds first: every later slice of the payload is in range, and
+ // allocations stay bounded by the actual byte count present.
+ if bytes.len() - cursor < payload_len {
+ return Err(invalid("chunk payload extends past end of data"));
+ }
+ let payload = &bytes[cursor..cursor + payload_len];
+ cursor += payload_len;
+
+ match (tag, chunk_version) {
+ (TAG_ARTIFACT, ARTIFACT_CHUNK_VERSION) => {
+ if artifact_seen {
+ return Err(invalid("duplicate ARTF chunk"));
+ }
+ artifact_seen = true;
+ // Payload-level decode failure degrades to "absent", like
+ // an unknown chunk version — the peaks stay usable.
+ let artifact: Option = serde_json::from_slice(payload).ok();
+ file.artifact = match (artifact, expected) {
+ (Some(a), Some((sr, len, hash))) => {
+ a.matches_identity(sr, len, hash).then_some(a)
+ }
+ (a, None) => a,
+ (None, _) => None,
+ };
+ }
+ (TAG_PEAKS, PEAKS_CHUNK_VERSION) => {
+ if peaks_seen {
+ return Err(invalid("duplicate PEAK chunk"));
+ }
+ peaks_seen = true;
+ file.peaks = decode_peaks(payload, expected.map(|(sr, len, _)| (sr, len)))?;
+ }
+ // Unknown tag, or a known tag from a future envelope revision:
+ // skip — degrades to "chunk absent", never an error.
+ _ => {}
+ }
+ }
+
+ Ok(file)
+}
+
+/// Decode a PEAK payload. Envelope errors (wrong payload length) are
+/// `Err`; with `expected` identity, parameter or bucket-count mismatches
+/// degrade to `Ok(None)` (stale cache, rebuild) instead.
+fn decode_peaks(
+ payload: &[u8],
+ expected: Option<(u32, usize)>,
+) -> Result, StretchError> {
+ if payload.len() < PEAKS_PARAMS_LEN {
+ return Err(invalid("PEAK payload shorter than its parameter block"));
+ }
+ let u64_at = |off: usize| u64::from_le_bytes(payload[off..off + 8].try_into().unwrap());
+ let buckets_per_sec = f64::from_bits(u64_at(0));
+ let crossover_low_bits = u64_at(8);
+ let crossover_high_bits = u64_at(16);
+ let num_buckets = usize::try_from(u64_at(24))
+ .map_err(|_| invalid("PEAK bucket count exceeds addressable memory"))?;
+ if payload.len() != PEAKS_PARAMS_LEN + 6 * num_buckets {
+ return Err(invalid("PEAK payload length disagrees with bucket count"));
+ }
+
+ if let Some((sample_rate, source_len_samples)) = expected {
+ // Stale analyzer parameters or a bucket count that doesn't match
+ // the audio: not this cache's audio/format anymore — rebuild.
+ if buckets_per_sec.to_bits() != BASE_BUCKETS_PER_SEC.to_bits()
+ || crossover_low_bits != CROSSOVER_LOW_HZ.to_bits()
+ || crossover_high_bits != CROSSOVER_HIGH_HZ.to_bits()
+ || num_buckets != base_num_buckets(source_len_samples, sample_rate)
+ {
+ return Ok(None);
+ }
+ }
+
+ let plane = |idx: usize| {
+ let start = PEAKS_PARAMS_LEN + idx * num_buckets;
+ &payload[start..start + num_buckets]
+ };
+ let pos: [Vec; NUM_BANDS] =
+ std::array::from_fn(|band| plane(band).iter().map(|&q| dequantize_unit(q)).collect());
+ let neg: [Vec; NUM_BANDS] = std::array::from_fn(|band| {
+ plane(NUM_BANDS + band)
+ .iter()
+ .map(|&q| -dequantize_unit(q))
+ .collect()
+ });
+ Ok(Some(BandPeaks::from_base_level(PeakLevel {
+ buckets_per_sec,
+ pos,
+ neg,
+ })))
+}
+
+/// Sidecar path: `.tsa` (suffix-append, so distinct source
+/// extensions never collide).
+pub fn analysis_file_path(audio_path: &Path) -> PathBuf {
+ let mut os = audio_path.as_os_str().to_os_string();
+ os.push(".tsa");
+ PathBuf::from(os)
+}
+
+/// Read and structurally decode a `.tsa` file (no identity check).
+pub fn read_analysis_file(path: &Path) -> Result {
+ let bytes = std::fs::read(path)?;
+ AnalysisFile::from_bytes(&bytes)
+}
+
+/// Load-boundary read: `None` on a missing, corrupt, or
+/// identity-mismatched file; per-chunk gating as in
+/// [`AnalysisFile::from_bytes_validated`].
+pub fn read_analysis_file_validated(
+ path: &Path,
+ sample_rate: u32,
+ source_len_samples: usize,
+ content_hash: u64,
+) -> Option {
+ let bytes = std::fs::read(path).ok()?;
+ AnalysisFile::from_bytes_validated(&bytes, sample_rate, source_len_samples, content_hash)
+}
+
+/// Write the container atomically: serialize, write a pid-suffixed temp
+/// sibling, rename over the destination. A crash mid-write can't leave a
+/// truncated file shadowing the real cache.
+pub fn write_analysis_file(path: &Path, file: &AnalysisFile) -> Result<(), StretchError> {
+ let bytes = file.to_bytes();
+ let mut temp_os = path.as_os_str().to_os_string();
+ temp_os.push(format!(".tmp{}", std::process::id()));
+ let temp = PathBuf::from(temp_os);
+ std::fs::write(&temp, &bytes).inspect_err(|_| {
+ let _ = std::fs::remove_file(&temp);
+ })?;
+ std::fs::rename(&temp, path).inspect_err(|_| {
+ let _ = std::fs::remove_file(&temp);
+ })?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SR: u32 = 44_100;
+
+ /// Unique temp dir per test (tests run in parallel in one process).
+ fn temp_dir(tag: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("tsa_test_{}_{tag}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ /// One second of mixed low+high tone, mono.
+ fn test_mono() -> Vec {
+ let n = SR as usize;
+ (0..n)
+ .map(|i| {
+ let t = i as f64 / SR as f64;
+ (0.7 * (std::f64::consts::TAU * 60.0 * t).sin()
+ + 0.2 * (std::f64::consts::TAU * 8_000.0 * t).sin()) as f32
+ })
+ .collect()
+ }
+
+ fn test_artifact(mono: &[f32]) -> PreAnalysisArtifact {
+ PreAnalysisArtifact {
+ version: crate::core::preanalysis::PREANALYSIS_VERSION,
+ sample_rate: SR,
+ bpm: 128.0,
+ confidence: 0.9,
+ beat_positions_fractional: vec![100.5, 200.25],
+ downbeat_beat_indices: vec![0],
+ source_len_samples: mono.len(),
+ content_hash: hash_samples(mono),
+ ..Default::default()
+ }
+ }
+
+ /// A fully-populated container for the test signal.
+ fn full_file() -> (AnalysisFile, Vec) {
+ let mono = test_mono();
+ let mut af = AnalysisFile::for_source(&mono, SR);
+ af.artifact = Some(test_artifact(&mono));
+ af.peaks = Some(BandPeaks::compute(&mono, 1, SR));
+ (af, mono)
+ }
+
+ fn identity(af: &AnalysisFile) -> (u32, usize, u64) {
+ (af.sample_rate, af.source_len_samples, af.content_hash)
+ }
+
+ #[test]
+ fn roundtrip_both_chunks() {
+ let (af, _) = full_file();
+ let bytes = af.to_bytes();
+ let (sr, len, hash) = identity(&af);
+ let back = AnalysisFile::from_bytes_validated(&bytes, sr, len, hash)
+ .expect("valid container must load");
+ let artifact = back.artifact.expect("artifact chunk survives");
+ assert_eq!(artifact.bpm, 128.0);
+ assert_eq!(artifact.beat_positions_fractional, vec![100.5, 200.25]);
+ let peaks = back.peaks.expect("peaks chunk survives");
+ let (a, b) = (peaks.level(0), af.peaks.as_ref().unwrap().level(0));
+ assert_eq!(a.num_buckets(), b.num_buckets());
+ for band in 0..NUM_BANDS {
+ for (x, y) in a.pos[band].iter().zip(&b.pos[band]) {
+ assert!((x - y).abs() <= 0.5 / 255.0 + f32::EPSILON, "{x} vs {y}");
+ }
+ for (x, y) in a.neg[band].iter().zip(&b.neg[band]) {
+ assert!((x - y).abs() <= 0.5 / 255.0 + f32::EPSILON, "{x} vs {y}");
+ }
+ }
+ // Rebuilt pyramid has the same level structure.
+ assert_eq!(
+ peaks.level_index_for(1.0),
+ af.peaks.as_ref().unwrap().level_index_for(1.0)
+ );
+ }
+
+ #[test]
+ fn roundtrip_partial_and_empty_containers() {
+ let mono = test_mono();
+ for (with_artifact, with_peaks) in [(true, false), (false, true), (false, false)] {
+ let mut af = AnalysisFile::for_source(&mono, SR);
+ if with_artifact {
+ af.artifact = Some(test_artifact(&mono));
+ }
+ if with_peaks {
+ af.peaks = Some(BandPeaks::compute(&mono, 1, SR));
+ }
+ let (sr, len, hash) = identity(&af);
+ let back = AnalysisFile::from_bytes_validated(&af.to_bytes(), sr, len, hash)
+ .expect("container must load");
+ assert_eq!(back.artifact.is_some(), with_artifact);
+ assert_eq!(back.peaks.is_some(), with_peaks);
+ assert_eq!(back.sample_rate, SR);
+ assert_eq!(back.source_len_samples, mono.len());
+ }
+ }
+
+ #[test]
+ fn empty_track_single_bucket_roundtrips() {
+ let mut af = AnalysisFile::for_source(&[], SR);
+ af.peaks = Some(BandPeaks::compute(&[], 1, SR));
+ let (sr, len, hash) = identity(&af);
+ let back = AnalysisFile::from_bytes_validated(&af.to_bytes(), sr, len, hash).unwrap();
+ assert_eq!(back.peaks.unwrap().level(0).num_buckets(), 1);
+ }
+
+ #[test]
+ fn quantize_clamps_out_of_range() {
+ assert_eq!(quantize_unit(1.5), 255);
+ assert_eq!(quantize_unit(-0.1), 0);
+ assert_eq!(quantize_unit(0.0), 0);
+ assert_eq!(quantize_unit(1.0), 255);
+ assert_eq!(quantize_unit(-(-1.5f32)), 255);
+ assert_eq!(quantize_unit(-(0.1f32)), 0);
+ }
+
+ #[test]
+ fn missing_file_read_paths() {
+ let path = Path::new("/nonexistent/x.tsa");
+ assert!(read_analysis_file(path).is_err());
+ assert!(read_analysis_file_validated(path, SR, 100, 1).is_none());
+ }
+
+ /// Encode a valid container, apply one mutation, assert both decode
+ /// paths reject it structurally.
+ fn corrupt_and_check(mutate: impl FnOnce(&mut Vec)) {
+ let (af, _) = full_file();
+ let mut bytes = af.to_bytes();
+ mutate(&mut bytes);
+ let (sr, len, hash) = identity(&af);
+ assert!(AnalysisFile::from_bytes(&bytes).is_err());
+ assert!(AnalysisFile::from_bytes_validated(&bytes, sr, len, hash).is_none());
+ }
+
+ #[test]
+ fn wrong_magic_rejected() {
+ corrupt_and_check(|b| b[0] = b'X');
+ }
+
+ #[test]
+ fn wrong_container_version_rejected() {
+ corrupt_and_check(|b| b[4..8].copy_from_slice(&99u32.to_le_bytes()));
+ }
+
+ #[test]
+ fn truncated_file_header_rejected() {
+ corrupt_and_check(|b| b.truncate(FILE_HEADER_LEN - 1));
+ }
+
+ #[test]
+ fn truncated_chunk_header_rejected() {
+ corrupt_and_check(|b| b.truncate(FILE_HEADER_LEN + CHUNK_HEADER_LEN - 3));
+ }
+
+ #[test]
+ fn truncated_chunk_payload_rejected() {
+ corrupt_and_check(|b| {
+ let n = b.len();
+ b.truncate(n - 7);
+ });
+ }
+
+ #[test]
+ fn oversized_chunk_length_rejected() {
+ // Inflate the first chunk's payload_len beyond the data present.
+ corrupt_and_check(|b| {
+ b[FILE_HEADER_LEN + 8..FILE_HEADER_LEN + 16].copy_from_slice(&u64::MAX.to_le_bytes());
+ });
+ }
+
+ #[test]
+ fn peak_length_bucket_count_disagreement_rejected() {
+ // A peaks-only container whose stored bucket count is off by one.
+ let mono = test_mono();
+ let mut af = AnalysisFile::for_source(&mono, SR);
+ af.peaks = Some(BandPeaks::compute(&mono, 1, SR));
+ let mut bytes = af.to_bytes();
+ let count_off = FILE_HEADER_LEN + CHUNK_HEADER_LEN + 24;
+ let stored = u64::from_le_bytes(bytes[count_off..count_off + 8].try_into().unwrap());
+ bytes[count_off..count_off + 8].copy_from_slice(&(stored + 1).to_le_bytes());
+ assert!(AnalysisFile::from_bytes(&bytes).is_err());
+ }
+
+ #[test]
+ fn duplicate_chunk_rejected() {
+ // Append a second copy of the ARTF chunk (chunk 1) after the end.
+ let mono = test_mono();
+ let mut af = AnalysisFile::for_source(&mono, SR);
+ af.artifact = Some(test_artifact(&mono));
+ let mut bytes = af.to_bytes();
+ let chunk = bytes[FILE_HEADER_LEN..].to_vec();
+ bytes.extend_from_slice(&chunk);
+ assert!(AnalysisFile::from_bytes(&bytes).is_err());
+ }
+
+ #[test]
+ fn unknown_chunk_tag_skipped() {
+ let (af, _) = full_file();
+ let mut bytes = af.to_bytes();
+ push_chunk_header(&mut bytes, *b"XXXX", 7, 5);
+ bytes.extend_from_slice(&[1, 2, 3, 4, 5]);
+ let (sr, len, hash) = identity(&af);
+ let back = AnalysisFile::from_bytes_validated(&bytes, sr, len, hash)
+ .expect("unknown chunks must be skipped");
+ assert!(back.artifact.is_some());
+ assert!(back.peaks.is_some());
+ }
+
+ #[test]
+ fn unknown_chunk_version_skipped_as_absent() {
+ // An ARTF chunk from a future envelope revision: skipped, no error.
+ let mono = test_mono();
+ let af = AnalysisFile::for_source(&mono, SR);
+ let mut bytes = af.to_bytes();
+ let payload = b"not even json";
+ push_chunk_header(&mut bytes, TAG_ARTIFACT, 99, payload.len());
+ bytes.extend_from_slice(payload);
+ let (sr, len, hash) = identity(&af);
+ let back = AnalysisFile::from_bytes_validated(&bytes, sr, len, hash).unwrap();
+ assert!(back.artifact.is_none());
+ }
+
+ #[test]
+ fn garbage_artifact_json_degrades_to_none() {
+ // Corrupt the ARTF payload in place: structure intact, JSON not.
+ let (af, _) = full_file();
+ let mut bytes = af.to_bytes();
+ let json_start = FILE_HEADER_LEN + CHUNK_HEADER_LEN;
+ bytes[json_start] = b'!';
+ let (sr, len, hash) = identity(&af);
+ let back = AnalysisFile::from_bytes_validated(&bytes, sr, len, hash)
+ .expect("file stays structurally valid");
+ assert!(back.artifact.is_none(), "garbage JSON must degrade");
+ assert!(back.peaks.is_some(), "peaks must survive");
+ // The structural read behaves identically.
+ assert!(AnalysisFile::from_bytes(&bytes).unwrap().artifact.is_none());
+ }
+
+ #[test]
+ fn identity_mismatches_reject_validated_read_only() {
+ let (af, _) = full_file();
+ let bytes = af.to_bytes();
+ let (sr, len, hash) = identity(&af);
+ assert!(AnalysisFile::from_bytes_validated(&bytes, 48_000, len, hash).is_none());
+ assert!(AnalysisFile::from_bytes_validated(&bytes, sr, len + 1, hash).is_none());
+ assert!(AnalysisFile::from_bytes_validated(&bytes, sr, len, hash ^ 1).is_none());
+ // The structural read doesn't care.
+ assert!(AnalysisFile::from_bytes(&bytes).is_ok());
+ }
+
+ #[test]
+ fn stale_peaks_params_dropped_on_validated_read() {
+ let (af, _) = full_file();
+ let mut bytes = af.to_bytes();
+ // The PEAK chunk follows the ARTF chunk; corrupt its crossover-low
+ // field (payload offset 8).
+ let artf_payload = u64::from_le_bytes(
+ bytes[FILE_HEADER_LEN + 8..FILE_HEADER_LEN + 16]
+ .try_into()
+ .unwrap(),
+ ) as usize;
+ let peak_payload_start =
+ FILE_HEADER_LEN + CHUNK_HEADER_LEN + artf_payload + CHUNK_HEADER_LEN;
+ bytes[peak_payload_start + 8..peak_payload_start + 16]
+ .copy_from_slice(&250.0f64.to_le_bytes());
+ let (sr, len, hash) = identity(&af);
+ let back = AnalysisFile::from_bytes_validated(&bytes, sr, len, hash).unwrap();
+ assert!(back.peaks.is_none(), "stale-parameter peaks must drop");
+ assert!(back.artifact.is_some(), "artifact must survive");
+ // Structural read keeps the peaks as stored.
+ assert!(AnalysisFile::from_bytes(&bytes).unwrap().peaks.is_some());
+ }
+
+ #[test]
+ fn stale_artifact_schema_dropped_on_validated_read() {
+ let mono = test_mono();
+ let mut af = AnalysisFile::for_source(&mono, SR);
+ let mut artifact = test_artifact(&mono);
+ artifact.version = 2; // pre-MIN_COMPATIBLE_VERSION
+ af.artifact = Some(artifact);
+ af.peaks = Some(BandPeaks::compute(&mono, 1, SR));
+ let (sr, len, hash) = identity(&af);
+ let back = AnalysisFile::from_bytes_validated(&af.to_bytes(), sr, len, hash).unwrap();
+ assert!(back.artifact.is_none(), "incompatible schema must drop");
+ assert!(back.peaks.is_some());
+ }
+
+ #[test]
+ fn file_wrappers_roundtrip_and_write_atomically() {
+ let dir = temp_dir("wrappers");
+ let (af, _) = full_file();
+ let path = dir.join("track.wav.tsa");
+ write_analysis_file(&path, &af).unwrap();
+ let (sr, len, hash) = identity(&af);
+ assert!(read_analysis_file(&path).is_ok());
+ assert!(read_analysis_file_validated(&path, sr, len, hash).is_some());
+ // Only the final file — no temp left behind.
+ let entries: Vec<_> = std::fs::read_dir(&dir)
+ .unwrap()
+ .map(|e| e.unwrap().file_name())
+ .collect();
+ assert_eq!(entries.len(), 1, "only the final file: {entries:?}");
+ // Failed write (missing parent): Err and no stray temp anywhere.
+ let bad = dir.join("missing_subdir").join("x.tsa");
+ assert!(write_analysis_file(&bad, &af).is_err());
+ assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn analysis_file_path_appends_suffix() {
+ assert_eq!(
+ analysis_file_path(Path::new("/a/b.mp3")),
+ PathBuf::from("/a/b.mp3.tsa")
+ );
+ }
+
+ #[test]
+ fn matches_identity_parity_with_matches_source() {
+ let mono = test_mono();
+ let artifact = test_artifact(&mono);
+ assert!(artifact.matches_source(&mono, SR));
+ assert!(artifact.matches_identity(SR, mono.len(), hash_samples(&mono)));
+ assert!(!artifact.matches_identity(48_000, mono.len(), hash_samples(&mono)));
+ assert!(!artifact.matches_identity(SR, mono.len() + 1, hash_samples(&mono)));
+ assert!(!artifact.matches_identity(SR, mono.len(), 12345));
+ // Zero bindings are skipped, exactly like matches_source.
+ let unbound = PreAnalysisArtifact {
+ source_len_samples: 0,
+ content_hash: 0,
+ ..test_artifact(&mono)
+ };
+ assert!(unbound.matches_identity(SR, 999, 999));
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 185d15a..52c77ec 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -66,16 +66,25 @@ pub use analysis::preanalysis::{
};
pub use analysis::rigid_grid::{RigidGridFit, fit_rigid_grid, refine_grid_rigid};
pub use analysis::tempogram::TempoTrackingOptions;
+pub use analysis::waveform::{BandPeaks, NUM_BANDS, PeakLevel};
pub use core::preanalysis::{
KeyEstimate, KeyMode, LoudnessMeasurement, PREANALYSIS_VERSION, PreAnalysisArtifact,
- hash_samples, read_preanalysis_json, write_preanalysis_json,
+ hash_samples,
};
+// Deprecated JSON sidecar API, re-exported for downstream compatibility
+// while consumers migrate to the `.tsa` container (`io::tsa`).
+#[allow(deprecated)]
+pub use core::preanalysis::{read_preanalysis_json, write_preanalysis_json};
pub use core::types::{
AudioBuffer, Channels, EnvelopePreset, FrameIter, QualityMode, Sample, StretchParams,
TransientThresholdPolicy,
};
pub use core::window::WindowType;
pub use error::StretchError;
+pub use io::tsa::{
+ AnalysisFile, TSA_CONTAINER_VERSION, analysis_file_path, read_analysis_file,
+ read_analysis_file_validated, write_analysis_file,
+};
pub use stretch::phase_locking::PhaseLockingMode;
/// Creates params adjusted for the given buffer's sample rate and channels,
diff --git a/tests/preanalysis_pipeline.rs b/tests/preanalysis_pipeline.rs
index 55e2a37..713dfc3 100644
--- a/tests/preanalysis_pipeline.rs
+++ b/tests/preanalysis_pipeline.rs
@@ -1,9 +1,9 @@
use std::f32::consts::PI;
use std::path::PathBuf;
-use timestretch::{
- PreAnalysisArtifact, StretchParams, analyze_for_dj, read_preanalysis_json, stretch,
- write_preanalysis_json,
-};
+use timestretch::{PreAnalysisArtifact, StretchParams, analyze_for_dj, stretch};
+// Deprecated JSON pair, still round-trip-tested until removal.
+#[allow(deprecated)]
+use timestretch::{read_preanalysis_json, write_preanalysis_json};
fn click_train(sample_rate: u32, bpm: f64, seconds: f64) -> Vec {
let len = (sample_rate as f64 * seconds) as usize;
@@ -42,7 +42,9 @@ fn detect_peaks(signal: &[f32], threshold: f32, min_distance: usize) -> Vec