diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b496a7..e987ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,16 @@ Building the 0.0 pilot — no released artifact yet. low-confidence shift); and the null-diff verdict ladder ported verbatim from `experiments/null_diff.py` (−80/−40/−12 dB thresholds, relative-not-absolute reasoning) — [PR #27](https://github.com/sep-lab/Wit/pull/27) +- **M5 (first slice)** — `wit-demo` and `wit demo-library`: a deterministic generator that + writes a synthetic `~/Music`-shaped tree (two Logic bundles, one with two alternatives; + a GarageBand bundle with no backups, matching the real shape; and a five-save Ableton + lineage in Live's `Backup/` layout — 21 versions total). The files carry real + `ProjectData` and `.als` framing with only the fields Wit extracts, so `wit scan`, + `logic-report` and `diff-als` all read them, but Logic and Live cannot open them and the + command says so. The Logic chain is shaped to the measured 33% empty-verdict rate rather + than to look impressive. Refuses any destination that is not empty, so it cannot + overwrite a real library. This also fixes `just demo-library`, which called a + `demo-library` subcommand that had never existed — [#18](https://github.com/sep-lab/Wit/issues/18) - Research findings across Ableton `.als`, Logic `ProjectData`, GarageBand `.band` and FL Studio `.flp`, measured on real projects ([docs/EXPERIMENTS.md](docs/EXPERIMENTS.md)) - Working prototypes: Ableton semantic differ, CDC dedup harness, FLP parser, storage bench diff --git a/Cargo.lock b/Cargo.lock index e101420..c20107b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -957,6 +957,20 @@ dependencies = [ "clap", "flate2", "wit-als", + "wit-demo", + "wit-diff", + "wit-index", + "wit-logic", + "wit-model", +] + +[[package]] +name = "wit-demo" +version = "0.0.0" +dependencies = [ + "flate2", + "tempfile", + "wit-als", "wit-diff", "wit-index", "wit-logic", diff --git a/crates/wit-cli/Cargo.toml b/crates/wit-cli/Cargo.toml index ed90387..8bfe7dc 100644 --- a/crates/wit-cli/Cargo.toml +++ b/crates/wit-cli/Cargo.toml @@ -19,6 +19,7 @@ wit-als = { path = "../wit-als" } wit-diff = { path = "../wit-diff" } wit-logic = { path = "../wit-logic" } wit-index = { path = "../wit-index" } +wit-demo = { path = "../wit-demo" } clap = { version = "4.5", features = ["derive"] } [dev-dependencies] diff --git a/crates/wit-cli/src/main.rs b/crates/wit-cli/src/main.rs index 699174d..58eac51 100644 --- a/crates/wit-cli/src/main.rs +++ b/crates/wit-cli/src/main.rs @@ -8,8 +8,11 @@ //! `dupes` (`wit-index`) — the first commands that persist anything, via //! the one crate in the workspace allowed to write. M2.5 adds //! `logic-report` — the issue #15 reality-gate tool, running `logic-probe`'s -//! comparison across an entire library instead of one pair. `wit log`/ -//! `diff`/`report` land later; see `docs/ROADMAP.md`. +//! comparison across an entire library instead of one pair. M5 adds +//! `demo-library` (`wit-demo`), which writes the synthetic library the app +//! is developed and demoed against, so neither needs a real Logic library +//! on the machine. `wit log`/`diff`/`report` land later; see +//! `docs/ROADMAP.md`. use clap::{Parser, Subcommand}; use std::collections::BTreeSet; @@ -58,6 +61,11 @@ enum Command { /// comparison on every consecutive pair, and print the empty-verdict /// rate across the whole library. Read-only. LogicReport { path: PathBuf }, + /// Write a synthetic `~/Music`-shaped library to `dest` — two Logic + /// projects, a GarageBand project, and an Ableton lineage — so the app + /// is demoable on a machine with no real Logic library. Refuses to + /// write into a directory that already has anything in it. + DemoLibrary { dest: PathBuf }, } fn main() -> ExitCode { @@ -68,9 +76,30 @@ fn main() -> ExitCode { Command::Scan { path, data_dir } => scan(&path, data_dir), Command::Dupes { path } => dupes(&path), Command::LogicReport { path } => logic_report(&path), + Command::DemoLibrary { dest } => demo_library(&dest), } } +/// M5 (issue #18): build the synthetic library `just demo-library` wraps. +fn demo_library(dest: &std::path::Path) -> ExitCode { + let lib = match wit_demo::build_demo_library(dest) { + Ok(lib) => lib, + Err(e) => { + eprintln!("wit: {e}"); + return ExitCode::FAILURE; + } + }; + println!( + " wrote {} Logic project(s), {} GarageBand project(s), {} Ableton lineage(s) — {} version(s) total", + lib.logic_projects, lib.garageband_projects, lib.ableton_lineages, lib.total_versions + ); + println!( + " these are synthetic fixtures for Wit's own readers — Logic and Live cannot open them" + ); + println!(" point the app at: {}", lib.root.display()); + ExitCode::SUCCESS +} + /// The default index location: `~/Library/Application Support/Wit` on /// macOS (the only platform the 0.0 pilot targets — ADR-0006). Falls back /// to a `wit-data` directory under the current directory if `$HOME` isn't diff --git a/crates/wit-cli/tests/demo_library.rs b/crates/wit-cli/tests/demo_library.rs new file mode 100644 index 0000000..2d7f2aa --- /dev/null +++ b/crates/wit-cli/tests/demo_library.rs @@ -0,0 +1,80 @@ +//! End-to-end tests for `wit demo-library` — the M5 (issue #18) recipe that +//! makes the app demoable on a machine with no real Logic library. +//! +//! `wit-demo`'s own tests cover what the generated files contain; these +//! cover the thing only the binary can prove — that the command wires up, +//! reports honestly, and refuses the one input that could destroy something. + +use std::process::Command; + +fn wit(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_wit")) + .args(args) + .output() + .unwrap() +} + +fn tempfile_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "wit-demo-test-{}-{}-{}", + std::process::id(), + tag, + format!("{:?}", std::thread::current().id()).replace(['(', ')'], "") + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn demo_library_writes_a_library_the_other_commands_can_read() { + let dir = tempfile_dir("build"); + let dest = dir.join("library"); + + let out = wit(&["demo-library", dest.to_str().unwrap()]); + assert!(out.status.success(), "demo-library should succeed"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("2 Logic project(s)") && stdout.contains("21 version(s)"), + "unexpected report: {stdout}" + ); + // The honesty line is not decoration — a demo fixture must never be + // mistaken for something Logic could open. + assert!(stdout.contains("Logic and Live cannot open them")); + + // The point of the whole command: `wit scan` finds what it wrote. + let scan = wit(&[ + "scan", + dest.to_str().unwrap(), + "--data-dir", + dir.join("index").to_str().unwrap(), + ]); + assert!(scan.status.success()); + let scanned = String::from_utf8_lossy(&scan.stdout); + assert!( + scanned.contains("3 Logic/GarageBand project(s), 1 Ableton lineage(s)"), + "scan did not discover the demo library: {scanned}" + ); + assert!(scanned.contains("Coastline (logic): 10 version(s)")); + + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn demo_library_refuses_a_non_empty_destination_and_changes_nothing() { + let dir = tempfile_dir("refuse"); + let precious = dir.join("MyRealSong.logicx"); + std::fs::write(&precious, b"not a demo").unwrap(); + + let out = wit(&["demo-library", dir.to_str().unwrap()]); + assert!(!out.status.success(), "must refuse a non-empty destination"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("refusing to write a demo library over it"), + "the refusal must say why: {stderr}" + ); + assert_eq!(std::fs::read(&precious).unwrap(), b"not a demo"); + assert!(!dir.join("Logic").exists()); + + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/wit-demo/Cargo.toml b/crates/wit-demo/Cargo.toml new file mode 100644 index 0000000..f1967d9 --- /dev/null +++ b/crates/wit-demo/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wit-demo" +version = "0.0.0" +description = "Synthetic demo library generator: writes a ~/Music-shaped tree of Logic/GarageBand bundles and an Ableton lineage, so first-run, the timeline, and the watcher are demoable on any machine. Deterministic; never touches a real project." +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +flate2 = "1.0" + +[dev-dependencies] +tempfile = "3" +wit-als = { path = "../wit-als" } +wit-diff = { path = "../wit-diff" } +wit-index = { path = "../wit-index" } +wit-logic = { path = "../wit-logic" } +wit-model = { path = "../wit-model" } diff --git a/crates/wit-demo/src/ableton.rs b/crates/wit-demo/src/ableton.rs new file mode 100644 index 0000000..fde1cd5 --- /dev/null +++ b/crates/wit-demo/src/ableton.rs @@ -0,0 +1,255 @@ +//! Synthesise a `.als` (gzipped Live-set XML) in the shape `wit-als` reads. +//! +//! Same contract as [`crate::logic`]: this emits only the whitelisted +//! elements `wit-als::build_model` extracts, in the nesting real Live uses. +//! It is not a Live writer — a real Live set carries thousands of elements +//! this omits, and Live would not open one of these. It exists so the app's +//! Ableton path (M5's exit criterion names "one Ableton `.als` lineage") is +//! demoable without shipping someone's real project. + +use flate2::write::GzEncoder; +use flate2::Compression; +use std::io::Write; + +#[derive(Debug, Clone, PartialEq)] +pub struct ClipSpec { + pub id: u32, + pub name: String, + pub start: f64, + pub end: f64, + /// Written as a `RelativePath`; `wit-als` reduces it to a basename. + pub sample: String, + pub disabled: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TrackSpec { + pub id: u32, + pub name: String, + pub volume: f64, + pub pan: f64, + /// Device element tags, e.g. `Eq8`, `Compressor2`. Each gets a single + /// `>` so the parameter fingerprint has something + /// to hash — a knob move is modelled by changing `device_knob`. + pub devices: Vec, + pub device_knob: f64, + pub clips: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SetSpec { + pub creator: String, + pub tempo_bpm: f64, + pub tracks: Vec, +} + +/// XML-escape a value destined for a double-quoted attribute. Demo project +/// names are ours, but a track name is the kind of string a future caller +/// will inevitably make user-supplied, and an unescaped `&` would produce a +/// file the parser rejects — a confusing failure to debug from the app. +fn esc(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +/// Format a float the way Live does — enough places that `wit-model`'s +/// 3-place rounding is exercised rather than bypassed. +fn num(v: f64) -> String { + format!("{v:.6}") +} + +fn clip_xml(clip: &ClipSpec) -> String { + format!( + r#" + + + + + + "#, + id = clip.id, + name = esc(&clip.name), + start = num(clip.start), + end = num(clip.end), + disabled = clip.disabled, + sample = esc(&clip.sample), + ) +} + +fn track_xml(track: &TrackSpec) -> String { + let devices: String = track + .devices + .iter() + .map(|tag| { + format!( + r#"<{tag} Id="0">"#, + tag = tag, + knob = num(track.device_knob) + ) + }) + .collect(); + let clips: String = track.clips.iter().map(clip_xml).collect(); + + format!( + r#" + + + + + + + + + {devices} + {clips} + + "#, + id = track.id, + name = esc(&track.name), + volume = num(track.volume), + pan = num(track.pan), + ) +} + +/// Render the uncompressed Live-set XML. Exposed for tests and for anyone +/// wanting to eyeball what the demo generator actually writes. +pub fn build_als_xml(spec: &SetSpec) -> String { + let tracks: String = spec.tracks.iter().map(track_xml).collect(); + format!( + r#" + + + {tracks} + + + + + + +"#, + creator = esc(&spec.creator), + tempo = num(spec.tempo_bpm), + ) +} + +/// Render and gzip — the on-disk `.als` form. +/// +/// Compression level is pinned rather than left at default so the output is +/// byte-reproducible across flate2 versions that might change the default; +/// the demo library's determinism is a property tests rely on. +pub fn build_als(spec: &SetSpec) -> std::io::Result> { + let xml = build_als_xml(spec); + let mut encoder = GzEncoder::new(Vec::new(), Compression::new(6)); + encoder.write_all(xml.as_bytes())?; + encoder.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + fn spec() -> SetSpec { + SetSpec { + creator: "Ableton Live 12.4.2".into(), + tempo_bpm: 120.0, + tracks: vec![TrackSpec { + id: 8, + name: "Rhodes".into(), + volume: 0.7943282127, + pan: 0.0, + devices: vec!["Eq8".into()], + device_knob: 1.0, + clips: vec![ClipSpec { + id: 3, + name: "verse rhodes".into(), + start: 0.0, + end: 16.0, + sample: "rhodes take 3.wav".into(), + disabled: false, + }], + }], + } + } + + fn model_of(spec: &SetSpec) -> wit_model::Model { + let gz = build_als(spec).unwrap(); + // Decompress independently first, so a gzip framing bug is + // distinguishable from a parse bug when this fails. + let mut xml = Vec::new(); + flate2::read::GzDecoder::new(&gz[..]) + .read_to_end(&mut xml) + .expect("output must be valid gzip"); + assert!(xml.starts_with(b" "wide""#.into(); + let model = model_of(&hostile); + assert_eq!( + model.tracks.values().next().unwrap().name, + r#"Bass & "wide""# + ); + } + + #[test] + fn generation_is_deterministic() { + assert_eq!(build_als(&spec()).unwrap(), build_als(&spec()).unwrap()); + } +} diff --git a/crates/wit-demo/src/lib.rs b/crates/wit-demo/src/lib.rs new file mode 100644 index 0000000..4975914 --- /dev/null +++ b/crates/wit-demo/src/lib.rs @@ -0,0 +1,466 @@ +//! Build a synthetic, `~/Music`-shaped library so Wit's first-run, timeline +//! and watcher are demoable on any machine — not only one with a real Logic +//! library on it (issue #18 / `just demo-library`). +//! +//! **Why this is its own crate.** `wit-index` documents, as a safety +//! property enforced by construction, that its only write API takes bytes +//! rather than a path — so no caller can hand it a project path even by +//! mistake. A generator that writes a directory tree to a path the user +//! names is exactly the capability that property excludes, so it lives +//! here instead of eroding the claim. The guard rail moves with it: +//! [`build_demo_library`] refuses any destination that is not empty, so it +//! can never overwrite a real library even if pointed at one. +//! +//! **What the generated files are.** Real `ProjectData` and `.als` +//! *framing*, carrying only the whitelisted fields Wit extracts. Logic and +//! Live would not open them. They are fixtures for Wit's own readers, and +//! must never be described to a user as projects. +//! +//! The chain is shaped to match measured reality rather than to look good: +//! 3 of the 9 Logic save pairs are byte-different but structurally +//! identical, matching the 33% empty-verdict rate measured across 32 real +//! projects (`docs/EXPERIMENTS.md` §11). A demo where every save has +//! something to show would misrepresent the product. + +pub mod ableton; +pub mod logic; + +use ableton::{ClipSpec, SetSpec, TrackSpec}; +use logic::{SongSpec, VERSION_GARAGEBAND, VERSION_LOGIC}; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub enum DemoError { + /// The destination exists and has something in it. Never overwrite — + /// the one thing this tool must not do is clobber a real library + /// because someone typed `~/Music` instead of `/tmp/demo`. + DestinationNotEmpty(PathBuf), + Io(String), +} + +impl std::fmt::Display for DemoError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DemoError::DestinationNotEmpty(p) => write!( + f, + "{} already exists and is not empty — refusing to write a demo library over it", + p.display() + ), + DemoError::Io(msg) => write!(f, "failed to write the demo library: {msg}"), + } + } +} + +impl std::error::Error for DemoError {} + +impl From for DemoError { + fn from(e: std::io::Error) -> Self { + DemoError::Io(e.to_string()) + } +} + +/// What was written, for the CLI to report and tests to assert on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DemoLibrary { + pub root: PathBuf, + pub logic_projects: usize, + pub garageband_projects: usize, + pub ableton_lineages: usize, + /// Every `ProjectData` plus every `.als` written. + pub total_versions: usize, +} + +fn write(path: &Path, bytes: &[u8]) -> Result<(), DemoError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, bytes)?; + Ok(()) +} + +fn is_empty_dir(path: &Path) -> Result { + Ok(std::fs::read_dir(path)?.next().is_none()) +} + +/// The 10-save chain for the headline demo project: 9 consecutive pairs, of +/// which 3 show nothing Wit can see. Each entry is one save, oldest first. +/// +/// The edits are the ones a producer actually makes, in an order that tells +/// a story when read top to bottom in the app's timeline — that is the +/// point of the demo, and it is why this is a hand-written list rather than +/// something generated from a seed. +fn coastline_chain() -> Vec { + let base = SongSpec { + tempo_bpm: 120.0, + track_names: vec!["Rhodes".into(), "Upright Bass".into()], + region_names: vec!["Verse Rhodes".into()], + audio_file_names: vec!["Upright Bass.caf".into()], + churn: 0, + }; + + let mut chain = Vec::new(); + chain.push(base.clone()); // 0 — the first save + + // 1 — you nudged a fader and saved. Nothing Wit can see on Logic. + chain.push(base.with_churn(1)); + + // 2 — recorded the chorus. + let mut v = base.with_churn(2); + v.region_names.push("Chorus Rhodes".into()); + chain.push(v.clone()); + + // 3 — named the bass track properly. + v = v.with_churn(3); + v.track_names[1] = "Upright Bass (DI)".into(); + chain.push(v.clone()); + + // 4 — another invisible save. + chain.push(v.with_churn(4)); + + // 5 — pushed the tempo. + v = v.with_churn(5); + v.tempo_bpm = 124.0; + chain.push(v.clone()); + + // 6 — dragged in a drum loop. + v = v.with_churn(6); + v.audio_file_names.push("Brushed Kit 124.caf".into()); + v.region_names.push("Brushed Kit".into()); + chain.push(v.clone()); + + // 7 — a third invisible save. + chain.push(v.with_churn(7)); + + // 8 — added a pad. + v = v.with_churn(8); + v.track_names.push("Wurli Pad".into()); + chain.push(v.clone()); + + // 9 — the current save: doubled the chorus. + v = v.with_churn(9); + v.region_names.push("Chorus Rhodes 2".into()); + chain.push(v); + + chain +} + +/// A shorter second project, so the Shelf has more than one card and the +/// "two alternatives" shape (Logic's own branching) is exercised. +fn night_bus_chain() -> Vec { + let base = SongSpec { + tempo_bpm: 88.0, + track_names: vec!["Tape Drums".into()], + region_names: vec!["Intro".into()], + audio_file_names: vec!["Tape Drums.caf".into()], + churn: 0, + }; + let mut second = base.with_churn(1); + second.region_names.push("Verse".into()); + let mut third = second.with_churn(2); + third.track_names.push("Sub".into()); + vec![base, second, third] +} + +/// Write one Logic/GarageBand bundle: `Alternatives//ProjectData` for +/// the newest save, and `Project File Backups/NN/ProjectData` for the older +/// ones — the exact layout `wit-index::discover` walks, and the real +/// on-disk shape (backups oldest-first in slots `00`..`09`, current save +/// outside them). +fn write_logic_bundle( + bundle: &Path, + alternative: &str, + chain: &[SongSpec], + version: [u8; 2], + with_backups: bool, +) -> Result { + let alt_dir = bundle.join("Alternatives").join(alternative); + let (backups, current) = chain.split_at(chain.len() - 1); + + let mut written = 0; + if with_backups { + for (slot, spec) in backups.iter().enumerate() { + let path = alt_dir + .join("Project File Backups") + .join(format!("{slot:02}")) + .join("ProjectData"); + write(&path, &logic::build_project_data(spec, version))?; + written += 1; + } + } + write( + &alt_dir.join("ProjectData"), + &logic::build_project_data(¤t[0], version), + )?; + written += 1; + + Ok(written) +} + +fn coastline_als_chain() -> Vec { + let base = SetSpec { + creator: "Ableton Live 12.4.2".into(), + tempo_bpm: 120.0, + tracks: vec![ + TrackSpec { + id: 8, + name: "Rhodes".into(), + volume: 0.7943282127, + pan: 0.0, + devices: vec!["Eq8".into()], + device_knob: 1.0, + clips: vec![ClipSpec { + id: 3, + name: "verse rhodes".into(), + start: 0.0, + end: 16.0, + sample: "rhodes take 3.wav".into(), + disabled: false, + }], + }, + TrackSpec { + id: 9, + name: "Upright Bass".into(), + volume: 0.6606934, + pan: -0.15, + devices: vec!["Compressor2".into()], + device_knob: 0.5, + clips: vec![], + }, + ], + }; + + // 1 — pure bookkeeping: nothing in the whitelist moves, so the app must + // say "no musical change detected" rather than inventing something. + let bookkeeping = base.clone(); + + // 2 — turned the Rhodes down. + let mut quieter = base.clone(); + quieter.tracks[0].volume = 0.5248075; + + // 3 — added a filter and muted the clip under it. + let mut filtered = quieter.clone(); + filtered.tracks[0].devices.push("AutoFilter".into()); + filtered.tracks[0].clips[0].disabled = true; + + // 4 — renamed the sample in Finder, and pushed the tempo. + let mut renamed = filtered.clone(); + renamed.tracks[0].clips[0].sample = "rhodes FINAL.wav".into(); + renamed.tempo_bpm = 124.0; + + vec![base, bookkeeping, quieter, filtered, renamed] +} + +/// Live's autosave filenames, which `wit-index::discover` parses to group a +/// lineage: ` [YYYY-MM-DD HHMMSS].als`. Fixed timestamps, never +/// `now()` — the generated tree has to be byte-identical run to run. +const ALS_TIMESTAMPS: [&str; 5] = [ + "2026-01-04 101500", + "2026-01-04 103012", + "2026-01-04 111845", + "2026-01-05 200133", + "2026-01-05 204417", +]; + +/// Build the whole demo library under `dest`. +/// +/// Refuses unless `dest` is missing or an empty directory. Deterministic: +/// the same `dest` twice produces byte-identical files, with no clock or +/// RNG anywhere in the generator. +pub fn build_demo_library(dest: &Path) -> Result { + if dest.exists() && !is_empty_dir(dest)? { + return Err(DemoError::DestinationNotEmpty(dest.to_path_buf())); + } + std::fs::create_dir_all(dest)?; + + let mut total_versions = 0; + + // Logic — the headline project, 10 saves in one alternative. + total_versions += write_logic_bundle( + &dest.join("Logic/Coastline.logicx"), + "000", + &coastline_chain(), + VERSION_LOGIC, + true, + )?; + + // Logic — a second project with two alternatives, so the Shelf shows + // more than one card and Logic's own branching is represented. + let night_bus = dest.join("Logic/Night Bus.logicx"); + let night_bus_chain = night_bus_chain(); + total_versions += write_logic_bundle(&night_bus, "000", &night_bus_chain, VERSION_LOGIC, true)?; + total_versions += write_logic_bundle( + &night_bus, + "001", + &night_bus_chain[..2], + VERSION_LOGIC, + true, + )?; + + // GarageBand — one alternative, no backups. That is the real shape: + // the probe confirmed GarageBand keeps no `Project File Backups`, so a + // demo that gave it some would teach the wrong thing. + total_versions += write_logic_bundle( + &dest.join("GarageBand/Kitchen Jam.band"), + "000", + &night_bus_chain[..1], + VERSION_GARAGEBAND, + false, + )?; + + // Ableton — one lineage of 5 autosaves, in Live's own Backup/ layout. + let backup_dir = dest.join("Ableton/Coastline Project/Backup"); + for (spec, stamp) in coastline_als_chain().iter().zip(ALS_TIMESTAMPS) { + let path = backup_dir.join(format!("Coastline [{stamp}].als")); + write(&path, &ableton::build_als(spec)?)?; + total_versions += 1; + } + + Ok(DemoLibrary { + root: dest.to_path_buf(), + logic_projects: 2, + garageband_projects: 1, + ableton_lineages: 1, + total_versions, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn built() -> (tempfile::TempDir, DemoLibrary) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("demo"); + let lib = build_demo_library(&root).unwrap(); + (dir, lib) + } + + #[test] + fn reports_what_it_wrote() { + let (_dir, lib) = built(); + assert_eq!(lib.logic_projects, 2); + assert_eq!(lib.garageband_projects, 1); + assert_eq!(lib.ableton_lineages, 1); + // 10 (Coastline) + 3 + 2 (Night Bus, two alternatives) + 1 + // (GarageBand) + 5 (.als) = 21. + assert_eq!(lib.total_versions, 21); + } + + #[test] + fn wit_index_discovers_every_project_it_writes() { + // The property that actually matters: the app finds these through + // the same discovery path it uses on a real library. + let (_dir, lib) = built(); + let projects = wit_index::discover_logic_projects(&lib.root); + assert_eq!(projects.len(), 3, "2 Logic + 1 GarageBand"); + + let coastline = projects.iter().find(|p| p.name == "Coastline").unwrap(); + assert_eq!(coastline.kind, wit_index::LogicKind::Logic); + assert_eq!(coastline.all_versions().len(), 10); + + let night_bus = projects.iter().find(|p| p.name == "Night Bus").unwrap(); + assert_eq!(night_bus.alternatives.len(), 2); + + let jam = projects.iter().find(|p| p.name == "Kitchen Jam").unwrap(); + assert_eq!(jam.kind, wit_index::LogicKind::GarageBand); + assert!( + jam.alternatives[0].backups.is_empty(), + "GarageBand keeps no Project File Backups — the demo must not invent any" + ); + } + + #[test] + fn wit_index_groups_the_ableton_saves_into_one_lineage() { + let (_dir, lib) = built(); + let lineages = wit_index::discover_ableton_lineages(&lib.root); + assert_eq!(lineages.len(), 1); + assert_eq!(lineages[0].name, "Coastline"); + assert_eq!(lineages[0].saves.len(), 5); + } + + #[test] + fn the_logic_chain_reproduces_the_measured_empty_verdict_rate() { + // EXPERIMENTS.md §11 measured 33% of real save pairs as showing no + // structural change. If the demo drifts away from that, first-run + // stops representing what a pilot user will actually see. + let (_dir, lib) = built(); + let projects = wit_index::discover_logic_projects(&lib.root); + let coastline = projects.iter().find(|p| p.name == "Coastline").unwrap(); + let versions = coastline.all_versions(); + + let mut empty = 0; + let mut byte_different_but_empty = 0; + for pair in versions.windows(2) { + let (a_bytes, b_bytes) = ( + std::fs::read(pair[0]).unwrap(), + std::fs::read(pair[1]).unwrap(), + ); + let a = wit_logic::walk(&a_bytes).unwrap(); + let b = wit_logic::walk(&b_bytes).unwrap(); + if wit_logic::semantic_equal(&a, &b) == wit_logic::Verdict::NoStructuralChange { + empty += 1; + if a_bytes != b_bytes { + byte_different_but_empty += 1; + } + } + } + assert_eq!(versions.len() - 1, 9, "9 consecutive pairs"); + assert_eq!(empty, 3, "3 of 9 pairs empty = 33%, matching §11"); + assert_eq!( + byte_different_but_empty, 3, + "and all 3 are byte-different — §11's 28% case" + ); + } + + #[test] + fn the_ableton_lineage_opens_with_a_bookkeeping_only_save() { + // The demo's first sentence is "no musical change detected", which + // is the single most distinctive thing Wit says. Assert it holds. + let (_dir, lib) = built(); + let lineage = &wit_index::discover_ableton_lineages(&lib.root)[0]; + let a = wit_als::parse_file(&lineage.saves[0]).unwrap(); + let b = wit_als::parse_file(&lineage.saves[1]).unwrap(); + assert!(wit_diff::diff(&a, &b).is_empty()); + + // ...and the next pair does have something to say. + let c = wit_als::parse_file(&lineage.saves[2]).unwrap(); + assert!(!wit_diff::diff(&b, &c).is_empty()); + } + + #[test] + fn refuses_to_write_into_a_non_empty_directory() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("MyRealSong.logicx"), b"precious").unwrap(); + let err = build_demo_library(dir.path()).unwrap_err(); + assert!(matches!(err, DemoError::DestinationNotEmpty(_))); + // And it wrote nothing. + assert!(!dir.path().join("Logic").exists()); + assert_eq!( + std::fs::read(dir.path().join("MyRealSong.logicx")).unwrap(), + b"precious" + ); + } + + #[test] + fn an_existing_empty_directory_is_fine() { + let dir = tempfile::tempdir().unwrap(); + assert!(build_demo_library(dir.path()).is_ok()); + } + + #[test] + fn two_builds_produce_byte_identical_trees() { + let (_a_dir, a) = built(); + let (_b_dir, b) = built(); + for project in wit_index::discover_logic_projects(&a.root) { + for version in project.all_versions() { + let relative = version.strip_prefix(&a.root).unwrap(); + assert_eq!( + std::fs::read(version).unwrap(), + std::fs::read(b.root.join(relative)).unwrap(), + "{} differs between runs", + relative.display() + ); + } + } + } +} diff --git a/crates/wit-demo/src/logic.rs b/crates/wit-demo/src/logic.rs new file mode 100644 index 0000000..99b6f9b --- /dev/null +++ b/crates/wit-demo/src/logic.rs @@ -0,0 +1,231 @@ +//! Synthesise a `ProjectData` container byte-for-byte in the shape +//! `wit-logic` walks. +//! +//! Every offset used here is the one `wit-logic` reads, and the two must +//! stay in lockstep — that is deliberate. If someone changes an extraction +//! offset in `wit-logic` without changing it here, this crate's round-trip +//! tests fail, which is exactly the alarm you want: the demo library is the +//! thing a pilot user sees first, and a demo that silently stops producing +//! readable names would be worse than no demo. +//! +//! What this is **not**: a Logic file writer. The records carry no real +//! payload schema (issue #3 is still open), only the whitelisted fields +//! `wit-logic` extracts, padded with zeros. Logic itself would not open one +//! of these, and nothing here should ever be presented as if it would. + +/// `d0 09` on real Logic files, `c5 09` on real GarageBand — both observed +/// by the probe (`wit-planning/PROBE-FINDINGS.md`). The walker accepts any +/// version word; using the real two keeps the demo honest about which app +/// each bundle is pretending to be. +pub const VERSION_LOGIC: [u8; 2] = [0xd0, 0x09]; +pub const VERSION_GARAGEBAND: [u8; 2] = [0xc5, 0x09]; + +const MAGIC: [u8; 4] = [0x23, 0x47, 0xC0, 0xAB]; +const ROOT_HEADER_LEN: usize = 0x18; +const RECORD_HEADER_LEN: usize = 0x24; + +// Offsets `wit-logic::extract` reads. Named here rather than inlined so the +// coupling is greppable from both sides. +const QESM_NAME_OFFSET: usize = 0x10; +const GRUA_NAME_OFFSET: usize = 0x4a; +const LFUA_NAME_OFFSET: usize = 0x08; +const TEMPO_OFFSETS: [usize; 3] = [0x6e, 0xc6, 0x382]; +/// An offset inside the `gnoS` payload that nothing reads — not a tempo +/// slot, not a name. Writing here changes the file's bytes while leaving +/// every extracted fact identical, which is how the demo reproduces the +/// measured 28% of real save pairs that are byte-different but +/// structurally identical (EXPERIMENTS.md §11). +const CHURN_OFFSET: usize = 0x200; +const GNOS_PAYLOAD_LEN: usize = 0x400; + +/// One save of a synthetic song — the whitelisted facts `wit-logic` can +/// actually see, plus a churn counter for bytes it cannot. +#[derive(Debug, Clone, PartialEq)] +pub struct SongSpec { + pub tempo_bpm: f64, + /// Becomes `qeSM` records. Note `wit-logic` filters system/generic + /// names, so avoid those here unless testing the filter. + pub track_names: Vec, + /// Becomes `gRuA` records. + pub region_names: Vec, + /// Becomes `lFuA` records (UTF-16LE on disk). + pub audio_file_names: Vec, + /// Bumped on a save that changed nothing Wit can see. Alters the bytes + /// and nothing else. + pub churn: u32, +} + +impl SongSpec { + /// A save that differs from this one only in bytes — the "you moved a + /// fader Wit can't see, or Logic rewrote a UUID" case. + pub fn with_churn(&self, churn: u32) -> SongSpec { + SongSpec { + churn, + ..self.clone() + } + } +} + +fn record(tag: &[u8; 4], payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(RECORD_HEADER_LEN + payload.len()); + out.extend_from_slice(tag); + out.extend_from_slice(&[0u8; 0x1c - 4]); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(&[0u8; RECORD_HEADER_LEN - 0x20]); + out.extend_from_slice(payload); + out +} + +fn len_prefixed_at(offset: usize, name: &str) -> Vec { + let mut p = vec![0u8; offset]; + p.extend_from_slice(&(name.len() as u16).to_le_bytes()); + p.extend_from_slice(name.as_bytes()); + p +} + +fn utf16_len_prefixed_at(offset: usize, name: &str) -> Vec { + let mut p = vec![0u8; offset]; + let units: Vec = name.encode_utf16().collect(); + p.extend_from_slice(&(units.len() as u16).to_le_bytes()); + for u in units { + p.extend_from_slice(&u.to_le_bytes()); + } + p +} + +fn gnos_payload(spec: &SongSpec) -> Vec { + let mut p = vec![0u8; GNOS_PAYLOAD_LEN]; + // `round(BPM * 10000)`, replicated at all three slots — wit-logic only + // trusts a tempo when at least two agree. + let ticks = (spec.tempo_bpm * 10_000.0).round() as u32; + for off in TEMPO_OFFSETS { + p[off..off + 4].copy_from_slice(&ticks.to_le_bytes()); + } + p[CHURN_OFFSET..CHURN_OFFSET + 4].copy_from_slice(&spec.churn.to_le_bytes()); + p +} + +/// Build a complete `ProjectData` file. The first record is always `gnoS`, +/// as it is on every real file. +pub fn build_project_data(spec: &SongSpec, version: [u8; 2]) -> Vec { + let mut body = record(b"gnoS", &gnos_payload(spec)); + for name in &spec.track_names { + body.extend_from_slice(&record(b"qeSM", &len_prefixed_at(QESM_NAME_OFFSET, name))); + } + for name in &spec.region_names { + body.extend_from_slice(&record(b"gRuA", &len_prefixed_at(GRUA_NAME_OFFSET, name))); + } + for name in &spec.audio_file_names { + body.extend_from_slice(&record( + b"lFuA", + &utf16_len_prefixed_at(LFUA_NAME_OFFSET, name), + )); + } + + let mut out = Vec::with_capacity(ROOT_HEADER_LEN + body.len()); + out.extend_from_slice(&MAGIC); + out.extend_from_slice(&version); + out.extend_from_slice(&[0u8; 10]); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(&[0u8; 4]); + out.extend_from_slice(&body); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec() -> SongSpec { + SongSpec { + tempo_bpm: 122.0028, + track_names: vec!["Rhodes".into(), "Upright Bass".into()], + region_names: vec!["Verse Rhodes".into()], + audio_file_names: vec!["Upright Bass.caf".into()], + churn: 0, + } + } + + #[test] + fn a_generated_file_walks_to_a_clean_eof() { + let data = build_project_data(&spec(), VERSION_LOGIC); + let header = wit_logic::parse_root_header(&data).unwrap(); + assert_eq!(header.version_word, VERSION_LOGIC); + // 1 gnoS + 2 qeSM + 1 gRuA + 1 lFuA. walk_records only returns Ok + // when it lands exactly on EOF, so this also asserts the framing. + assert_eq!(wit_logic::walk_records(&data).unwrap().len(), 5); + } + + #[test] + fn every_whitelisted_field_survives_a_round_trip_through_wit_logic() { + let spec = spec(); + let data = build_project_data(&spec, VERSION_LOGIC); + let walked = wit_logic::walk(&data).unwrap(); + assert_eq!(walked.extracted.tempo_bpm, Some(122.0028)); + assert_eq!(walked.extracted.possible_track_names, spec.track_names); + assert_eq!(walked.extracted.region_names, spec.region_names); + assert_eq!(walked.extracted.audio_file_names, spec.audio_file_names); + } + + #[test] + fn churn_changes_the_bytes_and_nothing_wit_can_see() { + // The 28%-of-real-pairs case from EXPERIMENTS.md §11, reproduced on + // demand: different bytes, identical verdict. + let a = build_project_data(&spec(), VERSION_LOGIC); + let b = build_project_data(&spec().with_churn(1), VERSION_LOGIC); + assert_ne!(a, b, "churn must change the bytes"); + assert_eq!(a.len(), b.len()); + let (wa, wb) = (wit_logic::walk(&a).unwrap(), wit_logic::walk(&b).unwrap()); + assert_eq!( + wit_logic::semantic_equal(&wa, &wb), + wit_logic::Verdict::NoStructuralChange + ); + } + + #[test] + fn adding_a_region_is_a_structural_change() { + let a = build_project_data(&spec(), VERSION_LOGIC); + let mut changed = spec(); + changed.region_names.push("Chorus Rhodes".into()); + let b = build_project_data(&changed, VERSION_LOGIC); + let (wa, wb) = (wit_logic::walk(&a).unwrap(), wit_logic::walk(&b).unwrap()); + assert_eq!( + wit_logic::semantic_equal(&wa, &wb), + wit_logic::Verdict::StructuralChange + ); + } + + #[test] + fn a_tempo_change_is_visible() { + let a = build_project_data(&spec(), VERSION_LOGIC); + let mut faster = spec(); + faster.tempo_bpm = 124.0; + let b = build_project_data(&faster, VERSION_LOGIC); + assert_eq!( + wit_logic::walk(&b).unwrap().extracted.tempo_bpm, + Some(124.0) + ); + let (wa, wb) = (wit_logic::walk(&a).unwrap(), wit_logic::walk(&b).unwrap()); + assert_eq!( + wit_logic::semantic_equal(&wa, &wb), + wit_logic::Verdict::StructuralChange + ); + } + + #[test] + fn generation_is_deterministic() { + assert_eq!( + build_project_data(&spec(), VERSION_LOGIC), + build_project_data(&spec(), VERSION_LOGIC) + ); + } + + #[test] + fn a_garageband_file_carries_the_garageband_version_word() { + let data = build_project_data(&spec(), VERSION_GARAGEBAND); + assert_eq!( + wit_logic::parse_root_header(&data).unwrap().version_word, + VERSION_GARAGEBAND + ); + } +} diff --git a/justfile b/justfile index 0c64c9c..9753233 100644 --- a/justfile +++ b/justfile @@ -35,18 +35,14 @@ licenses: # --- What lands starting M5 (the Tauri app) — stubs until then --- -# Build a synthetic ~/Music-shaped tree (two .logicx packages with N backups, one .als -# lineage) so first-run, the timeline, and the watcher are demoable on any machine, not -# just a machine with real Logic projects on it. See PLAN.md M5. -demo-library: - #!/usr/bin/env bash - set -euo pipefail - if [ ! -d crates/wit-cli ]; then - echo "error: crates/wit-cli doesn't exist yet — demo-library lands with M3/M5." >&2 - echo "See docs/ROADMAP.md 'Now: the 0.0 pilot' for what's built so far." >&2 - exit 1 - fi - cargo run -p wit-cli -- demo-library "$@" +# Build a synthetic ~/Music-shaped tree (two .logicx packages with backups, a .band, and +# one .als lineage) so first-run, the timeline, and the watcher are demoable on any +# machine, not just a machine with real Logic projects on it. See issue #18. +# +# Refuses to write into a directory that already has anything in it, so pointing this at +# a real library by mistake cannot destroy anything. +demo-library dest="target/demo-library": + cargo run -p wit-cli -- demo-library "{{dest}}" # Build an unsigned, ad-hoc-signed .app + .zip for pilot distribution (no Apple Developer # ID). Apple Silicon requires at least an ad-hoc signature to launch at all — this recipe diff --git a/tests/test_repo_hygiene.py b/tests/test_repo_hygiene.py index 3baa3f6..beab28e 100644 --- a/tests/test_repo_hygiene.py +++ b/tests/test_repo_hygiene.py @@ -31,12 +31,25 @@ ) +# Build output, not repository content. `/target/` is root-anchored in +# .gitignore, so nothing under it can ever be committed — and the CI mirror of +# this check (.github/workflows/scripts/check_no_binaries.sh) reads `git +# ls-files`, so it never sees these either. Without this exclusion the local +# test diverges from CI the moment anything writes there: `just demo-library` +# generates a synthetic library with `.als` files under `target/`, which is +# exactly what it is supposed to do. +IGNORED_TOP_LEVEL_DIRS = {".git", "target"} +IGNORED_ANY_LEVEL_DIRS = {"__pycache__", ".pytest_cache", ".ruff_cache"} + + def repo_files(repo_root: Path): for path in repo_root.rglob("*"): if not path.is_file(): continue parts = path.relative_to(repo_root).parts - if ".git" in parts or "__pycache__" in parts or ".pytest_cache" in parts: + if parts[0] in IGNORED_TOP_LEVEL_DIRS: + continue + if IGNORED_ANY_LEVEL_DIRS.intersection(parts): continue yield path