From 78e182a599090ab5150891ea7fb829d729a8b530 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:05:54 +0930 Subject: [PATCH 01/59] phase6c: add private MIDI corpus harness --- scripts/phase6c-midi-corpus.py | 815 +++++++++++++++++++++++++++++++++ 1 file changed, 815 insertions(+) create mode 100644 scripts/phase6c-midi-corpus.py diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py new file mode 100644 index 00000000..a477c5d4 --- /dev/null +++ b/scripts/phase6c-midi-corpus.py @@ -0,0 +1,815 @@ +#!/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 pathlib +import shutil +import struct +import subprocess +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" +SUMMARY_CONTRACT = "legacy-midi-real-world-summary" + +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 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() + sysex = 0 + pitch_bend = 0 + aftertouch = 0 + time_signatures = 0 + end_tick = 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 + + 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] + 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] + 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 + + 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), + "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, + } + + +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"]] + bpms = [60_000_000.0 / usec for _, usec in tempos if usec] + 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"]}) + 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), + "same_note_overlaps": sum(track["same_note_overlaps"] for track in tracks), + "zero_duration_pairs": sum(track["zero_duration_pairs"] for track in tracks), + "max_polyphony": max((track["max_polyphony"] for track in tracks), default=0), + "tempo_events": len(tempos), + "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, + "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": sum(track["unmatched_note_offs"] for track in tracks), + "remaining_active_notes": sum(track["remaining_active_notes"] for track in tracks), + "balanced_note_pairs": all( + track["unmatched_note_offs"] == 0 and track["remaining_active_notes"] == 0 + for track in tracks + ), + "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_set_aggregate(set_spec: dict[str, Any], stems: list[dict[str, Any]]) -> dict[str, Any]: + expected = set_spec.get("analysis_expectations") + if not isinstance(expected, dict): + die(f"{set_spec['name']}: missing analysis_expectations") + + 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": sum(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}), + } + + exact_fields = { + "stems": "stems", + "tracks": "tracks", + "notes": "notes", + "same_note_overlaps": "same_note_overlaps", + "max_polyphony": "max_polyphony", + "tempo_events": "tempo_events", + } + for actual_key, expected_key in exact_fields.items(): + if aggregate[actual_key] != expected.get(expected_key): + die( + f"{set_spec['name']}: {actual_key} mismatch: " + f"expected={expected.get(expected_key)} actual={aggregate[actual_key]}" + ) + if aggregate["formats"] != [1] or aggregate["divisions"] != [480]: + die(f"{set_spec['name']}: corpus files are not uniformly SMF1/480 PPQN") + if not aggregate["balanced_note_pairs"]: + die(f"{set_spec['name']}: unbalanced note pairs detected") + for field in ("sysex_events", "pitch_bend_events", "aftertouch_events", "time_signature_events"): + if aggregate[field] != 0: + die(f"{set_spec['name']}: unexpected {field}={aggregate[field]}") + + tempo_range = expected.get("tempo_range_bpm") + if not ( + isinstance(tempo_range, list) and len(tempo_range) == 2 + 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['tempo_min_bpm'], aggregate['tempo_max_bpm']]}" + ) + if not math.isclose( + aggregate["duration_seconds_max"], + float(expected.get("duration_seconds_max")), + abs_tol=0.02, + ): + die( + f"{set_spec['name']}: duration changed: " + f"expected={expected.get('duration_seconds_max')} " + f"actual={aggregate['duration_seconds_max']}" + ) + + expected_key = expected.get("malformed_key_signature") + expected_keys = [] if expected_key is None else [tuple(expected_key)] + if aggregate["malformed_key_signatures"] != expected_keys: + die( + f"{set_spec['name']}: malformed key-signature observation changed: " + f"expected={expected_keys} actual={aggregate['malformed_key_signatures']}" + ) + expected_zero = bool(expected.get("zero_duration_pairs_present")) + if (aggregate["zero_duration_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["channels"] != expected_channels: + die( + f"{set_spec['name']}: channel-use observation changed: " + f"expected={expected_channels} actual={aggregate['channels']}" + ) + 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 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 + with path.open("rb") as handle: + prefix = handle.read(4) + if prefix in {b"MThd", b"PK\x03\x04"}: + leaks.append(relative) + if leaks: + die("public MIDI evidence contains raw corpus bytes: " + ", ".join(sorted(leaks))) + + +def candidate_boundary( + analysis_path: pathlib.Path, + player: pathlib.Path, + output: pathlib.Path, +) -> None: + analysis = load_json(analysis_path) + if analysis.get("contract") != CONTRACT: + die("candidate boundary received the wrong corpus analysis contract") + if not player.is_file(): + die(f"candidate player is missing: {player}") + player_sha = sha256_file(player) + 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" + results.append({ + "set_name": set_info["name"], + "representative_stem_id": rep["stem_id"], + "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 historical MIDI boundary was not a scoped direct-load rejection") + receipt = { + "schema_version": 1, + "phase": "6C", + "contract": CANDIDATE_CONTRACT, + "evidence_role": "candidate-observation", + "player": "candidate-psycle-player", + "player_sha256": player_sha, + "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 donor_summary( + analysis_path: pathlib.Path, + observations_root: pathlib.Path, + output: pathlib.Path, +) -> None: + analysis = load_json(analysis_path) + if analysis.get("contract") != CONTRACT: + die("donor summary received the wrong corpus analysis contract") + 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("source_sha256") != stem["sha256"]: + die(f"{stem['stem_id']}: donor source identity changed") + if obs.get("imported_notes") != stem["analysis"]["note_ons"]: + die( + f"{stem['stem_id']}: imported note count differs from raw SMF: " + f"source={stem['analysis']['note_ons']} imported={obs.get('imported_notes')}" + ) + if obs.get("non_silent_projection") is not True: + die(f"{stem['stem_id']}: execution projection is silent") + if obs.get("projection_kind") != "deterministic-sampulse": + die(f"{stem['stem_id']}: execution projection kind changed") + if obs.get("parity_status") != "UNKNOWN": + die(f"{stem['stem_id']}: donor observation attempted parity promotion") + observations.append(obs) + set_summaries.append({ + "name": set_info["name"], + "slug": set_info["slug"], + "role": set_info["role"], + "archive_sha256": set_info["archive_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), + "event_digest_fnv64": [item["import_event_digest_fnv64"] for item in observations], + "render_sha256": [item["render_sha256"] for item in observations], + }) + receipt = { + "schema_version": 1, + "phase": "6C", + "contract": DONOR_CONTRACT, + "evidence_role": "cpsycle-donor-corpus-summary", + "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 Sampulse 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) + 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("parity_status") != "UNKNOWN" + or donor.get("all_sets_imported") is not True + or donor.get("all_sets_non_silent_projection") is not True + ): + die("donor MIDI corpus summary is incomplete or invalid") + 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 len(candidate.get("sets", [])) != 6 + or any(item.get("direct_midi_load") != "rejected" for item in candidate["sets"]) + ): + die("candidate MIDI corpus boundary is incomplete or invalid") + + 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 build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + 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())) + + candidate = sub.add_parser("candidate-boundary") + candidate.add_argument("analysis", 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.player.resolve(), args.output.resolve() + ) + ) + + donor = sub.add_parser("donor-summary") + donor.add_argument("analysis", type=pathlib.Path) + donor.add_argument("observations", type=pathlib.Path) + donor.add_argument("output", type=pathlib.Path) + donor.set_defaults( + func=lambda args: donor_summary( + args.analysis.resolve(), args.observations.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()) From a870500fc16310c0fedf11b6da75b909e7a9d8cf Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:06:21 +0930 Subject: [PATCH 02/59] phase6c: formalize MIDI corpus evidence contract --- phase6c/reference-corpus/manifest.json | 209 +++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 9 deletions(-) diff --git a/phase6c/reference-corpus/manifest.json b/phase6c/reference-corpus/manifest.json index 99a93c3b..9b85d252 100644 --- a/phase6c/reference-corpus/manifest.json +++ b/phase6c/reference-corpus/manifest.json @@ -1,20 +1,211 @@ { - "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, + "tracks": 45, + "notes": 13947, + "max_polyphony": 8, + "same_note_overlaps": 425, + "tempo_events": 539, + "tempo_range_bpm": [ + 127.87751, + 133.422282 + ], + "duration_seconds_max": 287.409263, + "malformed_key_signature": [ + 9, + 1 + ], + "zero_duration_pairs_present": false + } + }, + { + "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_events": 449, + "tempo_range_bpm": [ + 124.89592, + 130.321743 + ], + "duration_seconds_max": 217.492473, + "malformed_key_signature": [ + 8, + 1 + ], + "zero_duration_pairs_present": false + } + }, + { + "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_events": 319, + "tempo_range_bpm": [ + 128.728017, + 131.348799 + ], + "duration_seconds_max": 166.349007, + "malformed_key_signature": [ + 17, + 1 + ], + "zero_duration_pairs_present": false + } + }, + { + "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_events": 425, + "tempo_range_bpm": [ + 137.017584, + 141.810782 + ], + "duration_seconds_max": 197.626864, + "malformed_key_signature": [ + 9, + 1 + ], + "zero_duration_pairs_present": true + } + }, + { + "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_events": 501, + "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 + ] + } + }, + { + "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_events": 379, + "tempo_range_bpm": [ + 142.348754, + 145.27845 + ], + "duration_seconds_max": 177.511301, + "malformed_key_signature": [ + 13, + 1 + ], + "zero_duration_pairs_present": false + } + } ], "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 Sampulse execution substrate; that projection is evidence of engine execution, not native MIDI instrument-selection semantics." } } From c0ba79d1475e5cce94f8397d127f98614f51bf61 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:07:40 +0930 Subject: [PATCH 03/59] phase6c: add real-world MIDI donor playback probe --- tests/phase6c_midi_corpus_probe.c | 594 ++++++++++++++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 tests/phase6c_midi_corpus_probe.c diff --git a/tests/phase6c_midi_corpus_probe.c b/tests/phase6c_midi_corpus_probe.c new file mode 100644 index 00000000..c938f72e --- /dev/null +++ b/tests/phase6c_midi_corpus_probe.c @@ -0,0 +1,594 @@ +// 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 Sampulse 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 1u +#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; + uint32_t midi_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 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(1469598103934665603); + 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) { + ++stats->notes; + 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; + } else if (event->note == psy_audio_NOTECOMMANDS_MIDICC) { + ++stats->midi_cc; + if (event->mach < 16u) + stats->midi_channel_mask |= UINT32_C(1) << event->mach; + } + if (event->cmd == psy_audio_PATTERNCMD_SET_TEMPO) + ++stats->tempo_commands; + } + } + } + + 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 < 16u; ++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_XMSAMPLER, + NULL, + 0, + psy_INDEX_INVALID); + if (!sampler) + return fail("could not create deterministic Sampulse 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; + int rc; + + if (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"); + } + + rc = collect_import_stats(song, &import_stats); + if (rc == 0) + rc = install_projection_substrate(song, &player, &import_stats); + if (rc == 0) + 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," + "\"tempo_commands\":%lu," + "\"midi_channel_mask\":%u," + "\"machines_before_projection\":%lu," + "\"projection_notes\":%lu," + "\"projection_beats\":%.0f," + "\"projection_kind\":\"deterministic-sampulse\"," + "\"import_event_digest_fnv64\":\"%016llx\"," + "\"render_frames\":%u," + "\"render_peak\":%d," + "\"non_silent_projection\":true," + "\"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)import_stats.tempo_commands, + (unsigned)import_stats.midi_channel_mask, + (unsigned long)import_stats.machines_before_projection, + (unsigned long)import_stats.projection_notes, + PROJECTION_BEATS, + (unsigned long long)import_stats.digest, + (unsigned)wav_stats.frames, + wav_stats.peak); + } + + 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; +} From 49c0ddd3936e39a49dbd031449f613d1a548bce2 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:07:56 +0930 Subject: [PATCH 04/59] phase6c: keep unfrozen Blue Glare track total optional --- phase6c/reference-corpus/manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/phase6c/reference-corpus/manifest.json b/phase6c/reference-corpus/manifest.json index 9b85d252..24d6df50 100644 --- a/phase6c/reference-corpus/manifest.json +++ b/phase6c/reference-corpus/manifest.json @@ -29,7 +29,6 @@ "known_edge": "SMF1/480 PPQN; repeated tempo map; exporter emits out-of-range key-signature metadata", "analysis_expectations": { "stems": 11, - "tracks": 45, "notes": 13947, "max_polyphony": 8, "same_note_overlaps": 425, From 13c713c822dc5a3a1c43b0bd47306117711f7f3e Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:08:00 +0930 Subject: [PATCH 05/59] phase6c: allow explicitly unfrozen corpus aggregates --- scripts/phase6c-midi-corpus.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index a477c5d4..36d9dc43 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -393,10 +393,13 @@ def validate_set_aggregate(set_spec: dict[str, Any], stems: list[dict[str, Any]] "tempo_events": "tempo_events", } for actual_key, expected_key in exact_fields.items(): - if aggregate[actual_key] != expected.get(expected_key): + expected_value = expected.get(expected_key) + if expected_value is None: + continue + if aggregate[actual_key] != expected_value: die( f"{set_spec['name']}: {actual_key} mismatch: " - f"expected={expected.get(expected_key)} actual={aggregate[actual_key]}" + f"expected={expected_value} actual={aggregate[actual_key]}" ) if aggregate["formats"] != [1] or aggregate["divisions"] != [480]: die(f"{set_spec['name']}: corpus files are not uniformly SMF1/480 PPQN") From 2c93ab207cda7aeef111b5cd16df7fe8c6b41d68 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:08:10 +0930 Subject: [PATCH 06/59] phase6c: use canonical FNV-1a offset basis --- tests/phase6c_midi_corpus_probe.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phase6c_midi_corpus_probe.c b/tests/phase6c_midi_corpus_probe.c index c938f72e..1d79d2c1 100644 --- a/tests/phase6c_midi_corpus_probe.c +++ b/tests/phase6c_midi_corpus_probe.c @@ -155,7 +155,7 @@ static int collect_import_stats(psy_audio_Song* song, ImportStats* stats) uintptr_t sequence_track; memset(stats, 0, sizeof(*stats)); - stats->digest = UINT64_C(1469598103934665603); + stats->digest = UINT64_C(14695981039346656037); stats->sequence_tracks = psy_audio_sequence_num_tracks(sequence); for (sequence_track = 0; From 4016983f1d3391c215a08aea5a9fbd3318987aac Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:09:00 +0930 Subject: [PATCH 07/59] phase6c: add synthetic MIDI probe fixture --- scripts/phase6c-midi-corpus.py | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 36d9dc43..e835b8e5 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -761,10 +761,49 @@ def 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_synthetic_fixture(path: pathlib.Path) -> None: + conductor = bytearray() + conductor += midi_varlen(0) + b"\xff\x51\x03\x07\xa1\x20" + conductor += midi_varlen(0) + b"\xff\x59\x02\x09\x01" + conductor += midi_varlen(0) + b"\xff\x2f\x00" + + notes = bytearray() + notes += midi_varlen(0) + bytes((0x90, 60, 100)) + notes += midi_varlen(240) + bytes((0x91, 64, 96)) + notes += midi_varlen(240) + bytes((0x80, 60, 0)) + notes += midi_varlen(0) + bytes((0x81, 64, 0)) + notes += midi_varlen(0) + b"\xff\x2f\x00" + + payload = bytearray(b"MThd") + payload += struct.pack(">IHHH", 6, 1, 2, 480) + for track in (conductor, notes): + payload += b"MTrk" + struct.pack(">I", len(track)) + track + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + + 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()) + ) + prepare = sub.add_parser("prepare-bundle") prepare.add_argument("bundle", type=pathlib.Path) prepare.add_argument("root", type=pathlib.Path) From d7be0616fcc0b269ccae322379f877689916fc5b Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:09:31 +0930 Subject: [PATCH 08/59] phase6c: compute MIDI polyphony across SMF tracks --- scripts/phase6c-midi-corpus.py | 57 +++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index e835b8e5..3273d461 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -126,6 +126,8 @@ def parse_track(track: bytes) -> dict[str, Any]: aftertouch = 0 time_signatures = 0 end_tick = 0 + note_transitions: list[tuple[int, int, int, bool]] = [] + event_serial = 0 while pos < len(track): delta, pos = read_varlen(track, pos) @@ -209,6 +211,7 @@ def parse_track(track: bytes) -> dict[str, Any]: is_note_off = kind == 0x80 or (kind == 0x90 and len(payload) == 2 and payload[1] == 0) if is_note_on: note = payload[0] + note_transitions.append((tick, event_serial, channel, note, True)) key = (channel, note) queue = active[key] if queue: @@ -219,6 +222,7 @@ def parse_track(track: bytes) -> dict[str, Any]: note_ons += 1 elif is_note_off: note = payload[0] + note_transitions.append((tick, event_serial, channel, note, False)) key = (channel, note) queue = active[key] note_offs += 1 @@ -229,6 +233,7 @@ def parse_track(track: bytes) -> dict[str, Any]: if start_tick == tick: zero_duration_pairs += 1 active_total -= 1 + event_serial += 1 remaining = sum(len(queue) for queue in active.values()) return { @@ -247,6 +252,7 @@ def parse_track(track: bytes) -> dict[str, Any]: "unmatched_note_offs": unmatched_note_offs, "remaining_active_notes": remaining, "end_tick": end_tick, + "note_transitions": note_transitions, } @@ -292,6 +298,42 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: tempos = [item for track in tracks for item in track["tempo_events"]] bpms = [60_000_000.0 / usec for _, usec in tempos if usec] + + merged_transitions = sorted( + ( + tick, + track_index, + serial, + channel, + note, + is_on, + ) + for track_index, track in enumerate(tracks) + for tick, serial, channel, note, is_on in track["note_transitions"] + ) + 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, _track_index, _serial, channel, note, is_on in merged_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"]}) end_tick = max((track["end_tick"] for track in tracks), default=0) @@ -301,9 +343,9 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: "division": division, "note_ons": sum(track["note_ons"] for track in tracks), "note_offs": sum(track["note_offs"] for track in tracks), - "same_note_overlaps": sum(track["same_note_overlaps"] for track in tracks), - "zero_duration_pairs": sum(track["zero_duration_pairs"] for track in tracks), - "max_polyphony": max((track["max_polyphony"] for track in tracks), default=0), + "same_note_overlaps": global_same_note_overlaps, + "zero_duration_pairs": global_zero_duration_pairs, + "max_polyphony": global_max_polyphony, "tempo_events": len(tempos), "tempo_min_bpm": min(bpms) if bpms else None, "tempo_max_bpm": max(bpms) if bpms else None, @@ -316,11 +358,10 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: "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": sum(track["unmatched_note_offs"] for track in tracks), - "remaining_active_notes": sum(track["remaining_active_notes"] for track in tracks), - "balanced_note_pairs": all( - track["unmatched_note_offs"] == 0 and track["remaining_active_notes"] == 0 - 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), } From 70e5c90d769c7bc19ce9faaf0ce05b543facf005 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:09:41 +0930 Subject: [PATCH 09/59] phase6c: correct MIDI transition type contract --- scripts/phase6c-midi-corpus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 3273d461..69ca88a3 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -126,7 +126,7 @@ def parse_track(track: bytes) -> dict[str, Any]: aftertouch = 0 time_signatures = 0 end_tick = 0 - note_transitions: list[tuple[int, int, int, bool]] = [] + note_transitions: list[tuple[int, int, int, int, bool]] = [] event_serial = 0 while pos < len(track): From 4adfabe16e0cd7f0b829ace0a5bb6657399fc46f Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:10:21 +0930 Subject: [PATCH 10/59] phase6c: bind MIDI corpus support artifacts --- scripts/phase6c-midi-corpus.py | 71 +++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 69ca88a3..c33e5ef1 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -660,6 +660,15 @@ def candidate_boundary( "evidence_role": "candidate-observation", "player": "candidate-psycle-player", "player_sha256": player_sha, + "working_directory": "artifact-root-with-private-input", + "private_input_required": True, + "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, @@ -675,6 +684,8 @@ def candidate_boundary( def donor_summary( analysis_path: pathlib.Path, observations_root: pathlib.Path, + probe_path: pathlib.Path, + source_path: pathlib.Path, output: pathlib.Path, ) -> None: analysis = load_json(analysis_path) @@ -718,11 +729,22 @@ def donor_summary( "event_digest_fnv64": [item["import_event_digest_fnv64"] for item in observations], "render_sha256": [item["render_sha256"] for item in observations], }) + if not probe_path.is_file() or not source_path.is_file(): + die("donor MIDI corpus support files are missing") + source_revision = subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True + ).strip() + receipt = { "schema_version": 1, "phase": "6C", "contract": DONOR_CONTRACT, "evidence_role": "cpsycle-donor-corpus-summary", + "source_revision": source_revision, + "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), @@ -746,6 +768,25 @@ def corpus_summary( ) -> 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}") + if ( donor.get("schema_version") != 1 or donor.get("phase") != "6C" @@ -756,6 +797,19 @@ def corpus_summary( or donor.get("all_sets_non_silent_projection") is not True ): die("donor MIDI corpus summary is incomplete or invalid") + 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", + ) + if ( candidate.get("schema_version") != 1 or candidate.get("phase") != "6C" @@ -767,6 +821,13 @@ def corpus_summary( ): die("candidate MIDI corpus boundary is incomplete or invalid") + require_support( + candidate_path, + candidate.get("player"), + candidate.get("player_sha256"), + "candidate player", + ) + summary = { "schema_version": 1, "phase": "6C", @@ -867,10 +928,16 @@ def build_parser() -> argparse.ArgumentParser: donor = sub.add_parser("donor-summary") donor.add_argument("analysis", type=pathlib.Path) donor.add_argument("observations", type=pathlib.Path) - donor.add_argument("output", 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.observations.resolve(), args.output.resolve() + args.analysis.resolve(), + args.observations.resolve(), + args.probe.resolve(), + args.source.resolve(), + args.output.resolve(), ) ) From 4e9075573813566531958056248ce196851e6765 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:11:13 +0930 Subject: [PATCH 11/59] phase6c: add private MIDI real-world corpus workflow --- .../workflows/phase6c-midi-corpus-private.yml | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 .github/workflows/phase6c-midi-corpus-private.yml diff --git a/.github/workflows/phase6c-midi-corpus-private.yml b/.github/workflows/phase6c-midi-corpus-private.yml new file mode 100644 index 00000000..5211504c --- /dev/null +++ b/.github/workflows/phase6c-midi-corpus-private.yml @@ -0,0 +1,279 @@ +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 Sampulse 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 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' + cp -- "$PLAYER" phase6c-midi-candidate-public/candidate-psycle-player + chmod +x phase6c-midi-candidate-public/candidate-psycle-player + python3 scripts/phase6c-midi-corpus.py candidate-boundary phase6c-midi-private/corpus-analysis.json phase6c-midi-candidate-public/candidate-psycle-player phase6c-midi-candidate-public/candidate-midi-boundary.json + 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 From c8bb323c0e03d2d612b08d91795d7529583c8c43 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:12:00 +0930 Subject: [PATCH 12/59] phase6c: harden MIDI corpus summary validation --- scripts/phase6c-midi-corpus.py | 62 +++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index c33e5ef1..52bc016f 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -652,7 +652,7 @@ def candidate_boundary( shutil.rmtree(replay_root) if any(item["direct_midi_load"] != "rejected" for item in results): - die("frozen candidate historical MIDI boundary was not a scoped direct-load rejection") + die("frozen candidate real-world MIDI boundary was not a scoped direct-load rejection") receipt = { "schema_version": 1, "phase": "6C", @@ -661,7 +661,6 @@ def candidate_boundary( "player": "candidate-psycle-player", "player_sha256": player_sha, "working_directory": "artifact-root-with-private-input", - "private_input_required": True, "procedure": ( "for each corpus set, place the exact representative SMF beside the " "artifact candidate player under the private replay root, chmod +x the " @@ -705,6 +704,39 @@ def donor_summary( die(f"{stem['stem_id']}: donor evidence_role 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") + if obs.get("machines_before_projection") != 0: + die(f"{stem['stem_id']}: MIDI importer unexpectedly created machines") + if not isinstance(obs.get("projection_notes"), int) or obs["projection_notes"] <= 0: + die(f"{stem['stem_id']}: donor projection contains no notes") + if not isinstance(obs.get("render_frames"), int) or obs["render_frames"] <= 0: + die(f"{stem['stem_id']}: donor projection rendered no frames") + if not isinstance(obs.get("render_peak"), int) or obs["render_peak"] <= 0: + die(f"{stem['stem_id']}: donor projection peak is invalid") + 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") if obs.get("imported_notes") != stem["analysis"]["note_ons"]: die( f"{stem['stem_id']}: imported note count differs from raw SMF: " @@ -795,6 +827,10 @@ def require_support( 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") require_support( @@ -810,16 +846,34 @@ def require_support( "donor probe", ) + candidate_sets = candidate.get("sets") 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 len(candidate.get("sets", [])) != 6 - or any(item.get("direct_midi_load") != "rejected" for item in candidate["sets"]) + 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: + if ( + 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") + 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") + if not isinstance(raw_sha, str) or len(raw_sha) != 64: + die(f"candidate {item.get('set_name')} raw-output SHA-256 is invalid") require_support( candidate_path, From 2e609cca64900f0755b3fc9a330ca6ff79126dc1 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:12:29 +0930 Subject: [PATCH 13/59] phase6c: add MIDI corpus contract tests --- tests/phase6c_midi_corpus.py | 190 +++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/phase6c_midi_corpus.py diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py new file mode 100644 index 00000000..7d431a11 --- /dev/null +++ b/tests/phase6c_midi_corpus.py @@ -0,0 +1,190 @@ +#!/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 tempfile +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" + +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 Sampulse" in manifest["shared_observation"]["playback_boundary"] + +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["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 + +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) + disguised_zip = public / "renamed-archive.txt" + disguised_zip.write_bytes(b"PK\x03\x04" + b"synthetic zip marker") + try: + helper.audit_public_tree(public) + except SystemExit as exc: + assert "raw corpus bytes" in str(exc) + else: + raise AssertionError("renamed ZIP payload must fail public-tree audit") + +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" 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 "non_silent_projection" in probe_source + +# 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 "audit-public" in workflow +assert "phase6c-midi-real-world-corpus-summary" in workflow +assert "original_psycle_1_12_0_x86" not in workflow +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 + +# Summary validation must reject malformed candidate progression evidence. +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_text("source", encoding="utf-8") + donor_probe.write_text("probe", encoding="utf-8") + candidate_player.write_text("player", encoding="utf-8") + + sha = lambda p: hashlib.sha256(p.read_bytes()).hexdigest() + donor = { + "schema_version": 1, + "phase": "6C", + "contract": helper.DONOR_CONTRACT, + "evidence_role": "cpsycle-donor-corpus-summary", + "source_file": donor_source.name, + "source_file_sha256": sha(donor_source), + "probe_executable": donor_probe.name, + "probe_executable_sha256": sha(donor_probe), + "progression_order": helper.PROGRESSION, + "sets": [{"name": name} for name in helper.PROGRESSION], + "all_sets_imported": True, + "all_sets_non_silent_projection": True, + "parity_status": "UNKNOWN", + } + candidate = { + "schema_version": 1, + "phase": "6C", + "contract": helper.CANDIDATE_CONTRACT, + "evidence_role": "candidate-observation", + "player": candidate_player.name, + "player_sha256": sha(candidate_player), + "private_input_required": True, + "progression_order": helper.PROGRESSION, + "sets": [ + { + "set_name": name, + "representative_sha256": "a" * 64, + "raw_output_sha256": "b" * 64, + "direct_midi_load": "rejected", + "exit_code": 2, + "diagnostic_could_not_load_song_file": True, + } + for name in helper.PROGRESSION + ], + "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) + + bad_candidate = dict(candidate) + bad_candidate["sets"] = list(candidate["sets"]) + bad_candidate["sets"][0] = dict(bad_candidate["sets"][0]) + bad_candidate["sets"][0]["direct_midi_load"] = "inconclusive" + helper.write_json(candidate_path, bad_candidate) + try: + helper.corpus_summary( + donor_path, + candidate_path, + root / "summary.json", + ) + except SystemExit as exc: + assert "direct-load boundary" in str(exc) + else: + raise AssertionError("incomplete candidate MIDI boundary must be rejected") + +print("phase6c-midi-corpus: PASS") From 784249adc736a53cd9cf44e8f8893e66b1818124 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:15:10 +0930 Subject: [PATCH 14/59] phase6c: gate MIDI corpus import and playback projection --- .../phase6c-legacy-playback-import.yml | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index e77c6d0a..12f67f55 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: | @@ -143,6 +152,65 @@ 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 + 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 + 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 + cat phase6c-generated/phase6c-midi-synthetic.log + 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["machines_before_projection"] == 0 + assert observation["projection_notes"] == 2 + assert observation["projection_kind"] == "deterministic-sampulse" + 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 + rm -f \ + phase6c-generated/phase6c-midi-synthetic.mid \ + phase6c-generated/phase6c-midi-synthetic.wav + - name: Build donor IT loader probe shell: bash run: | From 35db13495488879c19c4b86a8b6b921a2650ad40 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:15:26 +0930 Subject: [PATCH 15/59] phase6c: regress MIDI corpus workflow integration --- tests/phase6c_midi_corpus.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 7d431a11..8ae2a572 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -13,6 +13,7 @@ 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" spec = importlib.util.spec_from_file_location("phase6c_midi_corpus_helper_test", HELPER) helper = importlib.util.module_from_spec(spec) @@ -114,6 +115,32 @@ 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 "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["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 + # Summary validation must reject malformed candidate progression evidence. with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From 86595ffcf0e5afe22cb6230a4efcb7aed947762b Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:16:28 +0930 Subject: [PATCH 16/59] docs: advance legacy roadmap to MIDI corpus --- phase6c/LEGACY_PLAYBACK_IMPORT.md | 69 ++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/phase6c/LEGACY_PLAYBACK_IMPORT.md b/phase6c/LEGACY_PLAYBACK_IMPORT.md index 4722fb47..4b4edbd1 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,71 @@ 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. 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 Sampulse 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 Sampulse 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 From 88c105ac4f8cfdb92e6761596d158d2052e805bb Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:17:08 +0930 Subject: [PATCH 17/59] phase6c: pin MIDI corpus archive identities --- tests/phase6c_midi_corpus.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 8ae2a572..4763baac 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -30,6 +30,26 @@ assert manifest["private_workflow"]["bundle_url_secret"] == "PSYCLE_PHASE6C_MIDI_CORPUS_URL" assert manifest["shared_observation"]["ppqn"] == 480 assert "deterministic Sampulse" 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, +} with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From ae67857da3cc216798641182b75e1dfaf0346f63 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:18:48 +0930 Subject: [PATCH 18/59] phase6c: preserve per-stem MIDI tempo-map counts --- phase6c/reference-corpus/manifest.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/phase6c/reference-corpus/manifest.json b/phase6c/reference-corpus/manifest.json index 24d6df50..e185e946 100644 --- a/phase6c/reference-corpus/manifest.json +++ b/phase6c/reference-corpus/manifest.json @@ -32,7 +32,6 @@ "notes": 13947, "max_polyphony": 8, "same_note_overlaps": 425, - "tempo_events": 539, "tempo_range_bpm": [ 127.87751, 133.422282 @@ -42,7 +41,8 @@ 9, 1 ], - "zero_duration_pairs_present": false + "zero_duration_pairs_present": false, + "tempo_events_per_stem": 539 } }, { @@ -58,7 +58,6 @@ "notes": 4338, "max_polyphony": 12, "same_note_overlaps": 205, - "tempo_events": 449, "tempo_range_bpm": [ 124.89592, 130.321743 @@ -68,7 +67,8 @@ 8, 1 ], - "zero_duration_pairs_present": false + "zero_duration_pairs_present": false, + "tempo_events_per_stem": 449 } }, { @@ -84,7 +84,6 @@ "notes": 5381, "max_polyphony": 7, "same_note_overlaps": 241, - "tempo_events": 319, "tempo_range_bpm": [ 128.728017, 131.348799 @@ -94,7 +93,8 @@ 17, 1 ], - "zero_duration_pairs_present": false + "zero_duration_pairs_present": false, + "tempo_events_per_stem": 319 } }, { @@ -110,7 +110,6 @@ "notes": 13728, "max_polyphony": 14, "same_note_overlaps": 505, - "tempo_events": 425, "tempo_range_bpm": [ 137.017584, 141.810782 @@ -120,7 +119,8 @@ 9, 1 ], - "zero_duration_pairs_present": true + "zero_duration_pairs_present": true, + "tempo_events_per_stem": 425 } }, { @@ -136,7 +136,6 @@ "notes": 8986, "max_polyphony": 14, "same_note_overlaps": 162, - "tempo_events": 501, "tempo_range_bpm": [ 165.837479, 183.936235 @@ -164,7 +163,8 @@ 13, 14, 15 - ] + ], + "tempo_events_per_stem": 501 } }, { @@ -180,7 +180,6 @@ "notes": 3734, "max_polyphony": 6, "same_note_overlaps": 89, - "tempo_events": 379, "tempo_range_bpm": [ 142.348754, 145.27845 @@ -190,7 +189,8 @@ 13, 1 ], - "zero_duration_pairs_present": false + "zero_duration_pairs_present": false, + "tempo_events_per_stem": 379 } } ], From d6c4e00c30497b09f2441029da7cbc1f0494e051 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:18:52 +0930 Subject: [PATCH 19/59] phase6c: validate per-stem MIDI tempo maps --- scripts/phase6c-midi-corpus.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 52bc016f..bd70972a 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -399,7 +399,10 @@ def validate_set_aggregate(set_spec: dict[str, Any], stems: list[dict[str, Any]] "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": sum(stem["analysis"]["tempo_events"] for stem in stems), + "tempo_events_total": sum(stem["analysis"]["tempo_events"] 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 @@ -431,7 +434,6 @@ def validate_set_aggregate(set_spec: dict[str, Any], stems: list[dict[str, Any]] "notes": "notes", "same_note_overlaps": "same_note_overlaps", "max_polyphony": "max_polyphony", - "tempo_events": "tempo_events", } for actual_key, expected_key in exact_fields.items(): expected_value = expected.get(expected_key) @@ -442,6 +444,17 @@ def validate_set_aggregate(set_spec: dict[str, Any], stems: list[dict[str, Any]] f"{set_spec['name']}: {actual_key} mismatch: " f"expected={expected_value} actual={aggregate[actual_key]}" ) + expected_tempo_per_stem = expected.get("tempo_events_per_stem") + if ( + expected_tempo_per_stem is None + or aggregate["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['tempo_events_per_stem']}" + ) + if aggregate["formats"] != [1] or aggregate["divisions"] != [480]: die(f"{set_spec['name']}: corpus files are not uniformly SMF1/480 PPQN") if not aggregate["balanced_note_pairs"]: From 6f97e8cdaf96fcba16863a346a9f46c0385871ad Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:19:15 +0930 Subject: [PATCH 20/59] phase6c: pin per-stem MIDI tempo-map counts --- tests/phase6c_midi_corpus.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 4763baac..057d3ea9 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -50,6 +50,17 @@ "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) From a6979a6215bfb863e211cf88b342c580f6d666d0 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:20:36 +0930 Subject: [PATCH 21/59] phase6c: exercise cross-track MIDI polyphony --- scripts/phase6c-midi-corpus.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index bd70972a..711de71b 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -943,21 +943,21 @@ def midi_varlen(value: int) -> bytes: def write_synthetic_fixture(path: pathlib.Path) -> None: - conductor = bytearray() - conductor += midi_varlen(0) + b"\xff\x51\x03\x07\xa1\x20" - conductor += midi_varlen(0) + b"\xff\x59\x02\x09\x01" - conductor += midi_varlen(0) + b"\xff\x2f\x00" - - notes = bytearray() - notes += midi_varlen(0) + bytes((0x90, 60, 100)) - notes += midi_varlen(240) + bytes((0x91, 64, 96)) - notes += midi_varlen(240) + bytes((0x80, 60, 0)) - notes += midi_varlen(0) + bytes((0x81, 64, 0)) - notes += midi_varlen(0) + b"\xff\x2f\x00" + 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" payload = bytearray(b"MThd") payload += struct.pack(">IHHH", 6, 1, 2, 480) - for track in (conductor, notes): + for track in (track_zero, track_one): payload += b"MTrk" + struct.pack(">I", len(track)) + track path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(payload) From b0bdf1d6c2fa3b1d20e945b32a04851eb07f0991 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:21:10 +0930 Subject: [PATCH 22/59] phase6c: expose MIDI projection probe failures --- .github/workflows/phase6c-legacy-playback-import.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index 12f67f55..39196709 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -175,11 +175,15 @@ jobs: 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 + [[ "$rc" -eq 0 ]] || exit "$rc" tail -n 1 phase6c-generated/phase6c-midi-synthetic.log \ > phase6c-generated/phase6c-midi-synthetic.json python3 - <<'PY' From 71a2336bdaf4aa2dc2c1f1bb022152f73b865e32 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:23:31 +0930 Subject: [PATCH 23/59] phase6c: restore retained MIDI file loader gate --- cpsycle/detail/psyconf.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 494fb3ef90570acac1438adef6543cc654052760 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:23:43 +0930 Subject: [PATCH 24/59] phase6c: regress retained MIDI loader gate --- tests/phase6c_midi_corpus.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 057d3ea9..070578ed 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -14,6 +14,7 @@ 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) @@ -107,6 +108,10 @@ else: raise AssertionError("renamed ZIP payload must fail public-tree audit") +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 + probe_source = PROBE.read_text(encoding="utf-8") assert '"\\\"phase\\\":\\\"6C\\\","' in probe_source assert '"\\\"contract\\\":\\\"legacy-midi-real-world-donor\\\","' in probe_source From a31759c3fee38f4e51a8bb9730354f20282bc45a Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:24:44 +0930 Subject: [PATCH 25/59] docs: record restored retained MIDI loader gate --- phase6c/LEGACY_PLAYBACK_IMPORT.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/phase6c/LEGACY_PLAYBACK_IMPORT.md b/phase6c/LEGACY_PLAYBACK_IMPORT.md index 4b4edbd1..97402693 100644 --- a/phase6c/LEGACY_PLAYBACK_IMPORT.md +++ b/phase6c/LEGACY_PLAYBACK_IMPORT.md @@ -188,7 +188,9 @@ For every stem, the private-input analyser independently parses the Standard MID ### C-Psycle donor boundary -C-Psycle has a real `MThd`/`MidiLoader` song-I/O path. 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: +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 Sampulse substrate and rendered through the production Player/FileOutDriver path. From 3fb6bbe96ee9455898d12ff866c6bae89b44876c Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:32:17 +0930 Subject: [PATCH 26/59] phase6c: harden MIDI corpus identity and interval metrics --- scripts/phase6c-midi-corpus.py | 409 +++++++++++++++++++++++++-------- 1 file changed, 313 insertions(+), 96 deletions(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 711de71b..a5f58cc3 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -299,41 +299,108 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: tempos = [item for track in tracks for item in track["tempo_events"]] bpms = [60_000_000.0 / usec for _, usec in tempos if usec] - merged_transitions = sorted( - ( - tick, - track_index, - serial, - channel, - note, - is_on, - ) - for track_index, track in enumerate(tracks) - for tick, serial, channel, note, is_on in track["note_transitions"] + transitions_by_tick: dict[ + int, list[tuple[int, int, int, int, bool]] + ] = collections.defaultdict(list) + for track_index, track in enumerate(tracks): + for tick, serial, channel, note, is_on in track["note_transitions"]: + transitions_by_tick[tick].append( + (track_index, serial, channel, note, is_on) + ) + + active_notes: dict[ + tuple[int, int], collections.deque[int] + ] = collections.defaultdict(collections.deque) + note_intervals: list[tuple[int, int, int, int]] = [] + global_unmatched_note_offs = 0 + + for tick in sorted(transitions_by_tick): + events = sorted(transitions_by_tick[tick]) + consumed_offs: set[tuple[int, int]] = set() + + # Boundary rule: release notes that were already active before this tick + # before considering attacks at the same tick. This makes [start, end) + # interval metrics independent of MTrk chunk ordering. + for track_index, serial, channel, note, is_on in events: + if is_on: + continue + queue = active_notes[(channel, note)] + if queue and queue[0] < tick: + start_tick = queue.popleft() + note_intervals.append((channel, note, start_tick, tick)) + consumed_offs.add((track_index, serial)) + + # Preserve within-track event order for events that are genuinely + # simultaneous, including zero-duration note-on/note-off pairs. + for track_index, serial, channel, note, is_on in events: + key = (channel, note) + if is_on: + active_notes[key].append(tick) + continue + if (track_index, serial) in consumed_offs: + continue + queue = active_notes[key] + if not queue: + global_unmatched_note_offs += 1 + continue + start_tick = queue.popleft() + note_intervals.append((channel, note, start_tick, tick)) + + global_remaining = sum(len(queue) for queue in active_notes.values()) + global_zero_duration_pairs = sum( + 1 for _channel, _note, start_tick, end_tick in note_intervals + if start_tick == end_tick ) - active_notes: dict[tuple[int, int], collections.deque[int]] = collections.defaultdict(collections.deque) + positive_intervals = [ + interval for interval in note_intervals if interval[2] < interval[3] + ] + + # Global polyphony is the maximum number of positive-duration half-open + # intervals active after all releases at a boundary and all attacks there. + interval_boundaries: dict[int, list[int]] = collections.defaultdict( + lambda: [0, 0] + ) + for _channel, _note, start_tick, end_tick in positive_intervals: + interval_boundaries[start_tick][1] += 1 + interval_boundaries[end_tick][0] += 1 active_total = 0 global_max_polyphony = 0 + for tick in sorted(interval_boundaries): + ends, starts = interval_boundaries[tick] + active_total -= ends + if active_total < 0: + die(f"{label}: interval accounting underflow at tick {tick}") + active_total += starts + global_max_polyphony = max(global_max_polyphony, active_total) + + # Same-note overlap counts note attacks that begin while the same + # channel/note interval is already active. Simultaneous starts count all + # but one when no earlier interval is active. + intervals_by_key: dict[ + tuple[int, int], list[tuple[int, int]] + ] = collections.defaultdict(list) + for channel, note, start_tick, end_tick in positive_intervals: + intervals_by_key[(channel, note)].append((start_tick, end_tick)) global_same_note_overlaps = 0 - global_zero_duration_pairs = 0 - global_unmatched_note_offs = 0 - for tick, _track_index, _serial, channel, note, is_on in merged_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()) + for intervals in intervals_by_key.values(): + boundaries: dict[int, list[int]] = collections.defaultdict( + lambda: [0, 0] + ) + for start_tick, end_tick in intervals: + boundaries[start_tick][1] += 1 + boundaries[end_tick][0] += 1 + active_same = 0 + for tick in sorted(boundaries): + ends, starts = boundaries[tick] + active_same -= ends + if active_same < 0: + die(f"{label}: same-note interval accounting underflow at tick {tick}") + if starts: + if active_same: + global_same_note_overlaps += starts + elif starts > 1: + global_same_note_overlaps += starts - 1 + active_same += starts 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"]}) end_tick = max((track["end_tick"] for track in tracks), default=0) @@ -388,46 +455,14 @@ def find_outer_archive(bundle: zipfile.ZipFile, expected_name: str) -> zipfile.Z return matches[0] -def validate_set_aggregate(set_spec: dict[str, Any], stems: list[dict[str, Any]]) -> dict[str, Any]: +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") - 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_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}), - } - exact_fields = { "stems": "stems", "tracks": "tracks", @@ -439,69 +474,133 @@ def validate_set_aggregate(set_spec: dict[str, Any], stems: list[dict[str, Any]] expected_value = expected.get(expected_key) if expected_value is None: continue - if aggregate[actual_key] != expected_value: + if aggregate.get(actual_key) != expected_value: die( f"{set_spec['name']}: {actual_key} mismatch: " - f"expected={expected_value} actual={aggregate[actual_key]}" + 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["tempo_events_per_stem"] != [expected_tempo_per_stem] + 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['tempo_events_per_stem']}" + f"actual={aggregate.get('tempo_events_per_stem')}" ) - if aggregate["formats"] != [1] or aggregate["divisions"] != [480]: + if aggregate.get("formats") != [1] or aggregate.get("divisions") != [480]: die(f"{set_spec['name']}: corpus files are not uniformly SMF1/480 PPQN") - if not aggregate["balanced_note_pairs"]: + 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[field] != 0: - die(f"{set_spec['name']}: unexpected {field}={aggregate[field]}") + 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 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) + 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['tempo_min_bpm'], aggregate['tempo_max_bpm']]}" + f"{[aggregate.get('tempo_min_bpm'), aggregate.get('tempo_max_bpm')]}" ) - if not math.isclose( - aggregate["duration_seconds_max"], + + 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')} " - f"actual={aggregate['duration_seconds_max']}" + 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)] - if aggregate["malformed_key_signatures"] != expected_keys: + 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={aggregate['malformed_key_signatures']}" + f"expected={expected_keys} actual={actual_keys}" ) + expected_zero = bool(expected.get("zero_duration_pairs_present")) - if (aggregate["zero_duration_pairs"] > 0) != expected_zero: + 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["channels"] != expected_channels: + 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['channels']}" + 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_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 @@ -593,6 +692,71 @@ def prepare_bundle(bundle_path: pathlib.Path, root: pathlib.Path) -> dict[str, A 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 audit_public_tree(root: pathlib.Path) -> None: leaks: list[str] = [] for path in root.rglob("*"): @@ -604,7 +768,10 @@ def audit_public_tree(root: pathlib.Path) -> None: continue with path.open("rb") as handle: prefix = handle.read(4) - if prefix in {b"MThd", b"PK\x03\x04"}: + if prefix == b"MThd": + 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))) @@ -616,8 +783,7 @@ def candidate_boundary( output: pathlib.Path, ) -> None: analysis = load_json(analysis_path) - if analysis.get("contract") != CONTRACT: - die("candidate boundary received the wrong corpus analysis contract") + manifest_by_name = validate_analysis_manifest_bindings(analysis) if not player.is_file(): die(f"candidate player is missing: {player}") player_sha = sha256_file(player) @@ -653,9 +819,14 @@ def candidate_boundary( 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, @@ -701,8 +872,7 @@ def donor_summary( output: pathlib.Path, ) -> None: analysis = load_json(analysis_path) - if analysis.get("contract") != CONTRACT: - die("donor summary received the wrong corpus analysis contract") + manifest_by_name = validate_analysis_manifest_bindings(analysis) set_summaries = [] for set_info in analysis["sets"]: observations = [] @@ -762,11 +932,13 @@ def donor_summary( if obs.get("parity_status") != "UNKNOWN": die(f"{stem['stem_id']}: donor observation attempted parity promotion") observations.append(obs) + set_spec = manifest_by_name[set_info["name"]] set_summaries.append({ "name": set_info["name"], "slug": set_info["slug"], "role": set_info["role"], - "archive_sha256": set_info["archive_sha256"], + "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), @@ -846,6 +1018,47 @@ def require_support( 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") + digests = item.get("event_digest_fnv64") + render_hashes = item.get("render_sha256") + 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(digests, list) + or len(digests) != observed_stems + or not isinstance(render_hashes, list) + or len(render_hashes) != observed_stems + ): + die(f"donor {name} per-set evidence is incomplete") + validate_aggregate_against_manifest( + set_spec, item["source_aggregate"] + ) + for digest in digests: + if not isinstance(digest, str) or len(digest) != 16: + die(f"donor {name} event digest is invalid") + try: + int(digest, 16) + except ValueError: + die(f"donor {name} event digest is not hexadecimal") + for render_sha in render_hashes: + if not isinstance(render_sha, str) or len(render_sha) != 64: + die(f"donor {name} render SHA-256 is invalid") + try: + int(render_sha, 16) + except ValueError: + die(f"donor {name} render SHA-256 is not hexadecimal") + require_support( donor_path, donor.get("source_file"), @@ -875,8 +1088,12 @@ def require_support( 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("direct_midi_load") != "rejected" + 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 ): From 80e70d52c483e9ede630c803187fdf8418b0af7c Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:33:31 +0930 Subject: [PATCH 27/59] phase6c: regress MIDI corpus evidence hardening --- tests/phase6c_midi_corpus.py | 225 +++++++++++++++++++++++++++++++---- 1 file changed, 202 insertions(+), 23 deletions(-) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 070578ed..9a6903fb 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -5,7 +5,9 @@ import hashlib import importlib.util import json +import struct import tempfile +import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -86,6 +88,44 @@ 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 + with tempfile.TemporaryDirectory() as temporary: public = Path(temporary) disguised_midi = public / "renamed-midi.log" @@ -99,14 +139,24 @@ with tempfile.TemporaryDirectory() as temporary: public = Path(temporary) - disguised_zip = public / "renamed-archive.txt" - disguised_zip.write_bytes(b"PK\x03\x04" + b"synthetic zip marker") + 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("renamed ZIP payload must fail public-tree audit") + raise AssertionError("prefixed renamed ZIP payload must fail public-tree audit") psyconf_source = PSYCONF.read_text(encoding="utf-8") assert "#define PSYCLE_USE_MIDI_FILE" in psyconf_source @@ -123,6 +173,100 @@ assert "import_event_digest_fnv64" in probe_source assert "non_silent_projection" 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_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", []), + "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") + # 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 = [] @@ -177,7 +321,8 @@ assert "phase6c-midi-synthetic.mid" not in block assert "phase6c-midi-synthetic.wav" not in block -# Summary validation must reject malformed candidate progression evidence. +# 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" @@ -192,6 +337,39 @@ candidate_player.write_text("player", encoding="utf-8") sha = lambda p: hashlib.sha256(p.read_bytes()).hexdigest() + 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"] + 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, + "event_digest_fnv64": ["0123456789abcdef"] * stem_count, + "render_sha256": ["a" * 64] * stem_count, + }) + candidate_sets.append({ + "set_name": name, + "archive": set_spec["archive"], + "archive_sha256": set_spec["sha256"], + "representative_stem_id": f"{helper.slug(name)}-00-deadbeef0000", + "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", @@ -202,7 +380,7 @@ "probe_executable": donor_probe.name, "probe_executable_sha256": sha(donor_probe), "progression_order": helper.PROGRESSION, - "sets": [{"name": name} for name in helper.PROGRESSION], + "sets": donor_sets, "all_sets_imported": True, "all_sets_non_silent_projection": True, "parity_status": "UNKNOWN", @@ -216,38 +394,39 @@ "player_sha256": sha(candidate_player), "private_input_required": True, "progression_order": helper.PROGRESSION, - "sets": [ - { - "set_name": name, - "representative_sha256": "a" * 64, - "raw_output_sha256": "b" * 64, - "direct_midi_load": "rejected", - "exit_code": 2, - "diagnostic_could_not_load_song_file": True, - } - for name in 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() - bad_candidate = dict(candidate) - bad_candidate["sets"] = list(candidate["sets"]) - bad_candidate["sets"][0] = dict(bad_candidate["sets"][0]) - bad_candidate["sets"][0]["direct_midi_load"] = "inconclusive" + 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") + + 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 / "summary.json", + root / "bad-candidate.json", ) except SystemExit as exc: - assert "direct-load boundary" in str(exc) + assert "direct-load boundary is incomplete" in str(exc) else: - raise AssertionError("incomplete candidate MIDI boundary must be rejected") + raise AssertionError("wrong candidate archive identity must be rejected") print("phase6c-midi-corpus: PASS") From 7f9f76e927d7d925e4fa8d4004fc6edfed58873a Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:34:39 +0930 Subject: [PATCH 28/59] phase6c: initialize MIDI note pattern entries --- cpsycle/audio/src/midiloader.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index 386a3b17..5757bdf4 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -930,9 +930,10 @@ void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) } } if (channelvoice != MAX_MIDIFILE_POLYPHONY) { - psy_audio_PatternEntry entry; - + 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, From 7d621a415002951dbbba9190f58d27e93c628e3f Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:45:39 +0930 Subject: [PATCH 29/59] phase6c: bind MIDI stems and frozen candidate provenance --- scripts/phase6c-midi-corpus.py | 424 +++++++++++++++++++++++---------- 1 file changed, 304 insertions(+), 120 deletions(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index a5f58cc3..3356cd1f 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -21,6 +21,17 @@ DONOR_CONTRACT = "legacy-midi-real-world-donor" CANDIDATE_CONTRACT = "legacy-midi-real-world-candidate-boundary" 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" +) PROGRESSION = [ "FM Doom", @@ -299,108 +310,70 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: tempos = [item for track in tracks for item in track["tempo_events"]] bpms = [60_000_000.0 / usec for _, usec in tempos if usec] - transitions_by_tick: dict[ - int, list[tuple[int, int, int, int, bool]] - ] = collections.defaultdict(list) + 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"]: - transitions_by_tick[tick].append( - (track_index, serial, channel, note, is_on) + 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) - note_intervals: list[tuple[int, int, int, int]] = [] + active_total = 0 + global_max_polyphony = 0 + global_same_note_overlaps = 0 + global_zero_duration_pairs = 0 global_unmatched_note_offs = 0 - - for tick in sorted(transitions_by_tick): - events = sorted(transitions_by_tick[tick]) - consumed_offs: set[tuple[int, int]] = set() - - # Boundary rule: release notes that were already active before this tick - # before considering attacks at the same tick. This makes [start, end) - # interval metrics independent of MTrk chunk ordering. - for track_index, serial, channel, note, is_on in events: - if is_on: - continue - queue = active_notes[(channel, note)] - if queue and queue[0] < tick: - start_tick = queue.popleft() - note_intervals.append((channel, note, start_tick, tick)) - consumed_offs.add((track_index, serial)) - - # Preserve within-track event order for events that are genuinely - # simultaneous, including zero-duration note-on/note-off pairs. - for track_index, serial, channel, note, is_on in events: - key = (channel, note) - if is_on: - active_notes[key].append(tick) - continue - if (track_index, serial) in consumed_offs: - continue - queue = active_notes[key] - if not queue: - global_unmatched_note_offs += 1 - continue + 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() - note_intervals.append((channel, note, start_tick, tick)) + if start_tick == tick: + global_zero_duration_pairs += 1 + active_total -= 1 global_remaining = sum(len(queue) for queue in active_notes.values()) - global_zero_duration_pairs = sum( - 1 for _channel, _note, start_tick, end_tick in note_intervals - if start_tick == end_tick - ) - positive_intervals = [ - interval for interval in note_intervals if interval[2] < interval[3] - ] - - # Global polyphony is the maximum number of positive-duration half-open - # intervals active after all releases at a boundary and all attacks there. - interval_boundaries: dict[int, list[int]] = collections.defaultdict( - lambda: [0, 0] - ) - for _channel, _note, start_tick, end_tick in positive_intervals: - interval_boundaries[start_tick][1] += 1 - interval_boundaries[end_tick][0] += 1 - active_total = 0 - global_max_polyphony = 0 - for tick in sorted(interval_boundaries): - ends, starts = interval_boundaries[tick] - active_total -= ends - if active_total < 0: - die(f"{label}: interval accounting underflow at tick {tick}") - active_total += starts - global_max_polyphony = max(global_max_polyphony, active_total) - - # Same-note overlap counts note attacks that begin while the same - # channel/note interval is already active. Simultaneous starts count all - # but one when no earlier interval is active. - intervals_by_key: dict[ - tuple[int, int], list[tuple[int, int]] - ] = collections.defaultdict(list) - for channel, note, start_tick, end_tick in positive_intervals: - intervals_by_key[(channel, note)].append((start_tick, end_tick)) - global_same_note_overlaps = 0 - for intervals in intervals_by_key.values(): - boundaries: dict[int, list[int]] = collections.defaultdict( - lambda: [0, 0] - ) - for start_tick, end_tick in intervals: - boundaries[start_tick][1] += 1 - boundaries[end_tick][0] += 1 - active_same = 0 - for tick in sorted(boundaries): - ends, starts = boundaries[tick] - active_same -= ends - if active_same < 0: - die(f"{label}: same-note interval accounting underflow at tick {tick}") - if starts: - if active_same: - global_same_note_overlaps += starts - elif starts > 1: - global_same_note_overlaps += starts - 1 - active_same += starts 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"]}) end_tick = max((track["end_tick"] for track in tracks), default=0) @@ -777,15 +750,152 @@ def audit_public_tree(root: pathlib.Path) -> None: 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, str]: + 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") + 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", + } + + def candidate_boundary( analysis_path: pathlib.Path, + bundle_path: pathlib.Path, player: pathlib.Path, output: pathlib.Path, ) -> None: analysis = load_json(analysis_path) - manifest_by_name = validate_analysis_manifest_bindings(analysis) - if not player.is_file(): - die(f"candidate player is missing: {player}") + 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) results = [] for set_info in analysis["sets"]: @@ -842,6 +952,7 @@ def candidate_boundary( "phase": "6C", "contract": CANDIDATE_CONTRACT, "evidence_role": "candidate-observation", + **candidate_identity, "player": "candidate-psycle-player", "player_sha256": player_sha, "working_directory": "artifact-root-with-private-input", @@ -866,13 +977,17 @@ def candidate_boundary( 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) - manifest_by_name = validate_analysis_manifest_bindings(analysis) + 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 = [] @@ -933,6 +1048,18 @@ def donor_summary( die(f"{stem['stem_id']}: donor observation attempted parity promotion") 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"], + "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"], @@ -941,10 +1068,15 @@ def donor_summary( "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), - "event_digest_fnv64": [item["import_event_digest_fnv64"] for item in observations], - "render_sha256": [item["render_sha256"] for item in 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") @@ -1024,8 +1156,7 @@ def require_support( name = item["name"] set_spec = manifest_by_name[name] observed_stems = item.get("observed_stems") - digests = item.get("event_digest_fnv64") - render_hashes = item.get("render_sha256") + stem_records = item.get("stems") if ( item.get("slug") != slug(name) or item.get("role") != set_spec["role"] @@ -1035,29 +1166,69 @@ def require_support( 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(digests, list) - or len(digests) != observed_stems - or not isinstance(render_hashes, list) - or len(render_hashes) != observed_stems + 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"] ) - for digest in digests: - if not isinstance(digest, str) or len(digest) != 16: - die(f"donor {name} event digest is invalid") - try: - int(digest, 16) - except ValueError: - die(f"donor {name} event digest is not hexadecimal") - for render_sha in render_hashes: - if not isinstance(render_sha, str) or len(render_sha) != 64: - die(f"donor {name} render SHA-256 is invalid") - try: - int(render_sha, 16) - except ValueError: - die(f"donor {name} render SHA-256 is not hexadecimal") + seen_ids: set[str] = set() + seen_names: set[str] = set() + expected_indexes = set(range(observed_stems)) + actual_indexes: set[int] = set() + 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") + 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(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") + 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") require_support( donor_path, @@ -1079,6 +1250,9 @@ def require_support( 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("private_input_required") is not True or candidate.get("progression_order") != PROGRESSION or not isinstance(candidate_sets, list) @@ -1111,6 +1285,10 @@ def require_support( candidate.get("player_sha256"), "candidate player", ) + candidate_artifact = candidate_path.parent / candidate["player"] + 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, @@ -1201,16 +1379,21 @@ def build_parser() -> argparse.ArgumentParser: 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.player.resolve(), args.output.resolve() + args.analysis.resolve(), + args.bundle.resolve(), + args.player.resolve(), + args.output.resolve(), ) ) 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) @@ -1218,6 +1401,7 @@ def build_parser() -> argparse.ArgumentParser: donor.set_defaults( func=lambda args: donor_summary( args.analysis.resolve(), + args.bundle.resolve(), args.observations.resolve(), args.probe.resolve(), args.source.resolve(), From d3eab4f11840c61c407156120afb711ed944a491 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:46:16 +0930 Subject: [PATCH 30/59] phase6c: import candidate identity runtime support --- scripts/phase6c-midi-corpus.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 3356cd1f..0e67d16c 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -8,6 +8,7 @@ import io import json import math +import os import pathlib import shutil import struct From 4431e20abdc327152ea62cbb261ce764f3e07004 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:46:46 +0930 Subject: [PATCH 31/59] phase6c: verify MIDI observations from pinned private inputs --- .github/workflows/phase6c-midi-corpus-private.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phase6c-midi-corpus-private.yml b/.github/workflows/phase6c-midi-corpus-private.yml index 5211504c..a62b1056 100644 --- a/.github/workflows/phase6c-midi-corpus-private.yml +++ b/.github/workflows/phase6c-midi-corpus-private.yml @@ -141,7 +141,7 @@ jobs: shell: bash run: | set -euo pipefail - python3 scripts/phase6c-midi-corpus.py donor-summary phase6c-midi-private/corpus-analysis.json 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 + 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 @@ -230,9 +230,10 @@ jobs: 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 - python3 scripts/phase6c-midi-corpus.py candidate-boundary phase6c-midi-private/corpus-analysis.json phase6c-midi-candidate-public/candidate-psycle-player phase6c-midi-candidate-public/candidate-midi-boundary.json rm -rf -- phase6c-midi-private rm -f -- "$MIDI_CORPUS_BUNDLE" python3 scripts/phase6c-midi-corpus.py audit-public phase6c-midi-candidate-public From 5691ff47d005113626e290404243deda88bb3064 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:47:44 +0930 Subject: [PATCH 32/59] phase6c: regress pinned MIDI stems and candidate identity --- tests/phase6c_midi_corpus.py | 111 ++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 9a6903fb..6014f5f8 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -126,6 +126,23 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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" @@ -267,6 +284,48 @@ def canonical_analysis(root: Path): 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 = [] @@ -286,6 +345,9 @@ def canonical_analysis(root: Path): 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 @@ -334,7 +396,7 @@ def canonical_analysis(root: Path): candidate_player = candidate_dir / "candidate-psycle-player" donor_source.write_text("source", encoding="utf-8") donor_probe.write_text("probe", encoding="utf-8") - candidate_player.write_text("player", encoding="utf-8") + candidate_player.write_bytes(b"\x7fELF" + b"candidate-player") sha = lambda p: hashlib.sha256(p.read_bytes()).hexdigest() manifest_by_name = {item["name"]: item for item in manifest["sets"]} @@ -343,6 +405,28 @@ def canonical_analysis(root: Path): 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() + 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(), + "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), @@ -353,8 +437,7 @@ def canonical_analysis(root: Path): "observed_stems": stem_count, "all_imported": True, "all_non_silent_projection": True, - "event_digest_fnv64": ["0123456789abcdef"] * stem_count, - "render_sha256": ["a" * 64] * stem_count, + "stems": stem_records, }) candidate_sets.append({ "set_name": name, @@ -390,6 +473,10 @@ def canonical_analysis(root: Path): "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": "0" * 40, + "build_target": "psycle-player", "player": candidate_player.name, "player_sha256": sha(candidate_player), "private_input_required": True, @@ -414,6 +501,24 @@ def canonical_analysis(root: Path): 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 From 509a9098306f69a1a93fc25dc1d8438877a8ed82 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:48:45 +0930 Subject: [PATCH 33/59] phase6c: bind MIDI observations to exact stem identities --- scripts/phase6c-midi-corpus.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 0e67d16c..8ef8c143 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -1001,6 +1001,10 @@ def donor_summary( 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"]: @@ -1254,6 +1258,8 @@ def require_support( 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 not isinstance(candidate.get("repository_commit"), str) + or len(candidate["repository_commit"]) != 40 or candidate.get("private_input_required") is not True or candidate.get("progression_order") != PROGRESSION or not isinstance(candidate_sets, list) @@ -1274,11 +1280,33 @@ def require_support( ): 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") require_support( candidate_path, From 2995fc140925a2245072fee3af1e142125a18394 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:48:59 +0930 Subject: [PATCH 34/59] phase6c: align candidate representative test identity --- tests/phase6c_midi_corpus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 6014f5f8..adc3af2b 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -443,7 +443,7 @@ def canonical_analysis(root: Path): "set_name": name, "archive": set_spec["archive"], "archive_sha256": set_spec["sha256"], - "representative_stem_id": f"{helper.slug(name)}-00-deadbeef0000", + "representative_stem_id": f"{helper.slug(name)}-00-{"b" * 12}", "representative_relative_name": "00.mid", "representative_size_bytes": 32, "representative_sha256": "b" * 64, From a76d1558cce6e249400a42ab7e6fd73b9f19ce42 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:49:13 +0930 Subject: [PATCH 35/59] phase6c: keep MIDI test syntax portable --- tests/phase6c_midi_corpus.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index adc3af2b..eb2889c1 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -443,7 +443,9 @@ def canonical_analysis(root: Path): "set_name": name, "archive": set_spec["archive"], "archive_sha256": set_spec["sha256"], - "representative_stem_id": f"{helper.slug(name)}-00-{"b" * 12}", + "representative_stem_id": ( + f"{helper.slug(name)}-00-" + ("b" * 12) + ), "representative_relative_name": "00.mid", "representative_size_bytes": 32, "representative_sha256": "b" * 64, From cf8b495a3e27e8b048fae1ac2f5cfbcd4d967c20 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:52:36 +0930 Subject: [PATCH 36/59] phase6c: capture MIDI probe crash backtrace --- .github/workflows/phase6c-legacy-playback-import.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index 39196709..49e20e7a 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -140,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: | @@ -183,7 +183,15 @@ jobs: rc=$? set -e cat phase6c-generated/phase6c-midi-synthetic.log - [[ "$rc" -eq 0 ]] || exit "$rc" + if [[ "$rc" -ne 0 ]]; then + gdb -batch \ + -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 || true + exit "$rc" + fi tail -n 1 phase6c-generated/phase6c-midi-synthetic.log \ > phase6c-generated/phase6c-midi-synthetic.json python3 - <<'PY' From 4b88020a28fd416c5b9c8d48bdcaad1989855807 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:58:17 +0930 Subject: [PATCH 37/59] phase6c: ignore ended Sampulse foreground voices --- cpsycle/audio/src/xmsamplerchannel.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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); } } From 4dfadffdf7687999aedcee645a724c26abcd02ba Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:58:46 +0930 Subject: [PATCH 38/59] phase6c: preserve MIDI crash diagnostics --- .../workflows/phase6c-legacy-playback-import.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index 49e20e7a..1e0e03d9 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -185,11 +185,14 @@ jobs: 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 || true + 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 \ @@ -223,6 +226,16 @@ jobs: 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: | From c2e5948fd18517ac9c057b8e88d1c8da6fa8d870 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:03:19 +0930 Subject: [PATCH 39/59] phase6c: flush final MIDI note-offs --- cpsycle/audio/src/midiloader.c | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index 5757bdf4..1b5b2758 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -96,6 +96,7 @@ static int midiloader_readmeta_timesignature(MidiLoader*); static int midiloader_readmeta_keysignature(MidiLoader*); static int midiloader_readmeta_properietaryevent(MidiLoader*); /* Misc methods */ +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); @@ -189,6 +190,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; @@ -873,6 +875,56 @@ int midiloader_readbyte1(MidiLoader* self, uint8_t* rv) return PSY_OK; } +void midiloader_flushnoteoffs(MidiLoader* self) +{ + uint16_t voice; + + for (voice = 0; voice < MAX_MIDIFILE_POLYPHONY; ++voice) { + MidiChannel* channel; + + channel = &self->currtrack.channels[voice]; + if (!channel->noteoff) { + continue; + } + if (channel->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 = channel->tracknote.mach; + node = psy_audio_pattern_find_node( + self->currtrack.pattern, + voice, + psy_dsp_beatpos_make_real( + channel->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); + psy_audio_pattern_insert( + self->currtrack.pattern, + prev, + voice, + psy_dsp_beatpos_make_real( + channel->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; + } + } + channel->noteoff = FALSE; + } +} + void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) { uint16_t voice; From 742a3ef4428056b0bba4d3388707a90faba1c206 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:03:23 +0930 Subject: [PATCH 40/59] phase6c: attest candidate player with clean rebuild --- scripts/phase6c-midi-corpus.py | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 8ef8c143..9c13a1a8 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -874,6 +874,48 @@ def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, str]: ) if diff.returncode != 0: die("tracked frozen candidate source differs from HEAD") + + initial_sha = sha256_file(player) + player_root = CANDIDATE_SOURCE_ROOT / "psycle-player" + makefile = player_root / "Makefile" + if not makefile.is_file(): + die("canonical candidate qmake Makefile is missing") + 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:] + ) + rebuilt_sha = sha256_file(player) + if rebuilt_sha != initial_sha: + die( + "candidate player does not match an immediate clean rebuild: " + f"initial={initial_sha} rebuilt={rebuilt_sha}" + ) + 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() @@ -882,6 +924,7 @@ def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, str]: "source_revision": EXPECTED_CANDIDATE_SOURCE_REVISION, "repository_commit": repository_commit, "build_target": "psycle-player", + "clean_rebuild_sha256": rebuilt_sha, } @@ -956,6 +999,7 @@ def candidate_boundary( **candidate_identity, "player": "candidate-psycle-player", "player_sha256": player_sha, + "clean_rebuild_sha256": candidate_identity["clean_rebuild_sha256"], "working_directory": "artifact-root-with-private-input", "procedure": ( "for each corpus set, place the exact representative SMF beside the " @@ -1258,6 +1302,7 @@ def require_support( 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.get("player_sha256") or not isinstance(candidate.get("repository_commit"), str) or len(candidate["repository_commit"]) != 40 or candidate.get("private_input_required") is not True From 214ab0e0fbfec607839a0da6d1166ab5d25b14e0 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:04:02 +0930 Subject: [PATCH 41/59] phase6c: keep MIDI note-off flush private --- cpsycle/audio/src/midiloader.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index 1b5b2758..ec405be3 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -875,7 +875,7 @@ int midiloader_readbyte1(MidiLoader* self, uint8_t* rv) return PSY_OK; } -void midiloader_flushnoteoffs(MidiLoader* self) +static void midiloader_flushnoteoffs(MidiLoader* self) { uint16_t voice; From 5964474dd67efe9e1c2cd0f086324e10a130561b Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:04:06 +0930 Subject: [PATCH 42/59] phase6c: keep candidate rebuild identity singular --- scripts/phase6c-midi-corpus.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 9c13a1a8..b9651923 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -999,7 +999,6 @@ def candidate_boundary( **candidate_identity, "player": "candidate-psycle-player", "player_sha256": player_sha, - "clean_rebuild_sha256": candidate_identity["clean_rebuild_sha256"], "working_directory": "artifact-root-with-private-input", "procedure": ( "for each corpus set, place the exact representative SMF beside the " From de9edcdab6fb9c00808cf2ae7c6ad9a5ec9c41c5 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:04:10 +0930 Subject: [PATCH 43/59] phase6c: gate final MIDI release import --- .github/workflows/phase6c-legacy-playback-import.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index 1e0e03d9..019e4d62 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -213,6 +213,7 @@ jobs: assert observation["sequence_tracks"] == 2 assert observation["patterns"] == 2 assert observation["imported_notes"] == 2 + assert observation["releases"] == 2 assert observation["machines_before_projection"] == 0 assert observation["projection_notes"] == 2 assert observation["projection_kind"] == "deterministic-sampulse" From f359f6d433e05319c5ed28cfb46f4bc6f67ad9a9 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:04:26 +0930 Subject: [PATCH 44/59] phase6c: regress candidate rebuild and MIDI releases --- tests/phase6c_midi_corpus.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index eb2889c1..9e157c15 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -179,6 +179,18 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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 "candidate player does not match an immediate clean rebuild" in helper_source +assert '["make", "clean"]' in helper_source +assert '["make", "-j2"]' 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 @@ -373,6 +385,7 @@ def canonical_analysis(root: Path): 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["non_silent_projection"] is True' in legacy_workflow assert ( "phase6c-generated/phase6c-midi-synthetic.mid \\\n" @@ -481,6 +494,7 @@ def canonical_analysis(root: Path): "build_target": "psycle-player", "player": candidate_player.name, "player_sha256": sha(candidate_player), + "clean_rebuild_sha256": sha(candidate_player), "private_input_required": True, "progression_order": helper.PROGRESSION, "sets": candidate_sets, From 62ceb1e651d6b76fc9c33144383d13a1997f050b Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:05:23 +0930 Subject: [PATCH 45/59] phase6c: expose candidate clean-rebuild attestation --- scripts/phase6c-midi-corpus.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index b9651923..0f5c507e 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -1450,6 +1450,25 @@ def build_parser() -> argparse.ArgumentParser: 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(), + { + "schema_version": 1, + "phase": "6C", + "contract": "phase6b-candidate-player-clean-rebuild", + "evidence_role": "candidate-build-attestation", + **validate_frozen_candidate_player(args.player.resolve()), + "player": "candidate-psycle-player", + "player_sha256": sha256_file(args.player.resolve()), + "parity_status": "UNKNOWN", + }, + ) + ) + candidate = sub.add_parser("candidate-boundary") candidate.add_argument("analysis", type=pathlib.Path) candidate.add_argument("bundle", type=pathlib.Path) From c344326cb9bdbbfb98b2edd90552d5391dad06fa Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:05:28 +0930 Subject: [PATCH 46/59] phase6c: run candidate clean-rebuild attestation in CI --- .../phase6c-legacy-playback-import.yml | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index 019e4d62..c92b7a5a 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -530,6 +530,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: | @@ -601,6 +629,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 From 460f04f946795ab6a7192175784dea0b11a30b9f Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:05:45 +0930 Subject: [PATCH 47/59] phase6c: regress candidate attestation workflow --- tests/phase6c_midi_corpus.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 9e157c15..12d39311 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -380,6 +380,9 @@ def canonical_analysis(root: 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 From afc7dce2bd44086f7d4ce6ddd3723da8593e2ec2 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:19:15 +0930 Subject: [PATCH 48/59] phase6c: bind donor probe to clean source build --- scripts/phase6c-midi-corpus.py | 188 +++++++++++++++++++++++++++++++-- tests/phase6c_midi_corpus.py | 27 ++++- 2 files changed, 206 insertions(+), 9 deletions(-) mode change 100644 => 100755 scripts/phase6c-midi-corpus.py mode change 100644 => 100755 tests/phase6c_midi_corpus.py diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py old mode 100644 new mode 100755 index 0f5c507e..0a036b84 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -10,6 +10,7 @@ import math import os import pathlib +import shlex import shutil import struct import subprocess @@ -33,6 +34,9 @@ CANDIDATE_BASELINE_RECEIPT = ( ROOT / "phase6b-sanitized-manifest/baseline.sha256" ) +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", @@ -75,6 +79,161 @@ def sha256_file(path: pathlib.Path) -> str: return digest.hexdigest() +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 slug(name: str) -> str: out = [] dash = False @@ -1128,16 +1287,14 @@ def donor_summary( }) if not probe_path.is_file() or not source_path.is_file(): die("donor MIDI corpus support files are missing") - source_revision = subprocess.check_output( - ["git", "rev-parse", "HEAD"], text=True - ).strip() + 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", - "source_revision": source_revision, + **build_identity, "source_file": source_path.name, "source_file_sha256": sha256_file(source_path), "probe_executable": probe_path.name, @@ -1184,11 +1341,21 @@ def require_support( 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 @@ -1290,6 +1457,10 @@ def require_support( 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") if ( @@ -1302,8 +1473,7 @@ def require_support( or candidate.get("source_revision") != EXPECTED_CANDIDATE_SOURCE_REVISION or candidate.get("build_target") != "psycle-player" or candidate.get("clean_rebuild_sha256") != candidate.get("player_sha256") - or not isinstance(candidate.get("repository_commit"), str) - or len(candidate["repository_commit"]) != 40 + 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) @@ -1483,6 +1653,12 @@ def build_parser() -> argparse.ArgumentParser: ) ) + 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) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py old mode 100644 new mode 100755 index 12d39311..b944ec79 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -410,11 +410,12 @@ def canonical_analysis(root: Path): donor_source = donor_dir / "midiloader.c" donor_probe = donor_dir / "phase6c-midi-corpus-probe" candidate_player = candidate_dir / "candidate-psycle-player" - donor_source.write_text("source", encoding="utf-8") - donor_probe.write_text("probe", encoding="utf-8") + 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 = [] @@ -476,10 +477,15 @@ def canonical_analysis(root: Path): "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, @@ -493,7 +499,7 @@ def canonical_analysis(root: Path): "evidence_role": "candidate-observation", "candidate_baseline_sha256": helper.EXPECTED_CANDIDATE_BASELINE, "source_revision": helper.EXPECTED_CANDIDATE_SOURCE_REVISION, - "repository_commit": "0" * 40, + "repository_commit": current_revision, "build_target": "psycle-player", "player": candidate_player.name, "player_sha256": sha(candidate_player), @@ -510,6 +516,21 @@ def canonical_analysis(root: Path): helper.corpus_summary(donor_path, candidate_path, root / "summary.json") assert (root / "summary.json").exists() + 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) incomplete_donor = json.loads(json.dumps(donor)) del incomplete_donor["sets"][0]["observed_stems"] helper.write_json(donor_path, incomplete_donor) From 9cb27e6b1056df7d836910ebc63efdc11635e856 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:24:04 +0930 Subject: [PATCH 49/59] phase6c: harden MIDI corpus provenance and channel coverage --- cpsycle/audio/src/midiloader.c | 3 +- scripts/phase6c-midi-corpus.py | 126 ++++++++++++++++++++++++++++++++- tests/phase6c_midi_corpus.py | 34 ++++++++- 3 files changed, 160 insertions(+), 3 deletions(-) diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index ec405be3..46056836 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -942,7 +942,8 @@ void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) psy_audio_patternevent_init(¬eoff); noteoff.note = psy_audio_NOTECOMMANDS_RELEASE; - noteoff.mach = self->currtrack.channel; + noteoff.mach = + self->currtrack.channels[voice].tracknote.mach; node = psy_audio_pattern_find_node(self->currtrack.pattern, voice, psy_dsp_beatpos_make_real( diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 0a036b84..85882705 100755 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -234,6 +234,25 @@ def validate_donor_probe_build( } +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 @@ -1036,9 +1055,89 @@ def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, str]: initial_sha = sha256_file(player) 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]) + ) + + 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("canonical candidate qmake Makefile is missing") + die("qmake did not regenerate the canonical candidate Makefile") clean = subprocess.run( ["make", "clean"], cwd=player_root, @@ -1242,6 +1341,15 @@ def donor_summary( 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") + source_channels = stem["analysis"].get("channels") + expected_channel_mask = midi_channel_mask(source_channels) + if obs.get("midi_channel_mask") != expected_channel_mask: + die( + f"{stem['stem_id']}: imported MIDI channel coverage differs " + f"from raw SMF: source={source_channels} " + f"imported_mask={obs.get('midi_channel_mask')!r}" + ) + obs["_source_channels"] = list(source_channels) if obs.get("imported_notes") != stem["analysis"]["note_ons"]: die( f"{stem['stem_id']}: imported note count differs from raw SMF: " @@ -1262,6 +1370,8 @@ def donor_summary( "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"], "import_event_digest_fnv64": item["import_event_digest_fnv64"], "render_sha256": item["render_sha256"], } @@ -1392,12 +1502,15 @@ def require_support( seen_names: set[str] = set() expected_indexes = set(range(observed_stems)) actual_indexes: set[int] = set() + observed_channels: set[int] = set() 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") digest = record.get("import_event_digest_fnv64") render_sha = record.get("render_sha256") if ( @@ -1409,6 +1522,11 @@ def require_support( 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(digest, str) or len(digest) != 16 or not isinstance(render_sha, str) @@ -1427,6 +1545,10 @@ def require_support( 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") + observed_channels.update(source_channels) seen_ids.add(stem_id) seen_names.add(relative_name) actual_indexes.add(stem_index) @@ -1444,6 +1566,8 @@ def require_support( die(f"donor {name} {label} is not hexadecimal") if actual_indexes != expected_indexes: die(f"donor {name} stem indexes are incomplete or duplicated") + 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, diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index b944ec79..cdd90410 100755 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -216,7 +216,7 @@ def aggregate_for(set_spec): "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", []), + "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, @@ -363,6 +363,17 @@ def canonical_analysis(root: Path): 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 ( + "self->currtrack.channels[voice].tracknote.mach;" 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 @@ -427,6 +438,10 @@ def canonical_analysis(root: Path): 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]}" @@ -437,6 +452,8 @@ def canonical_analysis(root: Path): "source_analysis_sha256": hashlib.sha256( f"analysis:{name}:{index}".encode() ).hexdigest(), + "source_channels": source_channels, + "midi_channel_mask": channel_mask, "import_event_digest_fnv64": ( f"{index + 1:016x}"[-16:] ), @@ -530,6 +547,21 @@ def canonical_analysis(root: Path): "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"] From 5603de2b3158f230806e5a16b1ebcb7242da4e6f Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:33:30 +0930 Subject: [PATCH 50/59] phase6c: attest canonical rebuild and stabilize MIDI projection --- .github/workflows/phase6c-legacy-playback-import.yml | 2 +- .github/workflows/phase6c-midi-corpus-private.yml | 2 +- phase6c/LEGACY_PLAYBACK_IMPORT.md | 4 ++-- phase6c/reference-corpus/manifest.json | 2 +- scripts/phase6c-midi-corpus.py | 9 +++------ tests/phase6c_midi_corpus.py | 7 ++++--- tests/phase6c_midi_corpus_probe.c | 10 +++++----- 7 files changed, 17 insertions(+), 19 deletions(-) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index c92b7a5a..62420151 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -216,7 +216,7 @@ jobs: assert observation["releases"] == 2 assert observation["machines_before_projection"] == 0 assert observation["projection_notes"] == 2 - assert observation["projection_kind"] == "deterministic-sampulse" + assert observation["projection_kind"] == "deterministic-sampler" assert observation["render_frames"] > 0 assert observation["render_peak"] > 0 assert observation["non_silent_projection"] is True diff --git a/.github/workflows/phase6c-midi-corpus-private.yml b/.github/workflows/phase6c-midi-corpus-private.yml index a62b1056..7a47eb80 100644 --- a/.github/workflows/phase6c-midi-corpus-private.yml +++ b/.github/workflows/phase6c-midi-corpus-private.yml @@ -127,7 +127,7 @@ jobs: "projection_boundary": ( "untouched imported event graph was digested before " "retargeting notes/releases/MIDI CC to a deterministic " - "project-owned Sampulse substrate for a 16-beat FileOut render" + "project-owned classic Sampler substrate for a 16-beat FileOut render" ), }) (observations / f"{stem['stem_id']}.json").write_text( diff --git a/phase6c/LEGACY_PLAYBACK_IMPORT.md b/phase6c/LEGACY_PLAYBACK_IMPORT.md index 97402693..a147ac24 100644 --- a/phase6c/LEGACY_PLAYBACK_IMPORT.md +++ b/phase6c/LEGACY_PLAYBACK_IMPORT.md @@ -193,7 +193,7 @@ C-Psycle has a real `MThd`/`MidiLoader` song-I/O path. At the start of this phas 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 Sampulse substrate and rendered through the production Player/FileOutDriver path. +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. @@ -214,7 +214,7 @@ Ordinary PR CI does not need the private corpus. It generates a small project-ow ```text SongReader / MidiLoader -> untouched event-graph digest - -> deterministic Sampulse projection + -> deterministic classic Sampler projection -> Player -> FileOutDriver -> non-silent WAV validation diff --git a/phase6c/reference-corpus/manifest.json b/phase6c/reference-corpus/manifest.json index e185e946..4cb908bd 100644 --- a/phase6c/reference-corpus/manifest.json +++ b/phase6c/reference-corpus/manifest.json @@ -205,6 +205,6 @@ "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 Sampulse execution substrate; that projection is evidence of engine execution, not native MIDI instrument-selection semantics." + "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 index 85882705..2eb985a8 100755 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -1053,7 +1053,6 @@ def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, str]: if diff.returncode != 0: die("tracked frozen candidate source differs from HEAD") - initial_sha = sha256_file(player) player_root = CANDIDATE_SOURCE_ROOT / "psycle-player" # The qmake build graph is generated and therefore untracked. Purge every @@ -1164,12 +1163,10 @@ def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, str]: "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) - if rebuilt_sha != initial_sha: - die( - "candidate player does not match an immediate clean rebuild: " - f"initial={initial_sha} rebuilt={rebuilt_sha}" - ) with player.open("rb") as handle: if handle.read(4) != b"\x7fELF": die("clean rebuilt candidate player is not an ELF executable") diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index cdd90410..b0ce150a 100755 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -32,7 +32,7 @@ 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 Sampulse" in manifest["shared_observation"]["playback_boundary"] +assert "deterministic classic Sampler" in manifest["shared_observation"]["playback_boundary"] assert { item["name"]: item["sha256"] for item in manifest["sets"] } == { @@ -180,7 +180,7 @@ def boundary_smf(reverse_tracks: bool) -> bytes: assert "/* #define PSYCLE_USE_MIDI_FILE */" not in psyconf_source helper_source = HELPER.read_text(encoding="utf-8") -assert "candidate player does not match an immediate clean rebuild" in helper_source +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 @@ -196,7 +196,8 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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" 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 diff --git a/tests/phase6c_midi_corpus_probe.c b/tests/phase6c_midi_corpus_probe.c index 1d79d2c1..559a4526 100644 --- a/tests/phase6c_midi_corpus_probe.c +++ b/tests/phase6c_midi_corpus_probe.c @@ -4,7 +4,7 @@ ** ** 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 Sampulse substrate and renders a bounded 16-beat execution +** 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. @@ -42,7 +42,7 @@ #define PROJECTION_SAMPLE_RATE 44100u #define PROJECTION_SAMPLE_FRAMES 2048u #define PROJECTION_MACHINE 0u -#define PROJECTION_INSTRUMENT_GROUP 1u +#define PROJECTION_INSTRUMENT_GROUP 0u #define PROJECTION_INSTRUMENT 0u #define RENDER_TIMEOUT_TICKS 1500u #define RENDER_WAIT_US 10000u @@ -248,12 +248,12 @@ static int install_projection_substrate( sampler = psy_audio_machinefactory_make_machine_from_path( &player->machinefactory, - psy_audio_XMSAMPLER, + psy_audio_SAMPLER, NULL, 0, psy_INDEX_INVALID); if (!sampler) - return fail("could not create deterministic Sampulse projection machine"); + return fail("could not create deterministic classic Sampler projection machine"); psy_audio_machines_insert(machines, PROJECTION_MACHINE, sampler); psy_audio_machines_connect( machines, @@ -560,7 +560,7 @@ int main(int argc, char** argv) "\"machines_before_projection\":%lu," "\"projection_notes\":%lu," "\"projection_beats\":%.0f," - "\"projection_kind\":\"deterministic-sampulse\"," + "\"projection_kind\":\"deterministic-sampler\"," "\"import_event_digest_fnv64\":\"%016llx\"," "\"render_frames\":%u," "\"render_peak\":%d," From 6d95e2f200e1e20f4b43073c3cbea71ec93d0faf Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:56:46 +0930 Subject: [PATCH 51/59] phase6c: close MIDI corpus release and privacy gaps --- .../phase6c-legacy-playback-import.yml | 36 +++ cpsycle/audio/src/midiloader.c | 125 +++++----- scripts/phase6c-midi-corpus.py | 232 +++++++++++++++--- tests/phase6c_midi_corpus.py | 76 ++++++ tests/phase6c_midi_corpus_probe.c | 24 +- 5 files changed, 381 insertions(+), 112 deletions(-) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index 62420151..4cbe5ebb 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -158,6 +158,8 @@ jobs: 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 \ @@ -214,6 +216,8 @@ jobs: 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" @@ -223,6 +227,38 @@ jobs: assert observation["parity_status"] == "UNKNOWN" assert len(observation["import_event_digest_fnv64"]) == 16 PY + python3 - <<'PY' + import json + from pathlib import Path + import subprocess + + 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), + } + 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]) + 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["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 diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index 46056836..579ab3b8 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -96,8 +96,10 @@ static int midiloader_readmeta_timesignature(MidiLoader*); static int midiloader_readmeta_keysignature(MidiLoader*); static int midiloader_readmeta_properietaryevent(MidiLoader*); /* Misc methods */ +static uint8_t midiloader_playablenote(uint8_t); +static void midiloader_insertnoteoff(MidiLoader*, uint16_t, const MidiChannel*); static void midiloader_flushnoteoffs(MidiLoader*); -static void midiloader_writepatternevent(MidiLoader*, psy_audio_PatternEvent); +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); @@ -356,6 +358,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 { @@ -383,6 +386,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; } @@ -392,9 +396,13 @@ 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; + 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; break; } } @@ -875,6 +883,50 @@ int midiloader_readbyte1(MidiLoader* self, uint8_t* rv) return PSY_OK; } +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 = psy_dsp_beatpos_make_real( + channel->time - self->currtrack.patternoffset, + psy_dsp_DEFAULT_PPQ); + 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; @@ -887,39 +939,7 @@ static void midiloader_flushnoteoffs(MidiLoader* self) continue; } if (channel->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 = channel->tracknote.mach; - node = psy_audio_pattern_find_node( - self->currtrack.pattern, - voice, - psy_dsp_beatpos_make_real( - channel->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); - psy_audio_pattern_insert( - self->currtrack.pattern, - prev, - voice, - psy_dsp_beatpos_make_real( - channel->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; - } + midiloader_insertnoteoff(self, voice, channel); } channel->noteoff = FALSE; } @@ -936,35 +956,8 @@ void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) 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.channels[voice].tracknote.mach; - 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; - } + midiloader_insertnoteoff( + self, voice, &self->currtrack.channels[voice]); } else { /* noteoff was in previous patttern */ /* todo */ diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 2eb985a8..956b9a3c 100755 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -311,6 +311,8 @@ def parse_track(track: bytes) -> dict[str, Any]: 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 @@ -401,6 +403,7 @@ def parse_track(track: bytes) -> dict[str, Any]: 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)) key = (channel, note) queue = active[key] @@ -412,6 +415,7 @@ def parse_track(track: bytes) -> dict[str, Any]: 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] @@ -435,6 +439,8 @@ def parse_track(track: bytes) -> dict[str, Any]: "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, @@ -555,6 +561,12 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: 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, @@ -573,6 +585,8 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: 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), @@ -909,6 +923,49 @@ def validate_analysis_manifest_bindings( 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("*"): @@ -918,9 +975,9 @@ def audit_public_tree(root: pathlib.Path) -> None: if path.suffix.lower() in {".mid", ".midi", ".zip"}: leaks.append(relative) continue - with path.open("rb") as handle: - prefix = handle.read(4) - if prefix == b"MThd": + 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): @@ -1274,6 +1331,61 @@ def candidate_boundary( 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')}" + ) + 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')}" + ) + 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"], + } + + def donor_summary( analysis_path: pathlib.Path, bundle_path: pathlib.Path, @@ -1316,14 +1428,10 @@ def donor_summary( 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") - if obs.get("machines_before_projection") != 0: - die(f"{stem['stem_id']}: MIDI importer unexpectedly created machines") - if not isinstance(obs.get("projection_notes"), int) or obs["projection_notes"] <= 0: - die(f"{stem['stem_id']}: donor projection contains no notes") - if not isinstance(obs.get("render_frames"), int) or obs["render_frames"] <= 0: - die(f"{stem['stem_id']}: donor projection rendered no frames") - if not isinstance(obs.get("render_peak"), int) or obs["render_peak"] <= 0: - die(f"{stem['stem_id']}: donor projection peak is invalid") + 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"] 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: @@ -1338,26 +1446,6 @@ def donor_summary( 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") - source_channels = stem["analysis"].get("channels") - expected_channel_mask = midi_channel_mask(source_channels) - if obs.get("midi_channel_mask") != expected_channel_mask: - die( - f"{stem['stem_id']}: imported MIDI channel coverage differs " - f"from raw SMF: source={source_channels} " - f"imported_mask={obs.get('midi_channel_mask')!r}" - ) - obs["_source_channels"] = list(source_channels) - if obs.get("imported_notes") != stem["analysis"]["note_ons"]: - die( - f"{stem['stem_id']}: imported note count differs from raw SMF: " - f"source={stem['analysis']['note_ons']} imported={obs.get('imported_notes')}" - ) - if obs.get("non_silent_projection") is not True: - die(f"{stem['stem_id']}: execution projection is silent") - if obs.get("projection_kind") != "deterministic-sampulse": - die(f"{stem['stem_id']}: execution projection kind changed") - if obs.get("parity_status") != "UNKNOWN": - die(f"{stem['stem_id']}: donor observation attempted parity promotion") observations.append(obs) set_spec = manifest_by_name[set_info["name"]] stem_records = [ @@ -1369,6 +1457,10 @@ def donor_summary( "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"], "import_event_digest_fnv64": item["import_event_digest_fnv64"], "render_sha256": item["render_sha256"], } @@ -1415,7 +1507,7 @@ def donor_summary( "parity_status": "UNKNOWN", "claim_scope": ( "C-Psycle real-world SMF import plus deterministic execution projection. " - "The projection attaches a project-owned Sampulse substrate after freezing " + "The projection attaches a project-owned classic Sampler substrate after freezing " "the untouched imported event digest; it is not native instrument-selection semantics." ), } @@ -1508,6 +1600,10 @@ def require_support( 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") digest = record.get("import_event_digest_fnv64") render_sha = record.get("render_sha256") if ( @@ -1524,6 +1620,17 @@ def require_support( 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(digest, str) or len(digest) != 16 or not isinstance(render_sha, str) @@ -1545,6 +1652,11 @@ def require_support( 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") observed_channels.update(source_channels) seen_ids.add(stem_id) seen_names.add(relative_name) @@ -1701,6 +1813,21 @@ def midi_varlen(value: int) -> bytes: 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" @@ -1714,12 +1841,35 @@ def write_synthetic_fixture(path: pathlib.Path) -> None: track_one += midi_varlen(240) + bytes((0x81, 64, 0)) track_one += midi_varlen(0) + b"\xff\x2f\x00" - payload = bytearray(b"MThd") - payload += struct.pack(">IHHH", 6, 1, 2, 480) - for track in (track_zero, track_one): - payload += b"MTrk" + struct.pack(">I", len(track)) + track - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(payload) + 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 + ) def build_parser() -> argparse.ArgumentParser: @@ -1732,6 +1882,12 @@ def build_parser() -> argparse.ArgumentParser: 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) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index b0ce150a..9fa8b442 100755 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -79,6 +79,8 @@ 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 @@ -175,6 +177,71 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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 + +probe_shaped_stem = { + "stem_id": "probe-shaped", + "analysis": { + "channels": [0, 1], + "note_off_channels": [0, 1], + "note_ons": 2, + "note_offs": 2, + }, +} +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, + "releases": 2, + "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] + 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 @@ -201,6 +268,7 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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 def aggregate_for(set_spec): @@ -401,6 +469,10 @@ def canonical_analysis(root: Path): 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 'assert observation["non_silent_projection"] is True' in legacy_workflow assert ( "phase6c-generated/phase6c-midi-synthetic.mid \\\n" @@ -455,6 +527,10 @@ def canonical_analysis(root: Path): ).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, "import_event_digest_fnv64": ( f"{index + 1:016x}"[-16:] ), diff --git a/tests/phase6c_midi_corpus_probe.c b/tests/phase6c_midi_corpus_probe.c index 559a4526..0a7644e9 100644 --- a/tests/phase6c_midi_corpus_probe.c +++ b/tests/phase6c_midi_corpus_probe.c @@ -63,6 +63,7 @@ typedef struct ImportStats { uintptr_t midi_cc; uintptr_t tempo_commands; uint32_t midi_channel_mask; + uint32_t release_channel_mask; uintptr_t machines_before_projection; uintptr_t projection_notes; uint64_t digest; @@ -206,8 +207,10 @@ static int collect_import_stats(psy_audio_Song* song, ImportStats* stats) stats->midi_channel_mask |= UINT32_C(1) << event->mach; } else if (event->note == psy_audio_NOTECOMMANDS_RELEASE) { ++stats->releases; - if (event->mach < 16u) + 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) { ++stats->midi_cc; if (event->mach < 16u) @@ -505,8 +508,8 @@ int main(int argc, char** argv) WavStats wav_stats; int rc; - if (argc != 3) { - fprintf(stderr, "usage: %s INPUT.mid OUTPUT.wav\n", argv[0]); + if (argc != 2 && argc != 3) { + fprintf(stderr, "usage: %s INPUT.mid [OUTPUT.wav]\n", argv[0]); return 64; } @@ -534,10 +537,11 @@ int main(int argc, char** argv) return fail("C-Psycle SongReader rejected real-world MIDI stem"); } + memset(&wav_stats, 0, sizeof(wav_stats)); rc = collect_import_stats(song, &import_stats); - if (rc == 0) + if (rc == 0 && argc == 3) rc = install_projection_substrate(song, &player, &import_stats); - if (rc == 0) + if (rc == 0 && argc == 3) rc = render_projection(&player, song, argv[2], &wav_stats); if (rc == 0) { @@ -557,14 +561,15 @@ int main(int argc, char** argv) "\"midi_cc\":%lu," "\"tempo_commands\":%lu," "\"midi_channel_mask\":%u," + "\"release_channel_mask\":%u," "\"machines_before_projection\":%lu," "\"projection_notes\":%lu," "\"projection_beats\":%.0f," - "\"projection_kind\":\"deterministic-sampler\"," + "\"projection_kind\":\"%s\"," "\"import_event_digest_fnv64\":\"%016llx\"," "\"render_frames\":%u," "\"render_peak\":%d," - "\"non_silent_projection\":true," + "\"non_silent_projection\":%s," "\"parity_status\":\"UNKNOWN\"" "}\n", (unsigned long)import_stats.sequence_tracks, @@ -576,12 +581,15 @@ int main(int argc, char** argv) (unsigned long)import_stats.midi_cc, (unsigned long)import_stats.tempo_commands, (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); + wav_stats.peak, + argc == 3 ? "true" : "false"); } psy_audio_player_set_empty_song(&player); From 80ecfb6ef38a28537be30812232f792653ab54ae Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:58:02 +0930 Subject: [PATCH 52/59] phase6c: align MIDI corpus contract assertions --- tests/phase6c_midi_corpus.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 9fa8b442..0b3788b5 100755 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -440,9 +440,10 @@ def canonical_analysis(root: Path): encoding="utf-8" ) assert "noteoff.mach = self->currtrack.channel;" not in midiloader_source -assert ( - "self->currtrack.channels[voice].tracknote.mach;" 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 From ac54c658a5d2956293cc98d057b35afa12b9a9a0 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:34:56 +1030 Subject: [PATCH 53/59] phase6c: harden MIDI corpus evidence and voice reuse --- .../phase6c-legacy-playback-import.yml | 2 + cpsycle/audio/src/midiloader.c | 36 +++-- scripts/phase6c-midi-corpus.py | 146 ++++++++++++++++++ tests/phase6c_midi_corpus.py | 36 +++++ tests/phase6c_midi_corpus_probe.c | 2 +- 5 files changed, 206 insertions(+), 16 deletions(-) mode change 100755 => 100644 scripts/phase6c-midi-corpus.py mode change 100755 => 100644 tests/phase6c_midi_corpus.py diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index 4cbe5ebb..03de5908 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -238,6 +238,8 @@ jobs: "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), } for name, (notes, releases, channel_mask, release_mask) in cases.items(): proc = subprocess.run( diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index 579ab3b8..0362f873 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -942,6 +942,7 @@ static void midiloader_flushnoteoffs(MidiLoader* self) midiloader_insertnoteoff(self, voice, channel); } channel->noteoff = FALSE; + channel->tracknote.note = psy_audio_NOTECOMMANDS_EMPTY; } } @@ -951,21 +952,23 @@ void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) 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) { - midiloader_insertnoteoff( - self, voice, &self->currtrack.channels[voice]); - } else { - /* noteoff was in previous patttern */ - /* todo */ - } - } - channelvoice = voice; - self->currtrack.channels[voice].noteoff = FALSE; - } + for (voice = 0; voice < MAX_MIDIFILE_POLYPHONY; ++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 == MAX_MIDIFILE_POLYPHONY) { + channelvoice = voice; } } if (channelvoice == MAX_MIDIFILE_POLYPHONY) { @@ -993,6 +996,9 @@ void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) } +/* midi variable length + + /* midi variable length fileio funtions */ int midiloader_readvarlentext(PsyFile* fp, char_dyn_t** rv) { diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py old mode 100755 new mode 100644 index 956b9a3c..e0c39ec6 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -14,6 +14,7 @@ import shutil import struct import subprocess +import tempfile import zipfile from typing import Any @@ -34,6 +35,12 @@ 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" DONOR_SOURCE = ROOT / "cpsycle/audio/src/midiloader.c" DONOR_PROBE_SOURCE = ROOT / "tests/phase6c_midi_corpus_probe.c" DONOR_BUILD_ROOT = ROOT / "cpsycle/player" @@ -79,6 +86,71 @@ def sha256_file(path: pathlib.Path) -> str: 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 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 repository_commit() -> str: try: revision = subprocess.check_output( @@ -578,6 +650,7 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: "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_min_bpm": min(bpms) if bpms else None, "tempo_max_bpm": max(bpms) if bpms else None, "key_signatures": sorted(set(key_sigs)), @@ -1175,6 +1248,7 @@ def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, str]: "unexpected untracked frozen-candidate build input: " + ", ".join(unexpected[:10]) ) + dependency_identity = validate_candidate_dependencies() qmake = subprocess.run( [ @@ -1236,6 +1310,7 @@ def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, str]: "source_revision": EXPECTED_CANDIDATE_SOURCE_REVISION, "repository_commit": repository_commit, "build_target": "psycle-player", + **dependency_identity, "clean_rebuild_sha256": rebuilt_sha, } @@ -1373,6 +1448,26 @@ def validate_donor_execution_observation( f"{stem_id}: imported release count differs from raw SMF: " f"source={analysis.get('note_offs')} imported={obs.get('releases')}" ) + 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") + 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("non_silent_projection") is not True: die(f"{stem_id}: execution projection is silent") if obs.get("projection_kind") != "deterministic-sampler": @@ -1383,6 +1478,8 @@ def validate_donor_execution_observation( "source_channels": list(source_channels), "source_note_off_channels": list(source_note_off_channels), "source_note_offs": analysis["note_offs"], + "source_tempo_events": source_tempo_events, + "source_tempo_events_at_zero": source_tempo_events_at_zero, } @@ -1432,6 +1529,10 @@ def donor_summary( 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_tempo_events"] = coverage["source_tempo_events"] + obs["_source_tempo_events_at_zero"] = coverage[ + "source_tempo_events_at_zero" + ] 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: @@ -1461,6 +1562,11 @@ def donor_summary( "source_note_offs": item["_source_note_offs"], "release_channel_mask": item["release_channel_mask"], "releases": item["releases"], + "source_tempo_events": item["_source_tempo_events"], + "source_tempo_events_at_zero": item[ + "_source_tempo_events_at_zero" + ], + "tempo_commands": item["tempo_commands"], "import_event_digest_fnv64": item["import_event_digest_fnv64"], "render_sha256": item["render_sha256"], } @@ -1604,6 +1710,9 @@ def require_support( source_note_offs = record.get("source_note_offs") release_channel_mask = record.get("release_channel_mask") releases = record.get("releases") + 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") digest = record.get("import_event_digest_fnv64") render_sha = record.get("render_sha256") if ( @@ -1631,6 +1740,16 @@ def require_support( or not isinstance(releases, int) or isinstance(releases, bool) or releases < 0 + 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(digest, str) or len(digest) != 16 or not isinstance(render_sha, str) @@ -1657,6 +1776,8 @@ def require_support( 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 tempo_commands != source_tempo_events - source_tempo_events_at_zero: + die(f"donor {name} stem tempo-map coverage is inconsistent") observed_channels.update(source_channels) seen_ids.add(stem_id) seen_names.add(relative_name) @@ -1871,6 +1992,31 @@ def write_regression_fixtures(root: pathlib.Path) -> None: 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 + ) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py old mode 100755 new mode 100644 index 0b3788b5..4d1c3c68 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -213,6 +213,13 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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] probe_shaped_stem = { "stem_id": "probe-shaped", @@ -221,6 +228,8 @@ def boundary_smf(reverse_tracks: bool) -> bytes: "note_off_channels": [0, 1], "note_ons": 2, "note_offs": 2, + "tempo_events": 1, + "tempo_events_at_zero": 1, }, } probe_shaped_observation = { @@ -232,6 +241,7 @@ def boundary_smf(reverse_tracks: bool) -> bytes: "release_channel_mask": 3, "imported_notes": 2, "releases": 2, + "tempo_commands": 0, "non_silent_projection": True, "projection_kind": "deterministic-sampler", "parity_status": "UNKNOWN", @@ -241,6 +251,22 @@ def boundary_smf(reverse_tracks: bool) -> bytes: ) 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_tempo_stem = json.loads(json.dumps(probe_shaped_stem)) +bad_tempo_stem["analysis"]["tempo_events"] = 539 +bad_tempo_stem["analysis"]["tempo_events_at_zero"] = 1 +bad_tempo_observation = dict(probe_shaped_observation) +bad_tempo_observation["tempo_commands"] = 0 +try: + helper.validate_donor_execution_observation( + bad_tempo_stem, bad_tempo_observation + ) +except SystemExit as exc: + assert "imported tempo map differs" in str(exc) +else: + raise AssertionError("lost imported tempo map must be rejected") psyconf_source = PSYCONF.read_text(encoding="utf-8") assert "#define PSYCLE_USE_MIDI_FILE" in psyconf_source @@ -250,6 +276,10 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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" @@ -270,6 +300,7 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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 def aggregate_for(set_spec): expected = set_spec["analysis_expectations"] @@ -474,6 +505,8 @@ def canonical_analysis(root: Path): 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 'assert observation["non_silent_projection"] is True' in legacy_workflow assert ( "phase6c-generated/phase6c-midi-synthetic.mid \\\n" @@ -532,6 +565,9 @@ def canonical_analysis(root: Path): "source_note_offs": 1, "release_channel_mask": channel_mask, "releases": 1, + "source_tempo_events": 1, + "source_tempo_events_at_zero": 1, + "tempo_commands": 0, "import_event_digest_fnv64": ( f"{index + 1:016x}"[-16:] ), diff --git a/tests/phase6c_midi_corpus_probe.c b/tests/phase6c_midi_corpus_probe.c index 0a7644e9..86148e48 100644 --- a/tests/phase6c_midi_corpus_probe.c +++ b/tests/phase6c_midi_corpus_probe.c @@ -242,7 +242,7 @@ static int install_projection_substrate( psy_audio_Sequence* sequence = psy_audio_song_sequence(song); uintptr_t sequence_track; - for (slot = 0; slot < 16u; ++slot) { + for (slot = 0; slot < psy_audio_MASTER_INDEX; ++slot) { if (psy_audio_machines_at(machines, slot)) ++stats->machines_before_projection; } From 0b8c4a43d479839621f11cdb7ba7302c6c542e39 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:36:53 +1030 Subject: [PATCH 54/59] phase6c: bind tempo evidence to corpus aggregates --- scripts/phase6c-midi-corpus.py | 23 ++++++++++++++++++++++- tests/phase6c_midi_corpus.py | 1 + 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index e0c39ec6..985c4433 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -729,6 +729,13 @@ def validate_aggregate_against_manifest( 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") @@ -811,6 +818,9 @@ def validate_set_aggregate( "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} ), @@ -1154,7 +1164,7 @@ def bind_analysis_root( analysis["_analysis_root"] = str(analysis_path.parent.resolve()) -def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, str]: +def validate_frozen_candidate_player(player: pathlib.Path) -> dict[str, Any]: expected_player = CANDIDATE_PLAYER.resolve() if player.resolve() != expected_player: die( @@ -1698,6 +1708,8 @@ def require_support( 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") @@ -1778,6 +1790,8 @@ def require_support( die(f"donor {name} stem release count is inconsistent") if tempo_commands != source_tempo_events - source_tempo_events_at_zero: die(f"donor {name} stem tempo-map coverage is inconsistent") + 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) @@ -1796,6 +1810,13 @@ def require_support( 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") diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 4d1c3c68..15e154c5 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -312,6 +312,7 @@ def aggregate_for(set_spec): "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], From 1b0520c537b9374aef3402dab0e95a0815b573bb Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:38:16 +1030 Subject: [PATCH 55/59] phase6c: align tempo summary regression data --- tests/phase6c_midi_corpus.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index 15e154c5..dc3a5e0c 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -566,9 +566,13 @@ def canonical_analysis(root: Path): "source_note_offs": 1, "release_channel_mask": channel_mask, "releases": 1, - "source_tempo_events": 1, + "source_tempo_events": set_spec["analysis_expectations"][ + "tempo_events_per_stem" + ], "source_tempo_events_at_zero": 1, - "tempo_commands": 0, + "tempo_commands": ( + set_spec["analysis_expectations"]["tempo_events_per_stem"] - 1 + ), "import_event_digest_fnv64": ( f"{index + 1:016x}"[-16:] ), From 73a112111df5b385c1491bd892f88a3299c2aad8 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:55:07 +1030 Subject: [PATCH 56/59] phase6c: bind MIDI semantics and candidate evidence --- .../phase6c-legacy-playback-import.yml | 2 + cpsycle/audio/src/midiloader.c | 48 ++-- cpsycle/audio/src/midiloader.h | 3 +- scripts/phase6c-midi-corpus.py | 217 ++++++++++++++++-- tests/phase6c_midi_corpus.py | 95 +++++++- tests/phase6c_midi_corpus_probe.c | 25 +- 6 files changed, 350 insertions(+), 40 deletions(-) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index 03de5908..c6e76928 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -240,6 +240,8 @@ jobs: "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), } for name, (notes, releases, channel_mask, release_mask) in cases.items(): proc = subprocess.run( diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index 0362f873..2c577c9d 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -45,7 +45,7 @@ void miditrackstate_reset(MidiTrackState* self) self->position = 0.0; self->patternoffset = 0.0; self->runningstatus = 0; - for (v = 0; v < MAX_MIDIFILE_POLYPHONY; ++v) { + for (v = 0; v < MIDIFILE_TRACK_SLOTS; ++v) { psy_audio_PatternEvent ev; psy_audio_patternevent_init(&ev); @@ -77,6 +77,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*); @@ -282,10 +283,14 @@ int midiloader_readtrackevents(MidiLoader* self, MCHUNK chunk, uintptr_t trackid status = midiloader_readpitchbend(self); break; case 0xF: - if (ln == 0xF) { + if (self->currtrack.runningstatus == 0xFF) { status = midiloader_readmetaevent(self, &eoftrack); - } else { - psyfile_skip(self->fp, 1); + } else if ( + self->currtrack.runningstatus == 0xF0 || + self->currtrack.runningstatus == 0xF7) { + status = midiloader_readsysex(self); + } else if (psyfile_skip(self->fp, 1) == -1) { + status = PSY_ERRFILE; } break; default: @@ -395,7 +400,7 @@ int midiloader_marknoteoff(MidiLoader* self, uint8_t note) { uint16_t voice; voice = 0; - for (voice = 0; voice < MAX_MIDIFILE_POLYPHONY; ++voice) { + for (voice = 0; voice < MIDIFILE_TRACK_SLOTS; ++voice) { MidiChannel* channel = &self->currtrack.channels[voice]; if (!channel->noteoff && @@ -506,6 +511,20 @@ int midiloader_readpitchbend(MidiLoader* self) return PSY_OK; } +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; @@ -931,7 +950,7 @@ static void midiloader_flushnoteoffs(MidiLoader* self) { uint16_t voice; - for (voice = 0; voice < MAX_MIDIFILE_POLYPHONY; ++voice) { + for (voice = 0; voice < MIDIFILE_TRACK_SLOTS; ++voice) { MidiChannel* channel; channel = &self->currtrack.channels[voice]; @@ -951,8 +970,8 @@ void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) uint16_t voice; uint16_t channelvoice; - channelvoice = MAX_MIDIFILE_POLYPHONY; - for (voice = 0; voice < MAX_MIDIFILE_POLYPHONY; ++voice) { + channelvoice = MIDIFILE_TRACK_SLOTS; + for (voice = 0; voice < MIDIFILE_TRACK_SLOTS; ++voice) { MidiChannel* channel; channel = &self->currtrack.channels[voice]; @@ -967,18 +986,18 @@ void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) } channel->noteoff = FALSE; channel->tracknote.note = psy_audio_NOTECOMMANDS_EMPTY; - if (channelvoice == MAX_MIDIFILE_POLYPHONY) { + 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) { + if (channelvoice != MIDIFILE_TRACK_SLOTS) { psy_audio_PatternEntry entry; self->currtrack.channels[channelvoice].tracknote = ev; @@ -996,10 +1015,7 @@ void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) } -/* midi variable length - - -/* 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..4e100627 100644 --- a/cpsycle/audio/src/midiloader.h +++ b/cpsycle/audio/src/midiloader.h @@ -33,6 +33,7 @@ extern "C" { */ #define MAX_MIDIFILE_POLYPHONY 64 +#define MIDIFILE_TRACK_SLOTS (MAX_MIDIFILE_POLYPHONY + 1) typedef struct MidiChannel { psy_audio_PatternEvent tracknote; @@ -50,7 +51,7 @@ typedef struct MidiTrackState 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 byte1; bool hasrunningstatus; diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 985c4433..1d2960f2 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -23,6 +23,7 @@ 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" @@ -41,6 +42,10 @@ 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" @@ -141,6 +146,11 @@ def validate_candidate_dependencies() -> dict[str, Any]: 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 { @@ -151,6 +161,57 @@ def validate_candidate_dependencies() -> dict[str, Any]: } +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( @@ -306,6 +367,34 @@ def validate_donor_probe_build( } +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 imported_tempo_parameter(usec_per_quarter: int) -> int: + bpm = 60.0 * 1_000_000.0 / usec_per_quarter + bpm = int(bpm * 1000) / 1000.0 + return int(bpm) & 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 midi_channel_mask(channels: Any) -> int: if not isinstance(channels, list): die("MIDI channel coverage must be a list") @@ -565,6 +654,12 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: pos = end tempos = [item for track in tracks for item in track["tempo_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] ordered_transitions: list[ @@ -651,6 +746,7 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: "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)), @@ -1338,6 +1434,11 @@ def candidate_boundary( ) 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"] @@ -1396,6 +1497,8 @@ def candidate_boundary( **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 " @@ -1470,6 +1573,16 @@ def validate_donor_execution_observation( 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( @@ -1478,6 +1591,12 @@ def validate_donor_execution_observation( 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": @@ -1490,6 +1609,7 @@ def validate_donor_execution_observation( "source_note_offs": analysis["note_offs"], "source_tempo_events": source_tempo_events, "source_tempo_events_at_zero": source_tempo_events_at_zero, + "source_tempo_map_digest_fnv64": source_tempo_digest, } @@ -1543,6 +1663,9 @@ def donor_summary( 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: @@ -1577,6 +1700,10 @@ def donor_summary( "_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"], } @@ -1725,6 +1852,8 @@ def require_support( 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 ( @@ -1762,6 +1891,10 @@ def require_support( 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) @@ -1790,6 +1923,16 @@ def require_support( die(f"donor {name} stem release count 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_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) @@ -1838,6 +1981,25 @@ def require_support( 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" @@ -1847,7 +2009,18 @@ def require_support( 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.get("player_sha256") + 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 @@ -1897,13 +2070,6 @@ def require_support( except ValueError: die(f"candidate {item.get('set_name')} raw-output SHA-256 is not hexadecimal") - require_support( - candidate_path, - candidate.get("player"), - candidate.get("player_sha256"), - "candidate player", - ) - candidate_artifact = candidate_path.parent / candidate["player"] with candidate_artifact.open("rb") as handle: if handle.read(4) != b"\x7fELF": die("candidate player artifact is not an ELF executable") @@ -2037,6 +2203,27 @@ def write_regression_fixtures(root: pathlib.Path) -> None: 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 + ) + def build_parser() -> argparse.ArgumentParser: @@ -2070,16 +2257,10 @@ def build_parser() -> argparse.ArgumentParser: attest.set_defaults( func=lambda args: write_json( args.output.resolve(), - { - "schema_version": 1, - "phase": "6C", - "contract": "phase6b-candidate-player-clean-rebuild", - "evidence_role": "candidate-build-attestation", - **validate_frozen_candidate_player(args.player.resolve()), - "player": "candidate-psycle-player", - "player_sha256": sha256_file(args.player.resolve()), - "parity_status": "UNKNOWN", - }, + candidate_attestation_document( + validate_frozen_candidate_player(args.player.resolve()), + sha256_file(args.player.resolve()), + ), ) ) diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index dc3a5e0c..c4729e58 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -220,6 +220,14 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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 probe_shaped_stem = { "stem_id": "probe-shaped", @@ -230,6 +238,7 @@ def boundary_smf(reverse_tracks: bool) -> bytes: "note_offs": 2, "tempo_events": 1, "tempo_events_at_zero": 1, + "tempo_map_digest_fnv64": helper.tempo_map_digest_fnv64([]), }, } probe_shaped_observation = { @@ -242,6 +251,7 @@ def boundary_smf(reverse_tracks: bool) -> bytes: "imported_notes": 2, "releases": 2, "tempo_commands": 0, + "tempo_map_digest_fnv64": helper.tempo_map_digest_fnv64([]), "non_silent_projection": True, "projection_kind": "deterministic-sampler", "parity_status": "UNKNOWN", @@ -255,18 +265,24 @@ def boundary_smf(reverse_tracks: bool) -> bytes: assert coverage["source_tempo_events_at_zero"] == 1 bad_tempo_stem = json.loads(json.dumps(probe_shaped_stem)) -bad_tempo_stem["analysis"]["tempo_events"] = 539 +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"] = 0 +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 "imported tempo map differs" in str(exc) + assert "tempo-map contents differ" in str(exc) else: - raise AssertionError("lost imported tempo map must be rejected") + 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 @@ -301,6 +317,7 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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"] @@ -508,6 +525,8 @@ def canonical_analysis(root: Path): 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 'assert observation["non_silent_projection"] is True' in legacy_workflow assert ( "phase6c-generated/phase6c-midi-synthetic.mid \\\n" @@ -573,6 +592,26 @@ def canonical_analysis(root: Path): "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:] ), @@ -628,6 +667,31 @@ def canonical_analysis(root: Path): "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", @@ -637,9 +701,19 @@ def canonical_analysis(root: Path): "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, @@ -652,6 +726,19 @@ def canonical_analysis(root: Path): 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) diff --git a/tests/phase6c_midi_corpus_probe.c b/tests/phase6c_midi_corpus_probe.c index 86148e48..34e48e53 100644 --- a/tests/phase6c_midi_corpus_probe.c +++ b/tests/phase6c_midi_corpus_probe.c @@ -62,6 +62,7 @@ typedef struct ImportStats { uintptr_t releases; uintptr_t midi_cc; uintptr_t tempo_commands; + uint64_t tempo_digest; uint32_t midi_channel_mask; uint32_t release_channel_mask; uintptr_t machines_before_projection; @@ -106,6 +107,19 @@ static void fnv_u64(uint64_t* hash, uint64_t value) fnv_byte(hash, (uint8_t)((value >> shift) & 0xffu)); } +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, @@ -157,6 +171,7 @@ static int collect_import_stats(psy_audio_Song* song, ImportStats* stats) memset(stats, 0, sizeof(*stats)); stats->digest = UINT64_C(14695981039346656037); + stats->tempo_digest = UINT64_C(14695981039346656037); stats->sequence_tracks = psy_audio_sequence_num_tracks(sequence); for (sequence_track = 0; @@ -216,8 +231,14 @@ static int collect_import_stats(psy_audio_Song* song, ImportStats* stats) if (event->mach < 16u) stats->midi_channel_mask |= UINT32_C(1) << event->mach; } - if (event->cmd == psy_audio_PATTERNCMD_SET_TEMPO) + 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); + } } } } @@ -560,6 +581,7 @@ int main(int argc, char** argv) "\"releases\":%lu," "\"midi_cc\":%lu," "\"tempo_commands\":%lu," + "\"tempo_map_digest_fnv64\":\"%016llx\"," "\"midi_channel_mask\":%u," "\"release_channel_mask\":%u," "\"machines_before_projection\":%lu," @@ -580,6 +602,7 @@ int main(int argc, char** argv) (unsigned long)import_stats.releases, (unsigned long)import_stats.midi_cc, (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, From 274f7906da5c05eaa12740c48ce37f76d720d3d4 Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:56:48 +1030 Subject: [PATCH 57/59] phase6c: align SysEx reader linkage --- cpsycle/audio/src/midiloader.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index 2c577c9d..f16aa1c2 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -511,7 +511,7 @@ int midiloader_readpitchbend(MidiLoader* self) return PSY_OK; } -int midiloader_readsysex(MidiLoader* self) +static int midiloader_readsysex(MidiLoader* self) { uint32_t length; int status; From 16b154a50fd36d9260697f31b81c0c3ca216ce3d Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:19:12 +1030 Subject: [PATCH 58/59] phase6c: bind MIDI note and controller semantics --- .../phase6c-legacy-playback-import.yml | 28 +++ cpsycle/audio/src/midiloader.c | 27 +- cpsycle/audio/src/midiloader.h | 1 + scripts/phase6c-midi-corpus.py | 236 +++++++++++++++++- tests/phase6c_midi_corpus.py | 90 +++++++ tests/phase6c_midi_corpus_probe.c | 51 ++++ 6 files changed, 423 insertions(+), 10 deletions(-) diff --git a/.github/workflows/phase6c-legacy-playback-import.yml b/.github/workflows/phase6c-legacy-playback-import.yml index c6e76928..79ba3282 100644 --- a/.github/workflows/phase6c-legacy-playback-import.yml +++ b/.github/workflows/phase6c-legacy-playback-import.yml @@ -228,10 +228,20 @@ jobs: 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 = { @@ -242,6 +252,9 @@ jobs: "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( @@ -255,10 +268,25 @@ jobs: 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 diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index f16aa1c2..bb80c115 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -45,6 +45,7 @@ void miditrackstate_reset(MidiTrackState* self) self->position = 0.0; self->patternoffset = 0.0; self->runningstatus = 0; + self->eventstatus = 0; for (v = 0; v < MIDIFILE_TRACK_SLOTS; ++v) { psy_audio_PatternEvent ev; @@ -257,8 +258,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: @@ -283,11 +284,11 @@ int midiloader_readtrackevents(MidiLoader* self, MCHUNK chunk, uintptr_t trackid status = midiloader_readpitchbend(self); break; case 0xF: - if (self->currtrack.runningstatus == 0xFF) { + if (self->currtrack.eventstatus == 0xFF) { status = midiloader_readmetaevent(self, &eoftrack); } else if ( - self->currtrack.runningstatus == 0xF0 || - self->currtrack.runningstatus == 0xF7) { + self->currtrack.eventstatus == 0xF0 || + self->currtrack.eventstatus == 0xF7) { status = midiloader_readsysex(self); } else if (psyfile_skip(self->fp, 1) == -1) { status = PSY_ERRFILE; @@ -326,12 +327,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; } diff --git a/cpsycle/audio/src/midiloader.h b/cpsycle/audio/src/midiloader.h index 4e100627..c9b18e79 100644 --- a/cpsycle/audio/src/midiloader.h +++ b/cpsycle/audio/src/midiloader.h @@ -53,6 +53,7 @@ typedef struct MidiTrackState psy_audio_SequenceTrack* track; MidiChannel channels[MIDIFILE_TRACK_SLOTS]; uint8_t runningstatus; + uint8_t eventstatus; uint8_t byte1; bool hasrunningstatus; } MidiTrackState; diff --git a/scripts/phase6c-midi-corpus.py b/scripts/phase6c-midi-corpus.py index 1d2960f2..49a074aa 100644 --- a/scripts/phase6c-midi-corpus.py +++ b/scripts/phase6c-midi-corpus.py @@ -378,10 +378,13 @@ def fnv64_u64(hash_value: int, value: int) -> int: return hash_value -def imported_tempo_parameter(usec_per_quarter: int) -> int: +def normalized_tempo_bpm(usec_per_quarter: int) -> float: bpm = 60.0 * 1_000_000.0 / usec_per_quarter - bpm = int(bpm * 1000) / 1000.0 - return int(bpm) & 0xFF + 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( @@ -395,6 +398,29 @@ def tempo_map_digest_fnv64( 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") @@ -480,6 +506,8 @@ def parse_track(track: bytes) -> dict[str, Any]: 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): @@ -559,6 +587,10 @@ def parse_track(track: bytes) -> dict[str, Any]: 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) @@ -566,6 +598,9 @@ def parse_track(track: bytes) -> dict[str, Any]: 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: @@ -610,6 +645,8 @@ def parse_track(track: bytes) -> dict[str, Any]: "remaining_active_notes": remaining, "end_tick": end_tick, "note_transitions": note_transitions, + "note_attacks": note_attacks, + "controller_events": controller_events, } @@ -654,6 +691,28 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: 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) @@ -661,6 +720,10 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: 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] @@ -741,6 +804,14 @@ def parse_smf_bytes(data: bytes, *, label: str = "") -> dict[str, Any]: "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, @@ -1556,11 +1627,69 @@ def validate_donor_execution_observation( 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 ( @@ -1607,6 +1736,10 @@ def validate_donor_execution_observation( "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, @@ -1659,6 +1792,14 @@ def donor_summary( 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" @@ -1695,6 +1836,20 @@ def donor_summary( "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" @@ -1849,6 +2004,18 @@ def require_support( 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") @@ -1881,6 +2048,24 @@ def require_support( 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 @@ -1921,11 +2106,27 @@ def require_support( 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"), ): @@ -2224,6 +2425,35 @@ def write_regression_fixtures(root: pathlib.Path) -> None: 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: diff --git a/tests/phase6c_midi_corpus.py b/tests/phase6c_midi_corpus.py index c4729e58..f73837c4 100644 --- a/tests/phase6c_midi_corpus.py +++ b/tests/phase6c_midi_corpus.py @@ -5,6 +5,7 @@ import hashlib import importlib.util import json +import math import struct import tempfile import zipfile @@ -228,6 +229,13 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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", @@ -236,6 +244,15 @@ def boundary_smf(reverse_tracks: bool) -> bytes: "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([]), @@ -249,7 +266,15 @@ def boundary_smf(reverse_tracks: bool) -> bytes: "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, @@ -264,6 +289,48 @@ def boundary_smf(reverse_tracks: bool) -> bytes: 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 @@ -527,6 +594,9 @@ def canonical_analysis(root: Path): 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" @@ -585,6 +655,26 @@ def canonical_analysis(root: Path): "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" ], diff --git a/tests/phase6c_midi_corpus_probe.c b/tests/phase6c_midi_corpus_probe.c index 34e48e53..d91b7750 100644 --- a/tests/phase6c_midi_corpus_probe.c +++ b/tests/phase6c_midi_corpus_probe.c @@ -62,6 +62,8 @@ typedef struct ImportStats { 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; @@ -107,6 +109,24 @@ static void fnv_u64(uint64_t* hash, uint64_t value) 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, @@ -171,6 +191,8 @@ static int collect_import_stats(psy_audio_Song* song, ImportStats* stats) 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); @@ -217,7 +239,18 @@ static int collect_import_stats(psy_audio_Song* song, ImportStats* stats) 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) { @@ -227,7 +260,17 @@ static int collect_import_stats(psy_audio_Song* song, ImportStats* stats) 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; } @@ -527,6 +570,7 @@ int main(int argc, char** argv) psy_audio_Song* song; ImportStats import_stats; WavStats wav_stats; + double imported_song_bpm; int rc; if (argc != 2 && argc != 3) { @@ -559,6 +603,7 @@ int main(int argc, char** argv) } 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); @@ -580,6 +625,9 @@ int main(int argc, char** argv) "\"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," @@ -601,6 +649,9 @@ int main(int argc, char** argv) (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, From 798303d545297e8988c433b8776f019f7046351c Mon Sep 17 00:00:00 2001 From: Trent Slade <73952179+EmergentMonk@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:25:55 +1030 Subject: [PATCH 59/59] phase6c: preserve exact MIDI tick positions --- cpsycle/audio/src/midiloader.c | 54 +++++++++++++++++++++++----------- cpsycle/audio/src/midiloader.h | 3 ++ 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/cpsycle/audio/src/midiloader.c b/cpsycle/audio/src/midiloader.c index bb80c115..2625246a 100644 --- a/cpsycle/audio/src/midiloader.c +++ b/cpsycle/audio/src/midiloader.c @@ -44,6 +44,8 @@ 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; self->eventstatus = 0; for (v = 0; v < MIDIFILE_TRACK_SLOTS; ++v) { @@ -52,6 +54,7 @@ void miditrackstate_reset(MidiTrackState* self) 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; } } @@ -99,6 +102,8 @@ static int midiloader_readmeta_keysignature(MidiLoader*); static int midiloader_readmeta_properietaryevent(MidiLoader*); /* Misc methods */ 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); @@ -234,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; } @@ -353,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; } @@ -421,6 +427,7 @@ int midiloader_marknoteoff(MidiLoader* self, uint8_t note) channel->tracknote.mach == self->currtrack.channel) { channel->noteoff = TRUE; channel->time = self->currtrack.position; + channel->tick = self->currtrack.position_ticks; break; } } @@ -463,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; @@ -478,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; @@ -783,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); } @@ -915,6 +924,20 @@ 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 @@ -930,9 +953,7 @@ static void midiloader_insertnoteoff( psy_audio_PatternNode* prev; psy_dsp_beatpos_t offset; - offset = psy_dsp_beatpos_make_real( - channel->time - self->currtrack.patternoffset, - psy_dsp_DEFAULT_PPQ); + offset = midiloader_tick_offset(self, channel->tick); psy_audio_patternevent_init(¬eoff); noteoff.note = psy_audio_NOTECOMMANDS_RELEASE; noteoff.mach = channel->tracknote.mach; @@ -1019,9 +1040,8 @@ void midiloader_writepatternevent(MidiLoader* self, psy_audio_PatternEvent ev) 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); } diff --git a/cpsycle/audio/src/midiloader.h b/cpsycle/audio/src/midiloader.h index c9b18e79..c17a7859 100644 --- a/cpsycle/audio/src/midiloader.h +++ b/cpsycle/audio/src/midiloader.h @@ -39,6 +39,7 @@ typedef struct MidiChannel { psy_audio_PatternEvent tracknote; bool noteoff; double time; + uint64_t tick; } MidiChannel; typedef struct MidiTrackState @@ -48,6 +49,8 @@ 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;