Source code for melody_engine.lilypond

from __future__ import annotations

"""LilyPond export and rendering utilities for melodies, chorales, and thesis assets."""

import subprocess
from pathlib import Path

from .structure import ChoraleScore, Melody

BEAT_TO_DURATION = {
    4.0: "1",
    2.0: "2",
    1.0: "4",
    0.5: "8",
    0.25: "16",
}


[docs] def lilypond_duration(duration: float) -> str: if duration not in BEAT_TO_DURATION: raise ValueError(f"Unsupported duration for LilyPond export: {duration}") return BEAT_TO_DURATION[duration]
[docs] def lilypond_key_name(tonic: str) -> str: normalized = tonic.strip().lower() if not normalized: raise ValueError("Tonic may not be empty.") letter = normalized[0] accidental_text = normalized[1:] accidental_text = accidental_text.replace("#", "s").replace("b", "f") return f"{letter}{accidental_text}"
[docs] def choose_clef(melody: Melody) -> str: if melody.clef is not None: return melody.clef voice_name = melody.voice_profile.name.lower() if voice_name.startswith("tenor"): return "treble_8" if voice_name.startswith("bass"): return "bass" if voice_name.startswith("alto") or voice_name.startswith("soprano"): return "treble" if melody.voice_profile.clef_hint is not None: return melody.voice_profile.clef_hint pitched = melody.pitched_events if not pitched: return "treble" midi_values = [ melody.key.absolute_midi(event.scale_step, event.chromatic_adjustment) for event in pitched ] average_midi = sum(midi_values) / len(midi_values) lowest_midi = min(midi_values) highest_midi = max(midi_values) low_share = sum(1 for value in midi_values if value < 60) / len(midi_values) if average_midi < 50 or highest_midi <= 59: return "bass" if melody.key.tonic_octave <= 3 and low_share >= 0.5 and highest_midi <= 69: return "treble_8" return "treble"
[docs] def melody_to_lilypond_source(melody: Melody) -> str: key_name = lilypond_key_name(melody.key.tonic) music_body = voice_music_body(melody) return f"""\\version "2.24.4" \\language "english" \\score {{ \\new Staff {{ \\clef "{choose_clef(melody)}" \\key {key_name} \\{melody.key.mode} \\time {melody.time_signature} {music_body} \\bar "|." }} \\layout {{}} \\midi {{}} }} """
[docs] def note_token(melody: Melody, event) -> str: if event.is_rest: return f"r{lilypond_duration(event.duration)}" return f"{melody.key.chromatic_pitch(event.scale_step, event.chromatic_adjustment)}{lilypond_duration(event.duration)}"
[docs] def voice_music_body(melody: Melody) -> str: note_tokens = [note_token(melody, event) for event in melody.events] bar_length = melody.time_signature.bar_length bars: list[str] = [] current_bar: list[str] = [] beat_total = 0.0 for token, event in zip(note_tokens, melody.events): current_bar.append(token) beat_total += event.duration if beat_total >= bar_length - 1e-9: bars.append(" ".join(current_bar)) current_bar = [] beat_total = 0.0 if current_bar: bars.append(" ".join(current_bar)) return " |\n ".join(bars)
[docs] def export_melody(melody: Melody, output_path: str | Path) -> Path: path = Path(output_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(melody_to_lilypond_source(melody), encoding="utf-8") return path
[docs] def chorale_to_lilypond_source(score: ChoraleScore) -> str: key_name = lilypond_key_name(score.key.tonic) soprano_body = voice_music_body(score.soprano) alto_body = voice_music_body(score.alto) tenor_body = voice_music_body(score.tenor) bass_body = voice_music_body(score.bass) return f"""\\version "2.24.4" \\language "english" \\score {{ \\new ChoirStaff << \\new Staff << \\clef "{choose_clef(score.soprano)}" \\key {key_name} \\{score.key.mode} \\time {score.time_signature} \\new Voice = "soprano" {{ \\voiceOne {soprano_body} \\bar "|." }} \\new Voice = "alto" {{ \\voiceTwo {alto_body} }} >> \\new Staff << \\clef "{choose_clef(score.tenor)}" \\key {key_name} \\{score.key.mode} \\time {score.time_signature} \\new Voice = "tenor" {{ \\voiceOne {tenor_body} }} \\new Voice = "bass" {{ \\voiceTwo {bass_body} }} >> >> \\layout {{}} \\midi {{}} }} """
[docs] def export_chorale(score: ChoraleScore, output_path: str | Path) -> Path: path = Path(output_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(chorale_to_lilypond_source(score), encoding="utf-8") return path
[docs] def render_lilypond_file( source_path: str | Path, output_dir: str | Path | None = None, *, cropped: bool = False, lilypond_bin: str = "lilypond", ) -> list[Path]: source = Path(source_path) render_dir = Path(output_dir) if output_dir is not None else source.parent render_dir.mkdir(parents=True, exist_ok=True) command = [lilypond_bin, "-o", str(render_dir)] if cropped: command.append("-dcrop") command.append(str(source)) try: subprocess.run(command, check=True) except FileNotFoundError as error: raise RuntimeError( f"Could not find LilyPond binary '{lilypond_bin}'. Install LilyPond or pass --lilypond-bin." ) from error base_name = source.stem generated = [ render_dir / f"{base_name}.pdf", render_dir / f"{base_name}.midi", ] if cropped: generated.append(render_dir / f"{base_name}.cropped.pdf") return [path for path in generated if path.exists()]
[docs] def render_audio_from_midi( midi_path: str | Path, wav_path: str | Path | None = None, *, timidity_bin: str = "timidity", ) -> Path: midi_file = Path(midi_path) audio_file = Path(wav_path) if wav_path is not None else midi_file.with_suffix(".wav") try: subprocess.run( [timidity_bin, str(midi_file), "-Ow", "-o", str(audio_file)], check=True, ) except FileNotFoundError as error: raise RuntimeError( f"Could not find TiMidity++ binary '{timidity_bin}'. Install TiMidity++ or pass --timidity-bin." ) from error return audio_file
[docs] def render_sources( sources: list[Path], *, output_dir: Path | None = None, pdf: bool = False, wav: bool = False, lilypond_bin: str = "lilypond", timidity_bin: str = "timidity", ) -> list[Path]: rendered_assets: list[Path] = [] if not pdf and not wav: return rendered_assets for source in sources: source_output_dir = output_dir or source.parent rendered_assets.extend( render_lilypond_file( source, output_dir=source_output_dir, cropped=True, lilypond_bin=lilypond_bin, ) ) if wav: midi_path = source_output_dir / f"{source.stem}.midi" if midi_path.exists(): rendered_assets.append( render_audio_from_midi(midi_path, timidity_bin=timidity_bin) ) return rendered_assets