Spaces:
Sleeping
Sleeping
| """ABC and MIDI export helpers.""" | |
| from __future__ import annotations | |
| import sys | |
| from pathlib import Path | |
| from .common import ABC_DURATION, COMMON_DURATION_BEATS, PC_TO_SHARP_NAME, PITCH_CLASS, Symbol | |
| MUSES_SRC_PATH = Path("/Users/francoispachet/IdeaProjects/muses/src") | |
| MUSES_VENV_LIB = Path("/Users/francoispachet/IdeaProjects/muses/.venv/lib") | |
| # Generated symbols carry pitch class but not register; keep realization readable on treble staff. | |
| DEFAULT_MIN_PITCH = 60 | |
| DEFAULT_MAX_PITCH = 84 | |
| DEFAULT_CENTER_PITCH = 67 | |
| def ensure_muses_import_path() -> None: | |
| if MUSES_SRC_PATH.exists(): | |
| sys.path.insert(0, str(MUSES_SRC_PATH)) | |
| if MUSES_VENV_LIB.exists(): | |
| for candidate in MUSES_VENV_LIB.glob("python*/site-packages"): | |
| sys.path.insert(0, str(candidate)) | |
| def symbol_to_midi_pitch( | |
| symbol: Symbol, | |
| key_pc: int, | |
| previous_pitch: int | None, | |
| *, | |
| min_pitch: int = DEFAULT_MIN_PITCH, | |
| max_pitch: int = DEFAULT_MAX_PITCH, | |
| center_pitch: int = DEFAULT_CENTER_PITCH, | |
| ) -> int: | |
| target_pc = (key_pc + symbol.rpc) % 12 | |
| if previous_pitch is None: | |
| base = 60 + target_pc | |
| if base < 60: | |
| base += 12 | |
| return base | |
| candidates = [ | |
| target_pc + 12 * octave | |
| for octave in range(2, 9) | |
| if min_pitch <= target_pc + 12 * octave <= max_pitch | |
| ] | |
| if not candidates: | |
| candidates = [target_pc + 12 * octave for octave in range(2, 9)] | |
| return min(candidates, key=lambda pitch: (abs(pitch - previous_pitch), abs(pitch - center_pitch))) | |
| def midi_to_abc_note(pitch: int) -> str: | |
| pc = pitch % 12 | |
| octave = pitch // 12 - 1 | |
| name = PC_TO_SHARP_NAME[pc] | |
| accidental = "^" if "#" in name else "" | |
| letter = name[0] | |
| if octave >= 5: | |
| note = letter.lower() | |
| suffix = "'" * (octave - 5) | |
| elif octave == 4: | |
| note = letter | |
| suffix = "" | |
| else: | |
| note = letter | |
| suffix = "," * (4 - octave) | |
| return accidental + note + suffix | |
| def sequence_to_pitches(sequence: tuple[Symbol, ...], key_pc: int) -> list[int]: | |
| pitches = [] | |
| previous = None | |
| for symbol in sequence: | |
| pitch = symbol_to_midi_pitch(symbol, key_pc, previous) | |
| pitches.append(pitch) | |
| previous = pitch | |
| return pitches | |
| def sequence_to_abc(sequence: tuple[Symbol, ...], key_name: str, title: str, sample_id: int, composer: str) -> str: | |
| key_pc = PITCH_CLASS[key_name] | |
| pitches = sequence_to_pitches(sequence, key_pc) | |
| tokens = [ | |
| midi_to_abc_note(pitch) + ABC_DURATION.get(symbol.duration, "4") | |
| for pitch, symbol in zip(pitches, sequence) | |
| ] | |
| bars = [] | |
| current = [] | |
| beat_sum = 0.0 | |
| for token, symbol in zip(tokens, sequence): | |
| current.append(token) | |
| beat_sum += COMMON_DURATION_BEATS.get(symbol.duration, 1.0) | |
| if beat_sum >= 4.0: | |
| bars.append(" ".join(current)) | |
| current = [] | |
| beat_sum = 0.0 | |
| if current: | |
| bars.append(" ".join(current)) | |
| body = "| ".join(bars) + "|" | |
| return "\n".join( | |
| [ | |
| f"X:{sample_id}", | |
| f"T:{title}", | |
| f"C:{composer}", | |
| "M:4/4", | |
| "L:1/16", | |
| "Q:1/4=120", | |
| f"K:{key_name}", | |
| body, | |
| "", | |
| ] | |
| ) | |
| def write_midi(sequence: tuple[Symbol, ...], key_name: str, path: Path) -> None: | |
| try: | |
| from mido import Message, MetaMessage, MidiFile, MidiTrack, bpm2tempo | |
| except ImportError: | |
| muses_site = Path("/Users/francoispachet/IdeaProjects/muses/.venv/lib") | |
| for candidate in muses_site.glob("python*/site-packages"): | |
| sys.path.insert(0, str(candidate)) | |
| from mido import Message, MetaMessage, MidiFile, MidiTrack, bpm2tempo | |
| key_pc = PITCH_CLASS[key_name] | |
| pitches = sequence_to_pitches(sequence, key_pc) | |
| ticks_per_beat = 480 | |
| mid = MidiFile(ticks_per_beat=ticks_per_beat) | |
| track = MidiTrack() | |
| mid.tracks.append(track) | |
| track.append(MetaMessage("time_signature", numerator=4, denominator=4, time=0)) | |
| track.append(MetaMessage("key_signature", key=key_name, time=0)) | |
| track.append(MetaMessage("set_tempo", tempo=bpm2tempo(120), time=0)) | |
| for pitch, symbol in zip(pitches, sequence): | |
| duration_ticks = max(1, int(round(COMMON_DURATION_BEATS.get(symbol.duration, 1.0) * ticks_per_beat))) | |
| track.append(Message("note_on", note=pitch, velocity=72, time=0)) | |
| track.append(Message("note_off", note=pitch, velocity=0, time=duration_ticks)) | |
| track.append(MetaMessage("end_of_track", time=0)) | |
| mid.save(path) | |
| def sequence_to_muses_piece(sequence: tuple[Symbol, ...], key_name: str, title: str, composer: str): | |
| """Convert a generated theme sequence to a MusES Piece for notation export.""" | |
| ensure_muses_import_path() | |
| try: | |
| from muses.base.temporals import Piece, TemporalCollection | |
| except ImportError as exc: # pragma: no cover - depends on sibling project. | |
| raise RuntimeError( | |
| "MusicXML export requires the sibling muses package at " | |
| "/Users/francoispachet/IdeaProjects/muses." | |
| ) from exc | |
| key_pc = PITCH_CLASS[key_name] | |
| pitches = sequence_to_pitches(sequence, key_pc) | |
| melody = TemporalCollection(name="theme", instrument="piano", program_change=0) | |
| start = 0.0 | |
| for pitch, symbol in zip(pitches, sequence): | |
| duration = COMMON_DURATION_BEATS.get(symbol.duration) | |
| if duration is None: | |
| raise ValueError(f"Unsupported generated duration {symbol.duration!r}") | |
| melody.insert_note(pitch, start, duration, velocity=72, midi_channel=0) | |
| start += duration | |
| return Piece( | |
| name=title, | |
| title=title, | |
| composer=composer, | |
| melodies=[melody], | |
| ticks_per_beat=480, | |
| time_signature="4/4", | |
| key_signature=key_name, | |
| tempo=500000, | |
| ) | |
| def write_sequence_musicxml(sequence: tuple[Symbol, ...], key_name: str, path: Path, *, title: str, composer: str) -> None: | |
| ensure_muses_import_path() | |
| try: | |
| from muses.io.musicxml import write_musicxml as write_muses_musicxml | |
| except ImportError as exc: # pragma: no cover - depends on sibling project. | |
| raise RuntimeError( | |
| "MusicXML export requires muses.io.musicxml in the sibling muses package." | |
| ) from exc | |
| piece = sequence_to_muses_piece(sequence, key_name, title, composer) | |
| write_muses_musicxml(piece, path) | |
| def write_samples( | |
| sequences: list[tuple[Symbol, ...]], | |
| *, | |
| output_dir: Path, | |
| key_name: str, | |
| engine_name: str, | |
| write_abc: bool, | |
| write_musicxml_files: bool = False, | |
| ) -> None: | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| for index, sequence in enumerate(sequences, start=1): | |
| title = f"{engine_name} {index:02d}" | |
| write_midi(sequence, key_name, output_dir / f"generated_{index:02d}.mid") | |
| if write_abc: | |
| abc = sequence_to_abc(sequence, key_name, title, index, engine_name) | |
| (output_dir / f"generated_{index:02d}.abc").write_text(abc, encoding="utf-8") | |
| if write_musicxml_files: | |
| write_musicxml_file = output_dir / f"generated_{index:02d}.musicxml" | |
| write_sequence_musicxml(sequence, key_name, write_musicxml_file, title=title, composer=engine_name) | |