Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ Exit codes are stable: `0` success, `1` I/O failure, `2` invalid input, `3` miss

Run `scorekit <command> --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 <file>`. This
flag is independent of the musical renderer: it works alongside either an
SF2 `--soundfont` or an sfizz `--profile`.
3 changes: 3 additions & 0 deletions docs-site/src/scene-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 7 additions & 5 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand All @@ -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.
6 changes: 6 additions & 0 deletions scripts/build_sfizz.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down
28 changes: 25 additions & 3 deletions scripts/fetch_assets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading