Source code for paeonia.playback

"""External rendering, playback, downloading, and notebook display helpers."""

from __future__ import annotations

from importlib.resources import files
from pathlib import Path
import re
import shutil
from string import Template
import subprocess
import tempfile
from typing import TYPE_CHECKING
from urllib.request import urlretrieve
from zipfile import ZipFile

from mido import MetaMessage, MidiFile, MidiTrack

from .lilypond import (
    bar_to_lilypond,
    note_to_lilypond,
    score_to_lilypond,
    voice_to_lilypond,
)
from .midi import (
    _voice_to_midi_messages_and_offset,
    bar_to_midi_messages,
    note_to_midi_messages,
    score_to_midi,
)
if TYPE_CHECKING:
    from .annotations import AnalysisRenderOptions
    from .bar import Bar
    from .note import Note
    from .score import Score
    from .voice import Voice


SOUNDFONT_URL = (
    "https://keymusician01.s3.amazonaws.com/FluidR3_GM.zip"
)
SOUNDFONT_NAME = "FluidR3_GM.sf2"

_LILYPOND_PAGE_RE = re.compile(r"-page(\d+)\.png$")


[docs] def download_soundfont() -> Path: """Return the local GM soundfont, downloading it only when requested. The user cache directory ``~/.paeonia`` is created lazily by this function, never by importing Paeonia or constructing model objects. """ cache = Path.home() / ".paeonia" destination = cache / SOUNDFONT_NAME if destination.is_file(): return destination cache.mkdir(parents=True, exist_ok=True) archive = cache / "FluidR3_GM.zip" try: urlretrieve(SOUNDFONT_URL, archive) with ZipFile(archive) as soundfonts: soundfonts.extractall(cache) if not destination.is_file(): candidates = tuple(cache.rglob(SOUNDFONT_NAME)) if not candidates: raise FileNotFoundError( f"Downloaded archive did not contain {SOUNDFONT_NAME}" ) candidates[0].replace(destination) finally: archive.unlink(missing_ok=True) return destination
def _require_executable(name: str, *, display_name: str) -> str: executable = shutil.which(name) if executable is None: raise FileNotFoundError( f"{display_name} executable {name!r} was not found on PATH" ) return executable def _single_track_midi( messages, *, trailing_offset: int, tpb: int, ) -> MidiFile: midi = MidiFile(ticks_per_beat=tpb) track = MidiTrack(messages) track.append(MetaMessage( "end_of_track", time=trailing_offset, )) midi.tracks.append(track) return midi
[docs] def play_midi(midi: MidiFile, *, autoplay: bool = False) -> None: """Render a MIDI object with FluidSynth and display the resulting audio.""" if not isinstance(midi, MidiFile): raise TypeError("midi must be a MidiFile") fluidsynth = _require_executable( "fluidsynth", display_name="FluidSynth", ) soundfont = download_soundfont() with tempfile.TemporaryDirectory() as directory: temporary = Path(directory) midi_path = temporary / "preview.mid" wav_path = temporary / "preview.wav" midi.save(midi_path) subprocess.run( [ fluidsynth, "-T", "wav", "-F", str(wav_path), "-i", str(soundfont), str(midi_path), ], check=True, stdout=subprocess.DEVNULL, ) from IPython.display import Audio, display display(Audio(filename=str(wav_path), autoplay=autoplay))
[docs] def play_note( note: Note, *, tpb: int = 480, autoplay: bool = False, ) -> None: """Render and play one note or chord.""" messages = note_to_midi_messages(note, tpb=tpb) play_midi( _single_track_midi(messages, trailing_offset=0, tpb=tpb), autoplay=autoplay, )
[docs] def play_bar( bar: Bar, *, tpb: int = 480, autoplay: bool = False, ) -> None: """Render and play one bar.""" messages, trailing = bar_to_midi_messages(bar, tpb=tpb) play_midi( _single_track_midi( messages, trailing_offset=trailing, tpb=tpb, ), autoplay=autoplay, )
[docs] def play_voice( voice: Voice, *, tpb: int = 480, autoplay: bool = False, ) -> None: """Render and play a voice, including ties across bar boundaries.""" messages, trailing = _voice_to_midi_messages_and_offset( voice, tpb=tpb, channel=0, ) play_midi( _single_track_midi( messages, trailing_offset=trailing, tpb=tpb, ), autoplay=autoplay, )
[docs] def play_score( score: Score, *, tpb: int = 480, autoplay: bool = False, allow_unaligned: bool = False, ) -> None: """Render and play a complete score.""" play_midi( score_to_midi( score, tpb=tpb, allow_unaligned=allow_unaligned, ), autoplay=autoplay, )
def _lilypond_document(template_name: str, notation: str) -> str: template = Template( files("paeonia.data").joinpath(template_name).read_text() ) return template.substitute(notation=notation) def _lilypond_page_sort_key(path: Path) -> tuple[int, str]: """Sort LilyPond PNG pages numerically, including a single-page file.""" match = _LILYPOND_PAGE_RE.search(path.name) page = int(match.group(1)) if match is not None else 0 return page, path.name def _show_lilypond(notation: str, *, template_name: str) -> None: """Render and display every page produced by LilyPond.""" lilypond = _require_executable("lilypond", display_name="LilyPond") document = _lilypond_document(template_name, notation) with tempfile.TemporaryDirectory() as directory: temporary = Path(directory) source = temporary / "notation.ly" source.write_text(document) subprocess.run( [ lilypond, "--loglevel=ERROR", "-fpng", str(source), ], cwd=temporary, check=True, ) image_paths = sorted( temporary.glob("notation*.png"), key=_lilypond_page_sort_key, ) if not image_paths: raise FileNotFoundError( "LilyPond completed without producing a PNG file" ) from IPython.display import Image, display for image_path in image_paths: display(Image(filename=str(image_path)))
[docs] def show_note(note: Note) -> None: """Render and display one note or chord with LilyPond.""" _show_lilypond( note_to_lilypond(note), template_name="note_template.ly", )
[docs] def show_bar(bar: Bar) -> None: """Render and display one bar with LilyPond.""" _show_lilypond( bar_to_lilypond(bar), template_name="bar_template.ly", )
[docs] def show_voice(voice: Voice) -> None: """Render and display one voice with LilyPond.""" _show_lilypond( voice_to_lilypond(voice), template_name="voice_template.ly", )
[docs] def show_score( score: Score, *, bar_numbers: bool = True, analysis: bool | AnalysisRenderOptions = False, ) -> None: """Render and display a score with optional analytical annotations.""" _show_lilypond( score_to_lilypond( score, bar_numbers=bar_numbers, analysis=analysis, ), template_name="score_template.ly", )