diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index e77c6d0a..79ba3282 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -28,6 +28,10 @@ on: - 'scripts/phase6c-build-historical-it-observer.py' - 'scripts/phase6c-validate-historical-it-original.py' - '.github/workflows/phase6c-historical-it-private.yml' + - 'scripts/phase6c-midi-corpus.py' + - 'tests/phase6c_midi_corpus.py' + - 'tests/phase6c_midi_corpus_probe.c' + - '.github/workflows/phase6c-midi-corpus-private.yml' - 'cpsycle/**' - 'psycle-cpp-r12005-sanitized/**' - '.github/workflows/phase6c-legacy-playback-import.yml' @@ -57,6 +61,10 @@ on: - 'scripts/phase6c-build-historical-it-observer.py' - 'scripts/phase6c-validate-historical-it-original.py' - '.github/workflows/phase6c-historical-it-private.yml' + - 'scripts/phase6c-midi-corpus.py' + - 'tests/phase6c_midi_corpus.py' + - 'tests/phase6c_midi_corpus_probe.c' + - '.github/workflows/phase6c-midi-corpus-private.yml' - 'cpsycle/**' - 'psycle-cpp-r12005-sanitized/**' - '.github/workflows/phase6c-legacy-playback-import.yml' @@ -83,6 +91,7 @@ jobs: run: | python3 tests/phase6c_legacy_module_import.py python3 tests/phase6c_historical_it.py + python3 tests/phase6c_midi_corpus.py - name: Generate canonical IT fixture run: | @@ -131,7 +140,7 @@ jobs: DEBIAN_FRONTEND: noninteractive run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends build-essential pkg-config liblua5.4-dev liblilv-dev + sudo apt-get install -y --no-install-recommends build-essential gdb pkg-config liblua5.4-dev liblilv-dev - name: Generate canonical IT fixture run: | @@ -143,6 +152,159 @@ jobs: - name: Build C-Psycle donor player libraries run: make -C cpsycle/player -j1 + - name: Build synthetic MIDI corpus execution probe + shell: bash + run: | + set -euo pipefail + python3 scripts/phase6c-midi-corpus.py write-synthetic \ + phase6c-generated/phase6c-midi-synthetic.mid + python3 scripts/phase6c-midi-corpus.py write-regression-fixtures \ + phase6c-generated/midi-regressions + gcc -std=gnu11 -g -O0 -fno-omit-frame-pointer \ + -Wall -Wextra -Werror=implicit-function-declaration \ + -Icpsycle/script/src -Icpsycle/thread/src -Icpsycle/container/src \ + -Icpsycle/file/src -Icpsycle/diversalis/src -Icpsycle/src \ + -Icpsycle/audio/src -Icpsycle/dsp/src $(pkg-config --cflags lua) \ + tests/phase6c_midi_corpus_probe.c \ + -o phase6c-generated/phase6c-midi-corpus-probe \ + -Lcpsycle/thread/src -Lcpsycle/script/src -Lcpsycle/container/src \ + -Lcpsycle/dsp/src -Lcpsycle/audio/src -Lcpsycle/file/src \ + -Wl,--no-as-needed -laudio -lthread -llilv-0 -ldsp -lscript -lfile \ + -lm $(pkg-config --libs lua) -lpthread -ldl -lstdc++ -lcontainer + + - name: Prove synthetic SMF import and non-silent projection + shell: bash + run: | + set -euo pipefail + chmod +x phase6c-generated/phase6c-midi-corpus-probe + set +e + phase6c-generated/phase6c-midi-corpus-probe \ + phase6c-generated/phase6c-midi-synthetic.mid \ + phase6c-generated/phase6c-midi-synthetic.wav \ + > phase6c-generated/phase6c-midi-synthetic.log 2>&1 + rc=$? + set -e + cat phase6c-generated/phase6c-midi-synthetic.log + if [[ "$rc" -ne 0 ]]; then + gdb -batch \ + -ex 'set pagination off' \ + -ex run \ + -ex 'thread apply all bt full' \ + --args phase6c-generated/phase6c-midi-corpus-probe \ + phase6c-generated/phase6c-midi-synthetic.mid \ + phase6c-generated/phase6c-midi-synthetic.wav \ + > phase6c-generated/phase6c-midi-synthetic.gdb.log 2>&1 || true + cat phase6c-generated/phase6c-midi-synthetic.gdb.log + exit "$rc" + fi + tail -n 1 phase6c-generated/phase6c-midi-synthetic.log \ + > phase6c-generated/phase6c-midi-synthetic.json + python3 - <<'PY' + import json + from pathlib import Path + observation = json.loads( + Path("phase6c-generated/phase6c-midi-synthetic.json").read_text( + encoding="utf-8" + ) + ) + assert observation["schema_version"] == 1 + assert observation["phase"] == "6C" + assert observation["contract"] == "legacy-midi-real-world-donor" + assert observation["evidence_role"] == "cpsycle-donor-observation" + assert observation["load_result"] == "accepted" + assert observation["sequence_tracks"] == 2 + assert observation["patterns"] == 2 + assert observation["imported_notes"] == 2 + assert observation["releases"] == 2 + assert observation["midi_channel_mask"] == 3 + assert observation["release_channel_mask"] == 3 + assert observation["machines_before_projection"] == 0 + assert observation["projection_notes"] == 2 + assert observation["projection_kind"] == "deterministic-sampler" + assert observation["render_frames"] > 0 + assert observation["render_peak"] > 0 + assert observation["non_silent_projection"] is True + assert observation["parity_status"] == "UNKNOWN" + assert len(observation["import_event_digest_fnv64"]) == 16 + PY + python3 - <<'PY' + import importlib.util + import json + import math + from pathlib import Path + import subprocess + + spec = importlib.util.spec_from_file_location( + "phase6c_midi_corpus_ci", + Path("scripts/phase6c-midi-corpus.py"), + ) + helper = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(helper) + + probe = Path("phase6c-generated/phase6c-midi-corpus-probe").resolve() + root = Path("phase6c-generated/midi-regressions") + cases = { + "zero-duration.mid": (1, 1, 1, 1), + "same-note-multichannel.mid": (2, 2, 3, 3), + "high-note.mid": (1, 1, 1, 1), + "equal-tick-release-reuse.mid": (2, 2, 1, 1), + "repeated-two-note-chords.mid": (126, 126, 3, 3), + "polyphony-64.mid": (64, 64, 1, 1), + "sysex-then-note.mid": (1, 1, 1, 1), + "running-status-after-meta.mid": (1, 1, 1, 1), + "controller-event.mid": (1, 1, 1, 1), + "tick-zero-60bpm.mid": (1, 1, 1, 1), + } + for name, (notes, releases, channel_mask, release_mask) in cases.items(): + proc = subprocess.run( + [str(probe), str((root / name).resolve())], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + print(proc.stdout, end="") + assert proc.returncode == 0, (name, proc.returncode) + lines = [line for line in proc.stdout.splitlines() if line.strip()] + observation = json.loads(lines[-1]) + analysis = helper.parse_smf(root / name) + assert observation["imported_notes"] == notes, name + assert observation["releases"] == releases, name + assert observation["midi_channel_mask"] == channel_mask, name + assert observation["release_channel_mask"] == release_mask, name + assert ( + observation["note_event_digest_fnv64"] + == analysis["note_event_digest_fnv64"] + ), name + assert observation["midi_cc"] == analysis["midi_cc"], name + assert ( + observation["midi_cc_digest_fnv64"] + == analysis["midi_cc_digest_fnv64"] + ), name + assert math.isclose( + observation["song_bpm"], + analysis["song_bpm"], + abs_tol=0.001, + ), name + assert observation["projection_kind"] == "not-run", name + assert observation["non_silent_projection"] is False, name + PY + rm -rf phase6c-generated/midi-regressions + rm -f \ + phase6c-generated/phase6c-midi-synthetic.mid \ + phase6c-generated/phase6c-midi-synthetic.wav + + - name: Upload MIDI crash diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: phase6c-midi-crash-diagnostics + path: | + phase6c-generated/phase6c-midi-synthetic.log + phase6c-generated/phase6c-midi-synthetic.gdb.log + if-no-files-found: warn + - name: Build donor IT loader probe shell: bash run: | @@ -436,6 +598,34 @@ jobs: qmake CONFIG-=shared CONFIG+=release psycle-player.pro make -j2 + - name: Attest frozen C++ candidate player + shell: bash + run: | + set -euo pipefail + python3 scripts/phase6c-midi-corpus.py attest-candidate-player \ + psycle-cpp-r12005-sanitized/psycle-player/++qmake/psycle-player \ + phase6c-generated/candidate-player-attestation.json + python3 - <<'PY' + import json + from pathlib import Path + receipt = json.loads( + Path("phase6c-generated/candidate-player-attestation.json").read_text( + encoding="utf-8" + ) + ) + assert receipt["schema_version"] == 1 + assert receipt["phase"] == "6C" + assert receipt["contract"] == "phase6b-candidate-player-clean-rebuild" + assert receipt["evidence_role"] == "candidate-build-attestation" + assert receipt["candidate_baseline_sha256"] == ( + "00cd95562b78303b82e17f62fff4b58622f7c0e78c0b4dd850d448082a53893a" + ) + assert receipt["source_revision"] == "SourceForge SVN r12005" + assert receipt["build_target"] == "psycle-player" + assert receipt["clean_rebuild_sha256"] == receipt["player_sha256"] + assert receipt["parity_status"] == "UNKNOWN" + PY + - name: Observe direct IT import boundary shell: bash run: | @@ -507,6 +697,7 @@ jobs: phase6c-generated/candidate-psycle-player phase6c-generated/candidate-direct-import.log phase6c-generated/candidate-direct-import.json + phase6c-generated/candidate-player-attestation.json if-no-files-found: error diff --git a/.github/workflows/phase6c-midi-corpus-private.yml b/.github/workflows/phase6c-midi-corpus-private.yml new file mode 100644 index 00000000..7a47eb80 --- /dev/null +++ b/.github/workflows/phase6c-midi-corpus-private.yml @@ -0,0 +1,280 @@ +name: Phase 6C MIDI real-world corpus private observation + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: phase6c-midi-corpus-${{ github.ref }} + cancel-in-progress: false + +jobs: + donor-real-world-midi: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Acquire and verify private MIDI corpus + shell: bash + env: + MIDI_CORPUS_URL: ${{ secrets.PSYCLE_PHASE6C_MIDI_CORPUS_URL }} + run: | + set -euo pipefail + [[ -n "$MIDI_CORPUS_URL" ]] || { + echo "PSYCLE_PHASE6C_MIDI_CORPUS_URL secret is not configured" >&2 + exit 2 + } + BUNDLE="$RUNNER_TEMP/phase6c-midi-corpus-bundle.zip" + curl -fsSL --retry 3 --output "$BUNDLE" "$MIDI_CORPUS_URL" + rm -rf phase6c-midi-private + python3 scripts/phase6c-midi-corpus.py prepare-bundle "$BUNDLE" phase6c-midi-private + echo "MIDI_CORPUS_BUNDLE=$BUNDLE" >> "$GITHUB_ENV" + + - name: Install donor build dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends build-essential pkg-config liblua5.4-dev liblilv-dev + + - name: Build C-Psycle donor and MIDI corpus probe + shell: bash + run: | + set -euo pipefail + make -C cpsycle/player -j1 + mkdir -p phase6c-midi-public/observations + gcc -std=gnu11 -g -O0 -fno-omit-frame-pointer -Wall -Wextra -Werror=implicit-function-declaration -Icpsycle/script/src -Icpsycle/thread/src -Icpsycle/container/src -Icpsycle/file/src -Icpsycle/diversalis/src -Icpsycle/src -Icpsycle/audio/src -Icpsycle/dsp/src $(pkg-config --cflags lua) tests/phase6c_midi_corpus_probe.c -o phase6c-midi-public/phase6c-midi-corpus-probe -Lcpsycle/thread/src -Lcpsycle/script/src -Lcpsycle/container/src -Lcpsycle/dsp/src -Lcpsycle/audio/src -Lcpsycle/file/src -Wl,--no-as-needed -laudio -lthread -llilv-0 -ldsp -lscript -lfile -lm $(pkg-config --libs lua) -lpthread -ldl -lstdc++ -lcontainer + cp -- cpsycle/audio/src/midiloader.c phase6c-midi-public/midiloader.c + chmod +x phase6c-midi-public/phase6c-midi-corpus-probe + + - name: Observe all real-world MIDI stems + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import subprocess + + private_root = Path("phase6c-midi-private") + analysis = json.loads( + (private_root / "corpus-analysis.json").read_text(encoding="utf-8") + ) + public = Path("phase6c-midi-public") + observations = public / "observations" + probe = (public / "phase6c-midi-corpus-probe").resolve() + revision = subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True + ).strip() + + def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + for set_info in analysis["sets"]: + print(f"== {set_info['name']} ==") + for stem in set_info["stems"]: + source = (private_root / stem["private_path"]).resolve() + if sha(source) != stem["sha256"]: + raise SystemExit(f"{stem['stem_id']}: private stem hash changed") + wav = Path(os.environ["RUNNER_TEMP"]) / f"{stem['stem_id']}.wav" + proc = subprocess.run( + [str(probe), str(source), str(wav)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if proc.returncode != 0: + print(proc.stdout) + raise SystemExit( + f"{stem['stem_id']}: donor probe failed with exit {proc.returncode}" + ) + lines = [line for line in proc.stdout.splitlines() if line.strip()] + observation = json.loads(lines[-1]) + if observation["phase"] != "6C": + raise SystemExit(f"{stem['stem_id']}: donor phase changed") + if observation["contract"] != "legacy-midi-real-world-donor": + raise SystemExit(f"{stem['stem_id']}: donor contract changed") + if not wav.is_file(): + raise SystemExit(f"{stem['stem_id']}: projection WAV missing") + source_analysis_bytes = json.dumps( + stem["analysis"], sort_keys=True, separators=(",", ":") + ).encode() + observation.update({ + "stem_id": stem["stem_id"], + "set_name": set_info["name"], + "source_relative_name": stem["relative_name"], + "source_sha256": stem["sha256"], + "source_size_bytes": stem["size_bytes"], + "source_analysis_sha256": hashlib.sha256( + source_analysis_bytes + ).hexdigest(), + "probe_output_sha256": hashlib.sha256( + proc.stdout.encode() + ).hexdigest(), + "render_sha256": sha(wav), + "render_size_bytes": wav.stat().st_size, + "source_revision": revision, + "private_input_required": True, + "fixture_redistributed": False, + "projection_boundary": ( + "untouched imported event graph was digested before " + "retargeting notes/releases/MIDI CC to a deterministic " + "project-owned classic Sampler substrate for a 16-beat FileOut render" + ), + }) + (observations / f"{stem['stem_id']}.json").write_text( + json.dumps(observation, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + wav.unlink() + PY + + - name: Build sanitized donor corpus summary + shell: bash + run: | + set -euo pipefail + python3 scripts/phase6c-midi-corpus.py donor-summary phase6c-midi-private/corpus-analysis.json "$MIDI_CORPUS_BUNDLE" phase6c-midi-public/observations --probe phase6c-midi-public/phase6c-midi-corpus-probe --source phase6c-midi-public/midiloader.c --output phase6c-midi-public/donor-midi-corpus.json + rm -rf -- phase6c-midi-private + rm -f -- "$MIDI_CORPUS_BUNDLE" + python3 scripts/phase6c-midi-corpus.py audit-public phase6c-midi-public + + - name: Upload sanitized donor MIDI corpus evidence + uses: actions/upload-artifact@v4 + with: + name: phase6c-midi-real-world-donor + path: phase6c-midi-public/ + if-no-files-found: error + + candidate-midi-boundary: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Acquire and verify private MIDI corpus + shell: bash + env: + MIDI_CORPUS_URL: ${{ secrets.PSYCLE_PHASE6C_MIDI_CORPUS_URL }} + run: | + set -euo pipefail + [[ -n "$MIDI_CORPUS_URL" ]] || { + echo "PSYCLE_PHASE6C_MIDI_CORPUS_URL secret is not configured" >&2 + exit 2 + } + BUNDLE="$RUNNER_TEMP/phase6c-midi-corpus-bundle.zip" + curl -fsSL --retry 3 --output "$BUNDLE" "$MIDI_CORPUS_URL" + rm -rf phase6c-midi-private + python3 scripts/phase6c-midi-corpus.py prepare-bundle "$BUNDLE" phase6c-midi-private + echo "MIDI_CORPUS_BUNDLE=$BUNDLE" >> "$GITHUB_ENV" + + - name: Install candidate build dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ca-certificates curl subversion file build-essential pkg-config qt5-qmake qtbase5-dev libboost-thread-dev libboost-filesystem-dev libboost-system-dev libboost-chrono-dev libboost-date-time-dev libxml++2.6-dev zlib1g-dev libsoxr-dev libasound2-dev libjack-jackd2-dev liblilv-dev liblua5.4-dev + + - name: Verify frozen candidate identities + shell: bash + run: | + set -euo pipefail + scripts/phase6-upstream-audit.sh phase6-upstream-audit + bash scripts/phase6b-sanitized-manifest.sh phase6-upstream-audit phase6b-sanitized-manifest + bash scripts/phase6b-verify-committed-source.sh psycle-cpp-r12005-sanitized phase6b-sanitized-manifest + bash scripts/phase6b-verify-qmake-support.sh psycle-cpp-r12005-sanitized/build-systems/qmake + + - name: Stage pinned candidate build dependencies + shell: bash + run: | + set -euo pipefail + DEST='psycle-cpp-r12005-sanitized/psycle-plugins/src/psycle/plugin_interface.hpp' + ARCHIVE_COMMIT='1863c4177d38d37e8eba9e4eb8857e5023debe6d' + EXPECTED_BLOB='2cf4d8f756fa58098fd84b7b44d0ebe1350594c5' + test ! -e "$DEST" + mkdir -p "$(dirname "$DEST")" + curl -fsSL --retry 3 "https://raw.githubusercontent.com/QSOLKCB/PSYCLE-LINUX/$ARCHIVE_COMMIT/cpsycle/plugins/psycle/plugin_interface.hpp" -o "$DEST" + [[ "$(git hash-object "$DEST")" == "$EXPECTED_BLOB" ]] + DEST='psycle-cpp-r12005-sanitized/diversalis' + test ! -e "$DEST" + exported=false + for attempt in 1 2 3 4; do + rm -rf -- "$DEST" + if svn export --quiet --force -r 12005 'https://svn.code.sf.net/p/psycle/code/trunk/diversalis@12005' "$DEST"; then + exported=true + break + fi + sleep "$((attempt * 2))" + done + [[ "$exported" == true ]] + + - name: Build frozen C++ candidate player + shell: bash + working-directory: psycle-cpp-r12005-sanitized/psycle-player + run: | + set -euo pipefail + qmake CONFIG-=shared CONFIG+=release psycle-player.pro + make -j2 + + - name: Observe representative direct-MIDI boundary + shell: bash + run: | + set -euo pipefail + mkdir -p phase6c-midi-candidate-public + PLAYER='psycle-cpp-r12005-sanitized/psycle-player/++qmake/psycle-player' + chmod +x "$PLAYER" + python3 scripts/phase6c-midi-corpus.py candidate-boundary phase6c-midi-private/corpus-analysis.json "$MIDI_CORPUS_BUNDLE" "$PLAYER" phase6c-midi-candidate-public/candidate-midi-boundary.json + cp -- "$PLAYER" phase6c-midi-candidate-public/candidate-psycle-player + chmod +x phase6c-midi-candidate-public/candidate-psycle-player + rm -rf -- phase6c-midi-private + rm -f -- "$MIDI_CORPUS_BUNDLE" + python3 scripts/phase6c-midi-corpus.py audit-public phase6c-midi-candidate-public + + - name: Upload sanitized candidate MIDI boundary evidence + uses: actions/upload-artifact@v4 + with: + name: phase6c-midi-real-world-candidate + path: phase6c-midi-candidate-public/ + if-no-files-found: error + + midi-corpus-summary: + needs: [donor-real-world-midi, candidate-midi-boundary] + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download donor evidence + uses: actions/download-artifact@v4 + with: + name: phase6c-midi-real-world-donor + path: phase6c-midi-summary/donor + + - name: Download candidate evidence + uses: actions/download-artifact@v4 + with: + name: phase6c-midi-real-world-candidate + path: phase6c-midi-summary/candidate + + - name: Build public-safe corpus summary + shell: bash + run: | + set -euo pipefail + python3 scripts/phase6c-midi-corpus.py summary --donor phase6c-midi-summary/donor/donor-midi-corpus.json --candidate phase6c-midi-summary/candidate/candidate-midi-boundary.json --output phase6c-midi-summary/midi-real-world-corpus-summary.json + printf '%s\n' 'private-input-required: PSYCLE_PHASE6C_MIDI_CORPUS_URL' 'outer transport ZIP contains the six manifest-pinned MIDI archives' 'raw MIDI and ZIP bytes are intentionally excluded from evidence artifacts' > phase6c-midi-summary/private-input-required.txt + python3 scripts/phase6c-midi-corpus.py audit-public phase6c-midi-summary + + - name: Upload public-safe MIDI corpus summary + uses: actions/upload-artifact@v4 + with: + name: phase6c-midi-real-world-corpus-summary + path: phase6c-midi-summary/ + if-no-files-found: error diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index 386a3b17..2625246a 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -44,13 +44,17 @@ void miditrackstate_reset(MidiTrackState* self) self->automationchannel = 1; self->position = 0.0; self->patternoffset = 0.0; + self->position_ticks = 0; + self->patternoffset_ticks = 0; self->runningstatus = 0; - for (v = 0; v < MAX_MIDIFILE_POLYPHONY; ++v) { + self->eventstatus = 0; + for (v = 0; v < MIDIFILE_TRACK_SLOTS; ++v) { psy_audio_PatternEvent ev; psy_audio_patternevent_init(&ev); self->channels[v].tracknote = ev; self->channels[v].time = 0.0; + self->channels[v].tick = 0; self->channels[v].noteoff = FALSE; } } @@ -77,6 +81,7 @@ static int midiloader_readcontroller(MidiLoader*); static int midiloader_readprogramchange(MidiLoader*); static int midiloader_readchannelpressure(MidiLoader*); static int midiloader_readpitchbend(MidiLoader*); +static int midiloader_readsysex(MidiLoader*); /* Midi Meta Events */ static int midiloader_readmetaevent(MidiLoader*, bool* rv_eoftrack); static int midiloader_readmeta_text(MidiLoader*); @@ -96,7 +101,12 @@ static int midiloader_readmeta_timesignature(MidiLoader*); static int midiloader_readmeta_keysignature(MidiLoader*); static int midiloader_readmeta_properietaryevent(MidiLoader*); /* Misc methods */ -static void midiloader_writepatternevent(MidiLoader*, psy_audio_PatternEvent); +static uint8_t midiloader_playablenote(uint8_t); +static psy_dsp_beatpos_t midiloader_tick_offset( + const MidiLoader*, uint64_t absolute_tick); +static void midiloader_insertnoteoff(MidiLoader*, uint16_t, const MidiChannel*); +static void midiloader_flushnoteoffs(MidiLoader*); +static void midiloader_writepatternevent(MidiLoader*, psy_audio_PatternEvent); static int midiloader_readchunk(MidiLoader*, MCHUNK* rv); static int midiloader_readbyte1(MidiLoader*, uint8_t* rv_int); int readvarlen(PsyFile* fp, uint32_t* rv); @@ -189,6 +199,7 @@ int midiloader_readtrk(MidiLoader* self, MCHUNK chunk, uintptr_t trackidx) if ((status = midiloader_readtrackevents(self, chunk, trackidx))) { return status; } + midiloader_flushnoteoffs(self); if (self->currtrack.pattern) { if (self->currtrack.patternnode) { psy_audio_PatternEntry* lastentry; @@ -228,6 +239,7 @@ void midiloader_appendtrack(MidiLoader* self, uintptr_t trackidx) self->currtrack.pattern = pattern; /* insert pattern */ self->currtrack.position = 0.0; + self->currtrack.position_ticks = 0; self->currtrack.patternnode = NULL; } @@ -252,8 +264,8 @@ int midiloader_readtrackevents(MidiLoader* self, MCHUNK chunk, uintptr_t trackid if ((status = midiloader_readstatusbyte(self))) { return status; } - ln = self->currtrack.runningstatus & 0x0F; - hn = (self->currtrack.runningstatus & 0xF0) >> 4; + ln = self->currtrack.eventstatus & 0x0F; + hn = (self->currtrack.eventstatus & 0xF0) >> 4; self->currtrack.channel = (uint8_t)ln; switch (hn) { case 0x8: @@ -278,10 +290,14 @@ int midiloader_readtrackevents(MidiLoader* self, MCHUNK chunk, uintptr_t trackid status = midiloader_readpitchbend(self); break; case 0xF: - if (ln == 0xF) { + if (self->currtrack.eventstatus == 0xFF) { status = midiloader_readmetaevent(self, &eoftrack); - } else { - psyfile_skip(self->fp, 1); + } else if ( + self->currtrack.eventstatus == 0xF0 || + self->currtrack.eventstatus == 0xF7) { + status = midiloader_readsysex(self); + } else if (psyfile_skip(self->fp, 1) == -1) { + status = PSY_ERRFILE; } break; default: @@ -317,12 +333,24 @@ int midiloader_readstatusbyte(MidiLoader* self) if ((status = psyfile_read(self->fp, &statusbyte, 1))) { return status; } - if (statusbyte < 0x80) { + if (statusbyte < 0x80) { + if (self->currtrack.runningstatus < 0x80 || + self->currtrack.runningstatus > 0xEF) { + return PSY_ERRFILE; + } + self->currtrack.eventstatus = self->currtrack.runningstatus; self->currtrack.hasrunningstatus = TRUE; self->currtrack.byte1 = statusbyte; } else { - self->currtrack.runningstatus = statusbyte; + self->currtrack.eventstatus = statusbyte; self->currtrack.hasrunningstatus = FALSE; + if (statusbyte >= 0x80 && statusbyte <= 0xEF) { + self->currtrack.runningstatus = statusbyte; + } else if (statusbyte != 0xFF) { + /* System-exclusive/common status cancels channel running status. + ** SMF meta status 0xFF does not. */ + self->currtrack.runningstatus = 0; + } } return PSY_OK; } @@ -331,13 +359,13 @@ int midiloader_readdeltatime(MidiLoader* self) { int status; uint32_t deltatime; - double offset; if ((status = readvarlen(self->fp, &deltatime))) { return status; } - offset = deltatime / (double)self->mthd.division; - self->currtrack.position += offset; + self->currtrack.position_ticks += deltatime; + self->currtrack.position = + self->currtrack.position_ticks / (double)self->mthd.division; return PSY_OK; } @@ -354,6 +382,7 @@ int midiloader_readnoteon(MidiLoader* self) if ((status = psyfile_read(self->fp, &vol, 1))) { return status; } + note = midiloader_playablenote(note); if (vol == 0) { midiloader_marknoteoff(self, note); } else { @@ -381,6 +410,7 @@ int midiloader_readnoteoff(MidiLoader* self) if ((status = psyfile_read(self->fp, &vol, 1))) { return status; } + note = midiloader_playablenote(note); midiloader_marknoteoff(self, note); return PSY_OK; } @@ -389,10 +419,15 @@ int midiloader_marknoteoff(MidiLoader* self, uint8_t note) { uint16_t voice; voice = 0; - for (voice = 0; voice < MAX_MIDIFILE_POLYPHONY; ++voice) { - if (self->currtrack.channels[voice].tracknote.note == note) { - self->currtrack.channels[voice].noteoff = TRUE; - self->currtrack.channels[voice].time = self->currtrack.position; + for (voice = 0; voice < MIDIFILE_TRACK_SLOTS; ++voice) { + MidiChannel* channel = &self->currtrack.channels[voice]; + + if (!channel->noteoff && + channel->tracknote.note == note && + channel->tracknote.mach == self->currtrack.channel) { + channel->noteoff = TRUE; + channel->time = self->currtrack.position; + channel->tick = self->currtrack.position_ticks; break; } } @@ -435,9 +470,12 @@ int midiloader_readcontroller(MidiLoader* self) ev.mach = self->currtrack.channel; ev.cmd = controller; ev.parameter = value; - node = psy_audio_pattern_find_node(self->currtrack.pattern, 0, - psy_dsp_beatpos_make_real(self->currtrack.position, psy_dsp_DEFAULT_PPQ), - psy_dsp_beatpos_make_real(0.01, psy_dsp_DEFAULT_PPQ), &prev); + node = psy_audio_pattern_find_node( + self->currtrack.pattern, + 0, + midiloader_tick_offset(self, self->currtrack.position_ticks), + psy_dsp_beatpos_make_real(0.01, psy_dsp_DEFAULT_PPQ), + &prev); if (node) { psy_audio_PatternEntry* entry; @@ -450,8 +488,8 @@ int midiloader_readcontroller(MidiLoader* self) psy_audio_patternentry_set_event(&entry, ev, 0); psy_audio_pattern_insert(self->currtrack.pattern, self->currtrack.patternnode, 0, - psy_dsp_beatpos_make_real(self->currtrack.position - - self->currtrack.patternoffset, psy_dsp_DEFAULT_PPQ), + midiloader_tick_offset( + self, self->currtrack.position_ticks), &entry); psy_audio_patternentry_dispose(&entry); self->currtrack.patternnode = psy_audio_pattern_begin(self->currtrack.pattern)->tail; @@ -496,6 +534,20 @@ int midiloader_readpitchbend(MidiLoader* self) return PSY_OK; } +static int midiloader_readsysex(MidiLoader* self) +{ + uint32_t length; + int status; + + if ((status = readvarlen(self->fp, &length))) { + return status; + } + if (psyfile_skip(self->fp, length) == -1) { + return PSY_ERRFILE; + } + return PSY_OK; +} + int midiloader_readmetaevent(MidiLoader* self, bool* rv_eoftrack) { int status; @@ -741,9 +793,8 @@ int midiloader_readmeta_tempo(MidiLoader* self) self->currtrack.patternnode = psy_audio_pattern_insert( self->currtrack.pattern, self->currtrack.patternnode, self->currtrack.automationchannel, - psy_dsp_beatpos_make_real( - self->currtrack.position - self->currtrack.patternoffset, - psy_dsp_DEFAULT_PPQ), + midiloader_tick_offset( + self, self->currtrack.position_ticks), &entry); psy_audio_patternentry_dispose(&entry); } @@ -873,80 +924,131 @@ int midiloader_readbyte1(MidiLoader* self, uint8_t* rv) return PSY_OK; } +static psy_dsp_beatpos_t midiloader_tick_offset( + const MidiLoader* self, uint64_t absolute_tick) +{ + psy_dsp_beatpos_t rv; + uint64_t relative_tick; + + assert(self); + relative_tick = absolute_tick - self->currtrack.patternoffset_ticks; + rv.ticks = (int32_t)( + (relative_tick * psy_dsp_DEFAULT_PPQ) / self->mthd.division); + rv.res = psy_dsp_DEFAULT_PPQ; + return rv; +} + +static uint8_t midiloader_playablenote(uint8_t note) +{ + return note > psy_audio_NOTECOMMANDS_B9 + ? psy_audio_NOTECOMMANDS_B9 + : note; +} + +static void midiloader_insertnoteoff( + MidiLoader* self, uint16_t voice, const MidiChannel* channel) +{ + psy_audio_PatternEvent noteoff; + psy_audio_PatternNode* node; + psy_audio_PatternNode* prev; + psy_dsp_beatpos_t offset; + + offset = midiloader_tick_offset(self, channel->tick); + psy_audio_patternevent_init(¬eoff); + noteoff.note = psy_audio_NOTECOMMANDS_RELEASE; + noteoff.mach = channel->tracknote.mach; + node = psy_audio_pattern_find_node( + self->currtrack.pattern, + voice, + offset, + psy_dsp_beatpos_make_real( + 1.0 / psy_dsp_DEFAULT_PPQ, psy_dsp_DEFAULT_PPQ), + &prev); + if (node) { + psy_audio_patternentry_add_event( + psy_audio_patternnode_entry(node), noteoff); + } else { + psy_audio_PatternEntry entry; + + psy_audio_patternentry_init(&entry); + psy_audio_patternentry_set_event(&entry, noteoff, 0); + psy_audio_pattern_insert( + self->currtrack.pattern, prev, voice, offset, &entry); + psy_audio_patternentry_dispose(&entry); + self->currtrack.patternnode = + psy_audio_pattern_begin(self->currtrack.pattern)->tail; + } +} + +static void midiloader_flushnoteoffs(MidiLoader* self) +{ + uint16_t voice; + + for (voice = 0; voice < MIDIFILE_TRACK_SLOTS; ++voice) { + MidiChannel* channel; + + channel = &self->currtrack.channels[voice]; + if (!channel->noteoff) { + continue; + } + if (channel->time >= self->currtrack.patternoffset) { + midiloader_insertnoteoff(self, voice, channel); + } + channel->noteoff = FALSE; + channel->tracknote.note = psy_audio_NOTECOMMANDS_EMPTY; + } +} + void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) { uint16_t voice; uint16_t channelvoice; - channelvoice = MAX_MIDIFILE_POLYPHONY; - if (self->currtrack.patternoffset != self->currtrack.position) { - for (voice = 0; voice < MAX_MIDIFILE_POLYPHONY; ++voice) { - if (self->currtrack.channels[voice].noteoff) { - if (self->currtrack.channels[voice].time < self->currtrack.position) { - if (self->currtrack.channels[voice].time >= self->currtrack.patternoffset) { - psy_audio_PatternEvent noteoff; - psy_audio_PatternNode* node; - psy_audio_PatternNode* prev; - - psy_audio_patternevent_init(¬eoff); - noteoff.note = psy_audio_NOTECOMMANDS_RELEASE; - noteoff.mach = self->currtrack.channel; - node = psy_audio_pattern_find_node(self->currtrack.pattern, - voice, - psy_dsp_beatpos_make_real( - self->currtrack.channels[voice].time - self->currtrack.patternoffset, - psy_dsp_DEFAULT_PPQ), - psy_dsp_beatpos_make_real(0.05, psy_dsp_DEFAULT_PPQ), - &prev); - if (!node) { - psy_audio_PatternEntry entry; - - psy_audio_patternentry_init(&entry); - psy_audio_patternentry_set_event(&entry, noteoff, 0); - node = psy_audio_pattern_insert( - self->currtrack.pattern, prev, voice, - psy_dsp_beatpos_make_real( - self->currtrack.channels[voice].time - self->currtrack.patternoffset, - psy_dsp_DEFAULT_PPQ), - &entry); - psy_audio_patternentry_dispose(&entry); - self->currtrack.patternnode = psy_audio_pattern_begin(self->currtrack.pattern)->tail; - } - } else { - /* noteoff was in previous patttern */ - /* todo */ - } - } - channelvoice = voice; - self->currtrack.channels[voice].noteoff = FALSE; - } + channelvoice = MIDIFILE_TRACK_SLOTS; + for (voice = 0; voice < MIDIFILE_TRACK_SLOTS; ++voice) { + MidiChannel* channel; + + channel = &self->currtrack.channels[voice]; + if (!channel->noteoff || channel->time > self->currtrack.position) { + continue; + } + if (channel->time >= self->currtrack.patternoffset) { + midiloader_insertnoteoff(self, voice, channel); + } else { + /* noteoff was in previous patttern */ + /* todo */ + } + channel->noteoff = FALSE; + channel->tracknote.note = psy_audio_NOTECOMMANDS_EMPTY; + if (channelvoice == MIDIFILE_TRACK_SLOTS) { + channelvoice = voice; } } - if (channelvoice == MAX_MIDIFILE_POLYPHONY) { - for (channelvoice = 1; channelvoice < MAX_MIDIFILE_POLYPHONY; ++channelvoice) { + if (channelvoice == MIDIFILE_TRACK_SLOTS) { + for (channelvoice = 1; channelvoice < MIDIFILE_TRACK_SLOTS; ++channelvoice) { if (self->currtrack.channels[channelvoice].tracknote.note == psy_audio_NOTECOMMANDS_EMPTY) { break; } } } - if (channelvoice != MAX_MIDIFILE_POLYPHONY) { - psy_audio_PatternEntry entry; - + if (channelvoice != MIDIFILE_TRACK_SLOTS) { + psy_audio_PatternEntry entry; + self->currtrack.channels[channelvoice].tracknote = ev; + psy_audio_patternentry_init(&entry); psy_audio_patternentry_set_event(&entry, ev, 0); self->currtrack.patternnode = psy_audio_pattern_insert( self->currtrack.pattern, self->currtrack.patternnode, channelvoice, - psy_dsp_beatpos_make_real( - self->currtrack.position - self->currtrack.patternoffset, - psy_dsp_DEFAULT_PPQ), + midiloader_tick_offset( + self, self->currtrack.position_ticks), &entry); psy_audio_patternentry_dispose(&entry); } } -/* midi variable length fileio funtions */ +/* midi variable length fileio functions */ int midiloader_readvarlentext(PsyFile* fp, char_dyn_t** rv) { int status; diff --git a/cpsycle/audio/src/midiloader.h b/cpsycle/audio/src/midiloader.h index 2f14fb88..c17a7859 100644 --- a/cpsycle/audio/src/midiloader.h +++ b/cpsycle/audio/src/midiloader.h @@ -33,11 +33,13 @@ extern "C" { */ #define MAX_MIDIFILE_POLYPHONY 64 +#define MIDIFILE_TRACK_SLOTS (MAX_MIDIFILE_POLYPHONY + 1) typedef struct MidiChannel { psy_audio_PatternEvent tracknote; bool noteoff; double time; + uint64_t tick; } MidiChannel; typedef struct MidiTrackState @@ -47,11 +49,14 @@ typedef struct MidiTrackState uintptr_t automationchannel; double position; double patternoffset; + uint64_t position_ticks; + uint64_t patternoffset_ticks; psy_audio_PatternNode* patternnode; psy_audio_Pattern* pattern; psy_audio_SequenceTrack* track; - MidiChannel channels[MAX_MIDIFILE_POLYPHONY]; + MidiChannel channels[MIDIFILE_TRACK_SLOTS]; uint8_t runningstatus; + uint8_t eventstatus; uint8_t byte1; bool hasrunningstatus; } MidiTrackState; diff --git a/cpsycle/audio/src/xmsamplerchannel.c b/cpsycle/audio/src/xmsamplerchannel.c index 71b94c55..af9412b5 100644 --- a/cpsycle/audio/src/xmsamplerchannel.c +++ b/cpsycle/audio/src/xmsamplerchannel.c @@ -285,10 +285,13 @@ void psy_audio_xmsamplerchannel_setforegroundvoice(psy_audio_XMSamplerChannel* s void psy_audio_xmsamplerchannel_setnote(psy_audio_XMSamplerChannel* self, int note) { + psy_audio_XMSamplerVoice* foreground; + self->m_Note = note; - if (psy_audio_xmsamplerchannel_foregroundvoice(self)) { + foreground = psy_audio_xmsamplerchannel_foregroundvoice(self); + if (foreground && psy_audio_xmsamplervoice_isplaying(foreground)) { self->m_Period = psy_audio_xmsamplervoice_notetoperiod( - psy_audio_xmsamplerchannel_foregroundvoice(self), note, TRUE); + foreground, note, TRUE); } } diff --git a/cpsycle/detail/psyconf.h b/cpsycle/detail/psyconf.h index b43add9d..e23ff0e2 100644 --- a/cpsycle/detail/psyconf.h +++ b/cpsycle/detail/psyconf.h @@ -143,7 +143,7 @@ #define PSYCLE_USE_PSY3 #define PSYCLE_USE_PSY2 #define PSYCLE_USE_XM -/* #define PSYCLE_USE_MIDI_FILE */ +#define PSYCLE_USE_MIDI_FILE /* ** VST2 remains a supported feature. Microsoft builds retain Psycle's diff --git a/phase6c/LEGACY_PLAYBACK_IMPORT.md b/phase6c/LEGACY_PLAYBACK_IMPORT.md index 4722fb47..a147ac24 100644 --- a/phase6c/LEGACY_PLAYBACK_IMPORT.md +++ b/phase6c/LEGACY_PLAYBACK_IMPORT.md @@ -14,8 +14,8 @@ While the legacy lane is active, **this document is the working roadmap**. The b 1. [x] **Import/translation baseline** — canonical generated IT identity, donor import/decoder coverage, pinned-original acceptance observation, and frozen-candidate direct-import boundary. 2. [x] **Sample-mode playback witness** — C-Psycle sample-mode instrument/virtual-generator mapping is restored and the canonical generated fixture has a deterministic non-silent donor playback witness. -3. [ ] **Historical `SickMaate` three-way summary — current phase** — run the hash-bound historical witness through the available original/candidate/donor observation paths without redistributing its samples. -4. [ ] **MIDI real-world playback corpus** — begin with the gentler baseline and then exercise dense timing/polyphony/routing cases. +3. [ ] **Historical `SickMaate` three-way summary — private observation pending** — the privacy-preserving original/candidate/donor workflow is implemented; the three-way observation remains pending manual dispatch with the private historical input. +4. [ ] **MIDI real-world playback corpus — current phase** — exercise the contributor-supplied SMF corpus from gentle baseline through dense timing/polyphony/routing stress, keeping native MIDI import separate from the deterministic audibility projection. 5. [ ] **Legacy-lane closeout** — isolate or hand off any remaining shared playback defects, then resume the deferred Phase 6C contract ladder. No item in this temporary roadmap may promote original-Psycle parity without the normal paired/versioned evidence requirements. @@ -159,14 +159,73 @@ A remembered historical behavior is useful motivation, not a PASS verdict. ## MIDI reference corpus -[`reference-corpus/manifest.json`](reference-corpus/manifest.json) freezes the identities and roles of six contributor-supplied MIDI stem sets. They are not the first CI gate, but they are now the follow-on real-world playback corpus once the minimal legacy loader fixture is stable. +[`reference-corpus/manifest.json`](reference-corpus/manifest.json) is now an executable schema-v2 evidence contract for six contributor-supplied MIDI stem sets. The real archive bytes remain external to the repository and Actions artifacts; their archive SHA-256 identities, aggregate source observations and stress roles are frozen in the manifest. -The corpus spans controlled generation, gentle real-world arrangements, dense polyphony and same-note overlaps, 16-channel routing stress, complex tempo maps, and malformed-but-usable key-signature metadata from a modern exporter. +### Current MIDI procedure + +The private real-world workflow is: + +`.github/workflows/phase6c-midi-corpus-private.yml` + +It is manual (`workflow_dispatch`) and uses the masked repository secret: + +```text +PSYCLE_PHASE6C_MIDI_CORPUS_URL +``` + +The URL points to an **outer transport ZIP** containing the six exact contributor archives. The outer ZIP has no evidence identity of its own. Each inner archive is located by its frozen filename and accepted only if its SHA-256 matches the manifest. + +The stress progression is intentionally: + +1. **FM Doom** — gentler real-world baseline; +2. **Celestial Mechanics** — older composition/export behaviour; +3. **Deterministic Pattern** — controlled-generation/follow-tempo behaviour; +4. **Blue Glare** — dense modern tempo-map workload; +5. **Polyrhythmic Patterns** — overlap/polyphony/event-ordering stress; +6. **NGC3603 Quantum Demoscene** — final routing stress, including all 16 MIDI channels. + +For every stem, the private-input analyser independently parses the Standard MIDI File and freezes the observable source structure: SMF type, PPQN, note balance, note count, same-note overlaps, cross-track polyphony, tempo map, channel use, duration, zero-duration pairs and malformed key-signature metadata. The real-world exporter edge is retained rather than normalized away. + +### C-Psycle donor boundary + +C-Psycle has a real `MThd`/`MidiLoader` song-I/O path. At the start of this phase the retained loader source was already compiled into `libaudio`, but dispatch was disabled by the preserved `PSYCLE_USE_MIDI_FILE` feature gate in `cpsycle/detail/psyconf.h`. This lane restores that existing gate rather than adding a replacement MIDI importer. + +Native MIDI import creates sequence tracks and tracker events, but it **does not choose or instantiate a sound-generating machine**. Therefore this lane records two different claims: + +1. **native import evidence** — load acceptance plus a digest/count of the untouched imported event graph; +2. **execution projection** — after the untouched graph is frozen, imported note/release/MIDI-CC events are routed through one deterministic project-owned classic Sampler substrate and rendered through the production Player/FileOutDriver path. + +The projection keeps imported note numbers, event offsets, track geometry and tempo commands intact. It supplies only the missing sound source needed for audibility and is explicitly **not** evidence of native MIDI instrument-selection semantics. + +Real-corpus renders are bounded to the first 16 beats per imported pattern. The WAV files are used transiently for non-silence/hash observations and then deleted; uploaded evidence contains only sanitized JSON, the exact probe binary and the retained `midiloader.c` source used. + +### Frozen C++ candidate boundary + +The frozen sanitized C++ candidate has internal MIDI-event machinery but no retained Standard MIDI File loader entry point (`MThd`/`MidiLoader`). The private workflow therefore runs one manifest-bound representative stem from each set against the frozen player and records the direct-load capability boundary only. A direct-load rejection does not imply that its internal MIDI event or plugin semantics are absent. + +### Original-reference boundary + +Pinned Psycle 1.12.0 x86 is **not observed in this corpus phase**. This lane is real-world donor robustness evidence, not a new parity row. If an original-reference MIDI claim is later required, it must be added as a separately version-pinned observation rather than inferred from historical memory or C-Psycle behaviour. + +### Continuous CI boundary + +Ordinary PR CI does not need the private corpus. It generates a small project-owned SMF1/480 fixture and runs the same C-Psycle probe end-to-end through: + +```text +SongReader / MidiLoader + -> untouched event-graph digest + -> deterministic classic Sampler projection + -> Player + -> FileOutDriver + -> non-silent WAV validation +``` + +The contract tests also verify malformed-key-signature preservation, cross-track polyphony, candidate source boundaries and raw-MIDI/ZIP privacy rejection. This gives the project two complementary layers: -1. **minimal generated fixtures** that isolate one rule; -2. **real musical corpora** that reveal interactions and audible regressions. +1. **minimal generated fixtures** that continuously isolate and exercise the execution path; +2. **real musical corpora** that reveal interactions and audible regressions under private, identity-bound observation. ## Exit criteria for the temporary priority hold diff --git a/phase6c/reference-corpus/manifest.json b/phase6c/reference-corpus/manifest.json index 99a93c3b..4cb908bd 100644 --- a/phase6c/reference-corpus/manifest.json +++ b/phase6c/reference-corpus/manifest.json @@ -1,20 +1,210 @@ { - "schema_version": 1, + "schema_version": 2, + "contract": "legacy-midi-real-world-corpus", "corpus": "PSYCLE-LINUX contributor-supplied MIDI reference corpus", "authorization": "The contributor/rightsholder explicitly authorized these materials to be used by PSYCLE-LINUX for compatibility research. This manifest does not independently declare public-domain status or alter third-party service terms.", - "storage": "archive identities are frozen here; raw contributor archives are not required by CI in this first lane", + "storage": "Archive identities are frozen here. Real corpus bytes are private workflow inputs and are excluded from repository commits and uploaded evidence artifacts.", + "private_workflow": { + "workflow": ".github/workflows/phase6c-midi-corpus-private.yml", + "trigger": "workflow_dispatch", + "bundle_url_secret": "PSYCLE_PHASE6C_MIDI_CORPUS_URL", + "bundle_contract": "A private outer ZIP containing the six exact archive files named below; the outer ZIP itself is not authoritative, each inner archive SHA-256 is." + }, + "progression_order": [ + "FM Doom", + "Celestial Mechanics", + "Deterministic Pattern", + "Blue Glare", + "Polyrhythmic Patterns", + "NGC3603 Quantum Demoscene" + ], "sets": [ - {"name":"Blue Glare","archive":"Blue Glare MIDI.zip","sha256":"b1297c44d138d71bbcfddaa4278e1b1fd4e5aac2c6a559276ab124f2b44db67f","stems":11,"notes":13947,"role":"dense modern real-world MIDI workload and tempo-map stress reference","known_edge":"SMF1/480 PPQN; repeated tempo map; exporter emits out-of-range key-signature metadata"}, - {"name":"Celestial Mechanics","archive":"Celestial Mechanics MIDI.zip","sha256":"01596654e86bb8fe3b36d2b8ea30c5760521133b18fb27742c9d2f8d7f95fafd","stems":10,"notes":4338,"max_polyphony":12,"same_note_overlaps":205,"tempo_events_per_stem":449,"tempo_range_bpm":[124.90,130.32],"role":"older real-world composition and fixed-tempo-export behavior reference"}, - {"name":"Deterministic Pattern","archive":"Deterministic Pattern MIDI(1).zip","sha256":"cfde1bcb71935718fc4a7e8e16b6aa77ceac44d8fb31d8dcadfee87a5b178b32","stems":8,"notes":5381,"max_polyphony":7,"same_note_overlaps":241,"tempo_events_per_stem":319,"tempo_range_bpm":[128.73,131.35],"role":"controlled-generation MIDI reference; exposes follow-tempo exporter behavior"}, - {"name":"Polyrhythmic Patterns","archive":"Polyrhythmic Patterns MIDI.zip","sha256":"5e7a3a17569661f6817ab664411e5ca6d44b22c50306d4a82df0a0b984f2f6ce","stems":9,"notes":13728,"max_polyphony":14,"same_note_overlaps":505,"tempo_events_per_stem":425,"tempo_range_bpm":[137.02,141.81],"role":"polyphony, overlap and event-ordering torture reference"}, - {"name":"NGC3603 Quantum Demoscene","archive":"NGC3603 Quantum Demoscene MIDI(1).zip","sha256":"9fa0905ed0758dd23f61b144e22bf85150358c95f3a4268803a44351720af8c8","stems":12,"notes":8986,"max_polyphony":14,"same_note_overlaps":162,"tempo_events_per_stem":501,"tempo_range_bpm":[165.84,183.94],"role":"routing/channel stress reference; observed use spans all 16 MIDI channels"}, - {"name":"FM Doom","archive":"FM Doom MIDI.zip","sha256":"c78d8addbc8e6e95d2232c1505d5e668c5ab7c6a9f987d5f56005a52d5de5674","stems":12,"notes":3734,"max_polyphony":6,"same_note_overlaps":89,"tempo_events_per_stem":379,"tempo_range_bpm":[142.35,145.28],"role":"gentler real-world MIDI import/playback reference"} + { + "name": "Blue Glare", + "archive": "Blue Glare MIDI.zip", + "sha256": "b1297c44d138d71bbcfddaa4278e1b1fd4e5aac2c6a559276ab124f2b44db67f", + "stems": 11, + "notes": 13947, + "role": "dense modern real-world MIDI workload and tempo-map stress reference", + "known_edge": "SMF1/480 PPQN; repeated tempo map; exporter emits out-of-range key-signature metadata", + "analysis_expectations": { + "stems": 11, + "notes": 13947, + "max_polyphony": 8, + "same_note_overlaps": 425, + "tempo_range_bpm": [ + 127.87751, + 133.422282 + ], + "duration_seconds_max": 287.409263, + "malformed_key_signature": [ + 9, + 1 + ], + "zero_duration_pairs_present": false, + "tempo_events_per_stem": 539 + } + }, + { + "name": "Celestial Mechanics", + "archive": "Celestial Mechanics MIDI.zip", + "sha256": "01596654e86bb8fe3b36d2b8ea30c5760521133b18fb27742c9d2f8d7f95fafd", + "stems": 10, + "notes": 4338, + "role": "older real-world composition and fixed-tempo-export behavior reference", + "analysis_expectations": { + "stems": 10, + "tracks": 40, + "notes": 4338, + "max_polyphony": 12, + "same_note_overlaps": 205, + "tempo_range_bpm": [ + 124.89592, + 130.321743 + ], + "duration_seconds_max": 217.492473, + "malformed_key_signature": [ + 8, + 1 + ], + "zero_duration_pairs_present": false, + "tempo_events_per_stem": 449 + } + }, + { + "name": "Deterministic Pattern", + "archive": "Deterministic Pattern MIDI(1).zip", + "sha256": "cfde1bcb71935718fc4a7e8e16b6aa77ceac44d8fb31d8dcadfee87a5b178b32", + "stems": 8, + "notes": 5381, + "role": "controlled-generation MIDI reference; exposes follow-tempo exporter behavior", + "analysis_expectations": { + "stems": 8, + "tracks": 35, + "notes": 5381, + "max_polyphony": 7, + "same_note_overlaps": 241, + "tempo_range_bpm": [ + 128.728017, + 131.348799 + ], + "duration_seconds_max": 166.349007, + "malformed_key_signature": [ + 17, + 1 + ], + "zero_duration_pairs_present": false, + "tempo_events_per_stem": 319 + } + }, + { + "name": "Polyrhythmic Patterns", + "archive": "Polyrhythmic Patterns MIDI.zip", + "sha256": "5e7a3a17569661f6817ab664411e5ca6d44b22c50306d4a82df0a0b984f2f6ce", + "stems": 9, + "notes": 13728, + "role": "polyphony, overlap and event-ordering torture reference", + "analysis_expectations": { + "stems": 9, + "tracks": 51, + "notes": 13728, + "max_polyphony": 14, + "same_note_overlaps": 505, + "tempo_range_bpm": [ + 137.017584, + 141.810782 + ], + "duration_seconds_max": 197.626864, + "malformed_key_signature": [ + 9, + 1 + ], + "zero_duration_pairs_present": true, + "tempo_events_per_stem": 425 + } + }, + { + "name": "NGC3603 Quantum Demoscene", + "archive": "NGC3603 Quantum Demoscene MIDI(1).zip", + "sha256": "9fa0905ed0758dd23f61b144e22bf85150358c95f3a4268803a44351720af8c8", + "stems": 12, + "notes": 8986, + "role": "routing/channel stress reference; observed use spans all 16 MIDI channels", + "analysis_expectations": { + "stems": 12, + "tracks": 104, + "notes": 8986, + "max_polyphony": 14, + "same_note_overlaps": 162, + "tempo_range_bpm": [ + 165.837479, + 183.936235 + ], + "duration_seconds_max": 184.346216, + "malformed_key_signature": [ + 13, + 1 + ], + "zero_duration_pairs_present": true, + "channels_used": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "tempo_events_per_stem": 501 + } + }, + { + "name": "FM Doom", + "archive": "FM Doom MIDI.zip", + "sha256": "c78d8addbc8e6e95d2232c1505d5e668c5ab7c6a9f987d5f56005a52d5de5674", + "stems": 12, + "notes": 3734, + "role": "gentler real-world MIDI import/playback reference", + "analysis_expectations": { + "stems": 12, + "tracks": 56, + "notes": 3734, + "max_polyphony": 6, + "same_note_overlaps": 89, + "tempo_range_bpm": [ + 142.348754, + 145.27845 + ], + "duration_seconds_max": 177.511301, + "malformed_key_signature": [ + 13, + 1 + ], + "zero_duration_pairs_present": false, + "tempo_events_per_stem": 379 + } + } ], "shared_observation": { "format": "Standard MIDI File type 1", "ppqn": 480, "balanced_note_pairs": true, - "strict_parser_edge": "all inspected exporter files carry an out-of-range key-signature meta value; preserve this as a real-world robustness case rather than normalizing it away" + "absent_event_families": [ + "SysEx", + "pitch bend", + "polyphonic/channel aftertouch", + "time signature" + ], + "strict_parser_edge": "All inspected exporter files carry an out-of-range key-signature meta value; preserve this as a real-world robustness case rather than normalizing it away.", + "playback_boundary": "SMF import itself selects no sound-generating machine. Playback evidence therefore freezes the untouched imported event digest first, then attaches a project-owned deterministic classic Sampler execution substrate; that projection is evidence of engine execution, not native MIDI instrument-selection semantics." } } diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py new file mode 100644 index 00000000..49a074aa --- /dev/null +++ b/scripts/phase6c-midi-corpus.py @@ -0,0 +1,2556 @@ +#!/usr/bin/env python3 +"""Private-input helpers for the Phase 6C real-world MIDI corpus lane.""" +from __future__ import annotations + +import argparse +import collections +import hashlib +import io +import json +import math +import os +import pathlib +import shlex +import shutil +import struct +import subprocess +import tempfile +import zipfile +from typing import Any + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "phase6c/reference-corpus/manifest.json" +CONTRACT = "legacy-midi-real-world-corpus" +DONOR_CONTRACT = "legacy-midi-real-world-donor" +CANDIDATE_CONTRACT = "legacy-midi-real-world-candidate-boundary" +CANDIDATE_ATTESTATION_CONTRACT = "phase6b-candidate-player-clean-rebuild" +SUMMARY_CONTRACT = "legacy-midi-real-world-summary" +EXPECTED_CANDIDATE_BASELINE = ( + "00cd95562b78303b82e17f62fff4b58622f7c0e78c0b4dd850d448082a53893a" +) +EXPECTED_CANDIDATE_SOURCE_REVISION = "SourceForge SVN r12005" +CANDIDATE_SOURCE_ROOT = ROOT / "psycle-cpp-r12005-sanitized" +CANDIDATE_PLAYER = ( + CANDIDATE_SOURCE_ROOT / "psycle-player/++qmake/psycle-player" +) +CANDIDATE_BASELINE_RECEIPT = ( + ROOT / "phase6b-sanitized-manifest/baseline.sha256" +) +CANDIDATE_PLUGIN_INTERFACE = ( + CANDIDATE_SOURCE_ROOT / "psycle-plugins/src/psycle/plugin_interface.hpp" +) +EXPECTED_CANDIDATE_PLUGIN_BLOB = "2cf4d8f756fa58098fd84b7b44d0ebe1350594c5" +DIVERSALIS_SOURCE_URL = "https://svn.code.sf.net/p/psycle/code/trunk/diversalis@12005" +DIVERSALIS_REVISION = "12005" +EXPECTED_DIVERSALIS_FILE_COUNT = 12 +EXPECTED_DIVERSALIS_MANIFEST_SHA256 = ( + "0294a61fbb52d3972dd12be625756c12d1553294aa8b30df36407880810fac42" +) +DONOR_SOURCE = ROOT / "cpsycle/audio/src/midiloader.c" +DONOR_PROBE_SOURCE = ROOT / "tests/phase6c_midi_corpus_probe.c" +DONOR_BUILD_ROOT = ROOT / "cpsycle/player" + +PROGRESSION = [ + "FM Doom", + "Celestial Mechanics", + "Deterministic Pattern", + "Blue Glare", + "Polyrhythmic Patterns", + "NGC3603 Quantum Demoscene", +] + + +def die(message: str) -> "NoReturn": + raise SystemExit(f"phase6c-midi-corpus: {message}") + + +def load_json(path: pathlib.Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + die(f"cannot read JSON {path}: {exc}") + if not isinstance(data, dict): + die(f"JSON root must be an object: {path}") + return data + + +def write_json(path: pathlib.Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def directory_manifest_identity(root: pathlib.Path) -> tuple[int, str]: + if not root.is_dir(): + die(f"candidate dependency tree is missing: {root}") + records: list[str] = [] + for path in sorted( + (item for item in root.rglob("*") if item.is_file()), + key=lambda item: item.relative_to(root).as_posix(), + ): + relative = path.relative_to(root).as_posix() + records.append(f"{sha256_file(path)} {relative}\n") + if not records: + die(f"candidate dependency tree is empty: {root}") + return len(records), sha256_bytes("".join(records).encode("utf-8")) + + +def validate_candidate_dependencies() -> dict[str, Any]: + if not CANDIDATE_PLUGIN_INTERFACE.is_file(): + die("pinned candidate plugin interface is missing") + try: + plugin_blob = subprocess.check_output( + ["git", "hash-object", str(CANDIDATE_PLUGIN_INTERFACE)], + cwd=ROOT, + text=True, + ).strip() + except (OSError, subprocess.CalledProcessError) as exc: + die(f"cannot hash candidate plugin interface: {exc}") + if plugin_blob != EXPECTED_CANDIDATE_PLUGIN_BLOB: + die("pinned candidate plugin interface identity changed") + + staged_diversalis = CANDIDATE_SOURCE_ROOT / "diversalis" + staged_count, staged_manifest = directory_manifest_identity(staged_diversalis) + with tempfile.TemporaryDirectory(prefix="phase6c-diversalis-") as temporary: + canonical = pathlib.Path(temporary) / "diversalis" + export_output = "" + for _attempt in range(4): + if canonical.exists(): + shutil.rmtree(canonical) + proc = subprocess.run( + [ + "svn", "export", "--quiet", "--force", "-r", + DIVERSALIS_REVISION, DIVERSALIS_SOURCE_URL, str(canonical), + ], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + export_output = proc.stdout + if proc.returncode == 0: + break + else: + die("cannot reproduce pinned diversalis SVN export: " + export_output[-1000:]) + canonical_count, canonical_manifest = directory_manifest_identity(canonical) + + if ( + canonical_count != EXPECTED_DIVERSALIS_FILE_COUNT + or canonical_manifest != EXPECTED_DIVERSALIS_MANIFEST_SHA256 + ): + die("pinned SourceForge SVN r12005 diversalis identity changed") + if staged_count != canonical_count or staged_manifest != canonical_manifest: + die("staged diversalis dependency differs from pinned SourceForge SVN r12005") + return { + "plugin_interface_git_blob": plugin_blob, + "diversalis_revision": f"SourceForge SVN r{DIVERSALIS_REVISION}", + "diversalis_file_count": staged_count, + "diversalis_manifest_sha256": staged_manifest, + } + + +def candidate_attestation_document( + identity: dict[str, Any], + player_sha256: str, +) -> dict[str, Any]: + return { + "schema_version": 1, + "phase": "6C", + "contract": CANDIDATE_ATTESTATION_CONTRACT, + "evidence_role": "candidate-build-attestation", + **identity, + "player": "candidate-psycle-player", + "player_sha256": player_sha256, + "parity_status": "UNKNOWN", + } + + +def validate_candidate_attestation( + path: pathlib.Path, + player: pathlib.Path, + expected_repository_commit: str, +) -> dict[str, Any]: + attestation = load_json(path) + actual_player_sha = sha256_file(player) + if ( + attestation.get("schema_version") != 1 + or attestation.get("phase") != "6C" + or attestation.get("contract") != CANDIDATE_ATTESTATION_CONTRACT + or attestation.get("evidence_role") != "candidate-build-attestation" + or attestation.get("candidate_baseline_sha256") + != EXPECTED_CANDIDATE_BASELINE + or attestation.get("source_revision") + != EXPECTED_CANDIDATE_SOURCE_REVISION + or attestation.get("repository_commit") != expected_repository_commit + or attestation.get("build_target") != "psycle-player" + or attestation.get("plugin_interface_git_blob") + != EXPECTED_CANDIDATE_PLUGIN_BLOB + or attestation.get("diversalis_revision") + != f"SourceForge SVN r{DIVERSALIS_REVISION}" + or attestation.get("diversalis_file_count") + != EXPECTED_DIVERSALIS_FILE_COUNT + or attestation.get("diversalis_manifest_sha256") + != EXPECTED_DIVERSALIS_MANIFEST_SHA256 + or attestation.get("player") != "candidate-psycle-player" + or attestation.get("player_sha256") != actual_player_sha + or attestation.get("clean_rebuild_sha256") != actual_player_sha + or attestation.get("parity_status") != "UNKNOWN" + ): + die("candidate build attestation is incomplete or invalid") + return attestation + + +def repository_commit() -> str: + try: + revision = subprocess.check_output( + ["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True + ).strip() + except (OSError, subprocess.CalledProcessError) as exc: + die(f"cannot resolve repository revision: {exc}") + if len(revision) != 40: + die("repository revision is not a full commit SHA") + try: + int(revision, 16) + except ValueError: + die("repository revision is not hexadecimal") + return revision + + +def _run_build(command: list[str], label: str) -> None: + proc = subprocess.run( + command, + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if proc.returncode != 0: + die(f"{label} failed: " + proc.stdout[-2000:]) + + +def build_donor_probe(probe_path: pathlib.Path, *, clean: bool) -> None: + for required in (DONOR_SOURCE, DONOR_PROBE_SOURCE, DONOR_BUILD_ROOT): + if not required.exists(): + die(f"donor build input is missing: {required.relative_to(ROOT)}") + + diff = subprocess.run( + [ + "git", "-C", str(ROOT), "diff", "--quiet", "HEAD", "--", + "cpsycle", "tests/phase6c_midi_corpus_probe.c", + ], + check=False, + ) + if diff.returncode != 0: + die("tracked C-Psycle/probe source differs from HEAD") + + if clean: + _run_build( + ["make", "-C", str(DONOR_BUILD_ROOT), "clean"], + "clean donor rebuild preparation", + ) + _run_build( + ["make", "-C", str(DONOR_BUILD_ROOT), "-j1"], + "C-Psycle donor library build", + ) + + def pkg_config(option: str) -> list[str]: + proc = subprocess.run( + ["pkg-config", option, "lua"], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if proc.returncode != 0: + die("pkg-config lua lookup failed: " + proc.stdout[-1000:]) + return shlex.split(proc.stdout) + + probe_path.parent.mkdir(parents=True, exist_ok=True) + if probe_path.exists(): + probe_path.unlink() + try: + output_arg = str(probe_path.relative_to(ROOT)) + except ValueError: + output_arg = str(probe_path) + + command = [ + "gcc", + "-std=gnu11", + "-g", + "-O0", + "-fno-omit-frame-pointer", + "-Wall", + "-Wextra", + "-Werror=implicit-function-declaration", + "-Icpsycle/script/src", + "-Icpsycle/thread/src", + "-Icpsycle/container/src", + "-Icpsycle/file/src", + "-Icpsycle/diversalis/src", + "-Icpsycle/src", + "-Icpsycle/audio/src", + "-Icpsycle/dsp/src", + *pkg_config("--cflags"), + "tests/phase6c_midi_corpus_probe.c", + "-o", + output_arg, + "-Lcpsycle/thread/src", + "-Lcpsycle/script/src", + "-Lcpsycle/container/src", + "-Lcpsycle/dsp/src", + "-Lcpsycle/audio/src", + "-Lcpsycle/file/src", + "-Wl,--no-as-needed", + "-laudio", + "-lthread", + "-llilv-0", + "-ldsp", + "-lscript", + "-lfile", + "-lm", + *pkg_config("--libs"), + "-lpthread", + "-ldl", + "-lstdc++", + "-lcontainer", + ] + _run_build(command, "donor MIDI corpus probe build") + probe_path.chmod(0o755) + + +def validate_donor_probe_build( + probe_path: pathlib.Path, + source_path: pathlib.Path, +) -> dict[str, str]: + if not DONOR_SOURCE.is_file() or not DONOR_PROBE_SOURCE.is_file(): + die("canonical donor source inputs are missing") + if not source_path.is_file() or source_path.read_bytes() != DONOR_SOURCE.read_bytes(): + die("retained donor source does not match checked-out midiloader.c") + if not probe_path.is_file() or not os.access(probe_path, os.X_OK): + die("donor probe is missing or not executable") + with probe_path.open("rb") as handle: + if handle.read(4) != b"\x7fELF": + die("donor probe is not an ELF executable") + + initial_sha = sha256_file(probe_path) + build_donor_probe(probe_path, clean=True) + rebuilt_sha = sha256_file(probe_path) + if rebuilt_sha != initial_sha: + die( + "donor probe does not match an immediate clean rebuild: " + f"initial={initial_sha} rebuilt={rebuilt_sha}" + ) + with probe_path.open("rb") as handle: + if handle.read(4) != b"\x7fELF": + die("clean rebuilt donor probe is not an ELF executable") + + return { + "source_revision": repository_commit(), + "probe_source": DONOR_PROBE_SOURCE.relative_to(ROOT).as_posix(), + "probe_source_sha256": sha256_file(DONOR_PROBE_SOURCE), + "build_target": "phase6c-midi-corpus-probe", + "clean_rebuild_sha256": rebuilt_sha, + } + + +def fnv64_byte(hash_value: int, value: int) -> int: + hash_value ^= value & 0xFF + return (hash_value * 1099511628211) & 0xFFFFFFFFFFFFFFFF + + +def fnv64_u64(hash_value: int, value: int) -> int: + for shift in range(0, 64, 8): + hash_value = fnv64_byte(hash_value, (value >> shift) & 0xFF) + return hash_value + + +def normalized_tempo_bpm(usec_per_quarter: int) -> float: + bpm = 60.0 * 1_000_000.0 / usec_per_quarter + return int(bpm * 1000) / 1000.0 + + +def imported_tempo_parameter(usec_per_quarter: int) -> int: + return int(normalized_tempo_bpm(usec_per_quarter)) & 0xFF + + +def tempo_map_digest_fnv64( + commands: list[tuple[int, int, int]], +) -> str: + digest = 14695981039346656037 + for sequence_track, tick, parameter in commands: + digest = fnv64_u64(digest, sequence_track) + digest = fnv64_u64(digest, tick) + digest = fnv64_byte(digest, parameter) + return f"{digest:016x}" + + +def semantic_event_multiset_digest_fnv64( + events: list[tuple[int, ...]], +) -> str: + total = 0 + for event in events: + if len(event) < 2: + raise ValueError("semantic event requires sequence track and tick") + digest = 14695981039346656037 + digest = fnv64_u64(digest, event[0]) + digest = fnv64_u64(digest, event[1]) + for value in event[2:]: + digest = fnv64_byte(digest, value) + total = (total + digest) & 0xFFFFFFFFFFFFFFFF + return f"{total:016x}" + + +def imported_note_semantics(note: int, velocity: int) -> tuple[int, int, int]: + normalized_note = min(note, 119) + if velocity == 64: + return normalized_note, 0, 0 + return normalized_note, 0x0C, int((velocity / 127.0) * 128) & 0xFF + + +def midi_channel_mask(channels: Any) -> int: + if not isinstance(channels, list): + die("MIDI channel coverage must be a list") + mask = 0 + seen: set[int] = set() + for channel in channels: + if ( + not isinstance(channel, int) + or isinstance(channel, bool) + or channel < 0 + or channel > 15 + or channel in seen + ): + die(f"invalid MIDI channel coverage: {channels!r}") + seen.add(channel) + mask |= 1 << channel + return mask + + +def slug(name: str) -> str: + out = [] + dash = False + for ch in name.lower(): + if ch.isalnum(): + out.append(ch) + dash = False + elif not dash: + out.append("-") + dash = True + return "".join(out).strip("-") + + +def corpus_manifest() -> dict[str, Any]: + manifest = load_json(MANIFEST) + if manifest.get("schema_version") != 2: + die("MIDI corpus manifest schema_version must be 2") + if manifest.get("contract") != CONTRACT: + die("MIDI corpus manifest contract changed") + sets = manifest.get("sets") + if not isinstance(sets, list) or len(sets) != 6: + die("MIDI corpus manifest must contain exactly six sets") + names = [item.get("name") for item in sets if isinstance(item, dict)] + if sorted(names) != sorted(PROGRESSION): + die("MIDI corpus manifest set identities changed") + if manifest.get("progression_order") != PROGRESSION: + die("MIDI corpus progression order changed") + return manifest + + +def read_varlen(data: bytes, pos: int) -> tuple[int, int]: + value = 0 + for _ in range(5): + if pos >= len(data): + die("truncated MIDI variable-length value") + byte = data[pos] + pos += 1 + value = (value << 7) | (byte & 0x7F) + if not (byte & 0x80): + return value, pos + die("MIDI variable-length value exceeds five bytes") + + +def parse_track(track: bytes) -> dict[str, Any]: + pos = 0 + tick = 0 + running_status: int | None = None + note_ons = 0 + note_offs = 0 + unmatched_note_offs = 0 + same_note_overlaps = 0 + zero_duration_pairs = 0 + active_total = 0 + max_polyphony = 0 + active: dict[tuple[int, int], collections.deque[int]] = collections.defaultdict(collections.deque) + tempos: list[tuple[int, int]] = [] + key_signatures: list[tuple[int, int]] = [] + channels: set[int] = set() + note_on_channels: set[int] = set() + note_off_channels: set[int] = set() + sysex = 0 + pitch_bend = 0 + aftertouch = 0 + time_signatures = 0 + end_tick = 0 + note_transitions: list[tuple[int, int, int, int, bool]] = [] + note_attacks: list[tuple[int, int, int, int, int]] = [] + controller_events: list[tuple[int, int, int, int, int]] = [] + event_serial = 0 + + while pos < len(track): + delta, pos = read_varlen(track, pos) + tick += delta + end_tick = max(end_tick, tick) + if pos >= len(track): + die("truncated MIDI event after delta time") + + first = track[pos] + explicit_status = first >= 0x80 + if explicit_status: + status = first + pos += 1 + if 0x80 <= status <= 0xEF: + running_status = status + elif status in (0xF0, 0xF7): + running_status = None + else: + if running_status is None: + die("MIDI data byte encountered without running status") + status = running_status + + if status == 0xFF: + if pos >= len(track): + die("truncated MIDI meta event") + meta_type = track[pos] + pos += 1 + length, pos = read_varlen(track, pos) + end = pos + length + if end > len(track): + die("truncated MIDI meta payload") + payload = track[pos:end] + pos = end + if meta_type == 0x2F: + break + if meta_type == 0x51 and len(payload) == 3: + usec = int.from_bytes(payload, "big") + if usec: + tempos.append((tick, usec)) + elif meta_type == 0x59 and len(payload) >= 2: + sf = struct.unpack("b", payload[:1])[0] + mi = payload[1] + key_signatures.append((sf, mi)) + elif meta_type == 0x58: + time_signatures += 1 + continue + + if status in (0xF0, 0xF7): + length, pos = read_varlen(track, pos) + end = pos + length + if end > len(track): + die("truncated MIDI SysEx payload") + pos = end + sysex += 1 + continue + + if not (0x80 <= status <= 0xEF): + die(f"unsupported MIDI system status 0x{status:02x}") + + kind = status & 0xF0 + channel = status & 0x0F + channels.add(channel) + data_len = 1 if kind in (0xC0, 0xD0) else 2 + if explicit_status: + if pos + data_len > len(track): + die("truncated MIDI channel event") + payload = track[pos:pos + data_len] + pos += data_len + else: + if pos + data_len > len(track): + die("truncated MIDI running-status event") + payload = track[pos:pos + data_len] + pos += data_len + + if kind in (0xA0, 0xD0): + aftertouch += 1 + elif kind == 0xE0: + pitch_bend += 1 + elif kind == 0xB0 and len(payload) == 2: + controller_events.append( + (tick, event_serial, channel, payload[0], payload[1]) + ) + + is_note_on = kind == 0x90 and len(payload) == 2 and payload[1] != 0 + is_note_off = kind == 0x80 or (kind == 0x90 and len(payload) == 2 and payload[1] == 0) + if is_note_on: + note = payload[0] + note_on_channels.add(channel) + note_transitions.append((tick, event_serial, channel, note, True)) + note_attacks.append( + (tick, event_serial, channel, note, payload[1]) + ) + key = (channel, note) + queue = active[key] + if queue: + same_note_overlaps += 1 + queue.append(tick) + active_total += 1 + max_polyphony = max(max_polyphony, active_total) + note_ons += 1 + elif is_note_off: + note = payload[0] + note_off_channels.add(channel) + note_transitions.append((tick, event_serial, channel, note, False)) + key = (channel, note) + queue = active[key] + note_offs += 1 + if not queue: + unmatched_note_offs += 1 + else: + start_tick = queue.popleft() + if start_tick == tick: + zero_duration_pairs += 1 + active_total -= 1 + event_serial += 1 + + remaining = sum(len(queue) for queue in active.values()) + return { + "note_ons": note_ons, + "note_offs": note_offs, + "same_note_overlaps": same_note_overlaps, + "zero_duration_pairs": zero_duration_pairs, + "max_polyphony": max_polyphony, + "tempo_events": tempos, + "key_signatures": key_signatures, + "channels": sorted(channels), + "note_on_channels": sorted(note_on_channels), + "note_off_channels": sorted(note_off_channels), + "sysex_events": sysex, + "pitch_bend_events": pitch_bend, + "aftertouch_events": aftertouch, + "time_signature_events": time_signatures, + "unmatched_note_offs": unmatched_note_offs, + "remaining_active_notes": remaining, + "end_tick": end_tick, + "note_transitions": note_transitions, + "note_attacks": note_attacks, + "controller_events": controller_events, + } + + +def duration_seconds(end_tick: int, division: int, tempos: list[tuple[int, int]]) -> float: + by_tick: dict[int, int] = {} + for tick, usec in tempos: + by_tick[tick] = usec + if 0 not in by_tick: + by_tick[0] = 500000 + elapsed = 0.0 + current_tick = 0 + current_usec = by_tick[0] + for tick in sorted(k for k in by_tick if 0 < k <= end_tick): + elapsed += (tick - current_tick) * current_usec / (division * 1_000_000.0) + current_tick = tick + current_usec = by_tick[tick] + elapsed += (end_tick - current_tick) * current_usec / (division * 1_000_000.0) + return elapsed + + +def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: + if len(data) < 14 or data[:4] != b"MThd": + die(f"{label}: missing MThd header") + header_len = int.from_bytes(data[4:8], "big") + if header_len < 6 or 8 + header_len > len(data): + die(f"{label}: invalid MThd length") + fmt, ntracks, division = struct.unpack(">HHH", data[8:14]) + if division & 0x8000: + die(f"{label}: SMPTE division is outside this corpus contract") + + pos = 8 + header_len + tracks: list[dict[str, Any]] = [] + for index in range(ntracks): + if pos + 8 > len(data) or data[pos:pos + 4] != b"MTrk": + die(f"{label}: missing MTrk chunk {index}") + length = int.from_bytes(data[pos + 4:pos + 8], "big") + pos += 8 + end = pos + length + if end > len(data): + die(f"{label}: truncated MTrk chunk {index}") + tracks.append(parse_track(data[pos:end])) + pos = end + + tempos = [item for track in tracks for item in track["tempo_events"]] + note_events: list[tuple[int, ...]] = [] + for track_index, track in enumerate(tracks): + for tick, _serial, channel, note, velocity in track["note_attacks"]: + normalized_note, cmd, parameter = imported_note_semantics( + note, velocity + ) + note_events.append( + ( + track_index, + tick, + normalized_note, + channel, + cmd, + parameter, + ) + ) + controller_events = [ + (track_index, tick, channel, controller, value) + for track_index, track in enumerate(tracks) + for tick, _serial, channel, controller, value + in track["controller_events"] + ] + tempo_commands = [ + (track_index, tick, imported_tempo_parameter(usec)) + for track_index, track in enumerate(tracks) + for tick, usec in track["tempo_events"] + if tick != 0 + ] + bpms = [60_000_000.0 / usec for _, usec in tempos if usec] + song_bpm = 120.0 + for tick, usec in tempos: + if tick == 0: + song_bpm = normalized_tempo_bpm(usec) + + ordered_transitions: list[ + tuple[int, int, int, int, int, int, bool] + ] = [] + for track_index, track in enumerate(tracks): + by_tick: dict[ + int, list[tuple[int, int, int, bool]] + ] = collections.defaultdict(list) + for tick, serial, channel, note, is_on in track["note_transitions"]: + by_tick[tick].append((serial, channel, note, is_on)) + for tick, events in by_tick.items(): + events.sort() + first_attack = next( + ( + index for index, (_serial, _channel, _note, is_on) + in enumerate(events) + if is_on + ), + len(events), + ) + for index, (serial, channel, note, is_on) in enumerate(events): + # Leading releases on a track occur before cross-track attacks + # at the same tick. Once a track attacks, its remaining note + # events keep strict same-track serial order. + phase = index - first_attack + kind_priority = 1 if is_on else 0 + ordered_transitions.append( + ( + tick, + phase, + kind_priority, + channel, + note, + track_index, + is_on, + ) + ) + + ordered_transitions.sort() + active_notes: dict[ + tuple[int, int], collections.deque[int] + ] = collections.defaultdict(collections.deque) + active_total = 0 + global_max_polyphony = 0 + global_same_note_overlaps = 0 + global_zero_duration_pairs = 0 + global_unmatched_note_offs = 0 + for tick, _phase, _kind, channel, note, _track_index, is_on in ordered_transitions: + key = (channel, note) + queue = active_notes[key] + if is_on: + if queue: + global_same_note_overlaps += 1 + queue.append(tick) + active_total += 1 + global_max_polyphony = max(global_max_polyphony, active_total) + elif not queue: + global_unmatched_note_offs += 1 + else: + start_tick = queue.popleft() + if start_tick == tick: + global_zero_duration_pairs += 1 + active_total -= 1 + + global_remaining = sum(len(queue) for queue in active_notes.values()) + key_sigs = [pair for track in tracks for pair in track["key_signatures"]] + channels = sorted({ch for track in tracks for ch in track["channels"]}) + note_on_channels = sorted({ + ch for track in tracks for ch in track["note_on_channels"] + }) + note_off_channels = sorted({ + ch for track in tracks for ch in track["note_off_channels"] + }) + end_tick = max((track["end_tick"] for track in tracks), default=0) + return { + "format": fmt, + "tracks": ntracks, + "division": division, + "note_ons": sum(track["note_ons"] for track in tracks), + "note_offs": sum(track["note_offs"] for track in tracks), + "note_event_digest_fnv64": semantic_event_multiset_digest_fnv64( + note_events + ), + "midi_cc": len(controller_events), + "midi_cc_digest_fnv64": semantic_event_multiset_digest_fnv64( + controller_events + ), + "song_bpm": song_bpm, + "same_note_overlaps": global_same_note_overlaps, + "zero_duration_pairs": global_zero_duration_pairs, + "max_polyphony": global_max_polyphony, + "tempo_events": len(tempos), + "tempo_events_at_zero": sum(1 for tick, _usec in tempos if tick == 0), + "tempo_map_digest_fnv64": tempo_map_digest_fnv64(tempo_commands), + "tempo_min_bpm": min(bpms) if bpms else None, + "tempo_max_bpm": max(bpms) if bpms else None, + "key_signatures": sorted(set(key_sigs)), + "malformed_key_signatures": sorted( + set((sf, mi) for sf, mi in key_sigs if sf < -7 or sf > 7 or mi not in (0, 1)) + ), + "channels": channels, + "note_on_channels": note_on_channels, + "note_off_channels": note_off_channels, + "sysex_events": sum(track["sysex_events"] for track in tracks), + "pitch_bend_events": sum(track["pitch_bend_events"] for track in tracks), + "aftertouch_events": sum(track["aftertouch_events"] for track in tracks), + "time_signature_events": sum(track["time_signature_events"] for track in tracks), + "unmatched_note_offs": global_unmatched_note_offs, + "remaining_active_notes": global_remaining, + "balanced_note_pairs": ( + global_unmatched_note_offs == 0 and global_remaining == 0 + ), + "duration_seconds": duration_seconds(end_tick, division, tempos), + } + + +def parse_smf(path: pathlib.Path) -> dict[str, Any]: + return parse_smf_bytes(path.read_bytes(), label=path.name) + + +def safe_zip_member(name: str) -> pathlib.PurePosixPath: + pure = pathlib.PurePosixPath(name.replace("\\", "/")) + if pure.is_absolute() or ".." in pure.parts or not pure.name: + die(f"unsafe ZIP member path: {name!r}") + return pure + + +def find_outer_archive(bundle: zipfile.ZipFile, expected_name: str) -> zipfile.ZipInfo: + matches = [ + info for info in bundle.infolist() + if not info.is_dir() and pathlib.PurePosixPath(info.filename).name == expected_name + ] + if len(matches) != 1: + die(f"outer corpus bundle must contain exactly one {expected_name!r}") + return matches[0] + + +def validate_aggregate_against_manifest( + set_spec: dict[str, Any], + aggregate: dict[str, Any], +) -> None: + expected = set_spec.get("analysis_expectations") + if not isinstance(expected, dict): + die(f"{set_spec['name']}: missing analysis_expectations") + + exact_fields = { + "stems": "stems", + "tracks": "tracks", + "notes": "notes", + "same_note_overlaps": "same_note_overlaps", + "max_polyphony": "max_polyphony", + } + for actual_key, expected_key in exact_fields.items(): + expected_value = expected.get(expected_key) + if expected_value is None: + continue + if aggregate.get(actual_key) != expected_value: + die( + f"{set_spec['name']}: {actual_key} mismatch: " + f"expected={expected_value} actual={aggregate.get(actual_key)}" + ) + + expected_tempo_per_stem = expected.get("tempo_events_per_stem") + if ( + expected_tempo_per_stem is None + or aggregate.get("tempo_events_per_stem") != [expected_tempo_per_stem] + ): + die( + f"{set_spec['name']}: per-stem tempo-map count changed: " + f"expected={expected_tempo_per_stem} " + f"actual={aggregate.get('tempo_events_per_stem')}" + ) + expected_tempo_total = expected_tempo_per_stem * set_spec["stems"] + if aggregate.get("tempo_events_total") != expected_tempo_total: + die( + f"{set_spec['name']}: tempo-map total changed: " + f"expected={expected_tempo_total} " + f"actual={aggregate.get('tempo_events_total')}" + ) + + if aggregate.get("formats") != [1] or aggregate.get("divisions") != [480]: + die(f"{set_spec['name']}: corpus files are not uniformly SMF1/480 PPQN") + if aggregate.get("balanced_note_pairs") is not True: + die(f"{set_spec['name']}: unbalanced note pairs detected") + for field in ( + "sysex_events", + "pitch_bend_events", + "aftertouch_events", + "time_signature_events", + ): + if aggregate.get(field) != 0: + die(f"{set_spec['name']}: unexpected {field}={aggregate.get(field)}") + + tempo_range = expected.get("tempo_range_bpm") + if not ( + isinstance(tempo_range, list) + and len(tempo_range) == 2 + and isinstance(aggregate.get("tempo_min_bpm"), (int, float)) + and isinstance(aggregate.get("tempo_max_bpm"), (int, float)) + and math.isclose( + aggregate["tempo_min_bpm"], float(tempo_range[0]), abs_tol=0.01 + ) + and math.isclose( + aggregate["tempo_max_bpm"], float(tempo_range[1]), abs_tol=0.01 + ) + ): + die( + f"{set_spec['name']}: tempo range changed: " + f"expected={tempo_range} actual=" + f"{[aggregate.get('tempo_min_bpm'), aggregate.get('tempo_max_bpm')]}" + ) + + duration = aggregate.get("duration_seconds_max") + if not isinstance(duration, (int, float)) or not math.isclose( + duration, + float(expected.get("duration_seconds_max")), + abs_tol=0.02, + ): + die( + f"{set_spec['name']}: duration changed: " + f"expected={expected.get('duration_seconds_max')} actual={duration}" + ) + + expected_key = expected.get("malformed_key_signature") + expected_keys = [] if expected_key is None else [tuple(expected_key)] + actual_keys = [ + tuple(pair) for pair in aggregate.get("malformed_key_signatures", []) + ] + if actual_keys != expected_keys: + die( + f"{set_spec['name']}: malformed key-signature observation changed: " + f"expected={expected_keys} actual={actual_keys}" + ) + + expected_zero = bool(expected.get("zero_duration_pairs_present")) + zero_pairs = aggregate.get("zero_duration_pairs") + if not isinstance(zero_pairs, int) or (zero_pairs > 0) != expected_zero: + die(f"{set_spec['name']}: zero-duration-pair observation changed") + + expected_channels = expected.get("channels_used") + if ( + expected_channels is not None + and aggregate.get("channels") != expected_channels + ): + die( + f"{set_spec['name']}: channel-use observation changed: " + f"expected={expected_channels} actual={aggregate.get('channels')}" + ) + + +def validate_set_aggregate( + set_spec: dict[str, Any], + stems: list[dict[str, Any]], +) -> dict[str, Any]: + aggregate = { + "stems": len(stems), + "tracks": sum(stem["analysis"]["tracks"] for stem in stems), + "notes": sum(stem["analysis"]["note_ons"] for stem in stems), + "same_note_overlaps": sum(stem["analysis"]["same_note_overlaps"] for stem in stems), + "max_polyphony": max((stem["analysis"]["max_polyphony"] for stem in stems), default=0), + "tempo_events_total": sum(stem["analysis"]["tempo_events"] for stem in stems), + "tempo_events_at_zero_total": sum( + stem["analysis"]["tempo_events_at_zero"] for stem in stems + ), + "tempo_events_per_stem": sorted( + {stem["analysis"]["tempo_events"] for stem in stems} + ), + "tempo_min_bpm": min( + stem["analysis"]["tempo_min_bpm"] + for stem in stems if stem["analysis"]["tempo_min_bpm"] is not None + ), + "tempo_max_bpm": max( + stem["analysis"]["tempo_max_bpm"] + for stem in stems if stem["analysis"]["tempo_max_bpm"] is not None + ), + "duration_seconds_max": max(stem["analysis"]["duration_seconds"] for stem in stems), + "channels": sorted({ch for stem in stems for ch in stem["analysis"]["channels"]}), + "malformed_key_signatures": sorted({ + tuple(pair) + for stem in stems + for pair in stem["analysis"]["malformed_key_signatures"] + }), + "zero_duration_pairs": sum(stem["analysis"]["zero_duration_pairs"] for stem in stems), + "sysex_events": sum(stem["analysis"]["sysex_events"] for stem in stems), + "pitch_bend_events": sum(stem["analysis"]["pitch_bend_events"] for stem in stems), + "aftertouch_events": sum(stem["analysis"]["aftertouch_events"] for stem in stems), + "time_signature_events": sum(stem["analysis"]["time_signature_events"] for stem in stems), + "balanced_note_pairs": all(stem["analysis"]["balanced_note_pairs"] for stem in stems), + "formats": sorted({stem["analysis"]["format"] for stem in stems}), + "divisions": sorted({stem["analysis"]["division"] for stem in stems}), + } + + validate_aggregate_against_manifest(set_spec, aggregate) + return aggregate + + +def prepare_bundle(bundle_path: pathlib.Path, root: pathlib.Path) -> dict[str, Any]: + manifest = corpus_manifest() + if not bundle_path.is_file(): + die(f"private MIDI corpus bundle is missing: {bundle_path}") + private_root = root / "private" + private_root.mkdir(parents=True, exist_ok=True) + public_sets: list[dict[str, Any]] = [] + + with zipfile.ZipFile(bundle_path, "r") as outer: + for set_spec in manifest["sets"]: + archive_info = find_outer_archive(outer, set_spec["archive"]) + archive_bytes = outer.read(archive_info) + archive_sha = sha256_bytes(archive_bytes) + if archive_sha != set_spec["sha256"]: + die( + f"{set_spec['name']}: archive SHA-256 mismatch: " + f"expected={set_spec['sha256']} actual={archive_sha}" + ) + set_slug = slug(set_spec["name"]) + set_root = private_root / set_slug + set_root.mkdir(parents=True, exist_ok=True) + stems: list[dict[str, Any]] = [] + with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as inner: + midi_infos = [ + info for info in inner.infolist() + if not info.is_dir() + and pathlib.PurePosixPath(info.filename).suffix.lower() in {".mid", ".midi"} + ] + midi_infos.sort(key=lambda info: info.filename.lower()) + if len(midi_infos) != set_spec["stems"]: + die( + f"{set_spec['name']}: stem count mismatch: " + f"expected={set_spec['stems']} actual={len(midi_infos)}" + ) + for index, info in enumerate(midi_infos): + rel = safe_zip_member(info.filename) + data = inner.read(info) + stem_sha = sha256_bytes(data) + analysis = parse_smf_bytes(data, label=f"{set_spec['name']}/{rel}") + stem_id = f"{set_slug}-{index:02d}-{stem_sha[:12]}" + destination = set_root / f"{index:02d}-{rel.name}" + destination.write_bytes(data) + stems.append({ + "stem_id": stem_id, + "set_name": set_spec["name"], + "set_slug": set_slug, + "archive": set_spec["archive"], + "archive_sha256": archive_sha, + "relative_name": rel.as_posix(), + "private_path": destination.relative_to(root).as_posix(), + "sha256": stem_sha, + "size_bytes": len(data), + "analysis": analysis, + }) + aggregate = validate_set_aggregate(set_spec, stems) + representative = stems[0] + public_sets.append({ + "name": set_spec["name"], + "slug": set_slug, + "archive": set_spec["archive"], + "archive_sha256": archive_sha, + "role": set_spec["role"], + "aggregate": aggregate, + "representative": { + "stem_id": representative["stem_id"], + "relative_name": representative["relative_name"], + "private_path": representative["private_path"], + "sha256": representative["sha256"], + "size_bytes": representative["size_bytes"], + }, + "stems": stems, + }) + + by_name = {item["name"]: item for item in public_sets} + ordered = [by_name[name] for name in PROGRESSION] + analysis_doc = { + "schema_version": 1, + "phase": "6C", + "contract": CONTRACT, + "evidence_role": "private-input-analysis", + "progression_order": PROGRESSION, + "sets": ordered, + "parity_status": "UNKNOWN", + } + write_json(root / "corpus-analysis.json", analysis_doc) + return analysis_doc + + +def validate_analysis_manifest_bindings( + analysis: dict[str, Any], +) -> dict[str, dict[str, Any]]: + manifest = corpus_manifest() + manifest_by_name = {item["name"]: item for item in manifest["sets"]} + sets = analysis.get("sets") + if ( + analysis.get("schema_version") != 1 + or analysis.get("phase") != "6C" + or analysis.get("contract") != CONTRACT + or analysis.get("evidence_role") != "private-input-analysis" + or analysis.get("progression_order") != PROGRESSION + or analysis.get("parity_status") != "UNKNOWN" + or not isinstance(sets, list) + or len(sets) != len(PROGRESSION) + or [item.get("name") for item in sets] != PROGRESSION + ): + die("MIDI corpus analysis is not the canonical private-input analysis") + + for set_info in sets: + name = set_info["name"] + set_spec = manifest_by_name[name] + stems = set_info.get("stems") + representative = set_info.get("representative") + if ( + set_info.get("slug") != slug(name) + or set_info.get("archive") != set_spec["archive"] + or set_info.get("archive_sha256") != set_spec["sha256"] + or set_info.get("role") != set_spec["role"] + or not isinstance(stems, list) + or len(stems) != set_spec["stems"] + or not isinstance(representative, dict) + ): + die(f"{name}: analysis is not bound to the manifest-pinned archive") + + for stem in stems: + if ( + stem.get("set_name") != name + or stem.get("set_slug") != slug(name) + or stem.get("archive") != set_spec["archive"] + or stem.get("archive_sha256") != set_spec["sha256"] + or not isinstance(stem.get("sha256"), str) + or len(stem["sha256"]) != 64 + or not isinstance(stem.get("size_bytes"), int) + or stem["size_bytes"] <= 0 + or not isinstance(stem.get("analysis"), dict) + ): + die(f"{name}: stem analysis is not archive-bound") + + matches = [ + stem for stem in stems + if ( + stem.get("stem_id") == representative.get("stem_id") + and stem.get("relative_name") == representative.get("relative_name") + and stem.get("private_path") == representative.get("private_path") + and stem.get("sha256") == representative.get("sha256") + and stem.get("size_bytes") == representative.get("size_bytes") + ) + ] + if len(matches) != 1: + die(f"{name}: representative is not one canonical analysed stem") + validate_aggregate_against_manifest(set_spec, set_info.get("aggregate", {})) + return manifest_by_name + + +def contains_structural_smf(data: bytes) -> bool: + search_from = 0 + while True: + offset = data.find(b"MThd", search_from) + if offset < 0: + return False + payload = data[offset:] + if len(payload) >= 14: + header_len = int.from_bytes(payload[4:8], "big") + if header_len >= 6 and 8 + header_len <= len(payload): + fmt, ntracks, division = struct.unpack(">HHH", payload[8:14]) + if ( + fmt in (0, 1, 2) + and ntracks > 0 + and division != 0 + and not (division & 0x8000) + ): + pos = 8 + header_len + valid = True + for _index in range(ntracks): + if ( + pos + 8 > len(payload) + or payload[pos:pos + 4] != b"MTrk" + ): + valid = False + break + length = int.from_bytes(payload[pos + 4:pos + 8], "big") + start = pos + 8 + end = start + length + if end > len(payload): + valid = False + break + try: + parse_track(payload[start:end]) + except SystemExit: + valid = False + break + pos = end + if valid: + return True + search_from = offset + 1 + + +def audit_public_tree(root: pathlib.Path) -> None: + leaks: list[str] = [] + for path in root.rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(root).as_posix() + if path.suffix.lower() in {".mid", ".midi", ".zip"}: + leaks.append(relative) + continue + data = path.read_bytes() + prefix = data[:4] + if contains_structural_smf(data): + leaks.append(relative) + continue + if prefix == b"PK\x03\x04" or zipfile.is_zipfile(path): + leaks.append(relative) + if leaks: + die("public MIDI evidence contains raw corpus bytes: " + ", ".join(sorted(leaks))) + + +def verified_archive_members( + outer: zipfile.ZipFile, + set_spec: dict[str, Any], +) -> dict[str, bytes]: + archive_info = find_outer_archive(outer, set_spec["archive"]) + archive_bytes = outer.read(archive_info) + archive_sha = sha256_bytes(archive_bytes) + if archive_sha != set_spec["sha256"]: + die( + f"{set_spec['name']}: private archive SHA-256 mismatch during observation: " + f"expected={set_spec['sha256']} actual={archive_sha}" + ) + members: dict[str, bytes] = {} + with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as inner: + for info in inner.infolist(): + if info.is_dir(): + continue + rel = safe_zip_member(info.filename).as_posix() + if rel in members: + die(f"{set_spec['name']}: duplicate archive member {rel!r}") + members[rel] = inner.read(info) + return members + + +def validate_analysis_against_bundle( + analysis: dict[str, Any], + bundle_path: pathlib.Path, + *, + representatives_only: bool, +) -> dict[str, dict[str, Any]]: + manifest_by_name = validate_analysis_manifest_bindings(analysis) + if not bundle_path.is_file() or not zipfile.is_zipfile(bundle_path): + die("private MIDI corpus outer bundle is missing or invalid") + with zipfile.ZipFile(bundle_path, "r") as outer: + for set_info in analysis["sets"]: + name = set_info["name"] + set_spec = manifest_by_name[name] + members = verified_archive_members(outer, set_spec) + stems = ( + [set_info["representative"]] + if representatives_only + else set_info["stems"] + ) + for stem in stems: + relative_name = stem.get("relative_name") + if relative_name not in members: + die( + f"{name}: analysed stem {relative_name!r} is not present " + "in the manifest-pinned archive" + ) + archive_bytes = members[relative_name] + archive_sha = sha256_bytes(archive_bytes) + if ( + archive_sha != stem.get("sha256") + or len(archive_bytes) != stem.get("size_bytes") + ): + die( + f"{name}: analysed stem identity differs from its " + "manifest-pinned archive member" + ) + private_path = analysis_path_for_private_stem( + analysis, stem + ) + if ( + not private_path.is_file() + or private_path.read_bytes() != archive_bytes + ): + die( + f"{name}: extracted private stem differs from the " + "verified archive member" + ) + return manifest_by_name + + +def analysis_path_for_private_stem( + analysis: dict[str, Any], + stem: dict[str, Any], +) -> pathlib.Path: + root_value = analysis.get("_analysis_root") + if not isinstance(root_value, str): + die("private corpus analysis root is not bound") + root = pathlib.Path(root_value) + relative = pathlib.PurePosixPath(stem.get("private_path", "")) + if relative.is_absolute() or ".." in relative.parts: + die("private stem path escapes the corpus analysis root") + return root / pathlib.Path(*relative.parts) + + +def bind_analysis_root( + analysis: dict[str, Any], + analysis_path: pathlib.Path, +) -> None: + analysis["_analysis_root"] = str(analysis_path.parent.resolve()) + + +def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, Any]: + expected_player = CANDIDATE_PLAYER.resolve() + if player.resolve() != expected_player: + die( + "candidate player must be the canonical Phase 6B build output: " + f"{expected_player}" + ) + if not CANDIDATE_BASELINE_RECEIPT.is_file(): + die("Phase 6B sanitized baseline receipt is missing") + receipt = CANDIDATE_BASELINE_RECEIPT.read_text( + encoding="utf-8" + ).strip() + if receipt != EXPECTED_CANDIDATE_BASELINE: + die("Phase 6B sanitized baseline receipt identity changed") + if not player.is_file() or not os.access(player, os.X_OK): + die("canonical candidate player is missing or not executable") + with player.open("rb") as handle: + if handle.read(4) != b"\x7fELF": + die("canonical candidate player is not an ELF executable") + diff = subprocess.run( + [ + "git", "-C", str(ROOT), "diff", "--quiet", "HEAD", "--", + str(CANDIDATE_SOURCE_ROOT.relative_to(ROOT)), + ], + check=False, + ) + if diff.returncode != 0: + die("tracked frozen candidate source differs from HEAD") + + player_root = CANDIDATE_SOURCE_ROOT / "psycle-player" + + # The qmake build graph is generated and therefore untracked. Purge every + # generated Makefile/cache/build output from the sanitized tree before + # attesting the rebuild, then reject any other unexpected untracked input. + candidate_rel = str(CANDIDATE_SOURCE_ROOT.relative_to(ROOT)) + untracked_proc = subprocess.run( + [ + "git", "-C", str(ROOT), "ls-files", "--others", + "--exclude-standard", "--", candidate_rel, + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if untracked_proc.returncode != 0: + die("cannot enumerate untracked candidate build inputs: " + untracked_proc.stdout[-1000:]) + + generated_dirs: set[pathlib.Path] = set() + for value in untracked_proc.stdout.splitlines(): + relative = pathlib.PurePosixPath(value) + path = ROOT / pathlib.Path(*relative.parts) + if "++qmake" in relative.parts: + marker = relative.parts.index("++qmake") + generated_dirs.add(ROOT / pathlib.Path(*relative.parts[:marker + 1])) + elif relative.name.startswith("Makefile") or relative.name in { + ".qmake.stash", ".qmake.cache" + }: + if path.is_file() or path.is_symlink(): + path.unlink() + for generated in sorted( + generated_dirs, key=lambda value: len(value.parts), reverse=True + ): + if generated.exists(): + shutil.rmtree(generated) + + untracked_after = subprocess.run( + [ + "git", "-C", str(ROOT), "ls-files", "--others", + "--exclude-standard", "--", candidate_rel, + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if untracked_after.returncode != 0: + die("cannot re-check untracked candidate inputs: " + untracked_after.stdout[-1000:]) + allowed_plugin = ( + CANDIDATE_SOURCE_ROOT + / "psycle-plugins/src/psycle/plugin_interface.hpp" + ).relative_to(ROOT).as_posix() + allowed_diversalis = ( + CANDIDATE_SOURCE_ROOT / "diversalis" + ).relative_to(ROOT).as_posix() + "/" + unexpected = [ + value for value in untracked_after.stdout.splitlines() + if value != allowed_plugin and not value.startswith(allowed_diversalis) + ] + if unexpected: + die( + "unexpected untracked frozen-candidate build input: " + + ", ".join(unexpected[:10]) + ) + dependency_identity = validate_candidate_dependencies() + + qmake = subprocess.run( + [ + "qmake", "-nocache", "CONFIG-=shared", "CONFIG+=release", + "psycle-player.pro", + ], + cwd=player_root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if qmake.returncode != 0: + die( + "qmake regeneration failed for frozen candidate player: " + + qmake.stdout.decode(errors="replace")[-1000:] + ) + makefile = player_root / "Makefile" + if not makefile.is_file(): + die("qmake did not regenerate the canonical candidate Makefile") + clean = subprocess.run( + ["make", "clean"], + cwd=player_root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if clean.returncode != 0: + die( + "clean rebuild preparation failed for frozen candidate player: " + + clean.stdout.decode(errors="replace")[-1000:] + ) + if player.exists(): + player.unlink() + rebuild = subprocess.run( + ["make", "-j2"], + cwd=player_root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if rebuild.returncode != 0 or not player.is_file(): + die( + "clean rebuild failed for frozen candidate player: " + + rebuild.stdout.decode(errors="replace")[-1000:] + ) + # The caller-supplied binary is intentionally not trusted as evidence. + # Everything under the generated qmake graph was removed before qmake ran, + # and this path now names the binary produced from the pinned source tree. + rebuilt_sha = sha256_file(player) + with player.open("rb") as handle: + if handle.read(4) != b"\x7fELF": + die("clean rebuilt candidate player is not an ELF executable") + + repository_commit = subprocess.check_output( + ["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True + ).strip() + return { + "candidate_baseline_sha256": EXPECTED_CANDIDATE_BASELINE, + "source_revision": EXPECTED_CANDIDATE_SOURCE_REVISION, + "repository_commit": repository_commit, + "build_target": "psycle-player", + **dependency_identity, + "clean_rebuild_sha256": rebuilt_sha, + } + + +def candidate_boundary( + analysis_path: pathlib.Path, + bundle_path: pathlib.Path, + player: pathlib.Path, + output: pathlib.Path, +) -> None: + analysis = load_json(analysis_path) + bind_analysis_root(analysis, analysis_path) + manifest_by_name = validate_analysis_against_bundle( + analysis, bundle_path, representatives_only=True + ) + candidate_identity = validate_frozen_candidate_player(player) + player_sha = sha256_file(player) + attestation_path = output.parent / "candidate-player-attestation.json" + write_json( + attestation_path, + candidate_attestation_document(candidate_identity, player_sha), + ) + results = [] + for set_info in analysis["sets"]: + rep = set_info["representative"] + private_path = analysis_path.parent / rep["private_path"] + if sha256_file(private_path) != rep["sha256"]: + die(f"{set_info['name']}: representative private MIDI changed") + replay_root = analysis_path.parent / "candidate-replay" / set_info["slug"] + if replay_root.exists(): + shutil.rmtree(replay_root) + replay_root.mkdir(parents=True) + staged_player = replay_root / "candidate-psycle-player" + staged_midi = replay_root / "representative.mid" + shutil.copyfile(player, staged_player) + shutil.copyfile(private_path, staged_midi) + staged_player.chmod(0o755) + proc = subprocess.run( + [ + "./candidate-psycle-player", + "--output-driver", + "dummy", + "--input-file", + "representative.mid", + ], + cwd=replay_root, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + raw = proc.stdout + diagnostic = b"could not load song file:" in raw + direct = "rejected" if proc.returncode == 2 and diagnostic else "inconclusive" + set_spec = manifest_by_name[set_info["name"]] + results.append({ + "set_name": set_info["name"], + "archive": set_spec["archive"], + "archive_sha256": set_spec["sha256"], + "representative_stem_id": rep["stem_id"], + "representative_relative_name": rep["relative_name"], + "representative_size_bytes": rep["size_bytes"], + "representative_sha256": rep["sha256"], + "exit_code": proc.returncode, + "diagnostic_could_not_load_song_file": diagnostic, + "raw_output_sha256": sha256_bytes(raw), + "direct_midi_load": direct, + }) + shutil.rmtree(replay_root) + + if any(item["direct_midi_load"] != "rejected" for item in results): + die("frozen candidate real-world MIDI boundary was not a scoped direct-load rejection") + receipt = { + "schema_version": 1, + "phase": "6C", + "contract": CANDIDATE_CONTRACT, + "evidence_role": "candidate-observation", + **candidate_identity, + "player": "candidate-psycle-player", + "player_sha256": player_sha, + "build_attestation": attestation_path.name, + "build_attestation_sha256": sha256_file(attestation_path), + "working_directory": "artifact-root-with-private-input", + "procedure": ( + "for each corpus set, place the exact representative SMF beside the " + "artifact candidate player under the private replay root, chmod +x the " + "player, invoke --output-driver dummy --input-file representative.mid " + "with stdin=/dev/null, retain only the sanitized result/hash, then delete " + "the private replay root" + ), + "private_input_required": True, + "progression_order": PROGRESSION, + "sets": results, + "parity_status": "UNKNOWN", + "claim_scope": ( + "Frozen C++ candidate representative SMF direct-load boundary only; " + "absence of a direct loader is not a claim about internal MIDI event support." + ), + } + write_json(output, receipt) + + +def validate_donor_execution_observation( + stem: dict[str, Any], + obs: dict[str, Any], +) -> dict[str, Any]: + stem_id = stem.get("stem_id", "") + analysis = stem.get("analysis") + if not isinstance(analysis, dict): + die(f"{stem_id}: donor source analysis is missing") + if obs.get("machines_before_projection") != 0: + die(f"{stem_id}: MIDI importer unexpectedly created machines") + if not isinstance(obs.get("projection_notes"), int) or obs["projection_notes"] <= 0: + die(f"{stem_id}: donor projection contains no notes") + if not isinstance(obs.get("render_frames"), int) or obs["render_frames"] <= 0: + die(f"{stem_id}: donor projection rendered no frames") + if not isinstance(obs.get("render_peak"), int) or obs["render_peak"] <= 0: + die(f"{stem_id}: donor projection peak is invalid") + + source_channels = analysis.get("channels") + source_note_off_channels = analysis.get("note_off_channels") + expected_channel_mask = midi_channel_mask(source_channels) + expected_release_mask = midi_channel_mask(source_note_off_channels) + if obs.get("midi_channel_mask") != expected_channel_mask: + die( + f"{stem_id}: imported MIDI channel coverage differs from raw SMF: " + f"source={source_channels} imported_mask={obs.get('midi_channel_mask')!r}" + ) + if obs.get("release_channel_mask") != expected_release_mask: + die( + f"{stem_id}: imported release-channel coverage differs from raw SMF: " + f"source={source_note_off_channels} " + f"imported_mask={obs.get('release_channel_mask')!r}" + ) + if obs.get("imported_notes") != analysis.get("note_ons"): + die( + f"{stem_id}: imported note count differs from raw SMF: " + f"source={analysis.get('note_ons')} imported={obs.get('imported_notes')}" + ) + source_note_digest = analysis.get("note_event_digest_fnv64") + if ( + not isinstance(source_note_digest, str) + or len(source_note_digest) != 16 + ): + die(f"{stem_id}: raw SMF note-event digest is invalid") + try: + int(source_note_digest, 16) + except ValueError: + die(f"{stem_id}: raw SMF note-event digest is not hexadecimal") + if obs.get("note_event_digest_fnv64") != source_note_digest: + die( + f"{stem_id}: imported note-event contents differ from raw SMF: " + f"source_digest={source_note_digest} " + f"imported_digest={obs.get('note_event_digest_fnv64')!r}" + ) + if obs.get("releases") != analysis.get("note_offs"): + die( + f"{stem_id}: imported release count differs from raw SMF: " + f"source={analysis.get('note_offs')} imported={obs.get('releases')}" + ) + source_midi_cc = analysis.get("midi_cc") + source_midi_cc_digest = analysis.get("midi_cc_digest_fnv64") + if ( + not isinstance(source_midi_cc, int) + or isinstance(source_midi_cc, bool) + or source_midi_cc < 0 + or not isinstance(source_midi_cc_digest, str) + or len(source_midi_cc_digest) != 16 + ): + die(f"{stem_id}: raw SMF controller analysis is invalid") + try: + int(source_midi_cc_digest, 16) + except ValueError: + die(f"{stem_id}: raw SMF controller digest is not hexadecimal") + if obs.get("midi_cc") != source_midi_cc: + die( + f"{stem_id}: imported MIDI controller count differs from raw SMF: " + f"source={source_midi_cc} imported={obs.get('midi_cc')!r}" + ) + if obs.get("midi_cc_digest_fnv64") != source_midi_cc_digest: + die( + f"{stem_id}: imported MIDI controller contents differ from raw SMF" + ) + + source_song_bpm = analysis.get("song_bpm") + imported_song_bpm = obs.get("song_bpm") + if ( + not isinstance(source_song_bpm, (int, float)) + or isinstance(source_song_bpm, bool) + or not isinstance(imported_song_bpm, (int, float)) + or isinstance(imported_song_bpm, bool) + or not math.isclose( + float(imported_song_bpm), + float(source_song_bpm), + abs_tol=0.001, + ) + ): + die( + f"{stem_id}: imported tick-zero song BPM differs from raw SMF: " + f"source={source_song_bpm!r} imported={imported_song_bpm!r}" + ) + + source_tempo_events = analysis.get("tempo_events") + source_tempo_events_at_zero = analysis.get("tempo_events_at_zero") + if ( + not isinstance(source_tempo_events, int) + or isinstance(source_tempo_events, bool) + or source_tempo_events < 0 + or not isinstance(source_tempo_events_at_zero, int) + or isinstance(source_tempo_events_at_zero, bool) + or source_tempo_events_at_zero < 0 + or source_tempo_events_at_zero > source_tempo_events + ): + die(f"{stem_id}: raw SMF tempo analysis is invalid") + source_tempo_digest = analysis.get("tempo_map_digest_fnv64") + if ( + not isinstance(source_tempo_digest, str) + or len(source_tempo_digest) != 16 + ): + die(f"{stem_id}: raw SMF tempo-map digest is invalid") + try: + int(source_tempo_digest, 16) + except ValueError: + die(f"{stem_id}: raw SMF tempo-map digest is not hexadecimal") + expected_tempo_commands = source_tempo_events - source_tempo_events_at_zero + if obs.get("tempo_commands") != expected_tempo_commands: + die( + f"{stem_id}: imported tempo map differs from raw SMF: " + f"source_events={source_tempo_events} " + f"tick_zero={source_tempo_events_at_zero} " + f"imported_commands={obs.get('tempo_commands')!r}" + ) + if obs.get("tempo_map_digest_fnv64") != source_tempo_digest: + die( + f"{stem_id}: imported tempo-map contents differ from raw SMF: " + f"source_digest={source_tempo_digest} " + f"imported_digest={obs.get('tempo_map_digest_fnv64')!r}" + ) + if obs.get("non_silent_projection") is not True: + die(f"{stem_id}: execution projection is silent") + if obs.get("projection_kind") != "deterministic-sampler": + die(f"{stem_id}: execution projection kind changed") + if obs.get("parity_status") != "UNKNOWN": + die(f"{stem_id}: donor observation attempted parity promotion") + return { + "source_channels": list(source_channels), + "source_note_off_channels": list(source_note_off_channels), + "source_note_offs": analysis["note_offs"], + "source_note_event_digest_fnv64": source_note_digest, + "source_midi_cc": source_midi_cc, + "source_midi_cc_digest_fnv64": source_midi_cc_digest, + "source_song_bpm": float(source_song_bpm), + "source_tempo_events": source_tempo_events, + "source_tempo_events_at_zero": source_tempo_events_at_zero, + "source_tempo_map_digest_fnv64": source_tempo_digest, + } + + +def donor_summary( + analysis_path: pathlib.Path, + bundle_path: pathlib.Path, + observations_root: pathlib.Path, + probe_path: pathlib.Path, + source_path: pathlib.Path, + output: pathlib.Path, +) -> None: + analysis = load_json(analysis_path) + bind_analysis_root(analysis, analysis_path) + manifest_by_name = validate_analysis_against_bundle( + analysis, bundle_path, representatives_only=False + ) + set_summaries = [] + for set_info in analysis["sets"]: + observations = [] + for stem in set_info["stems"]: + path = observations_root / f"{stem['stem_id']}.json" + obs = load_json(path) + if obs.get("schema_version") != 1 or obs.get("phase") != "6C": + die(f"{stem['stem_id']}: donor observation schema/phase changed") + if obs.get("contract") != DONOR_CONTRACT: + die(f"{stem['stem_id']}: donor observation contract changed") + if obs.get("evidence_role") != "cpsycle-donor-observation": + die(f"{stem['stem_id']}: donor evidence_role changed") + if obs.get("stem_id") != stem["stem_id"]: + die(f"{stem['stem_id']}: donor stem identity changed") + if obs.get("source_relative_name") != stem["relative_name"]: + die(f"{stem['stem_id']}: donor source member changed") + if obs.get("source_sha256") != stem["sha256"]: + die(f"{stem['stem_id']}: donor source identity changed") + if obs.get("source_size_bytes") != stem["size_bytes"]: + die(f"{stem['stem_id']}: donor source size changed") + expected_analysis_sha = sha256_bytes( + json.dumps( + stem["analysis"], sort_keys=True, separators=(",", ":") + ).encode() + ) + if obs.get("source_analysis_sha256") != expected_analysis_sha: + die(f"{stem['stem_id']}: donor raw-SMF analysis binding changed") + if obs.get("load_result") != "accepted": + die(f"{stem['stem_id']}: donor load_result is not accepted") + coverage = validate_donor_execution_observation(stem, obs) + obs["_source_channels"] = coverage["source_channels"] + obs["_source_note_off_channels"] = coverage["source_note_off_channels"] + obs["_source_note_offs"] = coverage["source_note_offs"] + obs["_source_note_event_digest_fnv64"] = coverage[ + "source_note_event_digest_fnv64" + ] + obs["_source_midi_cc"] = coverage["source_midi_cc"] + obs["_source_midi_cc_digest_fnv64"] = coverage[ + "source_midi_cc_digest_fnv64" + ] + obs["_source_song_bpm"] = coverage["source_song_bpm"] + obs["_source_tempo_events"] = coverage["source_tempo_events"] + obs["_source_tempo_events_at_zero"] = coverage[ + "source_tempo_events_at_zero" + ] + obs["_source_tempo_map_digest_fnv64"] = coverage[ + "source_tempo_map_digest_fnv64" + ] + if obs.get("private_input_required") is not True: + die(f"{stem['stem_id']}: donor private-input flag changed") + if obs.get("fixture_redistributed") is not False: + die(f"{stem['stem_id']}: donor redistribution flag changed") + digest = obs.get("import_event_digest_fnv64") + if not isinstance(digest, str) or len(digest) != 16: + die(f"{stem['stem_id']}: donor event digest is invalid") + try: + int(digest, 16) + except ValueError: + die(f"{stem['stem_id']}: donor event digest is not hexadecimal") + render_sha = obs.get("render_sha256") + if not isinstance(render_sha, str) or len(render_sha) != 64: + die(f"{stem['stem_id']}: donor render SHA-256 is invalid") + observations.append(obs) + set_spec = manifest_by_name[set_info["name"]] + stem_records = [ + { + "stem_id": item["stem_id"], + "source_relative_name": item["source_relative_name"], + "source_sha256": item["source_sha256"], + "source_size_bytes": item["source_size_bytes"], + "source_analysis_sha256": item["source_analysis_sha256"], + "source_channels": item["_source_channels"], + "midi_channel_mask": item["midi_channel_mask"], + "source_note_off_channels": item["_source_note_off_channels"], + "source_note_offs": item["_source_note_offs"], + "release_channel_mask": item["release_channel_mask"], + "releases": item["releases"], + "source_note_event_digest_fnv64": item[ + "_source_note_event_digest_fnv64" + ], + "note_event_digest_fnv64": item[ + "note_event_digest_fnv64" + ], + "source_midi_cc": item["_source_midi_cc"], + "midi_cc": item["midi_cc"], + "source_midi_cc_digest_fnv64": item[ + "_source_midi_cc_digest_fnv64" + ], + "midi_cc_digest_fnv64": item["midi_cc_digest_fnv64"], + "source_song_bpm": item["_source_song_bpm"], + "song_bpm": item["song_bpm"], + "source_tempo_events": item["_source_tempo_events"], + "source_tempo_events_at_zero": item[ + "_source_tempo_events_at_zero" + ], + "tempo_commands": item["tempo_commands"], + "source_tempo_map_digest_fnv64": item[ + "_source_tempo_map_digest_fnv64" + ], + "tempo_map_digest_fnv64": item["tempo_map_digest_fnv64"], + "import_event_digest_fnv64": item["import_event_digest_fnv64"], + "render_sha256": item["render_sha256"], + } + for item in observations + ] + set_summaries.append({ + "name": set_info["name"], + "slug": set_info["slug"], + "role": set_info["role"], + "archive": set_spec["archive"], + "archive_sha256": set_spec["sha256"], + "source_aggregate": set_info["aggregate"], + "observed_stems": len(observations), + "all_imported": all( + item.get("load_result") == "accepted" + for item in observations + ), + "all_non_silent_projection": all( + item["non_silent_projection"] is True + for item in observations + ), + "stems": stem_records, + }) + if not probe_path.is_file() or not source_path.is_file(): + die("donor MIDI corpus support files are missing") + build_identity = validate_donor_probe_build(probe_path, source_path) + + receipt = { + "schema_version": 1, + "phase": "6C", + "contract": DONOR_CONTRACT, + "evidence_role": "cpsycle-donor-corpus-summary", + **build_identity, + "source_file": source_path.name, + "source_file_sha256": sha256_file(source_path), + "probe_executable": probe_path.name, + "probe_executable_sha256": sha256_file(probe_path), + "progression_order": PROGRESSION, + "sets": set_summaries, + "all_sets_imported": all(item["all_imported"] for item in set_summaries), + "all_sets_non_silent_projection": all( + item["all_non_silent_projection"] for item in set_summaries + ), + "parity_status": "UNKNOWN", + "claim_scope": ( + "C-Psycle real-world SMF import plus deterministic execution projection. " + "The projection attaches a project-owned classic Sampler substrate after freezing " + "the untouched imported event digest; it is not native instrument-selection semantics." + ), + } + write_json(output, receipt) + + +def corpus_summary( + donor_path: pathlib.Path, + candidate_path: pathlib.Path, + output: pathlib.Path, +) -> None: + donor = load_json(donor_path) + candidate = load_json(candidate_path) + def require_support( + receipt_path: pathlib.Path, + relative_name: Any, + expected_sha: Any, + label: str, + ) -> None: + if not isinstance(relative_name, str) or not relative_name: + die(f"{label} support path is missing") + relative = pathlib.PurePosixPath(relative_name) + if relative.is_absolute() or ".." in relative.parts: + die(f"{label} support path escapes its artifact root") + path = receipt_path.parent / pathlib.Path(*relative.parts) + if not path.is_file(): + die(f"{label} support file is missing: {relative_name}") + if not isinstance(expected_sha, str) or len(expected_sha) != 64: + die(f"{label} support SHA-256 is invalid") + if sha256_file(path) != expected_sha.lower(): + die(f"{label} support file hash mismatch: {relative_name}") + + current_revision = repository_commit() + canonical_donor_source_sha = sha256_file(DONOR_SOURCE) + canonical_probe_source_sha = sha256_file(DONOR_PROBE_SOURCE) + + if ( + donor.get("schema_version") != 1 + or donor.get("phase") != "6C" + or donor.get("contract") != DONOR_CONTRACT + or donor.get("evidence_role") != "cpsycle-donor-corpus-summary" + or donor.get("source_revision") != current_revision + or donor.get("source_file_sha256") != canonical_donor_source_sha + or donor.get("probe_source") != DONOR_PROBE_SOURCE.relative_to(ROOT).as_posix() + or donor.get("probe_source_sha256") != canonical_probe_source_sha + or donor.get("build_target") != "phase6c-midi-corpus-probe" + or donor.get("clean_rebuild_sha256") != donor.get("probe_executable_sha256") + or donor.get("parity_status") != "UNKNOWN" + or donor.get("all_sets_imported") is not True + or donor.get("all_sets_non_silent_projection") is not True + or donor.get("progression_order") != PROGRESSION + or not isinstance(donor.get("sets"), list) + or len(donor["sets"]) != 6 + or [item.get("name") for item in donor["sets"]] != PROGRESSION + ): + die("donor MIDI corpus summary is incomplete or invalid") + manifest = corpus_manifest() + manifest_by_name = {item["name"]: item for item in manifest["sets"]} + for item in donor["sets"]: + name = item["name"] + set_spec = manifest_by_name[name] + observed_stems = item.get("observed_stems") + stem_records = item.get("stems") + if ( + item.get("slug") != slug(name) + or item.get("role") != set_spec["role"] + or item.get("archive") != set_spec["archive"] + or item.get("archive_sha256") != set_spec["sha256"] + or observed_stems != set_spec["stems"] + or item.get("all_imported") is not True + or item.get("all_non_silent_projection") is not True + or not isinstance(item.get("source_aggregate"), dict) + or not isinstance(stem_records, list) + or len(stem_records) != observed_stems + ): + die(f"donor {name} per-set evidence is incomplete") + validate_aggregate_against_manifest( + set_spec, item["source_aggregate"] + ) + seen_ids: set[str] = set() + seen_names: set[str] = set() + expected_indexes = set(range(observed_stems)) + actual_indexes: set[int] = set() + observed_channels: set[int] = set() + observed_tempo_events = 0 + observed_tempo_events_at_zero = 0 + for record in stem_records: + stem_id = record.get("stem_id") + relative_name = record.get("source_relative_name") + source_sha = record.get("source_sha256") + source_size = record.get("source_size_bytes") + analysis_sha = record.get("source_analysis_sha256") + source_channels = record.get("source_channels") + imported_channel_mask = record.get("midi_channel_mask") + source_note_off_channels = record.get("source_note_off_channels") + source_note_offs = record.get("source_note_offs") + release_channel_mask = record.get("release_channel_mask") + releases = record.get("releases") + source_note_digest = record.get( + "source_note_event_digest_fnv64" + ) + imported_note_digest = record.get("note_event_digest_fnv64") + source_midi_cc = record.get("source_midi_cc") + imported_midi_cc = record.get("midi_cc") + source_midi_cc_digest = record.get( + "source_midi_cc_digest_fnv64" + ) + imported_midi_cc_digest = record.get("midi_cc_digest_fnv64") + source_song_bpm = record.get("source_song_bpm") + imported_song_bpm = record.get("song_bpm") + source_tempo_events = record.get("source_tempo_events") + source_tempo_events_at_zero = record.get("source_tempo_events_at_zero") + tempo_commands = record.get("tempo_commands") + source_tempo_digest = record.get("source_tempo_map_digest_fnv64") + imported_tempo_digest = record.get("tempo_map_digest_fnv64") + digest = record.get("import_event_digest_fnv64") + render_sha = record.get("render_sha256") + if ( + not isinstance(stem_id, str) + or not isinstance(relative_name, str) + or not isinstance(source_sha, str) + or len(source_sha) != 64 + or not isinstance(source_size, int) + or source_size <= 0 + or not isinstance(analysis_sha, str) + or len(analysis_sha) != 64 + or not isinstance(source_channels, list) + or not isinstance(imported_channel_mask, int) + or isinstance(imported_channel_mask, bool) + or imported_channel_mask < 0 + or imported_channel_mask > 0xFFFF + or not isinstance(source_note_off_channels, list) + or not isinstance(source_note_offs, int) + or isinstance(source_note_offs, bool) + or source_note_offs < 0 + or not isinstance(release_channel_mask, int) + or isinstance(release_channel_mask, bool) + or release_channel_mask < 0 + or release_channel_mask > 0xFFFF + or not isinstance(releases, int) + or isinstance(releases, bool) + or releases < 0 + or not isinstance(source_note_digest, str) + or len(source_note_digest) != 16 + or not isinstance(imported_note_digest, str) + or len(imported_note_digest) != 16 + or not isinstance(source_midi_cc, int) + or isinstance(source_midi_cc, bool) + or source_midi_cc < 0 + or not isinstance(imported_midi_cc, int) + or isinstance(imported_midi_cc, bool) + or imported_midi_cc < 0 + or not isinstance(source_midi_cc_digest, str) + or len(source_midi_cc_digest) != 16 + or not isinstance(imported_midi_cc_digest, str) + or len(imported_midi_cc_digest) != 16 + or not isinstance(source_song_bpm, (int, float)) + or isinstance(source_song_bpm, bool) + or not isinstance(imported_song_bpm, (int, float)) + or isinstance(imported_song_bpm, bool) + or not isinstance(source_tempo_events, int) + or isinstance(source_tempo_events, bool) + or source_tempo_events < 0 + or not isinstance(source_tempo_events_at_zero, int) + or isinstance(source_tempo_events_at_zero, bool) + or source_tempo_events_at_zero < 0 + or source_tempo_events_at_zero > source_tempo_events + or not isinstance(tempo_commands, int) + or isinstance(tempo_commands, bool) + or tempo_commands < 0 + or not isinstance(source_tempo_digest, str) + or len(source_tempo_digest) != 16 + or not isinstance(imported_tempo_digest, str) + or len(imported_tempo_digest) != 16 + or not isinstance(digest, str) + or len(digest) != 16 + or not isinstance(render_sha, str) + or len(render_sha) != 64 + ): + die(f"donor {name} stem evidence is malformed") + prefix = f"{slug(name)}-" + if not stem_id.startswith(prefix): + die(f"donor {name} stem_id is outside the set namespace") + remainder = stem_id[len(prefix):] + parts = remainder.split("-", 1) + if len(parts) != 2 or not parts[0].isdigit(): + die(f"donor {name} stem_id index is invalid") + stem_index = int(parts[0]) + if parts[1] != source_sha[:12]: + die(f"donor {name} stem_id is not bound to source SHA-256") + if stem_id in seen_ids or relative_name in seen_names: + die(f"donor {name} stem evidence is duplicated") + expected_channel_mask = midi_channel_mask(source_channels) + if imported_channel_mask != expected_channel_mask: + die(f"donor {name} stem MIDI channel coverage is inconsistent") + expected_release_mask = midi_channel_mask(source_note_off_channels) + if release_channel_mask != expected_release_mask: + die(f"donor {name} stem release-channel coverage is inconsistent") + if releases != source_note_offs: + die(f"donor {name} stem release count is inconsistent") + if imported_note_digest != source_note_digest: + die(f"donor {name} stem note-event contents are inconsistent") + if imported_midi_cc != source_midi_cc: + die(f"donor {name} stem MIDI controller count is inconsistent") + if imported_midi_cc_digest != source_midi_cc_digest: + die(f"donor {name} stem MIDI controller contents are inconsistent") + if not math.isclose( + float(imported_song_bpm), + float(source_song_bpm), + abs_tol=0.001, + ): + die(f"donor {name} stem song BPM is inconsistent") + if tempo_commands != source_tempo_events - source_tempo_events_at_zero: + die(f"donor {name} stem tempo-map coverage is inconsistent") + if imported_tempo_digest != source_tempo_digest: + die(f"donor {name} stem tempo-map contents are inconsistent") + for value, label in ( + (source_note_digest, "source note-event digest"), + (imported_note_digest, "imported note-event digest"), + (source_midi_cc_digest, "source MIDI controller digest"), + (imported_midi_cc_digest, "imported MIDI controller digest"), + (source_tempo_digest, "source tempo-map digest"), + (imported_tempo_digest, "imported tempo-map digest"), + ): + try: + int(value, 16) + except ValueError: + die(f"donor {name} {label} is not hexadecimal") + observed_tempo_events += source_tempo_events + observed_tempo_events_at_zero += source_tempo_events_at_zero + observed_channels.update(source_channels) + seen_ids.add(stem_id) + seen_names.add(relative_name) + actual_indexes.add(stem_index) + for value, label, length in ( + (source_sha, "source SHA-256", 64), + (analysis_sha, "analysis SHA-256", 64), + (render_sha, "render SHA-256", 64), + (digest, "event digest", 16), + ): + if len(value) != length: + die(f"donor {name} {label} length changed") + try: + int(value, 16) + except ValueError: + die(f"donor {name} {label} is not hexadecimal") + if actual_indexes != expected_indexes: + die(f"donor {name} stem indexes are incomplete or duplicated") + if observed_tempo_events != item["source_aggregate"].get("tempo_events_total"): + die(f"donor {name} stem tempo-event total differs from source aggregate") + if ( + observed_tempo_events_at_zero + != item["source_aggregate"].get("tempo_events_at_zero_total") + ): + die(f"donor {name} tick-zero tempo total differs from source aggregate") + if sorted(observed_channels) != item["source_aggregate"].get("channels"): + die(f"donor {name} imported MIDI channel union differs from source analysis") + + require_support( + donor_path, + donor.get("source_file"), + donor.get("source_file_sha256"), + "donor source", + ) + require_support( + donor_path, + donor.get("probe_executable"), + donor.get("probe_executable_sha256"), + "donor probe", + ) + donor_probe_artifact = donor_path.parent / donor["probe_executable"] + with donor_probe_artifact.open("rb") as handle: + if handle.read(4) != b"\x7fELF": + die("donor probe artifact is not an ELF executable") + + candidate_sets = candidate.get("sets") + require_support( + candidate_path, + candidate.get("player"), + candidate.get("player_sha256"), + "candidate player", + ) + require_support( + candidate_path, + candidate.get("build_attestation"), + candidate.get("build_attestation_sha256"), + "candidate build attestation", + ) + candidate_artifact = candidate_path.parent / candidate["player"] + attestation_path = candidate_path.parent / candidate["build_attestation"] + candidate_attestation = validate_candidate_attestation( + attestation_path, + candidate_artifact, + current_revision, + ) + if ( + candidate.get("schema_version") != 1 + or candidate.get("phase") != "6C" + or candidate.get("contract") != CANDIDATE_CONTRACT + or candidate.get("evidence_role") != "candidate-observation" + or candidate.get("parity_status") != "UNKNOWN" + or candidate.get("candidate_baseline_sha256") != EXPECTED_CANDIDATE_BASELINE + or candidate.get("source_revision") != EXPECTED_CANDIDATE_SOURCE_REVISION + or candidate.get("build_target") != "psycle-player" + or candidate.get("clean_rebuild_sha256") + != candidate_attestation.get("clean_rebuild_sha256") + or candidate.get("player_sha256") + != candidate_attestation.get("player_sha256") + or candidate.get("plugin_interface_git_blob") + != candidate_attestation.get("plugin_interface_git_blob") + or candidate.get("diversalis_revision") + != candidate_attestation.get("diversalis_revision") + or candidate.get("diversalis_file_count") + != candidate_attestation.get("diversalis_file_count") + or candidate.get("diversalis_manifest_sha256") + != candidate_attestation.get("diversalis_manifest_sha256") + or candidate.get("repository_commit") != current_revision + or candidate.get("private_input_required") is not True + or candidate.get("progression_order") != PROGRESSION + or not isinstance(candidate_sets, list) + or len(candidate_sets) != 6 + ): + die("candidate MIDI corpus boundary is incomplete or invalid") + if [item.get("set_name") for item in candidate_sets] != PROGRESSION: + die("candidate MIDI set order differs from the corpus progression") + for item in candidate_sets: + set_name = item.get("set_name") + set_spec = manifest_by_name[set_name] + if ( + item.get("archive") != set_spec["archive"] + or item.get("archive_sha256") != set_spec["sha256"] + or item.get("direct_midi_load") != "rejected" + or item.get("exit_code") != 2 + or item.get("diagnostic_could_not_load_song_file") is not True + ): + die(f"candidate {item.get('set_name')} direct-load boundary is incomplete") + rep_sha = item.get("representative_sha256") + rep_id = item.get("representative_stem_id") + rep_name = item.get("representative_relative_name") + rep_size = item.get("representative_size_bytes") + raw_sha = item.get("raw_output_sha256") + if not isinstance(rep_sha, str) or len(rep_sha) != 64: + die(f"candidate {item.get('set_name')} representative SHA-256 is invalid") + try: + int(rep_sha, 16) + except ValueError: + die(f"candidate {item.get('set_name')} representative SHA-256 is not hexadecimal") + expected_prefix = f"{slug(item['set_name'])}-" + if ( + not isinstance(rep_id, str) + or not rep_id.startswith(expected_prefix) + or not rep_id.endswith("-" + rep_sha[:12]) + or not isinstance(rep_name, str) + or not rep_name + or not isinstance(rep_size, int) + or rep_size <= 0 + ): + die(f"candidate {item.get('set_name')} representative identity is incomplete") + if not isinstance(raw_sha, str) or len(raw_sha) != 64: + die(f"candidate {item.get('set_name')} raw-output SHA-256 is invalid") + try: + int(raw_sha, 16) + except ValueError: + die(f"candidate {item.get('set_name')} raw-output SHA-256 is not hexadecimal") + + with candidate_artifact.open("rb") as handle: + if handle.read(4) != b"\x7fELF": + die("candidate player artifact is not an ELF executable") + + summary = { + "schema_version": 1, + "phase": "6C", + "contract": SUMMARY_CONTRACT, + "progression_order": PROGRESSION, + "observations": { + "cpsycle_donor": { + "receipt": "donor/donor-midi-corpus.json", + "receipt_sha256": sha256_file(donor_path), + "all_sets_imported": True, + "all_sets_non_silent_projection": True, + }, + "frozen_cpp_candidate": { + "receipt": "candidate/candidate-midi-boundary.json", + "receipt_sha256": sha256_file(candidate_path), + "representative_direct_load": "rejected", + }, + "original_psycle_1_12_0_x86": { + "status": "NOT_OBSERVED_IN_THIS_PHASE", + "reason": ( + "This real-world corpus phase is donor-first. Original-reference " + "MIDI import/playback remains a separate version-pinned observation " + "if parity classification is later required." + ), + }, + }, + "parity_status": "UNKNOWN", + "classification_note": ( + "Real-world robustness evidence only. No compatibility-matrix row is " + "created or promoted from this corpus summary." + ), + } + write_json(output, summary) + + +def midi_varlen(value: int) -> bytes: + if value < 0: + raise ValueError("MIDI variable length value must be non-negative") + buffer = value & 0x7F + out = bytearray([buffer]) + while value >> 7: + value >>= 7 + buffer = (value & 0x7F) | 0x80 + out.insert(0, buffer) + return bytes(out) + + +def write_smf_tracks( + path: pathlib.Path, + tracks: list[bytes | bytearray], + *, + fmt: int, + division: int = 480, +) -> None: + payload = bytearray(b"MThd") + payload += struct.pack(">IHHH", 6, fmt, len(tracks), division) + for track in tracks: + payload += b"MTrk" + struct.pack(">I", len(track)) + track + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + + +def write_synthetic_fixture(path: pathlib.Path) -> None: + track_zero = bytearray() + track_zero += midi_varlen(0) + b"\xff\x51\x03\x07\xa1\x20" + track_zero += midi_varlen(0) + b"\xff\x59\x02\x09\x01" + track_zero += midi_varlen(0) + bytes((0x90, 60, 100)) + track_zero += midi_varlen(480) + bytes((0x80, 60, 0)) + track_zero += midi_varlen(0) + b"\xff\x2f\x00" + + track_one = bytearray() + track_one += midi_varlen(240) + bytes((0x91, 64, 96)) + track_one += midi_varlen(240) + bytes((0x81, 64, 0)) + track_one += midi_varlen(0) + b"\xff\x2f\x00" + + write_smf_tracks(path, [track_zero, track_one], fmt=1) + + +def write_regression_fixtures(root: pathlib.Path) -> None: + zero_duration = bytearray() + zero_duration += midi_varlen(0) + bytes((0x90, 60, 100)) + zero_duration += midi_varlen(0) + bytes((0x80, 60, 0)) + zero_duration += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "zero-duration.mid", [zero_duration], fmt=0 + ) + + multichannel = bytearray() + multichannel += midi_varlen(0) + bytes((0x90, 60, 100)) + multichannel += midi_varlen(0) + bytes((0x91, 60, 100)) + multichannel += midi_varlen(10) + bytes((0x81, 60, 0)) + multichannel += midi_varlen(10) + bytes((0x80, 60, 0)) + multichannel += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "same-note-multichannel.mid", [multichannel], fmt=0 + ) + + high_note = bytearray() + high_note += midi_varlen(0) + bytes((0x90, 120, 100)) + high_note += midi_varlen(480) + bytes((0x80, 120, 0)) + high_note += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "high-note.mid", [high_note], fmt=0 + ) + + equal_tick_reuse = bytearray() + equal_tick_reuse += midi_varlen(0) + bytes((0x90, 60, 100)) + equal_tick_reuse += midi_varlen(10) + bytes((0x80, 60, 0)) + equal_tick_reuse += midi_varlen(0) + bytes((0x90, 62, 100)) + equal_tick_reuse += midi_varlen(10) + bytes((0x80, 62, 0)) + equal_tick_reuse += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "equal-tick-release-reuse.mid", [equal_tick_reuse], fmt=0 + ) + + repeated_chords = bytearray() + for index in range(63): + note = 60 + (index % 12) + repeated_chords += midi_varlen(0 if index == 0 else 1) + bytes( + (0x90, note, 100) + ) + repeated_chords += midi_varlen(0) + bytes((0x91, note, 100)) + repeated_chords += midi_varlen(1) + bytes((0x80, note, 0)) + repeated_chords += midi_varlen(0) + bytes((0x81, note, 0)) + repeated_chords += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "repeated-two-note-chords.mid", [repeated_chords], fmt=0 + ) + + polyphony_64 = bytearray() + for note in range(64): + polyphony_64 += midi_varlen(0) + bytes((0x90, note, 100)) + for note in range(64): + polyphony_64 += midi_varlen(10 if note == 0 else 0) + bytes( + (0x80, note, 0) + ) + polyphony_64 += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "polyphony-64.mid", [polyphony_64], fmt=0 + ) + + sysex_then_note = bytearray() + sysex_then_note += midi_varlen(0) + bytes((0xF0, 0x01, 0x7F)) + sysex_then_note += midi_varlen(0) + bytes((0x90, 60, 100)) + sysex_then_note += midi_varlen(10) + bytes((0x80, 60, 0)) + sysex_then_note += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "sysex-then-note.mid", [sysex_then_note], fmt=0 + ) + + running_after_meta = bytearray() + running_after_meta += midi_varlen(0) + bytes((0x90, 60, 100)) + running_after_meta += midi_varlen(0) + b"\xff\x01\x00" + running_after_meta += midi_varlen(480) + bytes((60, 0)) + running_after_meta += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "running-status-after-meta.mid", + [running_after_meta], + fmt=0, + ) + + controller_event = bytearray() + controller_event += midi_varlen(0) + bytes((0x90, 60, 100)) + controller_event += midi_varlen(240) + bytes((0xB0, 64, 127)) + controller_event += midi_varlen(240) + bytes((0x80, 60, 0)) + controller_event += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "controller-event.mid", [controller_event], fmt=0 + ) + + tick_zero_60 = bytearray() + tick_zero_60 += midi_varlen(0) + b"\xff\x51\x03\x0f\x42\x40" + tick_zero_60 += midi_varlen(0) + bytes((0x90, 60, 100)) + tick_zero_60 += midi_varlen(480) + bytes((0x80, 60, 0)) + tick_zero_60 += midi_varlen(0) + b"\xff\x2f\x00" + write_smf_tracks( + root / "tick-zero-60bpm.mid", [tick_zero_60], fmt=0 + ) + + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + synthetic_fixture = sub.add_parser("write-synthetic") + synthetic_fixture.add_argument("output", type=pathlib.Path) + synthetic_fixture.set_defaults( + func=lambda args: write_synthetic_fixture(args.output.resolve()) + ) + + regression_fixtures = sub.add_parser("write-regression-fixtures") + regression_fixtures.add_argument("output", type=pathlib.Path) + regression_fixtures.set_defaults( + func=lambda args: write_regression_fixtures(args.output.resolve()) + ) + + prepare = sub.add_parser("prepare-bundle") + prepare.add_argument("bundle", type=pathlib.Path) + prepare.add_argument("root", type=pathlib.Path) + prepare.set_defaults(func=lambda args: prepare_bundle(args.bundle.resolve(), args.root.resolve())) + + audit = sub.add_parser("audit-public") + audit.add_argument("root", type=pathlib.Path) + audit.set_defaults(func=lambda args: audit_public_tree(args.root.resolve())) + + attest = sub.add_parser("attest-candidate-player") + attest.add_argument("player", type=pathlib.Path) + attest.add_argument("output", type=pathlib.Path) + attest.set_defaults( + func=lambda args: write_json( + args.output.resolve(), + candidate_attestation_document( + validate_frozen_candidate_player(args.player.resolve()), + sha256_file(args.player.resolve()), + ), + ) + ) + + candidate = sub.add_parser("candidate-boundary") + candidate.add_argument("analysis", type=pathlib.Path) + candidate.add_argument("bundle", type=pathlib.Path) + candidate.add_argument("player", type=pathlib.Path) + candidate.add_argument("output", type=pathlib.Path) + candidate.set_defaults( + func=lambda args: candidate_boundary( + args.analysis.resolve(), + args.bundle.resolve(), + args.player.resolve(), + args.output.resolve(), + ) + ) + + build_donor = sub.add_parser("build-donor-probe") + build_donor.add_argument("output", type=pathlib.Path) + build_donor.set_defaults( + func=lambda args: build_donor_probe(args.output.resolve(), clean=True) + ) + + donor = sub.add_parser("donor-summary") + donor.add_argument("analysis", type=pathlib.Path) + donor.add_argument("bundle", type=pathlib.Path) + donor.add_argument("observations", type=pathlib.Path) + donor.add_argument("--probe", type=pathlib.Path, required=True) + donor.add_argument("--source", type=pathlib.Path, required=True) + donor.add_argument("--output", type=pathlib.Path, required=True) + donor.set_defaults( + func=lambda args: donor_summary( + args.analysis.resolve(), + args.bundle.resolve(), + args.observations.resolve(), + args.probe.resolve(), + args.source.resolve(), + args.output.resolve(), + ) + ) + + summary = sub.add_parser("summary") + summary.add_argument("--donor", type=pathlib.Path, required=True) + summary.add_argument("--candidate", type=pathlib.Path, required=True) + summary.add_argument("--output", type=pathlib.Path, required=True) + summary.set_defaults( + func=lambda args: corpus_summary( + args.donor.resolve(), args.candidate.resolve(), args.output.resolve() + ) + ) + return parser + + +def main() -> int: + args = build_parser().parse_args() + result = args.func(args) + if isinstance(result, dict): + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py new file mode 100644 index 00000000..f73837c4 --- /dev/null +++ b/tests/phase6c_midi_corpus.py @@ -0,0 +1,905 @@ +#!/usr/bin/env python3 +"""Behavioral/static contracts for the Phase 6C real-world MIDI corpus lane.""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import math +import struct +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / "scripts/phase6c-midi-corpus.py" +MANIFEST = ROOT / "phase6c/reference-corpus/manifest.json" +PROBE = ROOT / "tests/phase6c_midi_corpus_probe.c" +WORKFLOW = ROOT / ".github/workflows/phase6c-midi-corpus-private.yml" +LEGACY_WORKFLOW = ROOT / ".github/workflows/phase6c-legacy-playback-import.yml" +PSYCONF = ROOT / "cpsycle/detail/psyconf.h" + +spec = importlib.util.spec_from_file_location("phase6c_midi_corpus_helper_test", HELPER) +helper = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(helper) + +manifest = helper.corpus_manifest() +assert helper.MANIFEST.resolve() == MANIFEST.resolve() +assert manifest["schema_version"] == 2 +assert manifest["contract"] == "legacy-midi-real-world-corpus" +assert manifest["progression_order"] == helper.PROGRESSION +assert [item["name"] for item in manifest["sets"]] != helper.PROGRESSION +assert set(item["name"] for item in manifest["sets"]) == set(helper.PROGRESSION) +assert manifest["private_workflow"]["bundle_url_secret"] == "PSYCLE_PHASE6C_MIDI_CORPUS_URL" +assert manifest["shared_observation"]["ppqn"] == 480 +assert "deterministic classic Sampler" in manifest["shared_observation"]["playback_boundary"] +assert { + item["name"]: item["sha256"] for item in manifest["sets"] +} == { + "Blue Glare": "b1297c44d138d71bbcfddaa4278e1b1fd4e5aac2c6a559276ab124f2b44db67f", + "Celestial Mechanics": "01596654e86bb8fe3b36d2b8ea30c5760521133b18fb27742c9d2f8d7f95fafd", + "Deterministic Pattern": "cfde1bcb71935718fc4a7e8e16b6aa77ceac44d8fb31d8dcadfee87a5b178b32", + "Polyrhythmic Patterns": "5e7a3a17569661f6817ab664411e5ca6d44b22c50306d4a82df0a0b984f2f6ce", + "NGC3603 Quantum Demoscene": "9fa0905ed0758dd23f61b144e22bf85150358c95f3a4268803a44351720af8c8", + "FM Doom": "c78d8addbc8e6e95d2232c1505d5e668c5ab7c6a9f987d5f56005a52d5de5674", +} +assert { + item["name"]: item["stems"] for item in manifest["sets"] +} == { + "Blue Glare": 11, + "Celestial Mechanics": 10, + "Deterministic Pattern": 8, + "Polyrhythmic Patterns": 9, + "NGC3603 Quantum Demoscene": 12, + "FM Doom": 12, +} +assert { + item["name"]: item["analysis_expectations"]["tempo_events_per_stem"] + for item in manifest["sets"] +} == { + "Blue Glare": 539, + "Celestial Mechanics": 449, + "Deterministic Pattern": 319, + "Polyrhythmic Patterns": 425, + "NGC3603 Quantum Demoscene": 501, + "FM Doom": 379, +} + +with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + midi = root / "synthetic.mid" + helper.write_synthetic_fixture(midi) + parsed = helper.parse_smf(midi) + assert parsed["format"] == 1 + assert parsed["tracks"] == 2 + assert parsed["division"] == 480 + assert parsed["note_ons"] == 2 + assert parsed["note_offs"] == 2 + assert parsed["same_note_overlaps"] == 0 + assert parsed["max_polyphony"] == 2 + assert parsed["tempo_events"] == 1 + assert parsed["channels"] == [0, 1] + assert parsed["note_on_channels"] == [0, 1] + assert parsed["note_off_channels"] == [0, 1] + assert parsed["malformed_key_signatures"] == [(9, 1)] + assert parsed["balanced_note_pairs"] is True + assert parsed["zero_duration_pairs"] == 0 + assert parsed["sysex_events"] == 0 + assert parsed["pitch_bend_events"] == 0 + assert parsed["aftertouch_events"] == 0 + assert parsed["time_signature_events"] == 0 + assert abs(parsed["duration_seconds"] - 0.5) < 1e-9 + +def boundary_smf(reverse_tracks: bool) -> bytes: + old_note = bytearray() + old_note += helper.midi_varlen(0) + bytes((0x90, 60, 100)) + old_note += helper.midi_varlen(480) + bytes((0x80, 60, 0)) + old_note += helper.midi_varlen(0) + b"\xff\x2f\x00" + + new_note = bytearray() + new_note += helper.midi_varlen(480) + bytes((0x90, 60, 100)) + new_note += helper.midi_varlen(480) + bytes((0x80, 60, 0)) + new_note += helper.midi_varlen(0) + b"\xff\x2f\x00" + + tracks = [bytes(old_note), bytes(new_note)] + if reverse_tracks: + tracks.reverse() + payload = bytearray(b"MThd") + payload += struct.pack(">IHHH", 6, 1, 2, 480) + for track in tracks: + payload += b"MTrk" + struct.pack(">I", len(track)) + track + return bytes(payload) + +boundary_forward = helper.parse_smf_bytes( + boundary_smf(False), label="boundary-forward" +) +boundary_reversed = helper.parse_smf_bytes( + boundary_smf(True), label="boundary-reversed" +) +for field in ( + "same_note_overlaps", + "max_polyphony", + "zero_duration_pairs", + "balanced_note_pairs", +): + assert boundary_forward[field] == boundary_reversed[field], field +assert boundary_forward["same_note_overlaps"] == 0 +assert boundary_forward["max_polyphony"] == 1 +assert boundary_forward["zero_duration_pairs"] == 0 +assert boundary_forward["balanced_note_pairs"] is True + +same_track = bytearray() +same_track += helper.midi_varlen(0) + bytes((0x90, 60, 100)) +same_track += helper.midi_varlen(10) + bytes((0x90, 60, 100)) +same_track += helper.midi_varlen(0) + bytes((0x80, 60, 0)) +same_track += helper.midi_varlen(10) + bytes((0x80, 60, 0)) +same_track += helper.midi_varlen(0) + b"\xff\x2f\x00" +same_track_smf = bytearray(b"MThd") +same_track_smf += struct.pack(">IHHH", 6, 1, 1, 480) +same_track_smf += b"MTrk" + struct.pack(">I", len(same_track)) + same_track +same_track_parsed = helper.parse_smf_bytes( + bytes(same_track_smf), label="same-track-shared-boundary" +) +assert same_track_parsed["same_note_overlaps"] == 1 +assert same_track_parsed["max_polyphony"] == 2 +assert same_track_parsed["zero_duration_pairs"] == 0 +assert same_track_parsed["balanced_note_pairs"] is True + +with tempfile.TemporaryDirectory() as temporary: + public = Path(temporary) + disguised_midi = public / "renamed-midi.log" + helper.write_synthetic_fixture(disguised_midi) + try: + helper.audit_public_tree(public) + except SystemExit as exc: + assert "raw corpus bytes" in str(exc) + else: + raise AssertionError("renamed MThd payload must fail public-tree audit") + +with tempfile.TemporaryDirectory() as temporary: + public = Path(temporary) + embedded_midi = public / "private.mid" + helper.write_synthetic_fixture(embedded_midi) + ordinary_zip = public / "ordinary.zip" + with zipfile.ZipFile(ordinary_zip, "w", compression=zipfile.ZIP_STORED) as archive: + archive.write(embedded_midi, arcname="private.mid") + zip_bytes = ordinary_zip.read_bytes() + embedded_midi.unlink() + ordinary_zip.unlink() + + disguised_zip = public / "evidence.txt" + disguised_zip.write_bytes(b"SFX-PREFIX" + zip_bytes) + assert zipfile.is_zipfile(disguised_zip) + try: + helper.audit_public_tree(public) + except SystemExit as exc: + assert "raw corpus bytes" in str(exc) + else: + raise AssertionError("prefixed renamed ZIP payload must fail public-tree audit") + +with tempfile.TemporaryDirectory() as temporary: + public = Path(temporary) + embedded = public / "evidence.txt" + synthetic = public / "source.mid" + helper.write_synthetic_fixture(synthetic) + embedded.write_bytes(b"X" + synthetic.read_bytes()) + synthetic.unlink() + try: + helper.audit_public_tree(public) + except SystemExit as exc: + assert "raw corpus bytes" in str(exc) + else: + raise AssertionError("prefixed embedded SMF payload must fail public-tree audit") + +with tempfile.TemporaryDirectory() as temporary: + public = Path(temporary) + harmless = public / "notes.txt" + harmless.write_bytes(b"ordinary text MThd that is not a MIDI file") + helper.audit_public_tree(public) + +with tempfile.TemporaryDirectory() as temporary: + fixtures = Path(temporary) + helper.write_regression_fixtures(fixtures) + zero = helper.parse_smf(fixtures / "zero-duration.mid") + assert zero["note_ons"] == 1 + assert zero["note_offs"] == 1 + assert zero["zero_duration_pairs"] == 1 + assert zero["note_off_channels"] == [0] + multi = helper.parse_smf(fixtures / "same-note-multichannel.mid") + assert multi["note_ons"] == 2 + assert multi["note_offs"] == 2 + assert multi["channels"] == [0, 1] + assert multi["note_off_channels"] == [0, 1] + high = helper.parse_smf(fixtures / "high-note.mid") + assert high["note_ons"] == 1 + assert high["note_offs"] == 1 + equal_tick = helper.parse_smf(fixtures / "equal-tick-release-reuse.mid") + assert equal_tick["note_ons"] == 2 + assert equal_tick["note_offs"] == 2 + chords = helper.parse_smf(fixtures / "repeated-two-note-chords.mid") + assert chords["note_ons"] == 126 + assert chords["note_offs"] == 126 + assert chords["channels"] == [0, 1] + poly64 = helper.parse_smf(fixtures / "polyphony-64.mid") + assert poly64["note_ons"] == 64 + assert poly64["note_offs"] == 64 + assert poly64["max_polyphony"] == 64 + sysex = helper.parse_smf(fixtures / "sysex-then-note.mid") + assert sysex["sysex_events"] == 1 + assert sysex["note_ons"] == 1 + assert sysex["note_offs"] == 1 + running = helper.parse_smf(fixtures / "running-status-after-meta.mid") + assert running["note_ons"] == 1 + assert running["note_offs"] == 1 + controller = helper.parse_smf(fixtures / "controller-event.mid") + assert controller["midi_cc"] == 1 + sixty = helper.parse_smf(fixtures / "tick-zero-60bpm.mid") + assert math.isclose(sixty["song_bpm"], 60.0, abs_tol=0.001) + +probe_shaped_stem = { + "stem_id": "probe-shaped", + "analysis": { + "channels": [0, 1], + "note_off_channels": [0, 1], + "note_ons": 2, + "note_offs": 2, + "note_event_digest_fnv64": helper.semantic_event_multiset_digest_fnv64( + [ + (0, 0, 60, 0, 0x0C, int((100 / 127.0) * 128)), + (0, 0, 64, 1, 0x0C, int((96 / 127.0) * 128)), + ] + ), + "midi_cc": 0, + "midi_cc_digest_fnv64": helper.semantic_event_multiset_digest_fnv64([]), + "song_bpm": 120.0, + "tempo_events": 1, + "tempo_events_at_zero": 1, + "tempo_map_digest_fnv64": helper.tempo_map_digest_fnv64([]), + }, +} +probe_shaped_observation = { + "machines_before_projection": 0, + "projection_notes": 2, + "render_frames": 1024, + "render_peak": 100, + "midi_channel_mask": 3, + "release_channel_mask": 3, + "imported_notes": 2, + "note_event_digest_fnv64": probe_shaped_stem["analysis"][ + "note_event_digest_fnv64" + ], + "releases": 2, + "midi_cc": 0, + "midi_cc_digest_fnv64": probe_shaped_stem["analysis"][ + "midi_cc_digest_fnv64" + ], + "song_bpm": 120.0, + "tempo_commands": 0, + "tempo_map_digest_fnv64": helper.tempo_map_digest_fnv64([]), + "non_silent_projection": True, + "projection_kind": "deterministic-sampler", + "parity_status": "UNKNOWN", +} +coverage = helper.validate_donor_execution_observation( + probe_shaped_stem, probe_shaped_observation +) +assert coverage["source_channels"] == [0, 1] +assert coverage["source_note_off_channels"] == [0, 1] +assert coverage["source_tempo_events"] == 1 +assert coverage["source_tempo_events_at_zero"] == 1 + +bad_note_stem = json.loads(json.dumps(probe_shaped_stem)) +bad_note_stem["analysis"]["note_event_digest_fnv64"] = ( + helper.semantic_event_multiset_digest_fnv64( + [(0, 240, 72, 0, 0x0C, int((100 / 127.0) * 128))] + ) +) +try: + helper.validate_donor_execution_observation( + bad_note_stem, probe_shaped_observation + ) +except SystemExit as exc: + assert "note-event contents differ" in str(exc) +else: + raise AssertionError("wrong imported note pitch/timing must be rejected") + +bad_cc_stem = json.loads(json.dumps(probe_shaped_stem)) +bad_cc_stem["analysis"]["midi_cc"] = 1 +bad_cc_stem["analysis"]["midi_cc_digest_fnv64"] = ( + helper.semantic_event_multiset_digest_fnv64( + [(0, 240, 0, 64, 127)] + ) +) +try: + helper.validate_donor_execution_observation( + bad_cc_stem, probe_shaped_observation + ) +except SystemExit as exc: + assert "controller count differs" in str(exc) +else: + raise AssertionError("lost MIDI controller event must be rejected") + +bad_bpm_stem = json.loads(json.dumps(probe_shaped_stem)) +bad_bpm_stem["analysis"]["song_bpm"] = 60.0 +try: + helper.validate_donor_execution_observation( + bad_bpm_stem, probe_shaped_observation + ) +except SystemExit as exc: + assert "tick-zero song BPM differs" in str(exc) +else: + raise AssertionError("wrong normalized tick-zero song BPM must be rejected") + +bad_tempo_stem = json.loads(json.dumps(probe_shaped_stem)) +bad_tempo_stem["analysis"]["tempo_events"] = 2 +bad_tempo_stem["analysis"]["tempo_events_at_zero"] = 1 +bad_tempo_stem["analysis"]["tempo_map_digest_fnv64"] = helper.tempo_map_digest_fnv64( + [(0, 480, 90)] +) +bad_tempo_observation = dict(probe_shaped_observation) +bad_tempo_observation["tempo_commands"] = 1 +bad_tempo_observation["tempo_map_digest_fnv64"] = helper.tempo_map_digest_fnv64( + [(0, 960, 120)] +) +try: + helper.validate_donor_execution_observation( + bad_tempo_stem, bad_tempo_observation + ) +except SystemExit as exc: + assert "tempo-map contents differ" in str(exc) +else: + raise AssertionError("wrong imported tempo-map timing/value must be rejected") + +psyconf_source = PSYCONF.read_text(encoding="utf-8") +assert "#define PSYCLE_USE_MIDI_FILE" in psyconf_source +assert "/* #define PSYCLE_USE_MIDI_FILE */" not in psyconf_source + +helper_source = HELPER.read_text(encoding="utf-8") +assert "caller-supplied binary is intentionally not trusted as evidence" in helper_source +assert '["make", "clean"]' in helper_source +assert '["make", "-j2"]' in helper_source +assert "validate_candidate_dependencies" in helper_source +assert "EXPECTED_CANDIDATE_PLUGIN_BLOB" in helper_source +assert "DIVERSALIS_SOURCE_URL" in helper_source +assert "directory_manifest_identity" in helper_source + +midiloader_source = (ROOT / "cpsycle/audio/src/midiloader.c").read_text( + encoding="utf-8" +) +assert "static void midiloader_flushnoteoffs" in midiloader_source +assert "midiloader_flushnoteoffs(self);" in midiloader_source +assert "noteoff.note = psy_audio_NOTECOMMANDS_RELEASE;" in midiloader_source + +probe_source = PROBE.read_text(encoding="utf-8") +assert '"\\\"phase\\\":\\\"6C\\\","' in probe_source +assert '"\\\"contract\\\":\\\"legacy-midi-real-world-donor\\\","' in probe_source +assert "UINT64_C(14695981039346656037)" in probe_source +assert "PROJECTION_BEATS 16.0" in probe_source +assert "psy_audio_XMSAMPLER" not in probe_source +assert "psy_audio_SAMPLER" in probe_source +assert "psy_audio_create_fileout_driver" in probe_source +assert "machines_before_projection" in probe_source +assert "import_event_digest_fnv64" in probe_source +assert "release_channel_mask" in probe_source +assert "non_silent_projection" in probe_source +assert "slot < psy_audio_MASTER_INDEX" in probe_source +assert "tempo_map_digest_fnv64" in probe_source + +def aggregate_for(set_spec): + expected = set_spec["analysis_expectations"] + malformed = expected.get("malformed_key_signature") + return { + "stems": set_spec["stems"], + "tracks": expected.get("tracks", 1), + "notes": expected["notes"], + "same_note_overlaps": expected["same_note_overlaps"], + "max_polyphony": expected["max_polyphony"], + "tempo_events_total": expected["tempo_events_per_stem"] * set_spec["stems"], + "tempo_events_at_zero_total": set_spec["stems"], + "tempo_events_per_stem": [expected["tempo_events_per_stem"]], + "tempo_min_bpm": expected["tempo_range_bpm"][0], + "tempo_max_bpm": expected["tempo_range_bpm"][1], + "duration_seconds_max": expected["duration_seconds_max"], + "channels": expected.get("channels_used", [0]), + "malformed_key_signatures": [] if malformed is None else [malformed], + "zero_duration_pairs": 1 if expected["zero_duration_pairs_present"] else 0, + "sysex_events": 0, + "pitch_bend_events": 0, + "aftertouch_events": 0, + "time_signature_events": 0, + "balanced_note_pairs": True, + "formats": [1], + "divisions": [480], + } + + +def canonical_analysis(root: Path): + sets = [] + manifest_by_name = {item["name"]: item for item in manifest["sets"]} + for name in helper.PROGRESSION: + set_spec = manifest_by_name[name] + set_slug = helper.slug(name) + stems = [] + for index in range(set_spec["stems"]): + private_path = f"private/{set_slug}/{index:02d}.mid" + path = root / private_path + path.parent.mkdir(parents=True, exist_ok=True) + payload = f"{name}:{index}".encode() + path.write_bytes(payload) + stem_sha = hashlib.sha256(payload).hexdigest() + stems.append({ + "stem_id": f"{set_slug}-{index:02d}-{stem_sha[:12]}", + "set_name": name, + "set_slug": set_slug, + "archive": set_spec["archive"], + "archive_sha256": set_spec["sha256"], + "relative_name": f"{index:02d}.mid", + "private_path": private_path, + "sha256": stem_sha, + "size_bytes": len(payload), + "analysis": {}, + }) + rep = stems[0] + sets.append({ + "name": name, + "slug": set_slug, + "archive": set_spec["archive"], + "archive_sha256": set_spec["sha256"], + "role": set_spec["role"], + "aggregate": aggregate_for(set_spec), + "representative": { + "stem_id": rep["stem_id"], + "relative_name": rep["relative_name"], + "private_path": rep["private_path"], + "sha256": rep["sha256"], + "size_bytes": rep["size_bytes"], + }, + "stems": stems, + }) + return { + "schema_version": 1, + "phase": "6C", + "contract": helper.CONTRACT, + "evidence_role": "private-input-analysis", + "progression_order": helper.PROGRESSION, + "sets": sets, + "parity_status": "UNKNOWN", + } + + +with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + analysis = canonical_analysis(root) + helper.validate_analysis_manifest_bindings(analysis) + bad_analysis = json.loads(json.dumps(analysis)) + bad_analysis["sets"][0]["archive_sha256"] = "0" * 64 + try: + helper.validate_analysis_manifest_bindings(bad_analysis) + except SystemExit as exc: + assert "manifest-pinned archive" in str(exc) + else: + raise AssertionError("bogus corpus archive identity must be rejected") + + analysis_path = root / "corpus-analysis.json" + helper.write_json(analysis_path, analysis) + helper.bind_analysis_root(analysis, analysis_path) + outer_bundle = root / "fake-corpus-bundle.zip" + with zipfile.ZipFile(outer_bundle, "w", compression=zipfile.ZIP_STORED) as outer: + for set_info in analysis["sets"]: + inner_bytes_path = root / f"{set_info['slug']}.zip" + with zipfile.ZipFile( + inner_bytes_path, "w", compression=zipfile.ZIP_STORED + ) as inner: + for stem in set_info["stems"]: + inner.write( + root / stem["private_path"], + arcname=stem["relative_name"], + ) + outer.write( + inner_bytes_path, + arcname=set_info["archive"], + ) + inner_bytes_path.unlink() + try: + helper.validate_analysis_against_bundle( + analysis, outer_bundle, representatives_only=True + ) + except SystemExit as exc: + assert "archive SHA-256 mismatch" in str(exc) + else: + raise AssertionError( + "self-declared stems must not pass without the pinned archive bytes" + ) + +with tempfile.TemporaryDirectory() as temporary: + stub = Path(temporary) / "candidate-psycle-player" + stub.write_text("#!/bin/sh\nexit 2\n", encoding="utf-8") + stub.chmod(0o755) + try: + helper.validate_frozen_candidate_player(stub) + except SystemExit as exc: + assert "canonical Phase 6B build output" in str(exc) + else: + raise AssertionError("arbitrary candidate player stub must be rejected") + +# The sanitized C++ candidate has no retained Standard MIDI File loader entry point. +candidate_core = ROOT / "psycle-cpp-r12005-sanitized/psycle-core/src/psycle/core" +candidate_mthd_hits = [] +for path in candidate_core.rglob("*"): + if not path.is_file() or path.suffix.lower() not in {".c", ".cc", ".cpp", ".h", ".hpp"}: + continue + text = path.read_text(encoding="utf-8", errors="ignore") + if "MThd" in text or "MidiLoader" in text or "midiloader" in text: + candidate_mthd_hits.append(path.relative_to(ROOT).as_posix()) +assert candidate_mthd_hits == [] + +assert WORKFLOW.exists() +workflow = WORKFLOW.read_text(encoding="utf-8") +assert "workflow_dispatch:" in workflow +assert "PSYCLE_PHASE6C_MIDI_CORPUS_URL" in workflow +assert "prepare-bundle" in workflow +assert "tests/phase6c_midi_corpus_probe.c" in workflow +assert "donor-summary" in workflow +assert "candidate-boundary" in workflow +assert "donor-summary phase6c-midi-private/corpus-analysis.json \"$MIDI_CORPUS_BUNDLE\"" in workflow +assert "candidate-boundary phase6c-midi-private/corpus-analysis.json \"$MIDI_CORPUS_BUNDLE\" \"$PLAYER\"" in workflow +assert "cp -- \"$PLAYER\" phase6c-midi-candidate-public/candidate-psycle-player" in workflow +assert "audit-public" in workflow +assert "phase6c-midi-real-world-corpus-summary" in workflow +assert "original_psycle_1_12_0_x86" not in workflow + +helper_source = HELPER.read_text(encoding="utf-8") +assert '"qmake", "-nocache", "CONFIG-=shared", "CONFIG+=release"' in helper_source +assert '"ls-files", "--others",' in helper_source +midiloader_source = (ROOT / "cpsycle/audio/src/midiloader.c").read_text( + encoding="utf-8" +) +assert "noteoff.mach = self->currtrack.channel;" not in midiloader_source +assert "noteoff.mach = channel->tracknote.mach;" in midiloader_source +assert "channel->tracknote.mach == self->currtrack.channel" in midiloader_source +assert "psy_audio_patternentry_add_event(" in midiloader_source +assert "midiloader_playablenote(note)" in midiloader_source +for upload_block in workflow.split("uses: actions/upload-artifact@v4")[1:]: + block = upload_block.split("\n - name:", 1)[0] + assert ".mid" not in block.lower(), block + assert ".midi" not in block.lower(), block + assert ".zip" not in block.lower(), block + +legacy_workflow = LEGACY_WORKFLOW.read_text(encoding="utf-8") +for path in ( + "scripts/phase6c-midi-corpus.py", + "tests/phase6c_midi_corpus.py", + "tests/phase6c_midi_corpus_probe.c", + ".github/workflows/phase6c-midi-corpus-private.yml", +): + assert legacy_workflow.count(f" - '{path}'\n") == 2, path +assert "python3 tests/phase6c_midi_corpus.py" in legacy_workflow +assert "Build synthetic MIDI corpus execution probe" in legacy_workflow +assert "Prove synthetic SMF import and non-silent projection" in legacy_workflow +assert "Attest frozen C++ candidate player" in legacy_workflow +assert "attest-candidate-player" in legacy_workflow +assert "candidate-player-attestation.json" in legacy_workflow +assert "tests/phase6c_midi_corpus_probe.c" in legacy_workflow +assert "phase6c-midi-synthetic.mid" in legacy_workflow +assert 'assert observation["sequence_tracks"] == 2' in legacy_workflow +assert 'assert observation["machines_before_projection"] == 0' in legacy_workflow +assert 'assert observation["projection_notes"] == 2' in legacy_workflow +assert 'assert observation["releases"] == 2' in legacy_workflow +assert 'assert observation["release_channel_mask"] == 3' in legacy_workflow +assert "write-regression-fixtures" in legacy_workflow +assert "same-note-multichannel.mid" in legacy_workflow +assert "high-note.mid" in legacy_workflow +assert "equal-tick-release-reuse.mid" in legacy_workflow +assert "repeated-two-note-chords.mid" in legacy_workflow +assert "polyphony-64.mid" in legacy_workflow +assert "sysex-then-note.mid" in legacy_workflow +assert "running-status-after-meta.mid" in legacy_workflow +assert "controller-event.mid" in legacy_workflow +assert "tick-zero-60bpm.mid" in legacy_workflow +assert 'assert observation["non_silent_projection"] is True' in legacy_workflow +assert ( + "phase6c-generated/phase6c-midi-synthetic.mid \\\n" + " phase6c-generated/phase6c-midi-synthetic.wav" +) in legacy_workflow +for upload_block in legacy_workflow.split("uses: actions/upload-artifact@v4")[1:]: + block = upload_block.split("\n - name:", 1)[0] + assert "phase6c-midi-synthetic.mid" not in block + assert "phase6c-midi-synthetic.wav" not in block + +# Public summary validation must require complete per-set donor evidence and +# manifest-bound candidate archive identities. +with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + donor_dir = root / "donor" + candidate_dir = root / "candidate" + donor_dir.mkdir() + candidate_dir.mkdir() + donor_source = donor_dir / "midiloader.c" + donor_probe = donor_dir / "phase6c-midi-corpus-probe" + candidate_player = candidate_dir / "candidate-psycle-player" + donor_source.write_bytes(helper.DONOR_SOURCE.read_bytes()) + donor_probe.write_bytes(b"\x7fELF" + b"synthetic-donor-probe") + candidate_player.write_bytes(b"\x7fELF" + b"candidate-player") + + sha = lambda p: hashlib.sha256(p.read_bytes()).hexdigest() + current_revision = helper.repository_commit() + manifest_by_name = {item["name"]: item for item in manifest["sets"]} + donor_sets = [] + candidate_sets = [] + for name in helper.PROGRESSION: + set_spec = manifest_by_name[name] + stem_count = set_spec["stems"] + stem_records = [] + for index in range(stem_count): + source_sha = hashlib.sha256( + f"{name}:{index}".encode() + ).hexdigest() + source_channels = set_spec["analysis_expectations"].get( + "channels_used", [0] + ) + channel_mask = sum(1 << channel for channel in source_channels) + stem_records.append({ + "stem_id": ( + f"{helper.slug(name)}-{index:02d}-{source_sha[:12]}" + ), + "source_relative_name": f"{index:02d}.mid", + "source_sha256": source_sha, + "source_size_bytes": 32 + index, + "source_analysis_sha256": hashlib.sha256( + f"analysis:{name}:{index}".encode() + ).hexdigest(), + "source_channels": source_channels, + "midi_channel_mask": channel_mask, + "source_note_off_channels": source_channels, + "source_note_offs": 1, + "release_channel_mask": channel_mask, + "releases": 1, + "source_note_event_digest_fnv64": ( + helper.semantic_event_multiset_digest_fnv64( + [(0, index, 60, source_channels[0], 0, 0)] + ) + ), + "note_event_digest_fnv64": ( + helper.semantic_event_multiset_digest_fnv64( + [(0, index, 60, source_channels[0], 0, 0)] + ) + ), + "source_midi_cc": 0, + "midi_cc": 0, + "source_midi_cc_digest_fnv64": ( + helper.semantic_event_multiset_digest_fnv64([]) + ), + "midi_cc_digest_fnv64": ( + helper.semantic_event_multiset_digest_fnv64([]) + ), + "source_song_bpm": 120.0, + "song_bpm": 120.0, + "source_tempo_events": set_spec["analysis_expectations"][ + "tempo_events_per_stem" + ], + "source_tempo_events_at_zero": 1, + "tempo_commands": ( + set_spec["analysis_expectations"]["tempo_events_per_stem"] - 1 + ), + "source_tempo_map_digest_fnv64": helper.tempo_map_digest_fnv64( + [ + (0, tick + 1, 120) + for tick in range( + set_spec["analysis_expectations"][ + "tempo_events_per_stem" + ] - 1 + ) + ] + ), + "tempo_map_digest_fnv64": helper.tempo_map_digest_fnv64( + [ + (0, tick + 1, 120) + for tick in range( + set_spec["analysis_expectations"][ + "tempo_events_per_stem" + ] - 1 + ) + ] + ), + "import_event_digest_fnv64": ( + f"{index + 1:016x}"[-16:] + ), + "render_sha256": hashlib.sha256( + f"render:{name}:{index}".encode() + ).hexdigest(), + }) + donor_sets.append({ + "name": name, + "slug": helper.slug(name), + "role": set_spec["role"], + "archive": set_spec["archive"], + "archive_sha256": set_spec["sha256"], + "source_aggregate": aggregate_for(set_spec), + "observed_stems": stem_count, + "all_imported": True, + "all_non_silent_projection": True, + "stems": stem_records, + }) + candidate_sets.append({ + "set_name": name, + "archive": set_spec["archive"], + "archive_sha256": set_spec["sha256"], + "representative_stem_id": ( + f"{helper.slug(name)}-00-" + ("b" * 12) + ), + "representative_relative_name": "00.mid", + "representative_size_bytes": 32, + "representative_sha256": "b" * 64, + "raw_output_sha256": "c" * 64, + "direct_midi_load": "rejected", + "exit_code": 2, + "diagnostic_could_not_load_song_file": True, + }) + + donor = { + "schema_version": 1, + "phase": "6C", + "contract": helper.DONOR_CONTRACT, + "evidence_role": "cpsycle-donor-corpus-summary", + "source_revision": current_revision, + "source_file": donor_source.name, + "source_file_sha256": sha(donor_source), + "probe_source": helper.DONOR_PROBE_SOURCE.relative_to(helper.ROOT).as_posix(), + "probe_source_sha256": sha(helper.DONOR_PROBE_SOURCE), + "build_target": "phase6c-midi-corpus-probe", + "probe_executable": donor_probe.name, + "probe_executable_sha256": sha(donor_probe), + "clean_rebuild_sha256": sha(donor_probe), + "progression_order": helper.PROGRESSION, + "sets": donor_sets, + "all_sets_imported": True, + "all_sets_non_silent_projection": True, + "parity_status": "UNKNOWN", + } + candidate_attestation = { + "schema_version": 1, + "phase": "6C", + "contract": helper.CANDIDATE_ATTESTATION_CONTRACT, + "evidence_role": "candidate-build-attestation", + "candidate_baseline_sha256": helper.EXPECTED_CANDIDATE_BASELINE, + "source_revision": helper.EXPECTED_CANDIDATE_SOURCE_REVISION, + "repository_commit": current_revision, + "build_target": "psycle-player", + "plugin_interface_git_blob": helper.EXPECTED_CANDIDATE_PLUGIN_BLOB, + "diversalis_revision": ( + f"SourceForge SVN r{helper.DIVERSALIS_REVISION}" + ), + "diversalis_file_count": helper.EXPECTED_DIVERSALIS_FILE_COUNT, + "diversalis_manifest_sha256": ( + helper.EXPECTED_DIVERSALIS_MANIFEST_SHA256 + ), + "clean_rebuild_sha256": sha(candidate_player), + "player": candidate_player.name, + "player_sha256": sha(candidate_player), + "parity_status": "UNKNOWN", + } + candidate_attestation_path = candidate_dir / "candidate-player-attestation.json" + helper.write_json(candidate_attestation_path, candidate_attestation) + + candidate = { + "schema_version": 1, + "phase": "6C", + "contract": helper.CANDIDATE_CONTRACT, + "evidence_role": "candidate-observation", + "candidate_baseline_sha256": helper.EXPECTED_CANDIDATE_BASELINE, + "source_revision": helper.EXPECTED_CANDIDATE_SOURCE_REVISION, + "repository_commit": current_revision, + "build_target": "psycle-player", + "plugin_interface_git_blob": helper.EXPECTED_CANDIDATE_PLUGIN_BLOB, + "diversalis_revision": ( + f"SourceForge SVN r{helper.DIVERSALIS_REVISION}" + ), + "diversalis_file_count": helper.EXPECTED_DIVERSALIS_FILE_COUNT, + "diversalis_manifest_sha256": ( + helper.EXPECTED_DIVERSALIS_MANIFEST_SHA256 + ), + "player": candidate_player.name, + "player_sha256": sha(candidate_player), + "clean_rebuild_sha256": sha(candidate_player), + "build_attestation": candidate_attestation_path.name, + "build_attestation_sha256": sha(candidate_attestation_path), + "private_input_required": True, + "progression_order": helper.PROGRESSION, + "sets": candidate_sets, + "parity_status": "UNKNOWN", + } + donor_path = donor_dir / "donor-midi-corpus.json" + candidate_path = candidate_dir / "candidate-midi-boundary.json" + helper.write_json(donor_path, donor) + helper.write_json(candidate_path, candidate) + helper.corpus_summary(donor_path, candidate_path, root / "summary.json") + assert (root / "summary.json").exists() + + candidate_attestation_path.unlink() + try: + helper.corpus_summary( + donor_path, candidate_path, root / "missing-attestation.json" + ) + except SystemExit as exc: + assert "candidate build attestation support file is missing" in str(exc) + else: + raise AssertionError( + "candidate artifact without producer build attestation must be rejected" + ) + helper.write_json(candidate_attestation_path, candidate_attestation) + + unbound_donor = json.loads(json.dumps(donor)) + del unbound_donor["source_revision"] + helper.write_json(donor_path, unbound_donor) + try: + helper.corpus_summary( + donor_path, candidate_path, root / "unbound-donor.json" + ) + except SystemExit as exc: + assert "donor MIDI corpus summary is incomplete or invalid" in str(exc) + else: + raise AssertionError( + "donor evidence without checked-out source revision must be rejected" + ) + + helper.write_json(donor_path, donor) + missing_channels = json.loads(json.dumps(donor)) + del missing_channels["sets"][0]["stems"][0]["midi_channel_mask"] + helper.write_json(donor_path, missing_channels) + try: + helper.corpus_summary( + donor_path, candidate_path, root / "missing-channel-mask.json" + ) + except SystemExit as exc: + assert "stem evidence is malformed" in str(exc) + else: + raise AssertionError( + "donor stem without imported MIDI channel coverage must be rejected" + ) + + helper.write_json(donor_path, donor) + incomplete_donor = json.loads(json.dumps(donor)) + del incomplete_donor["sets"][0]["observed_stems"] + helper.write_json(donor_path, incomplete_donor) + try: + helper.corpus_summary(donor_path, candidate_path, root / "bad-donor.json") + except SystemExit as exc: + assert "per-set evidence is incomplete" in str(exc) + else: + raise AssertionError("incomplete donor per-set evidence must be rejected") + + duplicate_donor = json.loads(json.dumps(donor)) + first_record = duplicate_donor["sets"][0]["stems"][0] + duplicate_donor["sets"][0]["stems"] = [ + dict(first_record) + for _ in range(duplicate_donor["sets"][0]["observed_stems"]) + ] + helper.write_json(donor_path, duplicate_donor) + try: + helper.corpus_summary( + donor_path, candidate_path, root / "duplicate-donor.json" + ) + except SystemExit as exc: + assert "stem evidence is duplicated" in str(exc) + else: + raise AssertionError( + "one donor observation copied across N stems must be rejected" + ) + + helper.write_json(donor_path, donor) + bad_candidate = json.loads(json.dumps(candidate)) + bad_candidate["sets"][0]["archive_sha256"] = "0" * 64 + helper.write_json(candidate_path, bad_candidate) + try: + helper.corpus_summary( + donor_path, + candidate_path, + root / "bad-candidate.json", + ) + except SystemExit as exc: + assert "direct-load boundary is incomplete" in str(exc) + else: + raise AssertionError("wrong candidate archive identity must be rejected") + +print("phase6c-midi-corpus: PASS") diff --git a/tests/phase6c_midi_corpus_probe.c b/tests/phase6c_midi_corpus_probe.c new file mode 100644 index 00000000..d91b7750 --- /dev/null +++ b/tests/phase6c_midi_corpus_probe.c @@ -0,0 +1,676 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* +** Phase 6C real-world MIDI donor witness. +** +** Loads an external SMF through the retained C-Psycle MidiLoader, freezes a +** digest of the untouched imported event graph, then attaches a deterministic +** project-owned classic Sampler substrate and renders a bounded 16-beat execution +** projection through the production Player/FileOutDriver path. +** +** The projection is not a claim about native MIDI instrument selection. +*/ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define PROJECTION_BEATS 16.0 +#define PROJECTION_SAMPLE_RATE 44100u +#define PROJECTION_SAMPLE_FRAMES 2048u +#define PROJECTION_MACHINE 0u +#define PROJECTION_INSTRUMENT_GROUP 0u +#define PROJECTION_INSTRUMENT 0u +#define RENDER_TIMEOUT_TICKS 1500u +#define RENDER_WAIT_US 10000u +#define MIN_RENDER_PEAK 50 +#define PPQN_DIGEST 480.0 + +typedef struct RenderStopState { + atomic_bool stopped; +} RenderStopState; + +typedef struct ImportStats { + uintptr_t sequence_tracks; + uintptr_t patterns; + uintptr_t pattern_entries; + uintptr_t events; + uintptr_t notes; + uintptr_t releases; + uintptr_t midi_cc; + uintptr_t tempo_commands; + uint64_t note_event_digest; + uint64_t midi_cc_digest; + uint64_t tempo_digest; + uint32_t midi_channel_mask; + uint32_t release_channel_mask; + uintptr_t machines_before_projection; + uintptr_t projection_notes; + uint64_t digest; +} ImportStats; + +typedef struct WavStats { + uint32_t frames; + int peak; +} WavStats; + +static int fail(const char* message) +{ + fprintf(stderr, "phase6c-midi-corpus-probe: FAIL: %s\n", message); + return 1; +} + +static uint16_t read_u16_le(const unsigned char* p) +{ + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); +} + +static uint32_t read_u32_le(const unsigned char* p) +{ + return (uint32_t)p[0] | + ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | + ((uint32_t)p[3] << 24); +} + +static void fnv_byte(uint64_t* hash, uint8_t value) +{ + *hash ^= (uint64_t)value; + *hash *= UINT64_C(1099511628211); +} + +static void fnv_u64(uint64_t* hash, uint64_t value) +{ + int shift; + for (shift = 0; shift < 64; shift += 8) + fnv_byte(hash, (uint8_t)((value >> shift) & 0xffu)); +} + +static uint64_t semantic_event_hash( + uintptr_t sequence_track, + psy_dsp_beatpos_t offset, + const uint8_t* fields, + size_t field_count) +{ + uint64_t hash = UINT64_C(14695981039346656037); + int64_t tick = (int64_t)llround( + psy_dsp_beatpos_real(offset) * PPQN_DIGEST); + size_t index; + + fnv_u64(&hash, (uint64_t)sequence_track); + fnv_u64(&hash, (uint64_t)tick); + for (index = 0; index < field_count; ++index) + fnv_byte(&hash, fields[index]); + return hash; +} + +static void hash_tempo_command( + uint64_t* hash, + uintptr_t sequence_track, + psy_dsp_beatpos_t offset, + const psy_audio_PatternEvent* event) +{ + int64_t tick = (int64_t)llround( + psy_dsp_beatpos_real(offset) * PPQN_DIGEST); + fnv_u64(hash, (uint64_t)sequence_track); + fnv_u64(hash, (uint64_t)tick); + fnv_byte(hash, event->parameter); +} + +static void hash_event( + uint64_t* hash, + uintptr_t sequence_track, + uintptr_t tracker_track, + psy_dsp_beatpos_t offset, + uintptr_t event_index, + const psy_audio_PatternEvent* event) +{ + int64_t tick = (int64_t)llround( + psy_dsp_beatpos_real(offset) * PPQN_DIGEST); + fnv_u64(hash, (uint64_t)sequence_track); + fnv_u64(hash, (uint64_t)tracker_track); + fnv_u64(hash, (uint64_t)tick); + fnv_u64(hash, (uint64_t)event_index); + fnv_byte(hash, event->note); + fnv_byte(hash, event->inst); + fnv_byte(hash, event->mach); + fnv_byte(hash, event->cmd); + fnv_byte(hash, event->parameter); +} + +static psy_audio_Song* load_song( + psy_audio_Player* player, + psy_audio_MachineCallback* callback, + const char* path) +{ + psy_audio_Song* song = psy_audio_song_alloc_init(&player->machinefactory); + psy_audio_SongReader reader; + int status; + + if (!song) + return NULL; + psy_audio_machinecallback_set_song(callback, song); + psy_audio_machinecallback_set_player(callback, player); + psy_audio_songreader_init(&reader, song, NULL, FALSE); + status = psy_audio_songreader_load(&reader, path); + psy_audio_songreader_dispose(&reader); + if (status != PSY_OK) { + psy_audio_song_deallocate(song); + return NULL; + } + return song; +} + +static int collect_import_stats(psy_audio_Song* song, ImportStats* stats) +{ + psy_audio_Sequence* sequence = psy_audio_song_sequence(song); + uintptr_t sequence_track; + + memset(stats, 0, sizeof(*stats)); + stats->digest = UINT64_C(14695981039346656037); + stats->note_event_digest = 0; + stats->midi_cc_digest = 0; + stats->tempo_digest = UINT64_C(14695981039346656037); + stats->sequence_tracks = psy_audio_sequence_num_tracks(sequence); + + for (sequence_track = 0; + sequence_track < stats->sequence_tracks; + ++sequence_track) { + psy_audio_OrderIndex order = psy_audio_orderindex_make(sequence_track, 0); + uintptr_t pattern_index; + psy_audio_Pattern* pattern; + psy_audio_PatternNode* node; + + if (psy_audio_sequence_track_size(sequence, sequence_track) == 0) + continue; + pattern_index = psy_audio_sequence_patternindex(sequence, order); + if (pattern_index == psy_INDEX_INVALID) + continue; + pattern = psy_audio_patterns_at( + psy_audio_song_patterns(song), pattern_index); + if (!pattern) + return fail("sequence references a missing MIDI-import pattern"); + ++stats->patterns; + + for (node = psy_audio_pattern_begin(pattern); + node != NULL; + psy_audio_patternnode_next(&node)) { + psy_audio_PatternEntry* entry = + psy_audio_patternnode_entry(node); + psy_audio_PatternEventNode* event_node; + uintptr_t event_index = 0; + + ++stats->pattern_entries; + for (event_node = psy_audio_patternentry_begin(entry); + event_node != NULL; + event_node = event_node->next, ++event_index) { + psy_audio_PatternEvent* event = + (psy_audio_PatternEvent*)event_node->entry; + ++stats->events; + hash_event( + &stats->digest, + sequence_track, + psy_audio_patternentry_track(entry), + psy_audio_patternentry_offset(entry), + event_index, + event); + + if (event->note <= psy_audio_NOTECOMMANDS_B9) { + uint8_t fields[4]; + + ++stats->notes; + fields[0] = event->note; + fields[1] = event->mach; + fields[2] = event->cmd; + fields[3] = event->parameter; + stats->note_event_digest += semantic_event_hash( + sequence_track, + psy_audio_patternentry_offset(entry), + fields, + sizeof(fields)); + if (event->mach < 16u) + stats->midi_channel_mask |= UINT32_C(1) << event->mach; + } else if (event->note == psy_audio_NOTECOMMANDS_RELEASE) { + ++stats->releases; + if (event->mach < 16u) { + stats->midi_channel_mask |= UINT32_C(1) << event->mach; + stats->release_channel_mask |= UINT32_C(1) << event->mach; + } + } else if (event->note == psy_audio_NOTECOMMANDS_MIDICC) { + uint8_t fields[3]; + + ++stats->midi_cc; + fields[0] = event->mach; + fields[1] = event->cmd; + fields[2] = event->parameter; + stats->midi_cc_digest += semantic_event_hash( + sequence_track, + psy_audio_patternentry_offset(entry), + fields, + sizeof(fields)); + if (event->mach < 16u) + stats->midi_channel_mask |= UINT32_C(1) << event->mach; + } + if (event->cmd == psy_audio_PATTERNCMD_SET_TEMPO) { + ++stats->tempo_commands; + hash_tempo_command( + &stats->tempo_digest, + sequence_track, + psy_audio_patternentry_offset(entry), + event); + } + } + } + } + + if (stats->notes == 0) + return fail("MIDI import produced no playable note events"); + return 0; +} + +static int install_projection_substrate( + psy_audio_Song* song, + psy_audio_Player* player, + ImportStats* stats) +{ + psy_audio_Machines* machines = psy_audio_song_machines(song); + psy_audio_Machine* sampler; + psy_audio_Sample* sample; + psy_audio_Instrument* instrument; + psy_audio_InstrumentEntry entry; + uintptr_t slot; + uintptr_t frame; + psy_audio_Sequence* sequence = psy_audio_song_sequence(song); + uintptr_t sequence_track; + + for (slot = 0; slot < psy_audio_MASTER_INDEX; ++slot) { + if (psy_audio_machines_at(machines, slot)) + ++stats->machines_before_projection; + } + if (stats->machines_before_projection != 0) + return fail("MIDI importer unexpectedly created sound-generating machines"); + + sampler = psy_audio_machinefactory_make_machine_from_path( + &player->machinefactory, + psy_audio_SAMPLER, + NULL, + 0, + psy_INDEX_INVALID); + if (!sampler) + return fail("could not create deterministic classic Sampler projection machine"); + psy_audio_machines_insert(machines, PROJECTION_MACHINE, sampler); + psy_audio_machines_connect( + machines, + psy_audio_wire_make(PROJECTION_MACHINE, psy_audio_MASTER_INDEX)); + + sample = psy_audio_sample_alloc_init(1); + if (!sample) + return fail("could not allocate deterministic MIDI projection sample"); + sample->numframes = PROJECTION_SAMPLE_FRAMES; + psy_audio_sample_set_name(sample, "Phase 6C MIDI corpus projection tone"); + psy_audio_sample_set_sample_rate(sample, PROJECTION_SAMPLE_RATE); + psy_audio_sample_set_volume(sample, 0x80); + sample->loop.type = psy_audio_SAMPLE_LOOP_NORMAL; + sample->loop.start = 0; + sample->loop.end = PROJECTION_SAMPLE_FRAMES; + psy_audio_sample_alloc_wave_data(sample); + if (!sample->channels.samples || !sample->channels.samples[0]) { + psy_audio_sample_deallocate(sample); + return fail("could not allocate deterministic MIDI projection PCM"); + } + for (frame = 0; frame < PROJECTION_SAMPLE_FRAMES; ++frame) { + double phase = 2.0 * M_PI * (double)(frame % 128u) / 128.0; + sample->channels.samples[0][frame] = (float)(sin(phase) * 8000.0); + } + psy_audio_samples_insert( + psy_audio_song_samples(song), + sample, + psy_audio_sampleindex_make(0, 0)); + + instrument = psy_audio_instrument_allocinit(); + if (!instrument) + return fail("could not allocate deterministic MIDI projection instrument"); + psy_audio_instrument_set_name( + instrument, "Phase 6C MIDI corpus projection instrument"); + psy_audio_instrumententry_init(&entry); + entry.sampleindex = psy_audio_sampleindex_make(0, 0); + psy_audio_instrument_add_entry(instrument, &entry); + psy_audio_instruments_insert( + psy_audio_song_instruments(song), + instrument, + psy_audio_instrumentindex_make( + PROJECTION_INSTRUMENT_GROUP, PROJECTION_INSTRUMENT)); + + for (sequence_track = 0; + sequence_track < psy_audio_sequence_num_tracks(sequence); + ++sequence_track) { + psy_audio_OrderIndex order = psy_audio_orderindex_make(sequence_track, 0); + uintptr_t pattern_index; + psy_audio_Pattern* pattern; + psy_audio_PatternNode* node; + double pattern_beats; + + if (psy_audio_sequence_track_size(sequence, sequence_track) == 0) + continue; + pattern_index = psy_audio_sequence_patternindex(sequence, order); + if (pattern_index == psy_INDEX_INVALID) + continue; + pattern = psy_audio_patterns_at( + psy_audio_song_patterns(song), pattern_index); + if (!pattern) + continue; + + pattern_beats = psy_dsp_beatpos_real(psy_audio_pattern_length(pattern)); + if (pattern_beats > PROJECTION_BEATS) { + psy_audio_pattern_set_length( + pattern, + psy_dsp_beatpos_make_real( + PROJECTION_BEATS, psy_dsp_DEFAULT_PPQ)); + } + + for (node = psy_audio_pattern_begin(pattern); + node != NULL; + psy_audio_patternnode_next(&node)) { + psy_audio_PatternEntry* pattern_entry = + psy_audio_patternnode_entry(node); + psy_audio_PatternEventNode* event_node; + double offset = psy_dsp_beatpos_real( + psy_audio_patternentry_offset(pattern_entry)); + + if (offset >= PROJECTION_BEATS) + continue; + for (event_node = psy_audio_patternentry_begin(pattern_entry); + event_node != NULL; + event_node = event_node->next) { + psy_audio_PatternEvent* event = + (psy_audio_PatternEvent*)event_node->entry; + if (event->note <= psy_audio_NOTECOMMANDS_B9) { + event->mach = PROJECTION_MACHINE; + event->inst = PROJECTION_INSTRUMENT; + ++stats->projection_notes; + } else if ( + event->note == psy_audio_NOTECOMMANDS_RELEASE || + event->note == psy_audio_NOTECOMMANDS_MIDICC) { + event->mach = PROJECTION_MACHINE; + } + } + } + } + + if (stats->projection_notes == 0) + return fail("16-beat MIDI projection contains no note events"); + return 0; +} + +static void on_render_stopped(RenderStopState* state, psy_AudioDriver* sender) +{ + (void)sender; + atomic_store_explicit(&state->stopped, true, memory_order_release); +} + +static int inspect_wav(const char* path, WavStats* stats) +{ + FILE* file; + unsigned char header[44]; + uint32_t data_bytes; + uint16_t channels; + uint16_t bits; + uint16_t block_align; + uint32_t i; + int peak = 0; + + memset(stats, 0, sizeof(*stats)); + file = fopen(path, "rb"); + if (!file) + return fail("FileOutDriver did not create the projection WAV"); + if (fread(header, 1, sizeof(header), file) != sizeof(header)) { + fclose(file); + return fail("projection WAV is shorter than the canonical PCM header"); + } + if (memcmp(header, "RIFF", 4) != 0 || + memcmp(header + 8, "WAVE", 4) != 0 || + memcmp(header + 12, "fmt ", 4) != 0 || + memcmp(header + 36, "data", 4) != 0) { + fclose(file); + return fail("projection output is not canonical RIFF/WAVE PCM"); + } + if (read_u16_le(header + 20) != 1u) { + fclose(file); + return fail("projection WAV is not PCM"); + } + channels = read_u16_le(header + 22); + bits = read_u16_le(header + 34); + block_align = read_u16_le(header + 32); + data_bytes = read_u32_le(header + 40); + if (channels == 0 || bits != 16u || + block_align != channels * 2u || + data_bytes == 0 || + data_bytes % block_align != 0) { + fclose(file); + return fail("projection WAV geometry is invalid"); + } + stats->frames = data_bytes / block_align; + for (i = 0; i < data_bytes / 2u; ++i) { + unsigned char bytes[2]; + int16_t sample; + int magnitude; + if (fread(bytes, 1, 2, file) != 2) { + fclose(file); + return fail("projection WAV PCM is truncated"); + } + sample = (int16_t)read_u16_le(bytes); + magnitude = sample < 0 ? -(int)sample : (int)sample; + if (magnitude > peak) + peak = magnitude; + } + fclose(file); + stats->peak = peak; + if (peak < MIN_RENDER_PEAK) + return fail("deterministic MIDI execution projection is silent"); + return 0; +} + +static int render_projection( + psy_audio_Player* player, + psy_audio_Song* song, + const char* output, + WavStats* stats) +{ + psy_AudioDriver* fileout; + psy_AudioDriver* original_driver; + RenderStopState stop_state; + uintptr_t tick; + int rc = 0; + + fileout = psy_audio_create_fileout_driver(); + if (!fileout) + return fail("could not create FileOutDriver"); + psy_property_set_str( + (psy_Property*)psy_audiodriver_configuration(fileout), + "outputpath", + output); + psy_audiodriver_configure(fileout, NULL); + + original_driver = player->audiodrivers.driver_plugin.client; + player->audiodrivers.driver_plugin.client = fileout; + psy_audio_audiodriverplugin_connect( + &player->audiodrivers.driver_plugin, + player->audiodrivers.systemhandle, + player->audiodrivers.context, + (AUDIODRIVERWORKFN)player->audiodrivers.fp); + + psy_audio_player_set_song(player, song); + psy_audio_sequencer_stop_loop(&player->sequencer); + psy_audio_player_set_position(player, 0.0); + psy_audio_player_start(player); + atomic_init(&stop_state.stopped, false); + psy_signal_connect( + &fileout->signal_stop, &stop_state, on_render_stopped); + + if (psy_audiodriver_open(fileout) != 0) { + rc = fail("FileOutDriver failed to open"); + } else { + for (tick = 0; + tick < RENDER_TIMEOUT_TICKS && + !atomic_load_explicit( + &stop_state.stopped, memory_order_acquire); + ++tick) { + psy_sleep_for(RENDER_WAIT_US); + } + if (!atomic_load_explicit( + &stop_state.stopped, memory_order_acquire)) { + rc = fail("MIDI projection render exceeded timeout"); + } + } + + psy_audio_player_stop(player); + psy_audiodriver_close(fileout); + player->audiodrivers.driver_plugin.client = original_driver; + psy_audio_audiodriverplugin_connect( + &player->audiodrivers.driver_plugin, + player->audiodrivers.systemhandle, + player->audiodrivers.context, + (AUDIODRIVERWORKFN)player->audiodrivers.fp); + psy_audiodriver_deallocate(fileout); + + if (rc == 0) + rc = inspect_wav(output, stats); + return rc; +} + +int main(int argc, char** argv) +{ + psy_Property* config; + psy_audio_AudioConfig audioconfig; + psy_audio_MachineCallback callback; + psy_audio_Player player; + psy_audio_Song* song; + ImportStats import_stats; + WavStats wav_stats; + double imported_song_bpm; + int rc; + + if (argc != 2 && argc != 3) { + fprintf(stderr, "usage: %s INPUT.mid [OUTPUT.wav]\n", argv[0]); + return 64; + } + + psy_audio_init(); + config = psy_property_allocinit_key(NULL); + if (!config) { + psy_audio_dispose(); + return fail("could not allocate player configuration"); + } + psy_audio_audioconfig_init(&audioconfig, config); + psy_audio_machinecallback_init(&callback); + psy_audio_player_init( + &player, + &callback, + NULL, + psy_audio_audioconfig_base(&audioconfig), + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + + song = load_song(&player, &callback, argv[1]); + if (!song) { + psy_audio_player_dispose(&player); + psy_audio_audioconfig_dispose(&audioconfig); + psy_property_deallocate(config); + psy_audio_dispose(); + return fail("C-Psycle SongReader rejected real-world MIDI stem"); + } + + memset(&wav_stats, 0, sizeof(wav_stats)); + imported_song_bpm = psy_audio_song_bpm(song); + rc = collect_import_stats(song, &import_stats); + if (rc == 0 && argc == 3) + rc = install_projection_substrate(song, &player, &import_stats); + if (rc == 0 && argc == 3) + rc = render_projection(&player, song, argv[2], &wav_stats); + + if (rc == 0) { + printf( + "{" + "\"schema_version\":1," + "\"phase\":\"6C\"," + "\"contract\":\"legacy-midi-real-world-donor\"," + "\"evidence_role\":\"cpsycle-donor-observation\"," + "\"load_result\":\"accepted\"," + "\"sequence_tracks\":%lu," + "\"patterns\":%lu," + "\"pattern_entries\":%lu," + "\"events\":%lu," + "\"imported_notes\":%lu," + "\"releases\":%lu," + "\"midi_cc\":%lu," + "\"note_event_digest_fnv64\":\"%016llx\"," + "\"midi_cc_digest_fnv64\":\"%016llx\"," + "\"song_bpm\":%.3f," + "\"tempo_commands\":%lu," + "\"tempo_map_digest_fnv64\":\"%016llx\"," + "\"midi_channel_mask\":%u," + "\"release_channel_mask\":%u," + "\"machines_before_projection\":%lu," + "\"projection_notes\":%lu," + "\"projection_beats\":%.0f," + "\"projection_kind\":\"%s\"," + "\"import_event_digest_fnv64\":\"%016llx\"," + "\"render_frames\":%u," + "\"render_peak\":%d," + "\"non_silent_projection\":%s," + "\"parity_status\":\"UNKNOWN\"" + "}\n", + (unsigned long)import_stats.sequence_tracks, + (unsigned long)import_stats.patterns, + (unsigned long)import_stats.pattern_entries, + (unsigned long)import_stats.events, + (unsigned long)import_stats.notes, + (unsigned long)import_stats.releases, + (unsigned long)import_stats.midi_cc, + (unsigned long long)import_stats.note_event_digest, + (unsigned long long)import_stats.midi_cc_digest, + imported_song_bpm, + (unsigned long)import_stats.tempo_commands, + (unsigned long long)import_stats.tempo_digest, + (unsigned)import_stats.midi_channel_mask, + (unsigned)import_stats.release_channel_mask, + (unsigned long)import_stats.machines_before_projection, + (unsigned long)import_stats.projection_notes, + PROJECTION_BEATS, + argc == 3 ? "deterministic-sampler" : "not-run", + (unsigned long long)import_stats.digest, + (unsigned)wav_stats.frames, + wav_stats.peak, + argc == 3 ? "true" : "false"); + } + + psy_audio_player_set_empty_song(&player); + psy_audio_song_deallocate(song); + psy_audio_player_dispose(&player); + psy_audio_audioconfig_dispose(&audioconfig); + psy_property_deallocate(config); + psy_audio_dispose(); + return rc; +}