From 7fa10065003d8d9473424fa4f1f46845eddcdd7d Mon Sep 17 00:00:00 2001 From: jettwang Date: Wed, 22 Jul 2026 17:35:58 +0800 Subject: [PATCH] fix: harden build validation and rollback --- Dockerfile | 24 ++- docs-site/src/commands.md | 5 + docs-site/src/scene-protocol.md | 3 + docs/roadmap.md | 12 +- scripts/build_sfizz.sh | 6 + scripts/fetch_assets.sh | 28 +++- skills/scorekit/reference.md | 14 +- src/main.rs | 82 ++++++++++ src/pipeline.rs | 137 ++++++++++++++-- src/schema.rs | 70 ++++++-- tests/cli.rs | 279 ++++++++++++++++++++++++++++++++ 11 files changed, 612 insertions(+), 48 deletions(-) diff --git a/Dockerfile b/Dockerfile index 825eb36..d8d18b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,17 +11,29 @@ # docker run --rm -v "$PWD:/work" -w /work scorekit build scene.yaml -o scene.ogg --stems # docker run --rm -i scorekit mcp # stdio MCP server -FROM rust:1-bookworm AS build +FROM rust:1.88.0-bookworm@sha256:af306cfa71d987911a781c37b59d7d67d934f49684058f96cf72079c3626bfe0 AS build WORKDIR /src COPY Cargo.toml Cargo.lock ./ COPY src ./src RUN cargo build --release --locked -FROM debian:bookworm-slim -# bookworm ships FluidSynth 2.3.x and FFmpeg 5.1.x; the distro release pins -# the tool major/minor versions this image guarantees. -RUN apt-get update \ - && apt-get install -y --no-install-recommends fluidsynth ffmpeg curl ca-certificates \ +FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818 +# Freeze the complete APT dependency graph through an immutable Debian +# snapshot, then also name the top-level package versions as executable +# documentation. Rebuilding the same source either gets these exact tools or +# fails closed; it never silently advances the audio toolchain. +ARG DEBIAN_SNAPSHOT=20260713T000000Z +RUN printf '%s\n' \ + "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} bookworm main" \ + "deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} bookworm-security main" \ + > /etc/apt/sources.list \ + && rm -f /etc/apt/sources.list.d/debian.sources \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + fluidsynth=2.3.1-2 \ + ffmpeg=7:5.1.9-0+deb12u1 \ + curl=7.88.1-10+deb12u15 \ + ca-certificates=20230311+deb12u1 \ && rm -rf /var/lib/apt/lists/* \ && fluidsynth --version && ffmpeg -version diff --git a/docs-site/src/commands.md b/docs-site/src/commands.md index 43fc851..f0e4266 100644 --- a/docs-site/src/commands.md +++ b/docs-site/src/commands.md @@ -24,6 +24,11 @@ Exit codes are stable: `0` success, `1` I/O failure, `2` invalid input, `3` miss Run `scorekit --help` for the complete flag list shipped by the installed binary. +Numeric audio-command options reject non-finite or out-of-range values before +resolving tools or writing files: `--sample-rate` is 8000..=384000 Hz, +`--gain` is 0.0..=8.0, `--quality` is 0..=10, `--tail` is 0.0..=3600.0 +seconds, and `--crossfade-ms` is 0..=60000. + Scenes with `textures` require `build`/`batch --texture-profile `. This flag is independent of the musical renderer: it works alongside either an SF2 `--soundfont` or an sfizz `--profile`. diff --git a/docs-site/src/scene-protocol.md b/docs-site/src/scene-protocol.md index 5376b6a..017e1f9 100644 --- a/docs-site/src/scene-protocol.md +++ b/docs-site/src/scene-protocol.md @@ -118,6 +118,9 @@ needed at the loop boundary. | `intensity` | 0.0..=2.0 | 1.0 | multiplier on each track's intensity | Sections inherit the scene's key, tracks (including spatial fields), textures, motifs, harmony, and performance. +A shared texture schedule must be valid for every section: each trigger is +checked against the shortest derived section timeline, preventing a trigger +from silently wrapping into a later pass of a shorter cue. ### Performance diff --git a/docs/roadmap.md b/docs/roadmap.md index e2d7867..2b1b9fd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -40,7 +40,7 @@ Once finished, it is a **deterministic composition compiler**: - **Deterministic MIDI compilation**: `scorekit midi` outputs a byte-stable SMF (format 1, PPQ 480), patterns: sustain/arpeggio/bass/drums, locked against golden baselines. Evidence: `src/composer.rs`, `src/midi.rs`, `tests/golden/forest.mid`. - **Spatial performance fields**: per-track `pan`/`reverb`/`glide` compile to MIDI CC10/CC91/pitch-bend deterministically; `glide` is a melody-only tail portamento whose ramps stay loop-seam continuous; absent fields leave output byte-identical. Evidence: `src/composer.rs::glide_bends`, `src/midi.rs`, `tests/cli.rs`. - **Deterministic sound-texture layers**: `textures` schedules field recordings, ambience, and SFX as continuous loops or quarter-note one-shots; a separate `--texture-profile` maps portable source names to local audio files, FFmpeg owns decode/resample/channel conversion, and scorekit performs only sample-exact placement/gain/summation. Texture stems share the music window and loop seal, remain sample-aligned, and appear in `meta.json`; runtime world audio remains outside the compiler. Evidence: `src/texture.rs`, `src/audio.rs::arrange_texture`, `src/pipeline.rs::render_texture_tracks`, `tests/cli.rs`. -- **Rendering and export**: `scorekit render` (FluidSynth+SF2, SF2 magic-number precheck, catches fluidsynth's silent "exit 0 but error" failures), `scorekit export` (FFmpeg → OGG, libvorbis→vorbis fallback), `scorekit build` full pipeline; every file write goes through temp-file + atomic rename, so a failure leaves no partial artifact. Evidence: `src/tools.rs`, `tests/cli.rs`. +- **Rendering and export**: `scorekit render` (FluidSynth+SF2, SF2 magic-number precheck, catches fluidsynth's silent "exit 0 but error" failures), `scorekit export` (FFmpeg → OGG, libvorbis→vorbis fallback), `scorekit build` full pipeline; every file write goes through temp-file + atomic rename, and suite builds stage the complete output set before publishing it with rollback, so a failure leaves no partial artifact. Evidence: `src/tools.rs`, `src/pipeline.rs::publish_staged_suite`, `tests/cli.rs`. - **Machine-readable errors**: `--json` outputs structured errors (code/message/location/field/exit_code); exit-code convention is 1 io / 2 invalid input / 3 missing dependency / 4 external tool failed. Evidence: `src/error.rs`. - **Music grammar validation**: `scorekit lint scene.yaml --grammar grief.yaml` — an external grammar profile declares deterministic aesthetic constraints (tempo/rests/voice count/phrase length/cadence/harmony whitelist); deep rules are measured on the compiled IR, and violations report measured values; `scorekit schema --grammar` exports the profile's schema. Evidence: `src/grammar.rs`, `examples/grammars/grief.yaml`. - **SFZ sample rendering and renderer profiles**: `scorekit build/batch/render --renderer sfizz` (`sfizz_render`, built from source via `scripts/build_sfizz.sh`); an external renderer profile (`--profile`, `scorekit schema --profile`) maps portable `instrument`+`articulation` DSL vocabulary to `.sfz` files, so scene YAML never names a local sample-library path; `Track.articulation` is a render-time-only selector; per-track solo renders are mixed in-process (`audio::mix`) since sfizz has no bank/program concept; a profile can freely mix multiple sample libraries per-instrument. Evidence: `src/profile.rs`, `src/audio.rs::mix`, `src/tools.rs::render_sfz`, `examples/profiles/vsco2-ce.yaml`, `examples/profiles/vsco2-vcsl.yaml`. @@ -150,7 +150,9 @@ timeline material without turning scorekit into a DAW or game-audio runtime. positions quantize to PPQ 480 and then use the MIDI tempo's exact sample conversion. A loop texture must start at beat 0 in any looping scene; a one-shot used in a loop must fit inside one pass so the previous pass fully - supplies its boundary tail. + supplies its boundary tail. In suites, every shared trigger is validated + against the shortest derived section timeline, so a legal position in a + long cue cannot silently wrap in a shorter cue. - **Portable source binding**: scenes contain `[a-z][a-z0-9_-]{0,63}` logical keys only. `--texture-profile` maps those keys to recordings relative to a profile root, independently of whether music uses SF2 or SFZ. The profile @@ -206,9 +208,9 @@ Permissions note: scorekit is a local single-user CLI with no role/permission sy | DSL validation and schema export (validate/schema) | Low | ✅ | ✅ | N/A (local CLI) | N/A (read-only) | `tests/cli.rs::validate_happy_path` / `validate_rejects_unknown_field_with_location` / `validate_rejects_semantic_error_with_field_path_json` / `schema_emits_json_schema` / `story_is_informational_and_never_affects_midi_bytes` / `validate_rejects_non_string_story_with_location` | | MIDI generation (midi) | Medium | ✅ (golden byte comparison + double-run determinism) | ✅ | N/A (local CLI) | ✅ (failure leaves no partial artifact) | `tests/cli.rs::midi_matches_golden_bytes` / `midi_is_deterministic_across_runs` / `midi_invalid_scene_leaves_no_partial_file` | | Audio rendering (render, FluidSynth+SF2) | High (external process + file write) | ✅ (asserts sample rate and duration) | ✅ (corrupt SF2 / non-SF2 file / missing fluidsynth) | N/A (local CLI) | ✅ (no leftovers in the directory on failure) | `tests/cli.rs::render_happy_path_produces_exact_rate_wav` / `render_corrupt_soundfont_fails_without_partial_output` / `render_text_file_as_soundfont_is_input_error` / `render_missing_fluidsynth_is_dependency_error` | -| Export and loop metadata (export/build, FFmpeg) | High (external process + file write) | ✅ (loop is exactly L samples + bit-exact seamless seal + meta.json + fixed length for non-loop) | ✅ | N/A (local CLI) | ✅ (no leftovers in the directory on failure) | `tests/cli.rs::export_happy_path_produces_ogg` / `export_missing_input_is_input_error` / `export_seek_take_cuts_bit_exactly` / `build_full_chain_scene_to_ogg` / `build_loop_wav_is_sample_exact_and_sealed` / `build_nonloop_wav_has_exact_padded_length` | +| Export and loop metadata (export/build, FFmpeg) | High (external process + file write) | ✅ (loop is exactly L samples + bit-exact seamless seal + meta.json + fixed length for non-loop) | ✅ (invalid numeric options are structured exit 2 errors) | N/A (local CLI) | ✅ (validation and tool failures leave no partial artifact) | `tests/cli.rs::export_happy_path_produces_ogg` / `export_missing_input_is_input_error` / `export_seek_take_cuts_bit_exactly` / `build_full_chain_scene_to_ogg` / `build_loop_wav_is_sample_exact_and_sealed` / `build_nonloop_wav_has_exact_padded_length` / `build_rejects_nonfinite_tail_as_structured_input_error` / `numeric_cli_options_reject_out_of_range_values_before_writing` | | Stem rendering (stems) | Medium | ✅ (4 tracks equal length + sum ≈ full-mix RMS < 2%) | ✅ (corrupt SF2) | N/A (local CLI) | ✅ (staging directory atomic swap, no leftover stems on failure) | `tests/cli.rs::build_stems_are_aligned_and_sum_to_mix` / `build_corrupt_soundfont_leaves_no_partial_output_or_stems` | -| Structured composition and transitions (sections/motif) | Medium | ✅ (suite per-section assets + concatenated main playback file + exact section lengths + manifest + tempo override + mute) | ✅ (unknown motif reference / duplicate section names / all-muted section / unknown --section) | N/A (local CLI) | ✅ (no leftovers on failure or in OGG suite builds, reuses build's atomic mechanism) | `tests/cli.rs::build_suite_emits_per_section_assets_with_exact_lengths` / `build_suite_to_ogg_emits_main_file_without_leftover_cuts` / `midi_section_selector_compiles_that_section_deterministically` / `midi_unknown_section_is_input_error` / `validate_rejects_unknown_motif_reference` / `validate_rejects_duplicate_section_names_and_mute_all` / `example_suite_validates` | +| Structured composition and transitions (sections/motif) | Medium | ✅ (suite per-section assets + concatenated main playback file + exact section lengths + manifest + tempo override + mute) | ✅ (unknown motif reference / duplicate section names / all-muted section / unknown --section) | N/A (local CLI) | ✅ (the complete suite is staged and published as one recoverable set; a later section failure preserves the prior set) | `tests/cli.rs::build_suite_emits_per_section_assets_with_exact_lengths` / `successful_suite_build_replaces_existing_artifacts_as_one_set` / `suite_failure_rolls_back_all_previously_built_sections` / `build_suite_to_ogg_emits_main_file_without_leftover_cuts` / `midi_section_selector_compiles_that_section_deterministically` / `midi_unknown_section_is_input_error` / `validate_rejects_unknown_motif_reference` / `validate_rejects_duplicate_section_names_and_mute_all` / `example_suite_validates` | | Second render backend (--renderer, M3) | Medium | ✅ (full timidity pipeline: same exact length, different timbre, non-silent, for the same DSL) | ✅ (bad SF2 → header-only zero-frame WAV fallback → exit 4; missing SF2 → exit 2) | N/A (local CLI) | ✅ (failure deletes the partial artifact, no leftovers in the directory) | `tests/cli.rs::build_timidity_backend_same_length_different_timbre` / `render_timidity_corrupt_soundfont_fails_without_partial_output` / `render_timidity_missing_soundfont_is_input_error` | | Semantic diff (diff, M4) | Low (read-only) | ✅ (semantic changes as porcelain + `--json` array; formatting/key-order differences produce an empty diff) | ✅ (invalid scene → exit 2) | N/A (local CLI) | N/A (read-only, no state mutation) | `tests/cli.rs::diff_reports_semantic_changes_and_ignores_formatting` / `diff_invalid_scene_is_input_error` | | Batch rendering with machine-readable report (batch, M4) | High (external process + batch file writes) | ✅ (all scenes built + per-scene exact length + report.json stats) | ✅ (one scene's failure doesn't stop the rest, report records the error, exit code reflects the first failure; output-name collisions rejected before building) | N/A (local CLI) | ✅ (failed scenes leave no partial artifact, successful scenes' output stays intact) | `tests/cli.rs::batch_builds_all_scenes_and_writes_report` / `batch_partial_failure_reports_and_exits_nonzero` / `batch_duplicate_scene_stems_is_input_error` | @@ -222,6 +224,6 @@ Permissions note: scorekit is a local single-user CLI with no role/permission sy | 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`) | | MCP stdio server (`mcp`, M9) | Low (protocol adapter; every tool re-invokes the CLI, all file writes stay behind the wrapped commands' atomic paths) | ✅ (initialize handshake, tools/list exposes all six tools, tools/call validate succeeds on a shipped scene, schema tool returns parseable JSON Schema, notifications get no reply) | ✅ (failing tool returns `isError: true` with the CLI's structured `--json` error passed through verbatim — code/field/exit_code intact; unknown tool → JSON-RPC -32602; unknown method → -32601) | N/A (local stdio, no auth surface by design) | N/A (no direct state mutation; `build` writes delegate to the wrapped command's existing temp-file + atomic-rename guarantee) | `tests/cli.rs::mcp_initialize_lists_tools_and_validates_scene` / `mcp_tool_failure_passes_structured_error_through` | | Spatial performance fields (pan/reverb/glide, M10) | Medium | ✅ (CC10/CC91 values plus off-center pitch bends with a center reset, byte-deterministic across runs; a plain scene emits none of them; a glide loop stays sample-exact) | ✅ (pan out of range → exit 2 + field path; glide on a non-melody track → exit 2 + field path) | N/A (local CLI) | N/A (pure computation, reuses midi/build's atomic mechanism) | `tests/cli.rs::spatial_fields_emit_cc_and_pitch_bend_deterministically` / `spatial_build_keeps_loop_sample_exact` / `validate_rejects_bad_pan_and_glide_on_non_melody` | -| Deterministic sound textures (textures/--texture-profile, M11) | High (external recordings + FFmpeg normalization + file writes) | ✅ (mono/22.05 kHz sources normalize to stereo/build rate; loop + beat one-shots mix into the full asset; equal-length texture stems sum with instrument stems; metadata records the schedule; texture fields leave MIDI byte-identical) | ✅ (ambiguous loop placement → exit 2 + field path; missing mapped source → exit 2) | N/A (local CLI) | ✅ (missing source leaves no output, metadata, stem, normalized WAV, arranged WAV, or staging directory) | `tests/cli.rs::build_textures_normalizes_places_mixes_and_emits_stems` / `build_missing_texture_source_leaves_no_partial_artifact` / `textures_do_not_change_midi_bytes` / `validate_rejects_ambiguous_texture_placement` / `schema_emits_json_schema` | +| Deterministic sound textures (textures/--texture-profile, M11) | High (external recordings + FFmpeg normalization + file writes) | ✅ (mono/22.05 kHz sources normalize to stereo/build rate; loop + beat one-shots mix into the full asset; equal-length texture stems sum with instrument stems; metadata records the schedule; texture fields leave MIDI byte-identical) | ✅ (ambiguous/out-of-section placement → exit 2 + field path; missing mapped source → exit 2) | N/A (local CLI) | ✅ (missing source or a later suite-section failure leaves no newly published output, metadata, stem, normalized WAV, arranged WAV, or staging directory) | `tests/cli.rs::build_textures_normalizes_places_mixes_and_emits_stems` / `build_missing_texture_source_leaves_no_partial_artifact` / `suite_failure_rolls_back_all_previously_built_sections` / `textures_do_not_change_midi_bytes` / `validate_rejects_ambiguous_texture_placement` / `validate_rejects_texture_trigger_outside_shortest_section` / `schema_emits_json_schema` | The matrix currently has no known gaps; when adding a new tier-1 feature, register it per coverage-baseline rule 5. diff --git a/scripts/build_sfizz.sh b/scripts/build_sfizz.sh index 9afdf0c..0e6458d 100755 --- a/scripts/build_sfizz.sh +++ b/scripts/build_sfizz.sh @@ -19,6 +19,7 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" bin_dir="$root/assets/bin" out="$bin_dir/sfizz_render" +sfizz_commit='4e70dc0bef53b41f2853ed46e26f5911114c92d0' if [ -x "$out" ]; then echo "$out" @@ -40,6 +41,11 @@ echo "cloning sfizz 1.2.3 ..." >&2 git clone --branch 1.2.3 --depth 1 --recursive \ https://github.com/sfztools/sfizz.git "$work/sfizz" cd "$work/sfizz" +actual_commit="$(git rev-parse HEAD)" +if [[ "$actual_commit" != "$sfizz_commit" ]]; then + echo "sfizz 1.2.3 tag mismatch: expected $sfizz_commit, got $actual_commit" >&2 + exit 1 +fi echo "patching for arm64 clang ..." >&2 # 1. Don't apply 32-bit ARM NEON flags to arm64. diff --git a/scripts/fetch_assets.sh b/scripts/fetch_assets.sh index bd2d72c..c9df51a 100755 --- a/scripts/fetch_assets.sh +++ b/scripts/fetch_assets.sh @@ -6,12 +6,34 @@ set -euo pipefail dir="$(cd "$(dirname "$0")/.." && pwd)/assets" mkdir -p "$dir" sf2="$dir/TimGM6mb.sf2" +expected_sha256='82475b91a76de15cb28a104707d3247ba932e228bada3f47bba63c6b31aaf7a1' +part="$sf2.part" -if [ ! -f "$sf2" ]; then +sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +cleanup() { + if [ -f "$part" ]; then unlink "$part"; fi +} +trap cleanup EXIT HUP INT TERM + +if [ ! -f "$sf2" ] || [ "$(sha256 "$sf2")" != "$expected_sha256" ]; then echo "downloading TimGM6mb.sf2 ..." >&2 - curl -fsSL -o "$sf2.part" \ + curl -fsSL -o "$part" \ "https://github.com/craffel/pretty-midi/raw/main/pretty_midi/TimGM6mb.sf2" - mv "$sf2.part" "$sf2" + actual_sha256=$(sha256 "$part") + if [ "$actual_sha256" != "$expected_sha256" ]; then + printf 'checksum mismatch for TimGM6mb.sf2: expected %s, got %s\n' \ + "$expected_sha256" "$actual_sha256" >&2 + exit 1 + fi + mv "$part" "$sf2" fi +trap - EXIT HUP INT TERM echo "$sf2" diff --git a/skills/scorekit/reference.md b/skills/scorekit/reference.md index 9158396..9cac065 100644 --- a/skills/scorekit/reference.md +++ b/skills/scorekit/reference.md @@ -19,17 +19,18 @@ Exit codes: `0` ok · `1` io · `2` invalid input / lint violations · `3` missi | `doctor` | check OS/architecture, FFmpeg, and all render backends | global `--json` emits the full environment report; exit 3 if FFmpeg or every renderer is unavailable | | `validate ` | check DSL, print summary | — | | `schema` | JSON Schema of scene DSL | `--grammar` → grammar profile; `--profile` → renderer profile; `--texture-profile` → texture-source profile | -| `profile check ` | certify all explicit SFZ mappings with real probe renders | `--sample-rate` (44100); global `--json` emits the full report | +| `profile check ` | certify all explicit SFZ mappings with real probe renders | `--sample-rate` 8000..=384000 (44100); global `--json` emits the full report | | `lint --grammar ` | check scene against aesthetic grammar | — | | `midi -o ` | compile to SMF (format 1, PPQ 480) | `--passes` 1..=8 (1), `--solo `, `--section ` | -| `render -o ` | synthesize WAV | `--soundfont ` (defaults to `$SCOREKIT_SOUND_LIBRARY_DIR/sf2/MuseScore_General.sf2`) **or** `--sfz ` (sfizz, single instrument); `--renderer fluidsynth\|timidity\|sfizz` (fluidsynth), `--sample-rate` (44100), `--gain` (0.8, ignored by sfizz) | +| `render -o ` | synthesize WAV | `--soundfont ` (defaults to `$SCOREKIT_SOUND_LIBRARY_DIR/sf2/MuseScore_General.sf2`) **or** `--sfz ` (sfizz, single instrument); `--renderer fluidsynth\|timidity\|sfizz` (fluidsynth), `--sample-rate` 8000..=384000 (44100), `--gain` 0.0..=8.0 (0.8, ignored by sfizz) | | `export -o ` | FFmpeg convert (.ogg Vorbis / .wav PCM) | `--quality` 0..=10 (5), `--seek-samples` (0), `--take-samples` | -| `build -o ` | full chain + meta.json | default MuseScore General, explicit `--soundfont `, **or** `--profile ` (sfizz); `--texture-profile ` when `textures` are declared; `--renderer fluidsynth\|timidity\|sfizz`; plus `--stems`, `--tail` secs (4.0, non-loop), `--crossfade-ms` (50, loop seal), `--keep-intermediates` | +| `build -o ` | full chain + meta.json | default MuseScore General, explicit `--soundfont `, **or** `--profile ` (sfizz); `--texture-profile ` when `textures` are declared; `--renderer fluidsynth\|timidity\|sfizz`; plus `--stems`, `--tail` 0.0..=3600.0 secs (4.0, non-loop), `--crossfade-ms` 0..=60000 (50, loop seal), `--keep-intermediates` | | `diff ` | semantic scene diff (ignores formatting) | — | | `batch --out-dir ` | build many; report.json; failures don't stop the rest | default MuseScore General, explicit `--soundfont `, **or** `--profile ` (sfizz); `--format ogg\|wav` (ogg) + render/export flags | -All file writes are atomic (temp + rename): a failed command leaves no -partial output. +Individual file writes are atomic (temp + rename). Suite builds additionally +stage every section, main asset, stem directory, and manifest as one set; a +failed command leaves the previously published set unchanged. ## Scene DSL @@ -113,7 +114,8 @@ RPM belongs to the game runtime, not texture tracks. Sections share the scene's key, tracks, motifs, harmony and performance. `midi --section ` compiles one; `build` emits one asset per section -plus a manifest. +plus a manifest. A shared texture trigger must fit the shortest section +timeline, so it cannot wrap silently in a shorter cue. ### Performance (all optional, all deterministic) diff --git a/src/main.rs b/src/main.rs index ac97a14..505902d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,6 +235,82 @@ fn compile_midi( tools::write_atomic(output, &bytes) } +fn invalid_option(path: &str, message: impl Into) -> error::Error { + error::Error::Validation { + path: path.to_owned(), + message: message.into(), + } +} + +fn validate_sample_rate(sample_rate: u32) -> Result<()> { + if (8_000..=384_000).contains(&sample_rate) { + Ok(()) + } else { + Err(invalid_option( + "--sample-rate", + format!("{sample_rate} out of range 8000..=384000"), + )) + } +} + +fn validate_gain(gain: f32) -> Result<()> { + if gain.is_finite() && (0.0..=8.0).contains(&gain) { + Ok(()) + } else { + Err(invalid_option( + "--gain", + format!("{gain} out of range 0.0..=8.0 (finite values only)"), + )) + } +} + +fn validate_quality(quality: u8) -> Result<()> { + if quality <= 10 { + Ok(()) + } else { + Err(invalid_option( + "--quality", + format!("{quality} out of range 0..=10"), + )) + } +} + +fn validate_tail(tail: f64) -> Result<()> { + if tail.is_finite() && (0.0..=3_600.0).contains(&tail) { + Ok(()) + } else { + Err(invalid_option( + "--tail", + format!("{tail} out of range 0.0..=3600.0 seconds (finite values only)"), + )) + } +} + +fn validate_crossfade(crossfade_ms: u32) -> Result<()> { + if crossfade_ms <= 60_000 { + Ok(()) + } else { + Err(invalid_option( + "--crossfade-ms", + format!("{crossfade_ms} out of range 0..=60000"), + )) + } +} + +fn validate_build_options( + sample_rate: u32, + gain: f32, + quality: u8, + tail: f64, + crossfade_ms: u32, +) -> Result<()> { + validate_sample_rate(sample_rate)?; + validate_gain(gain)?; + validate_quality(quality)?; + validate_tail(tail)?; + validate_crossfade(crossfade_ms) +} + fn run(command: &Command, json: bool) -> Result { match command { Command::Doctor => { @@ -310,6 +386,7 @@ fn run(command: &Command, json: bool) -> Result { sample_rate, }, } => { + validate_sample_rate(*sample_rate)?; let report = profile_check::check(profile, *sample_rate)?; if json { Ok(report.to_json().to_string()) @@ -336,6 +413,8 @@ fn run(command: &Command, json: bool) -> Result { sample_rate, gain, } => { + validate_sample_rate(*sample_rate)?; + validate_gain(*gain)?; match renderer { tools::Renderer::Sfizz => { let sfz = sfz.as_deref().ok_or_else(|| error::Error::Validation { @@ -374,6 +453,7 @@ fn run(command: &Command, json: bool) -> Result { seek_samples, take_samples, } => { + validate_quality(*quality)?; if *seek_samples > 0 || take_samples.is_some() { let take = match take_samples { Some(t) => *t, @@ -417,6 +497,7 @@ fn run(command: &Command, json: bool) -> Result { crossfade_ms, keep_intermediates, } => { + validate_build_options(*sample_rate, *gain, *quality, *tail, *crossfade_ms)?; let soundfont = soundfont::for_renderer(*renderer, soundfont.as_deref())?; pipeline::build(&pipeline::BuildArgs { scene, @@ -469,6 +550,7 @@ fn run(command: &Command, json: bool) -> Result { crossfade_ms, report, } => { + validate_build_options(*sample_rate, *gain, *quality, *tail, *crossfade_ms)?; let soundfont = soundfont::for_renderer(*renderer, soundfont.as_deref())?; pipeline::batch(&pipeline::BatchArgs { scenes, diff --git a/src/pipeline.rs b/src/pipeline.rs index ba97b3d..46f793a 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -150,6 +150,97 @@ impl Drop for Cleanup { } } +fn path_io(path: &Path, source: std::io::Error) -> Error { + Error::Io { + path: path.display().to_string(), + source, + } +} + +fn create_work_dir(parent: &Path, label: &str) -> Result { + std::fs::create_dir_all(parent).map_err(|source| path_io(parent, source))?; + for attempt in 0..1_000u16 { + let candidate = parent.join(format!(".{label}-{}-{attempt}", std::process::id())); + match std::fs::create_dir(&candidate) { + Ok(()) => return Ok(candidate), + Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(source) => return Err(path_io(&candidate, source)), + } + } + Err(path_io( + parent, + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("could not allocate a unique {label} directory"), + ), + )) +} + +fn remove_path(path: &Path) -> std::io::Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + std::fs::remove_dir_all(path) + } + Ok(_) => std::fs::remove_file(path), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(source), + } +} + +/// Publish a complete suite staging directory as one recoverable operation. +/// Existing destinations are first moved into a same-filesystem backup. Any +/// rename failure removes newly published entries and restores every backup, +/// so a failed command never exposes a half-old/half-new suite. +fn publish_staged_suite(staging: &Path, destination_dir: &Path, label: &str) -> Result<()> { + let mut sources = std::fs::read_dir(staging) + .map_err(|source| path_io(staging, source))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>() + .map_err(|source| path_io(staging, source))?; + sources.sort(); + let backup = create_work_dir(destination_dir, &format!("{label}.suite.backup"))?; + let mut backups: Vec<(PathBuf, PathBuf)> = Vec::new(); + let mut published: Vec = Vec::new(); + + let publish_result = (|| -> Result<()> { + for source in sources { + let name = source.file_name().ok_or_else(|| { + validation( + "--output", + format!("staged artifact has no file name: {}", source.display()), + ) + })?; + let destination = destination_dir.join(name); + if std::fs::symlink_metadata(&destination).is_ok() { + let saved = backup.join(name); + std::fs::rename(&destination, &saved) + .map_err(|source| path_io(&destination, source))?; + backups.push((saved, destination.clone())); + } + std::fs::rename(&source, &destination) + .map_err(|source| path_io(&destination, source))?; + published.push(destination); + } + Ok(()) + })(); + + if let Err(error) = publish_result { + for destination in published.iter().rev() { + let _ = remove_path(destination); + } + for (saved, destination) in backups.iter().rev() { + let _ = std::fs::rename(saved, destination); + } + let _ = std::fs::remove_dir_all(&backup); + return Err(error); + } + + // Old complete artifacts are no longer needed. Public outputs are already + // fully committed; cleanup residue is private and best-effort on Drop. + let _ = std::fs::remove_dir_all(&backup); + Ok(()) +} + /// Batch: render many scenes in one run; per-scene failures don't abort the /// run, they land in a machine-readable report instead. pub struct BatchArgs<'a> { @@ -435,28 +526,37 @@ pub fn build(args: &BuildArgs) -> Result { .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); + let destination_dir = args + .output + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let output_name = args.output.file_name().ok_or_else(|| { + validation( + "--output", + format!("output has no file name: {}", args.output.display()), + ) + })?; + let staging = create_work_dir(destination_dir, &format!("{stem}.suite.tmp"))?; + let staged_output = staging.join(output_name); let mut cleanup = Cleanup { files: Vec::new(), - dirs: Vec::new(), - keep: args.keep_intermediates, + dirs: vec![staging.clone()], + keep: false, }; let mut entries = Vec::new(); let mut names = Vec::new(); let mut cuts: Vec = Vec::new(); for section in &scene.sections { let derived = scene.for_section(section); - let output = args - .output - .with_file_name(format!("{stem}-{}.{ext}", section.name)); + let output = staged_output.with_file_name(format!("{stem}-{}.{ext}", section.name)); // WAV sections are already sample-exact cuts; OGG sections keep - // their pre-encode `.cut.wav` alive (registered for cleanup first, - // so a failure mid-suite still removes it). + // their pre-encode `.cut.wav` inside the private suite staging + // directory until the main concatenation has finished. let cut = if ext == "wav" { output.clone() } else { - let cut = output.with_extension("cut.wav"); - cleanup.files.push(cut.clone()); - cut + output.with_extension("cut.wav") }; let mut entry = build_one(args, &derived, &output, &ext, ext != "wav")?; entry["name"] = json!(section.name); @@ -467,12 +567,15 @@ pub fn build(args: &BuildArgs) -> Result { // Main playback file: sample-exact concatenation of the section cuts. if ext == "wav" { - audio::concat(&cuts, args.output)?; + audio::concat(&cuts, &staged_output)?; } else { - let main_cut = args.output.with_extension("cut.wav"); - cleanup.files.push(main_cut.clone()); + let main_cut = staged_output.with_extension("cut.wav"); audio::concat(&cuts, &main_cut)?; - tools::export(&main_cut, args.output, args.quality)?; + tools::export(&main_cut, &staged_output, args.quality)?; + for cut in &cuts { + std::fs::remove_file(cut).map_err(|source| path_io(cut, source))?; + } + std::fs::remove_file(&main_cut).map_err(|source| path_io(&main_cut, source))?; } let total_samples: u64 = entries @@ -494,7 +597,11 @@ pub fn build(args: &BuildArgs) -> Result { "sections": entries, }); let meta_bytes = serde_json::to_vec_pretty(&manifest).expect("manifest serializes"); - tools::write_atomic(&meta_path, &meta_bytes)?; + let staged_meta = staged_output.with_extension("meta.json"); + tools::write_atomic(&staged_meta, &meta_bytes)?; + publish_staged_suite(&staging, destination_dir, &stem)?; + std::fs::remove_dir(&staging).map_err(|source| path_io(&staging, source))?; + cleanup.dirs.clear(); Ok(format!( "wrote {} ({} samples) from {} section(s) [{}], {}", args.output.display(), diff --git a/src/schema.rs b/src/schema.rs index 8198f78..fb9d16a 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -21,6 +21,7 @@ pub struct Scene { #[serde(default)] pub story: Option, /// Tempo in BPM. Range: 20..=300. + #[schemars(range(min = 20, max = 300))] pub tempo: u16, /// Key, e.g. `C_major`, `D_minor`, `F#_minor`, `Eb_major`. Default: C_major. #[serde(default = "default_key")] @@ -29,6 +30,7 @@ pub struct Scene { #[serde(default = "default_time_signature")] pub time_signature: String, /// Length in bars. Range: 1..=256. + #[schemars(range(min = 1, max = 256))] pub bars: u16, /// Whether this scene is intended to loop seamlessly (asset metadata). #[serde(default)] @@ -42,6 +44,7 @@ pub struct Scene { /// patterns (sustain/arpeggio/bass) derive from it. Default when absent: /// I-V-vi-IV in major, i-VI-III-VII in minor. #[serde(default)] + #[schemars(length(max = 32))] pub harmony: Vec, /// Performance rendering: deterministic humanization, dynamics, swing, /// legato. Absent means the exact mechanical rendering (byte-stable). @@ -51,8 +54,10 @@ pub struct Scene { /// effects. Logical source names are bound to audio files by a separate /// texture profile at build time; paths never enter the scene protocol. #[serde(default)] + #[schemars(length(max = 16))] pub textures: Vec, /// Instrument tracks. 1..=16 entries, at most 15 melodic plus one drums. + #[schemars(length(min = 1, max = 16))] pub tracks: Vec, /// Suite sections. When present, `build` emits one asset per section /// (e.g. intro / explore / combat / victory), all sharing this scene's @@ -72,12 +77,15 @@ pub struct TextureTrack { pub mode: TextureMode, /// Start in quarter-note beats, valid only for `mode: loop`. Default: 0. #[serde(default)] + #[schemars(range(min = 0.0))] pub start_beat: Option, /// Trigger positions in quarter-note beats, required for `mode: one_shot`. #[serde(default)] + #[schemars(length(max = 64), inner(range(min = 0.0)))] pub at: Vec, /// Linear amplitude multiplier applied before summation. Default: 1. #[serde(default = "default_texture_gain")] + #[schemars(range(min = 0.0, max = 1.0))] pub gain: f32, } @@ -104,6 +112,7 @@ pub struct Track { pub motif: Option, /// Dynamic level 0.0..=1.0, scales note velocities. Default: 0.6. #[serde(default = "default_intensity")] + #[schemars(range(min = 0.0, max = 1.0))] pub intensity: f32, /// Playing technique used to pick samples when rendering through an SFZ /// renderer profile (`--renderer sfizz`). Does not change the compiled @@ -114,11 +123,13 @@ pub struct Track { /// Compiled to MIDI CC10 at the start of the track. Absent: no CC10 is /// emitted and the synth default (center) applies. #[serde(default)] + #[schemars(range(min = 0.0, max = 1.0))] pub pan: Option, /// Reverb send 0.0..=1.0, compiled to MIDI CC91 at the start of the /// track — spatial depth (near/far). Absent: no CC91 is emitted. /// SFZ instruments respond only if the `.sfz` maps these controllers. #[serde(default)] + #[schemars(range(min = 0.0, max = 1.0))] pub reverb: Option, /// Tail portamento, only with pattern `melody`: during the final `glide` /// fraction of each note, pitch bends deterministically toward the next @@ -127,6 +138,7 @@ pub struct Track { /// note's pitch, so the gesture carries across the loop seam. Range: /// 0.0..=1.0. Absent or 0: no pitch-bend events are emitted. #[serde(default)] + #[schemars(range(min = 0.0, max = 1.0))] pub glide: Option, } @@ -159,8 +171,10 @@ pub enum Articulation { pub struct MotifNote { /// Scale degree: 1 = tonic, 8 = tonic an octave up, negative = below, /// 0 = rest. Range: -21..=21. + #[schemars(range(min = -21, max = 21))] pub degree: i8, /// Duration in beats. Range: 0.125..=16. + #[schemars(range(min = 0.125, max = 16.0))] pub beats: f64, } @@ -173,18 +187,21 @@ pub struct Section { /// Section name, used in output file names. `[A-Za-z0-9_-]+`. pub name: String, /// Length in bars. Range: 1..=256. + #[schemars(range(min = 1, max = 256))] pub bars: u16, /// Whether this section loops seamlessly. #[serde(default)] pub r#loop: bool, /// Optional tempo override in BPM. Range: 20..=300. #[serde(default)] + #[schemars(range(min = 20, max = 300))] pub tempo: Option, /// 0-based indices of tracks silenced in this section. #[serde(default)] pub mute: Vec, /// Multiplier applied to every track's intensity. Range: 0.0..=2.0. Default: 1. #[serde(default = "default_section_intensity")] + #[schemars(range(min = 0.0, max = 2.0))] pub intensity: f32, } @@ -215,6 +232,7 @@ pub struct Performance { /// Swing feel: offbeat eighths delayed by this fraction of half a beat. /// Range: 0.0..=0.5. Default: 0 (straight). #[serde(default)] + #[schemars(range(min = 0.0, max = 0.5))] pub swing: f32, /// Extend melodic note durations so consecutive notes overlap slightly. #[serde(default)] @@ -231,9 +249,11 @@ pub struct Performance { pub struct Humanize { /// Max onset shift in milliseconds, uniform in ±timing_ms. Range: 0..=50. #[serde(default)] + #[schemars(range(max = 50))] pub timing_ms: u8, /// Max velocity shift, uniform in ±velocity. Range: 0..=30. #[serde(default)] + #[schemars(range(max = 30))] pub velocity: u8, /// Random seed; same seed reproduces the same performance bit-exactly. #[serde(default)] @@ -569,15 +589,23 @@ impl Scene { ), ); } - let max_bars = self - .sections - .iter() - .map(|s| s.bars) - .chain(std::iter::once(self.bars)) - .max() - .unwrap_or(self.bars); - let max_beats = - f64::from(max_bars) * f64::from(time_sig.num) * 4.0 / f64::from(time_sig.den); + // Textures are shared by every section, so their schedule must fit the + // shortest compiled timeline. Validating against the longest section + // lets an event from pass one wrap into pass two of a shorter loop. + // If a section length is itself invalid, defer range checks here so the + // dedicated section validation below reports the primary error. + let beats_for = + |bars: u16| f64::from(bars) * f64::from(time_sig.num) * 4.0 / f64::from(time_sig.den); + let texture_timeline = if self.sections.is_empty() { + Some(("scene".to_owned(), beats_for(self.bars))) + } else if self.sections.iter().all(|s| (1..=256).contains(&s.bars)) { + self.sections + .iter() + .map(|s| (format!("section `{}`", s.name), beats_for(s.bars))) + .min_by(|a, b| a.1.total_cmp(&b.1)) + } else { + None + }; let has_loop_section = self.r#loop || self.sections.iter().any(|s| s.r#loop); for (i, texture) in self.textures.iter().enumerate() { if !crate::texture::valid_source_name(&texture.source) { @@ -604,10 +632,18 @@ impl Scene { ); } let start = texture.start_beat.unwrap_or(0.0); - if !start.is_finite() || !(0.0..max_beats).contains(&start) { + if !start.is_finite() { + return fail( + &format!("textures[{i}].start_beat"), + format!("{start} must be finite"), + ); + } + if let Some((timeline, max_beats)) = &texture_timeline + && !(0.0..*max_beats).contains(&start) + { return fail( &format!("textures[{i}].start_beat"), - format!("{start} out of range 0.0..{max_beats}"), + format!("{start} out of range 0.0..{max_beats} for {timeline}"), ); } if has_loop_section && start != 0.0 { @@ -638,10 +674,18 @@ impl Scene { ); } for (j, at) in texture.at.iter().enumerate() { - if !at.is_finite() || !(0.0..max_beats).contains(at) { + if !at.is_finite() { + return fail( + &format!("textures[{i}].at[{j}]"), + format!("{at} must be finite"), + ); + } + if let Some((timeline, max_beats)) = &texture_timeline + && !(0.0..*max_beats).contains(at) + { return fail( &format!("textures[{i}].at[{j}]"), - format!("{at} out of range 0.0..{max_beats}"), + format!("{at} out of range 0.0..{max_beats} for {timeline}"), ); } } diff --git a/tests/cli.rs b/tests/cli.rs index 7075004..a2de89c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -330,6 +330,42 @@ fn schema_emits_json_schema() { v["properties"]["textures"].is_object(), "schema has textures: {v}" ); + assert_eq!(v["properties"]["tempo"]["minimum"], 20); + assert_eq!(v["properties"]["tempo"]["maximum"], 300); + assert_eq!(v["properties"]["bars"]["minimum"], 1); + assert_eq!(v["properties"]["bars"]["maximum"], 256); + assert_eq!( + v["$defs"]["TextureTrack"]["properties"]["gain"]["minimum"], + 0.0 + ); + assert_eq!( + v["$defs"]["TextureTrack"]["properties"]["gain"]["maximum"], + 1.0 + ); + assert_eq!( + v["$defs"]["TextureTrack"]["properties"]["start_beat"]["minimum"], + 0.0 + ); + assert_eq!( + v["$defs"]["TextureTrack"]["properties"]["at"]["items"]["minimum"], + 0.0 + ); + assert_eq!( + v["$defs"]["MotifNote"]["properties"]["beats"]["minimum"], + 0.125 + ); + assert_eq!( + v["$defs"]["MotifNote"]["properties"]["beats"]["maximum"], + 16.0 + ); + assert_eq!( + v["$defs"]["Track"]["properties"]["intensity"]["minimum"], + 0.0 + ); + assert_eq!( + v["$defs"]["Track"]["properties"]["intensity"]["maximum"], + 1.0 + ); let out = bin() .args(["schema", "--texture-profile"]) @@ -431,6 +467,32 @@ fn validate_rejects_ambiguous_texture_placement() { assert_eq!(error["field"], "textures[0].start_beat"); } +#[test] +fn validate_rejects_texture_trigger_outside_shortest_section() { + let dir = tempfile::tempdir().unwrap(); + let scene = dir.path().join("suite.yaml"); + fs::write( + &scene, + "tempo: 60\nbars: 2\ntextures:\n - source: bell\n mode: one_shot\n at: [5]\ntracks:\n - instrument: piano\n pattern: sustain\nsections:\n - name: short\n bars: 1\n loop: true\n - name: long\n bars: 2\n loop: false\n", + ) + .unwrap(); + let out = bin() + .args(["--json", "validate"]) + .arg(&scene) + .assert() + .code(2); + let error: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); + assert_eq!(error["field"], "textures[0].at[0]"); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("section `short`"), + "error identifies the section whose timeline would wrap: {error}" + ); + assert_dir_contains_exactly(dir.path(), &["suite.yaml"]); +} + #[test] fn validate_rejects_non_string_story_with_location() { let dir = tempfile::tempdir().unwrap(); @@ -916,6 +978,103 @@ fn build_nonloop_wav_has_exact_padded_length() { assert_eq!(out.len() as u64, expected * u64::from(spec.channels)); } +#[test] +fn build_rejects_nonfinite_tail_as_structured_input_error() { + let dir = tempfile::tempdir().unwrap(); + let scene = dir.path().join("sting.yaml"); + fs::write( + &scene, + "tempo: 120\nbars: 1\nloop: false\ntracks:\n - instrument: piano\n pattern: sustain\n", + ) + .unwrap(); + let output = dir.path().join("sting.wav"); + let out = bin() + .args(["--json", "build"]) + .arg(&scene) + .arg("--soundfont") + .arg(sf2()) + .arg("--tail") + .arg("inf") + .arg("-o") + .arg(&output) + .assert() + .code(2); + let error: serde_json::Value = + serde_json::from_slice(&out.get_output().stderr).expect("stderr is one JSON error"); + assert_eq!(error["code"], "validation"); + assert_eq!(error["field"], "--tail"); + assert_dir_contains_exactly(dir.path(), &["sting.yaml"]); +} + +#[test] +fn numeric_cli_options_reject_out_of_range_values_before_writing() { + let dir = tempfile::tempdir().unwrap(); + let scene = dir.path().join("scene.yaml"); + fs::write( + &scene, + "tempo: 120\nbars: 1\ntracks:\n - instrument: piano\n pattern: sustain\n", + ) + .unwrap(); + let midi = dir.path().join("missing.mid"); + let audio = dir.path().join("missing.wav"); + + let cases = [ + ( + vec![ + "render".into(), + midi.as_os_str().to_owned(), + "--sample-rate".into(), + "0".into(), + "-o".into(), + dir.path().join("rate.wav").into_os_string(), + ], + "--sample-rate", + ), + ( + vec![ + "render".into(), + midi.as_os_str().to_owned(), + "--gain".into(), + "NaN".into(), + "-o".into(), + dir.path().join("gain.wav").into_os_string(), + ], + "--gain", + ), + ( + vec![ + "export".into(), + audio.as_os_str().to_owned(), + "--quality".into(), + "11".into(), + "-o".into(), + dir.path().join("quality.ogg").into_os_string(), + ], + "--quality", + ), + ( + vec![ + "build".into(), + scene.as_os_str().to_owned(), + "--crossfade-ms".into(), + "60001".into(), + "-o".into(), + dir.path().join("crossfade.wav").into_os_string(), + ], + "--crossfade-ms", + ), + ]; + + for (args, field) in cases { + let out = bin().arg("--json").args(args).assert().code(2); + let error: serde_json::Value = + serde_json::from_slice(&out.get_output().stderr).expect("stderr is one JSON error"); + assert_eq!(error["code"], "validation"); + assert_eq!(error["field"], field); + } + assert_dir_contains_exactly(dir.path(), &["scene.yaml"]); +} + #[test] fn build_stems_are_aligned_and_sum_to_mix() { let dir = tempfile::tempdir().unwrap(); @@ -1104,6 +1263,82 @@ fn build_missing_texture_source_leaves_no_partial_artifact() { assert_dir_contains_exactly(dir.path(), &["scene.yaml", "textures.yaml"]); } +#[test] +fn suite_failure_rolls_back_all_previously_built_sections() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("long-bell.wav"); + write_texture_wave(&source, 440.0, 5.0); + let profile = dir.path().join("textures.yaml"); + fs::write( + &profile, + "name: rollback-test\nsources:\n long_bell: long-bell.wav\n", + ) + .unwrap(); + let scene = dir.path().join("suite.yaml"); + fs::write( + &scene, + "tempo: 120\nbars: 2\ntextures:\n - source: long_bell\n mode: one_shot\n at: [0]\ntracks:\n - instrument: piano\n pattern: sustain\nsections:\n - name: long\n bars: 2\n loop: false\n - name: short\n bars: 1\n loop: true\n", + ) + .unwrap(); + let out = bin() + .args(["--json", "build"]) + .arg(&scene) + .arg("--soundfont") + .arg(sf2()) + .arg("--texture-profile") + .arg(&profile) + .args(["--sample-rate", "8000", "--tail", "0"]) + .arg("-o") + .arg(dir.path().join("suite.wav")) + .assert() + .code(2); + let error: serde_json::Value = serde_json::from_slice(&out.get_output().stderr).unwrap(); + assert_eq!(error["field"], "textures[0].source"); + assert_dir_contains_exactly( + dir.path(), + &["long-bell.wav", "suite.yaml", "textures.yaml"], + ); + + // A failed rebuild must also leave an already published suite byte-for-byte + // untouched, rather than exposing a mixture of old and new sections. + let prior = [ + ("suite.wav", b"old-main".as_slice()), + ("suite-long.wav", b"old-long".as_slice()), + ("suite-short.wav", b"old-short".as_slice()), + ("suite.meta.json", b"old-manifest".as_slice()), + ]; + for (name, contents) in prior { + fs::write(dir.path().join(name), contents).unwrap(); + } + bin() + .args(["--json", "build"]) + .arg(&scene) + .arg("--soundfont") + .arg(sf2()) + .arg("--texture-profile") + .arg(&profile) + .args(["--sample-rate", "8000", "--tail", "0"]) + .arg("-o") + .arg(dir.path().join("suite.wav")) + .assert() + .code(2); + for (name, contents) in prior { + assert_eq!(fs::read(dir.path().join(name)).unwrap(), contents); + } + assert_dir_contains_exactly( + dir.path(), + &[ + "long-bell.wav", + "suite.yaml", + "textures.yaml", + "suite.wav", + "suite-long.wav", + "suite-short.wav", + "suite.meta.json", + ], + ); +} + #[test] fn build_ogg_stems_leave_no_intermediates() { // Regression: encoded stems go through a `.cut.wav` intermediate inside @@ -1232,6 +1467,50 @@ fn build_suite_emits_per_section_assets_with_exact_lengths() { assert_eq!(sections[1]["tracks"].as_array().unwrap().len(), 1); } +#[test] +fn successful_suite_build_replaces_existing_artifacts_as_one_set() { + let dir = tempfile::tempdir().unwrap(); + let scene = dir.path().join("suite.yaml"); + fs::write(&scene, suite_yaml()).unwrap(); + for name in [ + "suite.wav", + "suite-explore.wav", + "suite-sting.wav", + "suite.meta.json", + ] { + fs::write(dir.path().join(name), b"old incomplete suite").unwrap(); + } + + let output = dir.path().join("suite.wav"); + bin() + .arg("build") + .arg(&scene) + .arg("--soundfont") + .arg(sf2()) + .arg("-o") + .arg(&output) + .assert() + .success(); + + let (_, main) = read_frames(&output); + let (_, explore) = read_frames(&dir.path().join("suite-explore.wav")); + let (_, sting) = read_frames(&dir.path().join("suite-sting.wav")); + assert_eq!(main.len(), explore.len() + sting.len()); + let meta: serde_json::Value = + serde_json::from_slice(&fs::read(dir.path().join("suite.meta.json")).unwrap()).unwrap(); + assert_eq!(meta["suite"], true); + assert_dir_contains_exactly( + dir.path(), + &[ + "suite.yaml", + "suite.wav", + "suite-explore.wav", + "suite-sting.wav", + "suite.meta.json", + ], + ); +} + #[test] fn build_suite_to_ogg_emits_main_file_without_leftover_cuts() { let dir = tempfile::tempdir().unwrap();