diff --git a/docs-site/src/profiles.md b/docs-site/src/profiles.md index 1a9de05..aa0dbbf 100644 --- a/docs-site/src/profiles.md +++ b/docs-site/src/profiles.md @@ -22,7 +22,9 @@ 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. 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. +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; set `SCOREKIT_TMPDIR` to place them on another disk (created if absent, otherwise the system temp dir is used). + +Probe renders are bounded: `sfizz_render` runs with `--use-eot` (stop at the MIDI end instead of waiting for output silence, which a looping sustain patch never reaches), and a watchdog kills any tool that exceeds its wall-clock timeout or output-size cap, reporting the patch as `render_failed`. `SCOREKIT_TOOL_TIMEOUT_SECS` and `SCOREKIT_TOOL_MAX_OUTPUT_MB` override the limits. Use the profile with the sfizz backend: diff --git a/docs-site/src/sound-library.md b/docs-site/src/sound-library.md index 8b2fd6c..27183c4 100644 --- a/docs-site/src/sound-library.md +++ b/docs-site/src/sound-library.md @@ -126,10 +126,12 @@ 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: +Occasionally an upstream file is broken as shipped. Two repairs exist so +far: 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), and the MuldjordKit SFZ uses DrumGizmo's nonstandard +drum keymap (kick on 48, snare on 50…) instead of General MIDI (remapped +to GM keys: kick 36, snare 38, hats 42/46…). The repair convention: 1. Keep the upstream file **byte-intact**. 2. Place the repaired copy alongside it with a `.scoredata-fixN.` infix and @@ -143,6 +145,15 @@ 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. +Not every defect is repairable. VPO's `all-brass-SEC-sustain`, +`all-brass-SOLO-sustain` and `all-strings-SOLO-sustain` ensemble files +hang sfizz 1.2.3 (rendering never finishes); bisecting shows any 4 of a +file's 7 groups render fine while any 5 hang — a cumulative voice-count +interaction with no single broken opcode to patch. Such files are recorded +as do-not-map in the library manifest's `notes:` and profiles re-orchestrate +around them visibly (map the individual section patches instead) — never by +silently substituting a different sound. + ## Certification workflow After placing libraries, write a renderer profile mapping scorekit @@ -166,12 +177,27 @@ 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). +The corpus currently certifies **four renderer profiles — 235 mappings +over 173 unique patches, 0 failures**: + +- `scoredata-open` — broad reference: 101 mappings / 85 patches, 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). +- `scoredata-chamber` — one player per part: VPO SOLO strings/winds/brass, + VSCO 2 CE upright piano and quiet organ, VCSL harpsichord and recorder + (49 / 42). No percussion, drums, synths or ensemble patches — deliberate + identity gaps. +- `scoredata-symphonic` — full sections: VPO SEC strings/winds/brass, + orchestral percussion, harp, celesta and choirs, VCSL Steinway B, VSCO 2 + CE loud organ (64 / 54). No synths or electric instruments. +- `scoredata-synth` — FreePats synth basses/leads/pads/strings, Karoryfer + electric guitar and bass, Wurlitzer EP200, MuldjordKit drums (21 / 15). + The acoustic orchestra is intentionally absent. + +The chamber/symphonic pair doubles as the documented solo-vs-section +variant pair: same score, audibly different orchestration identity. ## Minimal rebuild walkthrough diff --git a/docs/roadmap.md b/docs/roadmap.md index 0e51ae3..18d995c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -115,9 +115,10 @@ The audio-quality upgrade M3 deferred: a third render backend built on `sfizz_re - **Feasibility finding, superseding M3's "not feasible in CI" note**: sfizz still isn't packaged for homebrew/apt, and the official 1.2.3 macOS release binary is x86_64-only (no working path on Apple Silicon without Rosetta). `scripts/build_sfizz.sh` builds `sfizz_render` from the vanilla `1.2.3` source tag for any platform/arch clang can target, applying two small, documented patches (an arm64-vs-32-bit-ARM cmake regex fix; a `template` disambiguator newer clang rejects) and installing to `assets/bin/sfizz_render` — the same "fetch missing local dev asset" convention as `scripts/fetch_assets.sh`. - **Renderer profiles, not paths in scenes**: `sfizz_render` has no bank/program concept — it plays every note in a MIDI through one loaded `.sfz` instrument. Rather than let scene YAML name a `.sfz` file (which would tie a scene to one machine's disk layout — "scene files quickly get polluted by whichever library is installed on that computer, and eventually a YAML has `/Users/.../final-final-library-v2` in it"), a separate **renderer profile** YAML (`src/profile.rs`, `scorekit schema --profile`) maps the DSL's portable `instrument`+`articulation` vocabulary to concrete `.sfz` paths, resolved relative to a `root` (default: the profile file's own directory). Every instrument entry requires a `sustain` mapping as the fallback for articulations it has no dedicated sample for. Swapping sound sources means swapping `--profile`, never editing a scene. - **`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. +- **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. Every `sfizz_render` invocation passes `--use-eot` (stop at the MIDI EndOfTrack): without it sfizz keeps rendering until output power decays below 1e-12, so a patch that never decays (looping organ/string sustains) writes an unbounded WAV — observed in the field as a 44 GB temp file. Because the render now ends at EOT, the build path pushes the EndOfTrack past the musical end (release-tail seconds plus one beat of rounding slack; loop scenes one beat past the second pass) so the extract window still sees the same samples — output audio is unchanged. - **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. 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. +- **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; the scratch root honors `SCOREKIT_TMPDIR` (created if absent, falling back to the system temp dir) so probe renders can be pointed at another disk. +- **Runaway-tool watchdog**: every external tool run is bounded by a wall-clock timeout and an output-size cap (defaults derived from the expected audio duration for renders whose length is known — probe MIDIs and `build` scenes — generous fixed limits otherwise; `SCOREKIT_TOOL_TIMEOUT_SECS` / `SCOREKIT_TOOL_MAX_OUTPUT_MB` override both). A breach kills the tool and surfaces a structured tool failure (`render_failed` in `profile check` reports) with the partial temp file removed. Defence in depth behind `--use-eot`, which already makes sfizz renders finite by construction. - **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). @@ -231,17 +232,17 @@ use) stay with the caller. 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. +**Measured baseline (2026-07, after profile cycle 3 — curated identities chamber/symphonic/synth):** 4 certified sample-tier renderer profiles (was 1): `scoredata-open` (broad reference, 56/60 instruments, 101 mappings / 85 patches), `scoredata-chamber` (one-player-per-part: VPO SOLO strings/winds/brass, 49/42), `scoredata-symphonic` (full sections: VPO SEC + orchestral percussion/choirs, 64/54), `scoredata-synth` (FreePats synth sets + Karoryfer electric guitar/bass + Wurlitzer EP + Muldjord kit, 21/15) — 235 mappings over 173 unique certified patches, 0 failures. Articulation depth (open profile): 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, all 24 wired into shipping render paths (0 idle; cycle 3 wired the last four — Muldjord kit + three FreePats synth sets — into `scoredata-synth`); ~1,285 SFZ patches on disk (454 from the VPO scripts overlay) of which 173 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). Cycle 3 repaired the Muldjord kit's nonstandard DrumGizmo keymap into a GM-keyed `scoredata-fix1` copy (same convention), and diagnosed a VPO defect: three `all-*-sustain` ensemble files (brass SEC/SOLO, strings SOLO) hang sfizz 1.2.3 — bisect shows any 4 of a file's 7 groups render fine, any 5 hang (cumulative voice-count interaction, no clean single-opcode fix), so those files are documented in the VPO manifest as do-not-map and the affected profiles visibly re-orchestrate (chamber carries no ensemble patches by identity; symphonic keeps `strings` SEC but maps no `brass` ensemble — write the individual section parts). 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. +- **T3 Diversity:** ≥2 independent certified candidates for each core role in principle 3; at least one documented solo-vs-section strings variant pair. Cycle 3 realized the pair as whole profile identities: `scoredata-chamber` (VPO SOLO scripts) vs `scoredata-symphonic` (VPO SEC scripts), certified side by side. - **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. +**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; cycle 3 was content-only (no new libraries): it turned the anti-mega-profile principle into three additional certified identities (chamber/symphonic/synth), wired all idle libraries, and exercised the fixN repair convention a second time (Muldjord GM keymap). **Remaining-gap disposal (2026-07 adjudication — stop searching uniformly, route each gap by its failure mode):** @@ -309,7 +310,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, 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` | +| 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 with `--use-eot`, is non-silent, reports deterministic output plus a `render_sha256` golden hash as structured JSON; the probe scratch root honors `SCOREKIT_TMPDIR`) | ✅ (missing patch / silent patch / missing sfizz dependency return structured failures; a runaway render is killed at the output-size cap and a stuck render at the wall-clock timeout, both as structured `render_failed` without residue; 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, including watchdog kills) | `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` / `profile_check_kills_runaway_render_at_size_cap_without_residue` / `profile_check_kills_stuck_render_at_timeout_without_residue` / `profile_check_passes_use_eot_and_honors_scorekit_tmpdir` | | 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/main.rs b/src/main.rs index d390fd8..e1f681a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -504,7 +504,13 @@ fn run(command: &Command, json: bool) -> Result { .to_owned(), }); } - tools::render_sfz(midi, sfz, output, *sample_rate)?; + tools::render_sfz( + midi, + sfz, + output, + *sample_rate, + tools::ToolLimits::generic(), + )?; } tools::Renderer::Fluidsynth | tools::Renderer::Timidity => { if sfz.is_some() { diff --git a/src/midi.rs b/src/midi.rs index 5a48caa..2de7195 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -116,6 +116,19 @@ pub fn exact_samples(ticks: u32, tempo: u16, sample_rate: u32) -> u64 { ((num + den / 2) / den) as u64 } +/// Smallest tick count whose duration at the quantized MIDI tempo covers at +/// least `seconds`. Used to pad the MIDI EndOfTrack past the last note-off so +/// an `--use-eot` render still contains the requested release tail. +pub fn ticks_covering(seconds: f64, tempo: u16) -> u32 { + if seconds <= 0.0 { + return 0; + } + let ticks = (seconds * 1_000_000.0 * f64::from(crate::composer::PPQ) + / f64::from(micros_per_beat(tempo))) + .ceil(); + ticks as u32 +} + pub fn to_smf_bytes(ir: &ScoreIr) -> Vec { let header = Header::new( Format::Parallel, diff --git a/src/pipeline.rs b/src/pipeline.rs index 5c1e0fa..b74958c 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -107,6 +107,20 @@ fn validation(path: &str, message: String) -> Error { /// Compile a scene to SMF bytes with loop passes and optional track solo. pub fn midi_bytes(scene: &Scene, passes: u8, solo: Option) -> Result> { + midi_bytes_padded(scene, passes, solo, 0) +} + +/// `midi_bytes` with the EndOfTrack pushed `eot_pad_ticks` past the musical +/// end. No notes are added — the pad only extends how long an `--use-eot` +/// renderer (sfizz_render) keeps rendering, so release tails land in the raw +/// WAV instead of being cut at the last bar line. SF2 renderers and MIDI +/// export use a zero pad: their track end stays pinned to the bar boundary. +pub fn midi_bytes_padded( + scene: &Scene, + passes: u8, + solo: Option, + eot_pad_ticks: u32, +) -> Result> { if let Some(i) = solo && i >= scene.tracks.len() { @@ -123,6 +137,7 @@ pub fn midi_bytes(scene: &Scene, passes: u8, solo: Option) -> Result = Vec::with_capacity(scene.tracks.len()); for (i, track) in scene.tracks.iter().enumerate() { // The resolver may have substituted an available instrument @@ -762,12 +793,15 @@ fn build_one( let sfz = profile.resolve(&profile_dir, target, track.articulation)?; let mid_i = staging.join(format!("{:02}.mid", i + 1)); let raw_i = staging.join(format!("{:02}.raw.wav", i + 1)); - tools::write_atomic(&mid_i, &midi_bytes(scene, passes, Some(i))?)?; + tools::write_atomic( + &mid_i, + &midi_bytes_padded(scene, passes, Some(i), eot_pad_ticks)?, + )?; // sfizz_render has no gain flag (unlike fluidsynth -g / timidity // -A), so gain is applied here in-process instead — on each // track individually, so stems and the mixed-down full track // carry the same gain and still sum correctly. - tools::render_sfz(&mid_i, &sfz, &raw_i, args.sample_rate)?; + tools::render_sfz(&mid_i, &sfz, &raw_i, args.sample_rate, limits)?; let raw_i_gain = staging.join(format!("{:02}.gain.wav", i + 1)); audio::mix(std::slice::from_ref(&raw_i), &raw_i_gain, args.gain)?; track_raws.push(raw_i_gain); diff --git a/src/profile_check.rs b/src/profile_check.rs index f18b14a..a10a6fd 100644 --- a/src/profile_check.rs +++ b/src/profile_check.rs @@ -22,6 +22,11 @@ use std::time::Instant; const SILENCE_PEAK: u32 = 1; const DETERMINISM_TOLERANCE: f64 = 1.0e-6; +const PROBE_TEMPO: u16 = 240; +/// 16 probe notes × 240 ticks each + 960 ticks of release-tail pad past the +/// last note-off; the EndOfTrack lands here and `--use-eot` renders exactly +/// this long (2.5s at tempo 240), keeping probe renders bounded. +const PROBE_TOTAL_TICKS: u32 = 16 * 240 + 960; /// Environment + evidence snapshot for one failed render-pair attempt. #[derive(Debug, Clone, Serialize)] @@ -104,8 +109,17 @@ struct Scratch { impl Scratch { fn create() -> Result { - let path = - std::env::temp_dir().join(format!("scorekit-profile-check-{}", std::process::id())); + // Probe renders can be large; `SCOREKIT_TMPDIR` relocates them (e.g. + // to an external disk) without touching the system-wide TMPDIR. + let root = std::env::var_os("SCOREKIT_TMPDIR") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + std::fs::create_dir_all(&root).map_err(|source| Error::Io { + path: root.display().to_string(), + source, + })?; + let path = root.join(format!("scorekit-profile-check-{}", std::process::id())); std::fs::create_dir(&path).map_err(|source| Error::Io { path: path.display().to_string(), source, @@ -218,8 +232,9 @@ fn probe_midi(drum_channel: bool) -> Vec { }) .collect(); let total_ticks = keys.len() as u32 * step + 960; + debug_assert_eq!(total_ticks, PROBE_TOTAL_TICKS); midi::to_smf_bytes(&ScoreIr { - tempo: 240, + tempo: PROBE_TEMPO, ts: TimeSig { num: 4, den: 4 }, total_ticks, tracks: vec![TrackIr { @@ -327,11 +342,14 @@ fn render_pair( ) -> Result { let a_path = scratch.join(format!("{index:04}-{tag}-a.wav")); let b_path = scratch.join(format!("{index:04}-{tag}-b.wav")); + let probe_secs = midi::exact_samples(PROBE_TOTAL_TICKS, PROBE_TEMPO, sample_rate) as f64 + / f64::from(sample_rate); + let limits = tools::ToolLimits::for_expected_audio(probe_secs, sample_rate); 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) { + match tools::render_sfz_with_diagnostics(midi, sfz, out_path, sample_rate, limits) { Err(e @ Error::MissingDependency { .. }) => return Err(e), Err(e) => return Ok(PairResult::Failed(e.to_string())), Ok(diag) => diagnostics.push(diag), diff --git a/src/tools.rs b/src/tools.rs index 8b7e4a4..f0f05cf 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -3,7 +3,58 @@ use crate::error::{Error, Result}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Watchdog limits for one external tool run. Renderers have misbehaved in +/// the wild (`sfizz_render` without `--use-eot` keeps rendering until output +/// power decays below 1e-12 — a non-decaying patch writes an unbounded WAV), +/// so every run is bounded by wall-clock time and output size. +/// +/// Env overrides (both optional): `SCOREKIT_TOOL_TIMEOUT_SECS` and +/// `SCOREKIT_TOOL_MAX_OUTPUT_MB` replace the computed values when set. +#[derive(Debug, Clone, Copy)] +pub struct ToolLimits { + pub timeout: Duration, + pub max_output_bytes: u64, +} + +fn env_u64(name: &str) -> Option { + std::env::var(name).ok()?.trim().parse().ok() +} + +impl ToolLimits { + /// Limits for a render whose audio duration is known in advance: + /// generous multiples so slow disks and large sample libraries never + /// trip the guard, while a runaway tool is still stopped early. + pub fn for_expected_audio(expected_secs: f64, sample_rate: u32) -> Self { + let secs = expected_secs.max(0.0); + // Stereo 16-bit PCM upper bound for the expected duration. + let expected_bytes = (secs * f64::from(sample_rate)).ceil() as u64 * 4; + let max_output_bytes = env_u64("SCOREKIT_TOOL_MAX_OUTPUT_MB") + .map(|mb| mb.saturating_mul(1024 * 1024)) + .unwrap_or_else(|| expected_bytes.saturating_mul(10) + 64 * 1024 * 1024); + let timeout_secs = + env_u64("SCOREKIT_TOOL_TIMEOUT_SECS").unwrap_or(300 + (secs * 30.0).ceil() as u64); + Self { + timeout: Duration::from_secs(timeout_secs), + max_output_bytes, + } + } + + /// Fallback limits for tools whose output size is not known in advance + /// (SF2 renderers, FFmpeg): long timeout, large size cap. + pub fn generic() -> Self { + let timeout_secs = env_u64("SCOREKIT_TOOL_TIMEOUT_SECS").unwrap_or(1800); + let max_output_bytes = env_u64("SCOREKIT_TOOL_MAX_OUTPUT_MB") + .map(|mb| mb.saturating_mul(1024 * 1024)) + .unwrap_or(8 * 1024 * 1024 * 1024); + Self { + timeout: Duration::from_secs(timeout_secs), + max_output_bytes, + } + } +} /// Which external synthesizer turns MIDI + SF2 into PCM. The rest of the /// pipeline (loop-seal surgery, stems, export) is renderer-agnostic. @@ -80,21 +131,29 @@ pub struct ToolDiagnostics { /// Run a tool that writes `output`; the tool receives a temp path which is /// atomically renamed on success and removed on any failure. `error_markers` /// catches tools (FluidSynth) that report fatal errors on stderr yet exit 0. +/// A watchdog kills the tool when it exceeds `limits` (wall-clock time or +/// bytes written to the temp file) so a runaway render cannot fill the disk. fn run_to_file_capture( tool: &str, hint: &str, error_markers: &[&str], build_args: impl FnOnce(&Path) -> Vec, output: &Path, + limits: ToolLimits, ) -> Result { ensure_parent(output)?; let tmp = tmp_sibling(output); let args = build_args(&tmp); - let result = Command::new(tool).args(&args).output(); let cleanup = || { let _ = std::fs::remove_file(&tmp); }; - let out = match result { + let spawned = Command::new(tool) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + let mut child = match spawned { Err(e) if e.kind() == std::io::ErrorKind::NotFound => { cleanup(); return Err(Error::MissingDependency { @@ -106,15 +165,94 @@ fn run_to_file_capture( cleanup(); return Err(io_err(Path::new(tool), e)); } - Ok(out) => out, + Ok(child) => child, + }; + // Drain pipes on threads so a chatty tool cannot deadlock on a full pipe + // while the main thread polls the watchdog. Results come back over + // channels: when the tool is killed, surviving grandchildren may hold the + // pipe open indefinitely, so the kill path only waits briefly for the + // drained bytes instead of joining the reader threads. + let read_all = |r: Option>| { + let (tx, rx) = std::sync::mpsc::channel::>(); + std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut r) = r { + let _ = std::io::Read::read_to_end(&mut r, &mut buf); + } + let _ = tx.send(buf); + }); + rx + }; + let stdout_rx = read_all( + child + .stdout + .take() + .map(|s| Box::new(s) as Box), + ); + let stderr_rx = read_all( + child + .stderr + .take() + .map(|s| Box::new(s) as Box), + ); + let started = Instant::now(); + let (status, killed) = loop { + match child.try_wait() { + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + cleanup(); + return Err(io_err(Path::new(tool), e)); + } + Ok(Some(status)) => break (status, None), + Ok(None) => {} + } + let breach = if started.elapsed() > limits.timeout { + Some(format!( + "killed: no result within {}s (override with SCOREKIT_TOOL_TIMEOUT_SECS)", + limits.timeout.as_secs() + )) + } else { + let written = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0); + (written > limits.max_output_bytes).then(|| { + format!( + "killed: output exceeded {} MiB cap (override with SCOREKIT_TOOL_MAX_OUTPUT_MB)", + limits.max_output_bytes / (1024 * 1024) + ) + }) + }; + if let Some(reason) = breach { + let _ = child.kill(); + let status = child.wait().map_err(|e| io_err(Path::new(tool), e))?; + break (status, Some(reason)); + } + std::thread::sleep(Duration::from_millis(50)); }; - let stderr_full = String::from_utf8_lossy(&out.stderr).into_owned(); + let drain = |rx: std::sync::mpsc::Receiver>, killed: bool| { + if killed { + rx.recv_timeout(Duration::from_millis(500)) + .unwrap_or_default() + } else { + rx.recv().unwrap_or_default() + } + }; + let stdout = drain(stdout_rx, killed.is_some()); + let stderr = drain(stderr_rx, killed.is_some()); + let stderr_full = String::from_utf8_lossy(&stderr).into_owned(); let stderr_tail = tail(&stderr_full, 8); - if !out.status.success() { + if let Some(reason) = killed { cleanup(); return Err(Error::ToolFailure { tool: tool.to_owned(), - status: out.status.to_string(), + status: reason, + stderr: stderr_tail, + }); + } + if !status.success() { + cleanup(); + return Err(Error::ToolFailure { + tool: tool.to_owned(), + status: status.to_string(), stderr: stderr_tail, }); } @@ -141,7 +279,7 @@ fn run_to_file_capture( io_err(output, e) })?; Ok(ToolDiagnostics { - stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stdout: String::from_utf8_lossy(&stdout).into_owned(), stderr: stderr_full, }) } @@ -153,7 +291,15 @@ fn run_to_file( build_args: impl FnOnce(&Path) -> Vec, output: &Path, ) -> Result<()> { - run_to_file_capture(tool, hint, error_markers, build_args, output).map(|_| ()) + run_to_file_capture( + tool, + hint, + error_markers, + build_args, + output, + ToolLimits::generic(), + ) + .map(|_| ()) } /// Cheap structural check: an SF2 is a RIFF container with the `sfbk` form type. @@ -285,11 +431,18 @@ fn renderer_tool(renderer: Renderer) -> &'static str { /// plays every note through one loaded instrument — there's no bank/program /// concept. A scene with several instruments is therefore rendered one track /// at a time and mixed in-process (`audio::mix`); see `pipeline::build_one`. +/// +/// `--use-eot` stops the render at the MIDI EndOfTrack message. Without it, +/// sfizz_render keeps rendering until output power decays below 1e-12, so a +/// patch that never decays (looping organ/string sustains) writes an +/// unbounded WAV. Callers pad the MIDI end past the last note-off when they +/// need a release tail. `limits` bounds the run as defence in depth. pub fn render_sfz_with_diagnostics( midi: &Path, sfz: &Path, output: &Path, sample_rate: u32, + limits: ToolLimits, ) -> Result { require_file(midi, "midi")?; require_file(sfz, "--profile")?; @@ -307,9 +460,11 @@ pub fn render_sfz_with_diagnostics( tmp.as_os_str().to_owned(), "-s".into(), sample_rate.to_string().into(), + "--use-eot".into(), ] }, output, + limits, )?; // Same "exit 0 but zero audio frames" backstop `render` applies below, // duplicated here because this path never reaches that shared check. @@ -327,8 +482,14 @@ pub fn render_sfz_with_diagnostics( Ok(diagnostics) } -pub fn render_sfz(midi: &Path, sfz: &Path, output: &Path, sample_rate: u32) -> Result<()> { - render_sfz_with_diagnostics(midi, sfz, output, sample_rate).map(|_| ()) +pub fn render_sfz( + midi: &Path, + sfz: &Path, + output: &Path, + sample_rate: u32, + limits: ToolLimits, +) -> Result<()> { + render_sfz_with_diagnostics(midi, sfz, output, sample_rate, limits).map(|_| ()) } /// Convert audio via FFmpeg. The codec follows the output extension: diff --git a/tests/cli.rs b/tests/cli.rs index 6376769..7cdf662 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2466,6 +2466,163 @@ fn profile_check_persistent_nondeterminism_fails_with_both_diagnostics() { assert_eq!(flakes[1]["observed_status"], "nondeterministic"); } +/// A patch that never decays used to make `sfizz_render` write an unbounded +/// WAV (44 GB observed) because nothing stopped the render. The watchdog must +/// kill the tool at the output-size cap, report a structured render failure, +/// and leave no partial files behind. +#[cfg(unix)] +#[test] +fn profile_check_kills_runaway_render_at_size_cap_without_residue() { + let dir = tempfile::tempdir().unwrap(); + let fake_bin = dir.path().join("fakebin"); + fs::create_dir_all(&fake_bin).unwrap(); + // Ignores --use-eot and appends 1 MiB chunks forever, like a render whose + // output power never decays. + let script = "#!/bin/sh\nout=\"\"; prev=\"\"\nfor a in \"$@\"; do\n [ \"$prev\" = \"--wav\" ] && out=\"$a\"\n prev=\"$a\"\ndone\nwhile :; do\n dd if=/dev/zero bs=1048576 count=1 >> \"$out\" 2>/dev/null\n sleep 0.05\ndone\n"; + let tool = fake_bin.join("sfizz_render"); + fs::write(&tool, script).unwrap(); + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).unwrap(); + } + let work = dir.path().join("work"); + fs::create_dir_all(&work).unwrap(); + let profile = work.join("profile.yaml"); + fs::write( + &profile, + "name: runaway\ninstruments:\n violin:\n sustain: any.sfz\n", + ) + .unwrap(); + fs::write(work.join("any.sfz"), " sample=*sine\n").unwrap(); + let tmp = dir.path().join("tmp"); + fs::create_dir_all(&tmp).unwrap(); + let out = bin() + .args(["--json", "profile", "check"]) + .arg(&profile) + .env("PATH", format!("{}:/usr/bin:/bin", fake_bin.display())) + .env("TMPDIR", &tmp) + .env("SCOREKIT_TOOL_MAX_OUTPUT_MB", "1") + .assert() + .code(4); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); + assert_eq!(v["code"], "profile_check"); + let patch = &v["report"]["patches"][0]; + assert_eq!(patch["status"], "render_failed"); + let error = patch["error"].as_str().unwrap(); + assert!( + error.contains("exceeded 1 MiB cap"), + "error should name the size cap: {error}" + ); + // No scratch dir, no partial/giant WAV may survive the kill. + assert_dir_contains_exactly(&tmp, &[]); +} + +/// A render that produces no output at all (hung tool) must be killed at the +/// wall-clock timeout instead of blocking `profile check` forever. +#[cfg(unix)] +#[test] +fn profile_check_kills_stuck_render_at_timeout_without_residue() { + let dir = tempfile::tempdir().unwrap(); + let fake_bin = dir.path().join("fakebin"); + fs::create_dir_all(&fake_bin).unwrap(); + let tool = fake_bin.join("sfizz_render"); + fs::write(&tool, "#!/bin/sh\nsleep 30\n").unwrap(); + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).unwrap(); + } + let work = dir.path().join("work"); + fs::create_dir_all(&work).unwrap(); + let profile = work.join("profile.yaml"); + fs::write( + &profile, + "name: stuck\ninstruments:\n violin:\n sustain: any.sfz\n", + ) + .unwrap(); + fs::write(work.join("any.sfz"), " sample=*sine\n").unwrap(); + let tmp = dir.path().join("tmp"); + fs::create_dir_all(&tmp).unwrap(); + let out = bin() + .args(["--json", "profile", "check"]) + .arg(&profile) + .env("PATH", format!("{}:/usr/bin:/bin", fake_bin.display())) + .env("TMPDIR", &tmp) + .env("SCOREKIT_TOOL_TIMEOUT_SECS", "1") + .assert() + .code(4); + let v: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); + let patch = &v["report"]["patches"][0]; + assert_eq!(patch["status"], "render_failed"); + let error = patch["error"].as_str().unwrap(); + assert!( + error.contains("no result within 1s"), + "error should name the timeout: {error}" + ); + assert_dir_contains_exactly(&tmp, &[]); +} + +/// The render invocation must pass `--use-eot` (stop at EndOfTrack — the fix +/// that makes runaway renders impossible by construction), and the probe +/// scratch dir must honor `SCOREKIT_TMPDIR` so temp renders can be pointed at +/// another disk. +#[cfg(unix)] +#[test] +fn profile_check_passes_use_eot_and_honors_scorekit_tmpdir() { + let dir = tempfile::tempdir().unwrap(); + let fake_bin = dir.path().join("fakebin"); + fs::create_dir_all(&fake_bin).unwrap(); + write_const_wav(&fake_bin.join("w1.wav"), 1000, 4410); + // Records every argument, then emits a constant WAV (deterministic pass). + let script = "#!/bin/sh\ndir=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nout=\"\"; prev=\"\"\nfor a in \"$@\"; do\n printf '%s\\n' \"$a\" >> \"$dir/args.log\"\n [ \"$prev\" = \"--wav\" ] && out=\"$a\"\n prev=\"$a\"\ndone\ncp \"$dir/w1.wav\" \"$out\"\n"; + let tool = fake_bin.join("sfizz_render"); + fs::write(&tool, script).unwrap(); + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).unwrap(); + } + let work = dir.path().join("work"); + fs::create_dir_all(&work).unwrap(); + let profile = work.join("profile.yaml"); + fs::write( + &profile, + "name: eot\ninstruments:\n violin:\n sustain: any.sfz\n", + ) + .unwrap(); + fs::write(work.join("any.sfz"), " sample=*sine\n").unwrap(); + // SCOREKIT_TMPDIR does not exist yet: the check must create it and use it + // even though TMPDIR points elsewhere. + let sk_tmp = dir.path().join("sk-tmp"); + let sys_tmp = dir.path().join("sys-tmp"); + fs::create_dir_all(&sys_tmp).unwrap(); + bin() + .args(["--json", "profile", "check"]) + .arg(&profile) + .env("PATH", format!("{}:/usr/bin:/bin", fake_bin.display())) + .env("TMPDIR", &sys_tmp) + .env("SCOREKIT_TMPDIR", &sk_tmp) + .assert() + .success(); + let log = fs::read_to_string(fake_bin.join("args.log")).unwrap(); + let args: Vec<&str> = log.lines().collect(); + assert!( + args.contains(&"--use-eot"), + "sfizz_render must be invoked with --use-eot: {args:?}" + ); + let wav = args + .iter() + .position(|a| *a == "--wav") + .map(|i| args[i + 1]) + .expect("--wav argument recorded"); + assert!( + Path::new(wav).starts_with(&sk_tmp), + "probe render {wav} must live under SCOREKIT_TMPDIR {}", + sk_tmp.display() + ); + // Scratch cleanup applies to the relocated dir too. + assert_dir_contains_exactly(&sk_tmp, &[]); + assert_dir_contains_exactly(&sys_tmp, &[]); +} + // ---- diff: semantic scene comparison (M4) ---- #[test]