diff --git a/AGENTS.md b/AGENTS.md index 220ac1c..43bd2fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,15 @@ The project profile, roadmap, and full acceptance matrix live in [docs/roadmap.m - MUST guarantee determinism: the same DSL + same sound source + same tool versions produces byte-identical MIDI output. - The DSL schema MUST NOT introduce fields that only commercial sound sources can fulfill. +## Sound library & anti-homogenization (MUST) + +The standing program — goal, measured baseline, measurable targets (T1–T5), and operating loop — lives in the "Sound library & orchestration program" section of [docs/roadmap.md](docs/roadmap.md) (single source of truth). The following rules are MUST-level whenever touching sound sources, renderer/texture profiles, or the resolver: + +- Substitution fallback is a last-resort diagnostic, never a coverage strategy. When a wanted instrument or articulation is missing from the active profile, close the gap with a real source (acquire → manifest → map → `scorekit profile check`) or re-orchestrate the scene visibly; MUST NOT widen fallback policy, lower resolver score gates, or bind an unrelated patch just to silence the WARN. +- Every library enters the corpus through a versioned identity with license + checksum manifests; every new mapping MUST pass `scorekit profile check` (deterministic, non-silent) before anything relies on it. +- Preserve timbre diversity: renderer profiles are curated sound identities; prefer adding independent sources over deepening dependence on a single library, and MUST NOT wholesale-rebind existing mappings to a different library as a side effect — that is an audible style change and must be a visible, reviewed decision. +- Coverage gaps are closed in the library/profile layer, never by bending the DSL schema toward one sound source (corollary of the schema-neutrality iron rule). + ## Acceptance matrix (hard rules) The following five rules are MUST-level; the matrix itself is maintained in the "Acceptance matrix" section of [docs/roadmap.md](docs/roadmap.md) (single source of truth — not duplicated here): diff --git a/Cargo.lock b/Cargo.lock index d8abba2..4f58988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.13.0" @@ -151,6 +160,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -176,12 +200,32 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -254,6 +298,15 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -510,6 +563,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "sha2", "tempfile", "thiserror", ] @@ -581,6 +635,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "strsim" version = "0.11.1" @@ -637,6 +702,12 @@ dependencies = [ "syn", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index f1f6649..3343f79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ schemars = "1.2.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" serde_yaml_ng = "0.10.0" +sha2 = "0.11.0" thiserror = "2.0.18" [dev-dependencies] diff --git a/docs-site/src/SUMMARY.md b/docs-site/src/SUMMARY.md index 5183f04..2ec5163 100644 --- a/docs-site/src/SUMMARY.md +++ b/docs-site/src/SUMMARY.md @@ -8,6 +8,7 @@ - [Command Reference](commands.md) - [Rendering and Dependencies](rendering.md) - [SFZ Renderer Profiles](profiles.md) +- [Building a Sound Library](sound-library.md) - [Agent Skill](agent-skill.md) - [Machine Interface](machine-interface.md) - [Architecture and Guarantees](architecture.md) diff --git a/docs-site/src/profiles.md b/docs-site/src/profiles.md index 73b5422..1a9de05 100644 --- a/docs-site/src/profiles.md +++ b/docs-site/src/profiles.md @@ -1,6 +1,6 @@ # SFZ Renderer Profiles -A renderer profile keeps machine-specific sample paths out of portable scene files. It maps scorekit instruments and articulations to local SFZ patches. +A renderer profile keeps machine-specific sample paths out of portable scene files. It maps scorekit instruments and articulations to local SFZ patches. For where the patches themselves come from — public acquisition channels, the directory/manifest contract, and the certification workflow — see [Building a Sound Library](sound-library.md). ```yaml name: orchestral @@ -22,7 +22,7 @@ scorekit profile check profile.yaml scorekit --json profile check profile.yaml > profile-report.json ``` -The check deduplicates shared patch paths, renders melodic or drum probes twice, rejects missing and silent patches, captures sfizz warnings, and checks repeatability. Temporary probe files are removed on success and failure. +The check deduplicates shared patch paths, renders melodic or drum probes twice, rejects missing and silent patches, captures sfizz warnings, and checks repeatability. Each passing patch reports a `render_sha256` golden hash, so a saved report acts as a baseline: re-running the check after a tool or library change and diffing the hashes reveals exactly which patches drifted. If a comparison fails on the first attempt, the check records diagnostics (load average, tool identity, both render hashes, timings) and re-runs that patch once in isolation — a pass is reported as `ok` with a `load_sensitive_flake` warning and the evidence kept under `flake_diagnostics`; a repeat failure is final. Temporary probe files are removed on success and failure. Use the profile with the sfizz backend: diff --git a/docs-site/src/sound-library.md b/docs-site/src/sound-library.md new file mode 100644 index 0000000..8b2fd6c --- /dev/null +++ b/docs-site/src/sound-library.md @@ -0,0 +1,202 @@ +# Building a Sound Library + +scorekit ships no samples beyond the default MuseScore General SF2. The +reference sample corpus used to develop and certify the open renderer +profile (internally called **ScoreData**) is **not distributed** — partly +because several upstream licenses permit music use but restrict +repackaging (Virtual Playing Orchestra explicitly forbids redistribution), +and partly on principle: the corpus is a private, disk-local asset; what is +public is the **recipe**. This page is that recipe. Every library in the +corpus comes from a public channel listed below, so a third party can +rebuild an equivalent corpus from scratch and certify it with +`scorekit profile check`. + +## Design rules + +The corpus follows the anti-homogenization program in +[docs/roadmap.md](https://github.com/talkincode/scorekit/blob/main/docs/roadmap.md) +(section "Sound library & orchestration program"). The load-bearing rules: + +1. **Versioned identity.** A library enters the corpus only with a + publisher/version (or commit) identity, a license record, and an archive + checksum. Unversioned downloads are candidate material, not coverage. +2. **Certification before use.** A profile mapping counts as coverage only + after `scorekit profile check` passes it: rendered twice, deterministic, + non-silent, golden `render_sha256` recorded. +3. **Gaps close with real sources, never wider fallbacks.** A missing + instrument is either closed with a genuinely fitting library or stays a + visible, honest gap. Binding an unrelated patch to silence a warning is + the one move that is always wrong. +4. **Additive mappings.** New libraries add mappings; they never silently + rebind existing instruments to a different timbre. Rebinding is an + audible style change and must be an explicit, reviewed edit. +5. **Only clean licenses.** CC0, CC-BY, GPL-with-sampling-exception, and + similar. No `NC`/`ND` variants, no "converted from a commercial + SoundFont" material, no per-file-unclear collections. + +## Directory contract + +```text +/ # any disk location; not a git repo + libraries//// # extracted library content + archives//// # the original downloaded archive + manifests/ + sources.tsv # acquisition ledger: archive, version, license, official URL + archive-sha256sums # checksums of every archive (verify from this directory) + libraries/.yaml # one manifest per library (identity, path, formats, license) + patches/ # diffs for locally repaired upstream files + profiles/ + renderers/.yaml # scorekit renderer profiles (instrument -> .sfz) + textures/.yaml # scorekit texture profiles (source name -> audio file) + sf2/ # SF2 soundfonts (GM tier) + catalog/ # generated inventory + stored certification reports + incoming/ # scratch area for downloads under evaluation +``` + +Two invariants keep the corpus auditable: + +- `manifests/sources.tsv` is the append-only acquisition ledger — one line + per archive with its official source URL. +- `shasum -a 256 -c archive-sha256sums` (run inside `manifests/`) must + always pass; the certified `profile check --json` report stored under + `catalog/` doubles as a golden-render baseline for every patch. + +## Acquisition channels + +Everything below is publicly downloadable. Versions are the ones the +reference profile was certified against; newer upstream versions usually +work but re-certify after any change. + +### Foundation (orchestra, keyboards, percussion) + +| Library | Version | License | Channel | +|---|---|---|---| +| VSCO 2 Community Edition | 1.1.0 | CC0-1.0 | (also `github.com/sgossner/VSCO-2-CE`) | +| Versilian Community Sample Library (VCSL) | 1.2.2-rc | CC0-1.0 | | +| Virtual Playing Orchestra (waves) | 3.2 | VPO mixed-open: music use unrestricted, **no repackaging** | | +| Virtual Playing Orchestra SFZ scripts | 3.3 | same as above | `virtualplaying.com/vp-downloads/Virtual-Playing-Orchestra3-3-standard-scripts.zip` + `...-performance-scripts.zip` | +| MuseScore General (SF2, GM tier) | 0.2.0 | MIT (samples: public domain / CC) | (fetched by `make install`) | + +The VPO 3.3 SFZ scripts are overlaid onto the 3.2 wave set (merge the +`standard` and `performance` script trees into the extracted 3.2 library); +this is how the choir, solo voice, celesta, and english horn mappings are +sourced. + +### Guitars, basses, drums, e-pianos (sfzinstruments / Karoryfer) + +| Library | Version | License | Channel | +|---|---|---|---| +| Karoryfer Black & Green Guitars | 1.000 | CC0-1.0 | `github.com/sfzinstruments/karoryfer.black-and-green-guitars` (releases) | +| Karoryfer Black & Blue Basses | 1.002 | CC0-1.0 | `github.com/sfzinstruments/karoryfer.black-and-blue-basses` (releases) | +| Virtuosity Drums | 0.925 | CC0-1.0 | `github.com/sfzinstruments/virtuosity_drums` (releases) | +| Greg Sullivan E-Pianos | commit `8c3e581` | CC-BY-3.0 | `github.com/sfzinstruments/GregSullivan.E-Pianos` | + +### FreePats (synths, pads, folk & fretted instruments) + +All from or `github.com/freepats` releases; +CC0-1.0 unless noted. + +| Library | Version | Notes | +|---|---|---| +| Synth Square / Synth Bass Lead / Synth Bass 1 / Synth Bass 2 | 2020-05-12 / 2020-05-22 / 2019-07-23 / 2021-04-05 | | +| Lately Bass | 2024-04-09 | | +| Synth Strings 1 / Synth Strings 2 | 2020-05-28 | | +| Synth Pad Bowed / Synth Pad Choir / Sweep Pad / New Age | 2019-07-19 / 2020-05-16 / 2019-08-13 / 2019-07-30 | | +| Spanish Classical Guitar | 2019-06-18 | nylon guitar | +| FSS Steel String Guitar | 2020-05-21 | **GPL-3.0-or-later with FreePats sampling exception** (rendered music is unencumbered; see the package's `readme.txt`) | +| Button Accordion HN | 2024-03-29 | | +| MuldjordKit (acoustic drums) | 2020-10-18 | CC-BY-4.0 | + +### Community one-offs + +| Library | Version | License | Channel | +|---|---|---|---| +| SamsterBirdies Pan Flute | commit `60d4974` | CC0-1.0 | `github.com/SamsterBirdies/panflute` | + +This library ships with a defective SFZ (see next section) — repair it +before mapping. + +### Textures + +The reference texture profile draws ambience/sound-design sources from +libraries already in the corpus (VCSL ocean drum, wind chimes, bowed brake +drum, wine glasses; VSCO 2 CE "Miscellania" ambiences) — no additional +downloads. Texture profiles use the same portable-name-to-local-path model +as renderer profiles; see [SFZ Renderer Profiles](profiles.md). + +## Repairing defective upstream files + +Occasionally an upstream file is broken as shipped (the pan flute's SFZ was +exported with every `lokey/hikey`, `lovel/hivel`, and +`loop_start/loop_end` pair reversed — every region empty, rendering +silence). The repair convention: + +1. Keep the upstream file **byte-intact**. +2. Place the repaired copy alongside it with a `.scoredata-fixN.` infix and + a header comment stating what changed and why. +3. Store the diff under `manifests/patches/` and record a structured + `transforms:` entry in the library manifest (upstream and normalized + SHA-256, patch path, type, reason, reversibility). +4. Map only the repaired file. + +Anyone rebuilding the corpus can re-apply the published patch or re-derive +the fix from its description; nothing about the repair lives only in git +history or someone's memory. + +## Certification workflow + +After placing libraries, write a renderer profile mapping scorekit +instrument names to `.sfz` paths (see [SFZ Renderer +Profiles](profiles.md)), then: + +```bash +# 1. Archive integrity (inside manifests/) +shasum -a 256 -c archive-sha256sums + +# 2. Certify every mapping: rendered twice, deterministic, non-silent +scorekit profile check profiles/renderers/.yaml + +# 3. Store the machine-readable report as the golden baseline +scorekit --json profile check profiles/renderers/.yaml > catalog/reports/.json +``` + +Each passing patch reports a `render_sha256`; diffing two stored reports +pinpoints exactly which patches changed after a library or tool upgrade. A +failing comparison is retried once in isolation with diagnostics recorded +(`load_sensitive_flake`) so a loaded machine does not produce false +nondeterminism verdicts — see [SFZ Renderer Profiles](profiles.md). + +The reference profile built from the channels above currently certifies +**101 mappings over 85 unique patches, 0 failures**, covering 56 of the 60 +DSL instruments (the remaining gaps — `fretless_bass`, `music_box`, +`slap_bass`, `whistle` — have no license-clean open source yet and are +deliberately left unmapped rather than faked with substitutes; the GM SF2 +tier still resolves them). + +## Minimal rebuild walkthrough + +```bash +ROOT=/path/to/my-sound-corpus +mkdir -p $ROOT/{libraries,archives,manifests/{libraries,patches},profiles/renderers,catalog/reports,incoming} + +# For each library in the tables above: +# 1. download the pinned version/commit from its channel into incoming/ +# 2. verify + record: shasum -a 256 >> $ROOT/manifests/archive-sha256sums +# 3. append a line to $ROOT/manifests/sources.tsv (archive, version, license, URL) +# 4. extract into $ROOT/libraries//// +# 5. move the archive to $ROOT/archives//// +# 6. write $ROOT/manifests/libraries/.yaml (id, name, publisher, +# version, path, formats, tags, license, license_file, archive) + +# Write a renderer profile over the extracted .sfz files, then certify: +scorekit profile check $ROOT/profiles/renderers/my-profile.yaml + +# Point scorekit at the corpus: +export SCOREKIT_SOUND_LIBRARY_DIR=$ROOT +scorekit build scene.yaml --renderer sfizz --profile $ROOT/profiles/renderers/my-profile.yaml -o out.ogg +``` + +A rebuilt corpus will not be byte-identical to the reference one (different +download dates, archive re-compressions), but after certification it gives +the same guarantee that matters: every mapped patch renders, is audible, +and is deterministic — and your own stored report becomes your baseline. diff --git a/docs/roadmap.md b/docs/roadmap.md index 301d2d0..0e51ae3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -117,7 +117,7 @@ The audio-quality upgrade M3 deferred: a third render backend built on `sfizz_re - **`articulation` DSL field**: `Track.articulation` (sustain/staccato/spiccato/pizzicato/tremolo/mute, default sustain) is a render-time selector only — it changes which sample a profile resolves to, never the compiled MIDI. SF2 backends ignore it; it's forward-compatible groundwork for round-robin/multi-sample libraries. - **Per-track render + in-process mix**: since sfizz renders one instrument per invocation, `build`'s sfizz path renders every track solo (reusing the same solo/stem machinery `--stems` already needs), applies `gain` per track (sfizz has no gain flag, unlike fluidsynth `-g`/timidity `-A`), then sums with a new `audio::mix` (sample-exact, 16-bit PCM, clamped, zero-padded to the longest track). This makes "sum of stems == full mix" true by construction, and stems are a side effect of the mix rather than a second render pass. - **CLI**: `build`/`batch` take `--profile` in place of `--soundfont` for `--renderer sfizz` (validated: sfizz requires `--profile` and forbids `--soundfont`; the SF2 backends require `--soundfont` and forbid `--profile`); the low-level `render` command gets a parallel `--sfz ` for direct single-instrument use. `scorekit schema --profile` exports the profile DSL's JSON Schema. -- **Profile certification**: `scorekit profile check ` resolves every explicit instrument/articulation mapping, deduplicates shared patch paths, renders broad melodic or GM-drum probes at varied velocities twice, rejects missing and silent patches, captures sfizz warnings, and verifies repeatability. `--json` emits one structured report suitable for CI or inventory generation. Probe MIDI/WAV files live in a command-scoped scratch directory that is removed on both success and failure. +- **Profile certification**: `scorekit profile check ` resolves every explicit instrument/articulation mapping, deduplicates shared patch paths, renders broad melodic or GM-drum probes at varied velocities twice, rejects missing and silent patches, captures sfizz warnings, and verifies repeatability. Every passing patch reports its first render's SHA-256 (`render_sha256`), so a stored certified report doubles as a golden-render baseline — tool-version or corpus drift shows up as a hash diff between two reports. A failed comparison (silent or nondeterministic) is not final on first observation: the check captures environment diagnostics (load average, `sfizz_render` path, both render hashes, per-render timings) and re-runs that one patch once in isolation; an isolated pass yields `status: ok` with a `load_sensitive_flake` warning and the first attempt's `flake_diagnostics` preserved, an isolated failure stays a hard failure carrying both attempts' diagnostics (recorded, isolated recheck — not blind retry; motivated by false "two renders differ" failures observed on a loaded shared machine). `--json` emits one structured report suitable for CI or inventory generation. Probe MIDI/WAV files live in a command-scoped scratch directory that is removed on both success and failure. - **Failure modes verified and mapped to existing exit codes**: missing `.sfz`/missing profile/missing sfizz binary/unmapped instrument → exit 2 (validation) or 3 (missing dependency); malformed `.sfz` content → `sfizz_render` exits non-zero → exit 4 (tool failure), no partial WAV or staging directory left behind (same `Cleanup`-on-drop guarantee as the SF2 backends). - **Example profile**: `examples/profiles/vsco2-ce.yaml` maps every instrument used across `examples/scenes/*.yaml` to VSCO 2 Community Edition (CC0) `.sfz` files, substituting orchestral equivalents for the DSL's synth-flavored instruments (VSCO 2 is acoustic-only): `square_lead`→flute, `saw_lead`→muted trumpet, `pad`/`choir_pad`→sustained strings, `synth_bass`→pizzicato low strings. `root` is left unset in the shipped file on purpose (defaults to the profile's own directory) so the checked-in example never bakes in one machine's disk layout; verified end-to-end by copying it with a local `root:` pointing at a real VSCO 2 CE install and running `scorekit batch examples/scenes/*.yaml --renderer sfizz --profile --out-dir --stems` — all 8 shipped scenes (including the 4-section `forest_suite`) render non-silent, correct-length `.ogg` with sample-aligned stems and a clean `report.json` (`8/8 succeeded`). - **VCSL evaluated and found to be a supplement, not a substitute**: a hands-on A/B against VCSL 1.2.2-RC (downloaded standalone) showed it ships almost no orchestral strings/brass/choir — it's explicitly a CC0 *addition* to VSCO 2 CE (per its own README), not a replacement. What it does noticeably better than VSCO 2 CE: piano (a real grand vs. VSCO 2 CE's single close-mic'd upright), harp (fuller concert harp), and timpani (real multi-round-robin hits vs. a thinner single-velocity hit). `examples/profiles/vsco2-vcsl.yaml` is a second example profile that takes VSCO 2 CE for everything VCSL doesn't cover and VCSL for piano/harp/epiano/timpani, proving a renderer profile can freely mix multiple sample libraries per-instrument (via a `/vsco2-ce/` + `/vcsl/` subfolder convention, since `Profile.root` is one directory but individual `.sfz` paths can still traverse into named subfolders) — verified the same way as the VSCO 2 CE-only profile (local `root:` copy, full `batch --stems` over all 8 shipped scenes, `8/8 succeeded`, non-silent, correct-length output). @@ -216,6 +216,53 @@ use) stay with the caller. profile does *not* map. SF2/GM backends carry the whole vocabulary, so resolution is trivially exact there. +## Sound library & orchestration program (standing direction) + +> Adopted 2026-07, after the external sound corpus was reorganized into versioned, manifested libraries. Unlike M0–M12 this is not a finishable milestone but a **standing program** with measurable acceptance: the repo owns the portable vocabulary, resolution semantics, and certification tooling; the sample corpus lives outside the repo under `SCOREKIT_SOUND_LIBRARY_DIR` as versioned, license-tracked libraries with checksummed archives, a rebuildable catalog, and certified renderer/texture profiles. + +**Goal.** Back the DSL's portable vocabulary with a near-complete, high-quality, fully open sample-level sound library, and grow the orchestration depth (articulations, timbre variants, expressive fields) needed to actually use it. The failure mode this program exists to prevent is **style homogenization**: when coverage gaps are routinely papered over by fallback substitution, every project converges on the same few patches, "swap the profile, keep the scene" degrades into "everything sounds like the default library," and the vocabulary's promise quietly dies. M12 made substitution safe, explainable, and gated; this program's job is to make it *rare*. + +**Principles.** + +1. **Coverage over substitution.** A resolver fallback triggered under a reference profile is a coverage defect, not a convenience. The fix is acquiring and certifying a real source — or a visible, deliberate re-orchestration of the scene — never widening fallback policy, lowering score gates, or mapping an unrelated patch to silence the WARN. +2. **Certified, versioned identity.** A library enters the corpus only with publisher/version/license manifests and archive checksums; a mapping counts as coverage only after `scorekit profile check` passes it (rendered twice, deterministic, non-silent). Uncertified inventory is candidate material, not coverage. +3. **Diversity is a quality dimension.** For the core roles (keys, strings, guitars/basses, percussion, pads) keep at least two certified candidates from independent publishers, and treat renderer profiles as curated *sound identities* (chamber vs. symphonic vs. synth-heavy) rather than one canonical mega-profile. Solo vs. section variants (e.g. VSCO 2 chamber strings vs. VPO ensemble sections) are orchestration material, not duplicates. Wholesale-rebinding existing mappings to a different library is an audible style change and must be a visible, reviewed decision, never a side effect. +4. **Articulation depth is coverage.** An instrument mapped only through `sustain` while the corpus holds its staccato/pizzicato/tremolo is half-covered; the registry's idiomatic articulations define each instrument's coverage surface. +5. **Schema neutrality is non-negotiable.** Everything above happens in the library/profile layer. The DSL never grows fields only one library can honor (iron rule), and the GM/SF2 backends keep covering the full vocabulary as the baseline tier. +6. **Explainable and rebuildable.** The corpus itself is never published (several upstream licenses forbid repackaging), but its recipe is: every library's official channel, version, license, directory contract, repair convention, and certification workflow are documented in `docs-site/src/sound-library.md` so a third party can rebuild an equivalent corpus and certify it themselves. + +**Measured baseline (2026-07, after acquisition cycle 2 — FreePats accordion + steel-string guitar, SamsterBirdies pan flute):** 56/60 DSL instruments mapped through 101 mappings over 85 certified patches (0 failures). Articulation depth: 28/56 instruments expose ≥2 articulations (histogram: sustain 56, staccato 25, tremolo 7, pizzicato 6, spiccato 5, mute 2). Missing entirely (4): fretless_bass, music_box, slap_bass, whistle — each searched exhaustively in cycle 2; every candidate found was either license-unclean (CC BY-NC-SA, custom terms) or a commercial-SoundFont conversion, so per the anti-homogenization principle these stay honest gaps rather than fallback substitutions. Corpus: 24 manifested libraries, 20 wired into shipping render paths, 4 idle (one drum kit and three FreePats synth sets awaiting mapping); ~1,285 SFZ patches on disk (454 from the VPO scripts overlay) of which 85 are certified in use; 7 texture sources. Cycle 1 closed choir/voice/celesta/english_horn (VPO 3.3 scripts over the 3.2 wave set); cycle 2 closed accordion (FreePats Button Accordion HN, CC0), steel_guitar (FreePats FSS steel-string, GPL+sampling-exception), and pan_flute (SamsterBirdies CC0; upstream SFZ had a Polyphone export bug writing every lo/hi opcode pair reversed — repaired in a documented local `scoredata-fix1` copy, upstream file kept intact). The GM tier (MuseScore General) already resolves all 60 instruments trivially — this program targets the sample-level tier. + +**Measurable targets (monotonic — regressions are defects):** + +- **T1 Vocabulary complete:** 60/60 DSL instruments resolve `exact` under the open reference profile; shipped example scenes build with zero resolver fallbacks. Cycle 1 cleared choir/voice/celesta/english_horn; cycle 2 cleared accordion/steel_guitar/pan_flute. Remaining gaps (4, no license-clean open source found yet): fretless_bass, music_box, slap_bass, whistle. +- **T2 Articulation depth:** every idiomatic articulation the corpus can supply is mapped explicitly; the ≥2-articulation share only goes up. +- **T3 Diversity:** ≥2 independent certified candidates for each core role in principle 3; at least one documented solo-vs-section strings variant pair. +- **T4 Always certified:** `profile check` fully green (0 failed) and a zero-error catalog scan are standing invariants after every corpus change. +- **T5 Texture growth:** texture sources follow the same manifest + license discipline, and the certified source count only grows. + +**Operating loop (each cycle additive and reversible):** gap inventory (DSL enum × registry articulations vs. profile mappings) → acquisition into `incoming/` → license + checksum verification → versioned placement + manifest → catalog rebuild → profile mapping → `profile check` certification → collections and docs update. Cycles 1 (VPO official SFZ scripts) and 2 (FreePats accordion + steel-string guitar, SamsterBirdies pan flute) executed this loop end-to-end. The VPO SEC/SOLO section scripts already on disk are the first T3 solo-vs-section candidates. + +**Remaining-gap disposal (2026-07 adjudication — stop searching uniformly, route each gap by its failure mode):** + +- **fretless_bass, slap_bass — keep hunting real samples (P1).** Slides/vibrato/harmonics and slap/pop transients are the identity of these instruments; a fretted-bass stand-in is exactly the homogenization this program bans. Best lead for fretless: Musical Artifacts #5323 "GM LightCool Fretless Bass" (CC0, SF2) — needs an SF2→SFZ conversion pass (e.g. Polyphone batch export, with the cycle-2 lesson that Polyphone exports must be audited for the reversed-opcode-pair bug and probe-rendered before certification). +- **music_box, whistle — first-party synthesized sample libraries (P2, "S-tier" assets).** Both are structurally simple timbres (struck metal tine: inharmonic partials + fast attack + long decay + slight detune; human whistle: near-sine + breath noise + gentle vibrato) that offline synthesis can render better than the license-dirty scraps found in the wild. **Iron-rule boundary:** the no-in-house-DSP rule forbids synthesis *inside scorekit at compile/render time*; it does not govern how a sample library is *produced*. Synthesis happens in the asset pipeline (standalone generator scripts in ScoreData, pinned parameters, deterministic output, self-published CC0), producing ordinary wav+SFZ libraries that flow through the same manifest → license → checksum → certification chain as recorded samples. scorekit only ever sees sample files. Generator scripts live next to the library under `libraries/scoredata//`, never in the scorekit repo. + +**Upstream-defect repair convention (formalized after the cycle-2 panflute case):** when an upstream file must be modified to function (export bugs, broken paths), keep the upstream file byte-intact, place the repaired copy alongside it with a `.scoredata-fixN.` infix and a header comment, and record a structured `transforms:` entry in the library manifest (upstream/normalized SHA-256 pair, a `.patch` file under `manifests/patches/`, type, reason, reversibility). Mappings reference only the repaired file; the patch file makes the transform reviewable and re-appliable without archaeology. + +**Verification-stability program (P0 — shipped 2026-07):** cycle 2 exposed that `profile check` on a loaded shared machine yields false "two renders differ" failures (sfizz background streaming under load; isolated re-runs always pass, and byte-identical renders were confirmed 6× under load). Both remediations landed in M7's certification command: (1) per-patch `render_sha256` in the check report — the certified report stored at `catalog/reports/` doubles as a golden-render baseline for all 85 patches, so any tool-version or corpus drift shows up as a hash diff between two reports; (2) failed comparisons capture diagnostics (load average, sfizz identity, both render hashes, timings) and re-run that patch once in isolation — an isolated pass records a `load_sensitive_flake` warning on an `ok` patch with the evidence preserved under `flake_diagnostics`, an isolated failure stays a hard failure with both attempts attached. A recorded, isolated recheck with preserved evidence — not blind retry. + +**Selection-governance direction (P3, design phase):** the corpus risk is shifting from "not enough instruments" to "an agent facing 85 certified patches with no quality signal". Direction: annotate profile mappings with a quality tier (A: multi-dynamic/multi-articulation, production-grade; B: credible for general arrangement; C: sounds, preview-only; S: first-party synthesized or flagship assets) plus provenance, and make fallbacks **explicit, tiered, and auditable** in profile data rather than implicit in resolver behavior. Explicit declared fallbacks are governance; silent substitution remains banned. Any schema change to profiles registers per coverage-baseline rule 5 when it lands as code. + +**Compiler-side orchestration backlog (evidence-gated, per the on-hold ethos):** + +- **Per-track expression/modulation curves (CC11/CC1)** with deterministic MIDI semantics in the mold of M10's pan/reverb — gate: a real scoring exercise that `intensity` + `dynamics` + the spatial fields demonstrably cannot express. +- **Named profile variants** (e.g. strings `solo` vs. `section`) selectable per track — gate: a real A/B need from composing practice; any design keeps sample paths in profiles, never in scenes. +- **Coverage report command** (a read-only vocabulary × articulations diff against a profile) — gate: the ad-hoc gap-inventory scripts stabilizing into something worth freezing; registers per coverage-baseline rule 5 when it lands. +- **Mixed renderers in one scene stays rejected-by-default:** one renderer per invocation keeps determinism and the stems-sum invariant simple; revisit only with concrete evidence that per-track backend mixing is irreplaceable. + +Content-only corpus work (new libraries, mappings, certifications) adds no tier-1 compiler feature and therefore no acceptance-matrix rows; any backlog item above that lands as code registers per coverage-baseline rule 5. + ## Direction & intent (on hold, pending evidence) - **Declarative runtime manifest**: `meta.json` is already the engine's data contract; extending it to declare "state→section/stem mapping, fade durations" (the compiler only validates references, never executes them) is legitimate and valuable — but **on hold until a real engine integration** exists to drive the field design, to avoid guessing at a schema for a contract with no consumer. An executing runtime (real-time mixing) remains off-limits under the iron rules. - **Story-layer convention docs**: how an Agent translates game-world state into the scene DSL, captured as docs + examples (`docs/` or example comments), producing no code. @@ -262,7 +309,7 @@ Permissions note: scorekit is a local single-user CLI with no role/permission sy | Harmony progression declaration (harmony, M5) | Low | ✅ (a custom progression changes the notes but not the total length) | ✅ (invalid roman numeral → exit 2 + `harmony[i]` path) | N/A (local CLI) | N/A (pure computation) | `tests/cli.rs::harmony_changes_notes_at_same_length` / `validate_rejects_bad_swing_and_bad_numeral` | | Music grammar validation (lint/schema --grammar, M6) | Low (read-only) | ✅ (the shipped dunes×grief reference pair passes fully; `schema --grammar` exports the profile schema) | ✅ (violations report measured values → exit 2 + `--json` violations array; deep rules measured on the compiled IR; an empty rules profile → exit 2) | N/A (local CLI) | N/A (read-only, no state mutation) | `tests/cli.rs::lint_shipped_scene_conforms_to_shipped_grammar` / `lint_reports_violations_with_measured_values` / `lint_measures_rest_ratio_from_compiled_ir` / `lint_rejects_grammar_without_rules` / `schema_grammar_flag_emits_grammar_schema` | | Third render backend + renderer profiles (--renderer sfizz, M7) | High (external process + in-process mixing + batch file writes) | ✅ (build with `--profile`, 2 tracks rendered solo + mixed, stems sum to the mix, `render --sfz` single-instrument path, `schema --profile` exports the profile schema) | ✅ (missing `--profile` / `--profile`+`--soundfont` conflict / instrument with no profile mapping / missing `sfizz_render` binary / malformed `.sfz` → tool failure) | N/A (local CLI) | ✅ (per-track staging dir removed via the same `Cleanup`-on-drop guarantee; no partial WAV or leftover staging dir on any failure) | `tests/cli.rs::build_sfizz_happy_path_produces_stems_and_sums_to_mix` / `build_sfizz_missing_profile_is_input_error` / `build_sfizz_rejects_soundfont_flag` / `build_sfizz_unmapped_instrument_leaves_no_partial_output` / `build_sfizz_missing_binary_is_dependency_error` / `build_sfizz_corrupt_sfz_fails_without_partial_output` / `render_sfizz_happy_path_produces_exact_rate_wav` / `render_sfizz_requires_sfz_not_soundfont` / `schema_profile_flag_emits_renderer_profile_schema` / `src/profile.rs::shipped_vsco2_profile_validates_and_covers_shipped_scenes` / `src/profile.rs::shipped_vsco2_vcsl_profile_validates_and_covers_shipped_scenes` (guard both shipped example profiles against schema drift and against missing coverage for any instrument the shipped scenes use, without needing the real multi-GB libraries present) | -| Renderer profile certification (`profile check`, M7) | High (external process + untrusted sample-library paths/data) | ✅ (shared patch paths are deduplicated; a real SFZ is rendered twice, is non-silent, and reports deterministic output as structured JSON) | ✅ (missing patch / silent patch / missing sfizz dependency return structured failures) | N/A (local CLI) | ✅ (command-scoped probe MIDI/WAV scratch directory is removed after success and every tested failure) | `tests/cli.rs::profile_check_renders_unique_patches_and_reports_json` / `profile_check_missing_patch_is_structured_and_leaves_no_temp_files` / `profile_check_rejects_silent_patch_and_leaves_no_temp_files` / `profile_check_missing_sfizz_is_dependency_error_without_residue` | +| Renderer profile certification (`profile check`, M7) | High (external process + untrusted sample-library paths/data) | ✅ (shared patch paths are deduplicated; a real SFZ is rendered twice, is non-silent, reports deterministic output plus a `render_sha256` golden hash as structured JSON) | ✅ (missing patch / silent patch / missing sfizz dependency return structured failures; a flaky first comparison recovers through the recorded isolated recheck with `load_sensitive_flake` evidence, and persistent nondeterminism stays a hard failure carrying both attempts' diagnostics) | N/A (local CLI) | ✅ (command-scoped probe MIDI/WAV scratch directory is removed after success and every tested failure) | `tests/cli.rs::profile_check_renders_unique_patches_and_reports_json` / `profile_check_reports_render_sha256_golden_hash` / `profile_check_flaky_first_pair_recovers_via_isolated_recheck` / `profile_check_persistent_nondeterminism_fails_with_both_diagnostics` / `profile_check_missing_patch_is_structured_and_leaves_no_temp_files` / `profile_check_rejects_silent_patch_and_leaves_no_temp_files` / `profile_check_missing_sfizz_is_dependency_error_without_residue` | | Environment diagnostics (`doctor`, M8) | Low (read-only process probes) | ✅ (controlled PATH with FFmpeg plus all renderers emits platform/architecture and tool health as JSON) | ✅ (FFmpeg without any renderer exits 3 with a structured report and architecture-specific help) | N/A (local CLI) | N/A (read-only) | `tests/cli.rs::doctor_reports_platform_and_ready_toolchain_as_json` / `doctor_missing_renderer_returns_dependency_report_and_arch_help` | | Default MuseScore General resolution (M8) | Medium (environment/path resolution before external rendering) | ✅ (`render`, `build`, and `batch` without `--soundfont` resolve `$SCOREKIT_SOUND_LIBRARY_DIR/sf2/MuseScore_General.sf2` and produce audio) | ✅ (missing default returns structured exit 2 and no output; `batch` fails before creating its out-dir) | N/A (local CLI) | ✅ (resolution is read-only; renderer atomic output remains intact) | `tests/cli.rs::render_uses_musescore_general_from_default_sound_library` / `render_missing_default_soundfont_is_structured_and_writes_nothing` / `build_uses_musescore_general_from_default_sound_library` / `batch_uses_musescore_general_from_default_sound_library` / `batch_missing_default_soundfont_fails_before_writing_anything` | | Local toolchain + sound-root installation (`make install`, M8) | Medium (writes user-selected install destinations) | ✅ (scorekit, sfizz_render, both skill files, and `sf2`/`sfz`/`profiles` directories are installed and checked; `scripts/fetch_default_soundfont.sh` is exercised for real over `file://` sources — SF2 plus MIT license downloaded, checksum-verified, installed) | ✅ (forced scorekit, sfizz_render, and skill installer failures preserve prior complete installs; a fetch checksum mismatch fails without installing the SF2) | N/A (local CLI) | ✅ (binaries are staged then atomically renamed; skill replacement stages a complete directory and restores its backup on failure; fetch failure leaves no `.part` residue; no staging residue) | `Makefile::test-install` / `.github/workflows/ci.yml` (`Test local toolchain, Agent skill, and sound directory installation`) | diff --git a/src/doctor.rs b/src/doctor.rs index 6597064..dbf3fde 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -150,7 +150,7 @@ fn candidate_names(name: &str) -> Vec { } } -fn find_executable(name: &str) -> Option { +pub(crate) fn find_executable(name: &str) -> Option { let path = std::env::var_os("PATH")?; let candidates = candidate_names(name); for directory in std::env::split_paths(&path) { diff --git a/src/profile_check.rs b/src/profile_check.rs index 155a055..f18b14a 100644 --- a/src/profile_check.rs +++ b/src/profile_check.rs @@ -1,6 +1,13 @@ //! Active renderer-profile verification. Schema validation proves the YAML is //! shaped correctly; this module proves each referenced SFZ actually renders, //! produces audible PCM, and repeats deterministically with the pinned tool. +//! +//! Failure handling is a recorded, isolated recheck — not blind retry: a +//! failed comparison (silent or nondeterministic) captures environment +//! diagnostics (load average, tool identity, both render hashes, timings) +//! and re-runs that one patch once; an isolated pass downgrades the failure +//! to a `load_sensitive_flake` warning with the evidence attached, an +//! isolated failure stays a hard failure carrying both attempts' diagnostics. use crate::composer::{NoteEvent, ScoreIr, TrackIr}; use crate::error::{Error, Result}; @@ -8,12 +15,29 @@ use crate::profile; use crate::schema::{Instrument, TimeSig}; use crate::{midi, tools}; use serde::Serialize; +use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::time::Instant; const SILENCE_PEAK: u32 = 1; const DETERMINISM_TOLERANCE: f64 = 1.0e-6; +/// Environment + evidence snapshot for one failed render-pair attempt. +#[derive(Debug, Clone, Serialize)] +pub struct FlakeDiagnostics { + pub attempt: String, + pub observed_status: String, + pub difference_rms_ratio: f64, + pub peak_abs: u32, + pub render_sha256: [String; 2], + pub render_ms: [u64; 2], + #[serde(skip_serializing_if = "Option::is_none")] + pub load_average: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sfizz_render: Option, +} + #[derive(Debug, Clone, Serialize)] pub struct PatchReport { pub path: String, @@ -23,7 +47,14 @@ pub struct PatchReport { pub rms: f64, pub deterministic: bool, pub difference_rms_ratio: f64, + /// SHA-256 of the first render's WAV bytes. Stable across runs with the + /// same tool version, so a stored certified report doubles as a + /// golden-render baseline: corpus or tool drift shows up as a hash diff. + #[serde(skip_serializing_if = "Option::is_none")] + pub render_sha256: Option, pub warnings: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub flake_diagnostics: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } @@ -216,11 +247,133 @@ fn render_failure( rms: 0.0, deterministic: false, difference_rms_ratio: f64::INFINITY, + render_sha256: None, warnings: Vec::new(), + flake_diagnostics: Vec::new(), error: Some(error.into()), } } +/// Verdict of one double-render comparison. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Verdict { + Pass, + Silent, + Nondeterministic, +} + +impl Verdict { + fn status(self) -> &'static str { + match self { + Verdict::Pass => "ok", + Verdict::Silent => "silent", + Verdict::Nondeterministic => "nondeterministic", + } + } +} + +struct PairOutcome { + verdict: Verdict, + peak_abs: u32, + rms: f64, + difference_rms_ratio: f64, + hashes: [String; 2], + times_ms: [u64; 2], + diagnostics: Vec, +} + +enum PairResult { + Rendered(Box), + Failed(String), +} + +fn sha256_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|source| Error::Io { + path: path.display().to_string(), + source, + })?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + Ok(hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect()) +} + +fn load_average() -> Option { + let out = std::process::Command::new("uptime").output().ok()?; + let text = String::from_utf8_lossy(&out.stdout); + text.split("load average") + .nth(1) + .map(|tail| format!("load average{}", tail.trim())) +} + +fn sfizz_identity() -> Option { + let path = crate::doctor::find_executable("sfizz_render")?; + Some(path.display().to_string()) +} + +/// Render the probe twice into `--{a,b}.wav` and compare. +/// `Err` propagates only fatal conditions (missing dependency, unreadable +/// output); tool-level render failures come back as `PairResult::Failed`. +fn render_pair( + midi: &Path, + sfz: &Path, + scratch: &Path, + index: usize, + tag: &str, + sample_rate: u32, +) -> Result { + let a_path = scratch.join(format!("{index:04}-{tag}-a.wav")); + let b_path = scratch.join(format!("{index:04}-{tag}-b.wav")); + let mut diagnostics = Vec::with_capacity(2); + let mut times_ms = [0u64; 2]; + for (slot, out_path) in [(0usize, &a_path), (1, &b_path)] { + let started = Instant::now(); + match tools::render_sfz_with_diagnostics(midi, sfz, out_path, sample_rate) { + Err(e @ Error::MissingDependency { .. }) => return Err(e), + Err(e) => return Ok(PairResult::Failed(e.to_string())), + Ok(diag) => diagnostics.push(diag), + } + times_ms[slot] = started.elapsed().as_millis() as u64; + } + let a = read_pcm(&a_path)?; + let b = read_pcm(&b_path)?; + let (peak_abs, rms) = stats(&a.samples); + let difference_rms_ratio = difference_ratio(&a, &b); + let verdict = if peak_abs <= SILENCE_PEAK { + Verdict::Silent + } else if difference_rms_ratio > DETERMINISM_TOLERANCE { + Verdict::Nondeterministic + } else { + Verdict::Pass + }; + let hashes = [sha256_file(&a_path)?, sha256_file(&b_path)?]; + Ok(PairResult::Rendered(Box::new(PairOutcome { + verdict, + peak_abs, + rms, + difference_rms_ratio, + hashes, + times_ms, + diagnostics, + }))) +} + +fn flake_snapshot(attempt: &str, outcome: &PairOutcome) -> FlakeDiagnostics { + FlakeDiagnostics { + attempt: attempt.to_owned(), + observed_status: outcome.verdict.status().to_owned(), + difference_rms_ratio: outcome.difference_rms_ratio, + peak_abs: outcome.peak_abs, + render_sha256: outcome.hashes.clone(), + render_ms: outcome.times_ms, + load_average: load_average(), + sfizz_render: sfizz_identity(), + } +} + pub fn check(profile_path: &Path, sample_rate: u32) -> Result { let loaded = profile::load_profile(profile_path)?; let profile_dir = profile_path.parent().unwrap_or_else(|| Path::new(".")); @@ -257,61 +410,87 @@ pub fn check(profile_path: &Path, sample_rate: u32) -> Result { continue; } let midi = if drums { &drum_midi } else { &melodic_midi }; - let a_path = scratch.path.join(format!("{index:04}-a.wav")); - let b_path = scratch.path.join(format!("{index:04}-b.wav")); - let first = match tools::render_sfz_with_diagnostics(midi, &path, &a_path, sample_rate) { - Err(e @ Error::MissingDependency { .. }) => return Err(e), - Err(e) => { - reports.push(render_failure( - &path, - mapping_names, - "render_failed", - e.to_string(), - )); + let first = match render_pair(midi, &path, &scratch.path, index, "first", sample_rate)? { + PairResult::Failed(e) => { + reports.push(render_failure(&path, mapping_names, "render_failed", e)); continue; } - Ok(diagnostics) => diagnostics, + PairResult::Rendered(outcome) => outcome, }; - let second = match tools::render_sfz_with_diagnostics(midi, &path, &b_path, sample_rate) { - Err(e @ Error::MissingDependency { .. }) => return Err(e), - Err(e) => { - reports.push(render_failure( - &path, - mapping_names, - "render_failed", - e.to_string(), - )); + + if first.verdict == Verdict::Pass { + reports.push(PatchReport { + path: path.display().to_string(), + mappings: mapping_names, + status: "ok".to_owned(), + peak_abs: first.peak_abs, + rms: first.rms, + deterministic: true, + difference_rms_ratio: first.difference_rms_ratio, + render_sha256: Some(first.hashes[0].clone()), + warnings: warnings(&first.diagnostics), + flake_diagnostics: Vec::new(), + error: None, + }); + continue; + } + + // Failed comparison: capture evidence, then one recorded isolated + // recheck of this single patch (see module docs). + let first_snapshot = flake_snapshot("first", &first); + let recheck = match render_pair(midi, &path, &scratch.path, index, "recheck", sample_rate)? + { + PairResult::Failed(e) => { + let mut report = render_failure(&path, mapping_names, "render_failed", e); + report.flake_diagnostics = vec![first_snapshot]; + reports.push(report); continue; } - Ok(diagnostics) => diagnostics, + PairResult::Rendered(outcome) => outcome, }; - let a = read_pcm(&a_path)?; - let b = read_pcm(&b_path)?; - let (peak_abs, rms) = stats(&a.samples); - let difference_rms_ratio = difference_ratio(&a, &b); - let deterministic = difference_rms_ratio <= DETERMINISM_TOLERANCE; - let (status, error) = if peak_abs <= SILENCE_PEAK { - ("silent", Some("probe produced no audible PCM".to_owned())) - } else if !deterministic { - ( - "nondeterministic", - Some(format!( - "two renders differ (RMS ratio {difference_rms_ratio:.8})" - )), - ) - } else { - ("ok", None) + + if recheck.verdict == Verdict::Pass { + let mut patch_warnings = warnings(&recheck.diagnostics); + patch_warnings.push(format!( + "load_sensitive_flake: first attempt was {} (RMS ratio {:.8}); isolated recheck passed — see flake_diagnostics", + first_snapshot.observed_status, first_snapshot.difference_rms_ratio, + )); + reports.push(PatchReport { + path: path.display().to_string(), + mappings: mapping_names, + status: "ok".to_owned(), + peak_abs: recheck.peak_abs, + rms: recheck.rms, + deterministic: true, + difference_rms_ratio: recheck.difference_rms_ratio, + render_sha256: Some(recheck.hashes[0].clone()), + warnings: patch_warnings, + flake_diagnostics: vec![first_snapshot], + error: None, + }); + continue; + } + + let recheck_snapshot = flake_snapshot("recheck", &recheck); + let error = match recheck.verdict { + Verdict::Silent => "probe produced no audible PCM".to_owned(), + _ => format!( + "two renders differ (RMS ratio {:.8}); isolated recheck failed too", + recheck.difference_rms_ratio + ), }; reports.push(PatchReport { path: path.display().to_string(), mappings: mapping_names, - status: status.to_owned(), - peak_abs, - rms, - deterministic, - difference_rms_ratio, - warnings: warnings(&[first, second]), - error, + status: recheck.verdict.status().to_owned(), + peak_abs: recheck.peak_abs, + rms: recheck.rms, + deterministic: false, + difference_rms_ratio: recheck.difference_rms_ratio, + render_sha256: None, + warnings: warnings(&recheck.diagnostics), + flake_diagnostics: vec![first_snapshot, recheck_snapshot], + error: Some(error), }); } diff --git a/tests/cli.rs b/tests/cli.rs index 38e0eb8..6376769 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2332,6 +2332,140 @@ fn profile_check_missing_sfizz_is_dependency_error_without_residue() { assert_dir_contains_exactly(dir.path(), &["mini.sfz", "profile.yaml", "sine.wav"]); } +/// Write a WAV of `frames` mono samples all set to `amplitude`. +fn write_const_wav(path: &Path, amplitude: i16, frames: usize) { + let spec = hound::WavSpec { + channels: 1, + sample_rate: 44100, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + let mut writer = hound::WavWriter::create(path, spec).unwrap(); + for _ in 0..frames { + writer.write_sample(amplitude).unwrap(); + } + writer.finalize().unwrap(); +} + +/// Install a fake `sfizz_render` that serves canned WAVs by invocation count: +/// call N picks `w.wav`, falling back to the last one provided. Lets tests +/// script exactly which render attempts differ. +#[cfg(unix)] +fn install_counted_sfizz(fake_bin: &Path, outputs: &[i16]) { + fs::create_dir_all(fake_bin).unwrap(); + for (i, amp) in outputs.iter().enumerate() { + write_const_wav(&fake_bin.join(format!("w{}.wav", i + 1)), *amp, 4410); + } + let script = format!( + "#!/bin/sh\ndir=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nout=\"\"; prev=\"\"\nfor a in \"$@\"; do\n [ \"$prev\" = \"--wav\" ] && out=\"$a\"\n prev=\"$a\"\ndone\nn=$(cat \"$dir/count\" 2>/dev/null || echo 0)\nn=$((n+1)); printf %s \"$n\" > \"$dir/count\"\n[ $n -gt {max} ] && n={max}\ncp \"$dir/w$n.wav\" \"$out\"\n", + max = outputs.len() + ); + let tool = fake_bin.join("sfizz_render"); + fs::write(&tool, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).unwrap(); + } +} + +#[test] +fn profile_check_reports_render_sha256_golden_hash() { + let dir = tempfile::tempdir().unwrap(); + let profile = write_test_profile(dir.path()); + let out = bin() + .args(["--json", "profile", "check"]) + .arg(&profile) + .env("PATH", sfizz_path_env()) + .env("TMPDIR", dir.path()) + .assert() + .success(); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + let hash = v["patches"][0]["render_sha256"].as_str().unwrap(); + assert_eq!(hash.len(), 64, "render_sha256 must be hex SHA-256"); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + assert!(v["patches"][0].get("flake_diagnostics").is_none()); +} + +#[cfg(unix)] +#[test] +fn profile_check_flaky_first_pair_recovers_via_isolated_recheck() { + let dir = tempfile::tempdir().unwrap(); + let fake_bin = dir.path().join("fakebin"); + // First pair differs (1000 vs 2000) -> failed comparison; recheck pair is + // stable (1500, 1500) -> load-sensitive flake, overall pass. + install_counted_sfizz(&fake_bin, &[1000, 2000, 1500, 1500]); + let work = dir.path().join("work"); + fs::create_dir_all(&work).unwrap(); + let profile = work.join("profile.yaml"); + fs::write( + &profile, + "name: flaky\ninstruments:\n violin:\n sustain: any.sfz\n", + ) + .unwrap(); + fs::write(work.join("any.sfz"), " sample=w1.wav\n").unwrap(); + let out = bin() + .args(["--json", "profile", "check"]) + .arg(&profile) + .env("PATH", format!("{}:/usr/bin:/bin", fake_bin.display())) + .env("TMPDIR", dir.path()) + .assert() + .success(); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stdout).unwrap(); + assert_eq!(v["failed"], 0); + let patch = &v["patches"][0]; + assert_eq!(patch["status"], "ok"); + assert!(patch["render_sha256"].as_str().is_some()); + let warnings = patch["warnings"].as_array().unwrap(); + assert!( + warnings + .iter() + .any(|w| w.as_str().unwrap().contains("load_sensitive_flake")), + "expected load_sensitive_flake warning, got {warnings:?}" + ); + let flakes = patch["flake_diagnostics"].as_array().unwrap(); + assert_eq!(flakes.len(), 1); + assert_eq!(flakes[0]["attempt"], "first"); + assert_eq!(flakes[0]["observed_status"], "nondeterministic"); + let hashes = flakes[0]["render_sha256"].as_array().unwrap(); + assert_ne!(hashes[0], hashes[1], "differing renders must hash apart"); +} + +#[cfg(unix)] +#[test] +fn profile_check_persistent_nondeterminism_fails_with_both_diagnostics() { + let dir = tempfile::tempdir().unwrap(); + let fake_bin = dir.path().join("fakebin"); + // Both pairs differ -> hard failure carrying first + recheck evidence. + install_counted_sfizz(&fake_bin, &[1000, 2000, 1500, 2500]); + let work = dir.path().join("work"); + fs::create_dir_all(&work).unwrap(); + let profile = work.join("profile.yaml"); + fs::write( + &profile, + "name: broken\ninstruments:\n violin:\n sustain: any.sfz\n", + ) + .unwrap(); + fs::write(work.join("any.sfz"), " sample=w1.wav\n").unwrap(); + let out = bin() + .args(["--json", "profile", "check"]) + .arg(&profile) + .env("PATH", format!("{}:/usr/bin:/bin", fake_bin.display())) + .env("TMPDIR", dir.path()) + .assert() + .code(2); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); + assert_eq!(v["report"]["failed"], 1); + let patch = &v["report"]["patches"][0]; + assert_eq!(patch["status"], "nondeterministic"); + assert!(patch.get("render_sha256").is_none()); + let flakes = patch["flake_diagnostics"].as_array().unwrap(); + assert_eq!(flakes.len(), 2); + assert_eq!(flakes[0]["attempt"], "first"); + assert_eq!(flakes[1]["attempt"], "recheck"); + assert_eq!(flakes[1]["observed_status"], "nondeterministic"); +} + // ---- diff: semantic scene comparison (M4) ---- #[test]