"""MIDI output utilities.""" import dataclasses from collections.abc import Sequence from muscriptor.tokenizer.notes import ( Note, note2note_event, note_event2midi, trim_overlapping_notes, ) from muscriptor.utils.beats import BeatGrid from muscriptor.utils.chords import Chord, to_markers # Written when no grid was detected: 120 BPM and no time signature, leaving the # meter for notation software to guess. PLACEHOLDER_GRID = BeatGrid(bpm=120, beats_per_bar=None, first_downbeat=0.0) def shifted_notes(notes: list[Note], delay_s: float) -> list[Note]: """`notes` moved by `delay_s`. May go negative; the bar offset covers that.""" if not delay_s: return notes return [ dataclasses.replace( note, onset=note.onset + delay_s, offset=note.offset + delay_s ) for note in notes ] def notes_to_midi( notes: list[Note], velocity: int = 100, program_names: dict[int, str] | None = None, beat_grid: BeatGrid | None = None, chords: Sequence[Chord] | None = None, quantize: bool = False, ): """Convert a list of Note objects to a mido MidiFile. `program_names` maps program numbers to human-readable track names (see note_event2midi). `grid` is a detected beat grid (see muscriptor.utils.beats): it supplies the tempo, the time signature and a delay that puts bar lines on real downbeats. Defaults to PLACEHOLDER_GRID. The notes are moved onto the beat grid based on its `onset_delay` to better match the grid. `chords` are the recognized chords (see muscriptor.utils.chords), written as markers on the meta track. They move with the notes, so a chord change stays over the notes it belongs to. `quantize` additionally snaps every note onto the beat subdivision, if present in the passed `beat_grid`. """ beat_grid = beat_grid or PLACEHOLDER_GRID if beat_grid.onset_delay is None: beat_grid = beat_grid.with_onset_delay([n.onset for n in notes]) delay = beat_grid.onset_delay markers = [ (seconds - delay, symbol) for seconds, symbol in to_markers(chords or []) ] offset = beat_grid.bar_offset(min_shift=delay) notes = shifted_notes(notes, -delay) if quantize and beat_grid.beat_subdivision is not None: step = 60.0 / beat_grid.bpm / beat_grid.beat_subdivision notes = quantized_notes(notes, step, offset) return note_event2midi( note2note_event(notes), output_file=None, velocity=velocity, tempo=round(60_000_000 / beat_grid.bpm), program_names=program_names, beats_per_bar=beat_grid.beats_per_bar, offset_s=offset, chord_markers=markers, ) def quantized_notes(notes: list[Note], step: float, offset_s: float) -> list[Note]: """`notes` with every onset and offset moved onto a `step`-second grid. Snapped in the timeline the MIDI file will have — `offset_s` is the shift that puts bar 1 at tick 0 — so the notes land on whole grid steps from the start of the score rather than a constant fraction off it. """ def snap(t: float) -> float: return round((t + offset_s) / step) * step - offset_s snapped = [] for note in notes: onset = snap(note.onset) snapped.append( dataclasses.replace( note, onset=onset, # A note shorter than half a step rounds to nothing and would # vanish from the score; give it the shortest length the grid has. offset=max(snap(note.offset), onset + step), ) ) # Bumping the short ones can make two notes of the same pitch overlap. return trim_overlapping_notes(snapped)