-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmidi_builder.py
More file actions
192 lines (155 loc) · 6.79 KB
/
Copy pathmidi_builder.py
File metadata and controls
192 lines (155 loc) · 6.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
"""
midi_builder.py
================
Converts a list of ``math_generators.Note`` objects into a standard MIDI
file using ``mido``. The output is a standard Type-1 ``.mid`` file with a
tempo / time-signature meta track plus one note track (or one track per
layer), fully compatible with Ableton Live's MIDI import.
"""
from __future__ import annotations
from typing import List, Tuple, Optional
import mido
from math_generators import Note
DEFAULT_TICKS_PER_BEAT = 480
def beats_to_seconds(beats: float, bpm: float) -> float:
"""Convert a duration in quarter-note beats to seconds at ``bpm``."""
return beats * 60.0 / max(1e-6, bpm)
def seconds_to_beats(seconds: float, bpm: float) -> float:
return seconds * bpm / 60.0
def _build_note_track(notes: List[Note], ticks_per_beat: int, channel: int = 0) -> mido.MidiTrack:
"""Build a single MidiTrack containing note_on/note_off events for
``notes``, correctly interleaved and sorted by tick."""
events = [] # (tick, priority, message) priority 0=off, 1=on -> off first
for note in notes:
on_tick = int(round(note.start * ticks_per_beat))
off_tick = int(round((note.start + note.sounding_duration) * ticks_per_beat))
if off_tick <= on_tick:
off_tick = on_tick + 1
pitch = int(max(0, min(127, note.pitch)))
velocity = int(max(1, min(127, note.velocity)))
events.append((on_tick, 1, mido.Message(
"note_on", note=pitch, velocity=velocity, channel=channel)))
events.append((off_tick, 0, mido.Message(
"note_off", note=pitch, velocity=0, channel=channel)))
events.sort(key=lambda e: (e[0], e[1]))
track = mido.MidiTrack()
last_tick = 0
for tick, _priority, msg in events:
delta = max(0, tick - last_tick)
msg.time = delta
track.append(msg)
last_tick = tick
return track
def build_midi_file(
notes: List[Note],
bpm: float = 120.0,
time_sig: Tuple[int, int] = (4, 4),
ticks_per_beat: int = DEFAULT_TICKS_PER_BEAT,
separate_tracks_by_layer: bool = False,
program: int = 0,
track_name: str = "Math MIDI Studio",
) -> mido.MidiFile:
"""Build an in-memory ``mido.MidiFile`` from ``notes``.
Parameters
----------
notes:
List of Note objects (start/duration in quarter-note beats).
bpm:
Tempo in beats per minute.
time_sig:
``(numerator, denominator)`` time signature.
ticks_per_beat:
MIDI resolution (PPQ). 480 is a common, DAW-friendly default.
separate_tracks_by_layer:
If True, notes are split into separate tracks/channels by their
``layer`` attribute (useful when multiple math sources are
combined). Otherwise everything goes on a single track/channel.
program:
MIDI program (instrument) number 0-127 to assign via a
``program_change`` message. 0 = Acoustic Grand Piano.
"""
mid = mido.MidiFile(ticks_per_beat=ticks_per_beat, type=1)
meta = mido.MidiTrack()
meta.append(mido.MetaMessage("track_name", name=track_name, time=0))
meta.append(mido.MetaMessage("set_tempo", tempo=mido.bpm2tempo(bpm), time=0))
num, den = time_sig
meta.append(mido.MetaMessage(
"time_signature", numerator=int(num), denominator=int(den), time=0))
mid.tracks.append(meta)
if not notes:
# Still produce a valid (empty) file.
track = mido.MidiTrack()
track.append(mido.MetaMessage("track_name", name="Generated Notes", time=0))
mid.tracks.append(track)
return mid
if separate_tracks_by_layer:
layers = sorted(set(n.layer for n in notes))
for idx, layer in enumerate(layers):
channel = idx % 16
layer_notes = [n for n in notes if n.layer == layer]
track = _build_note_track(layer_notes, ticks_per_beat, channel=channel)
track.insert(0, mido.MetaMessage("track_name", name=f"Layer {layer + 1}", time=0))
track.insert(1, mido.Message(
"program_change", program=int(program) % 128, channel=channel, time=0))
mid.tracks.append(track)
else:
track = _build_note_track(notes, ticks_per_beat, channel=0)
track.insert(0, mido.MetaMessage("track_name", name="Generated Notes", time=0))
track.insert(1, mido.Message(
"program_change", program=int(program) % 128, channel=0, time=0))
mid.tracks.append(track)
return mid
def export_midi(
notes: List[Note],
filepath: str,
bpm: float = 120.0,
time_sig: Tuple[int, int] = (4, 4),
ticks_per_beat: int = DEFAULT_TICKS_PER_BEAT,
separate_tracks_by_layer: bool = False,
program: int = 0,
) -> None:
"""Build and save a ``.mid`` file to ``filepath``."""
mid = build_midi_file(
notes,
bpm=bpm,
time_sig=time_sig,
ticks_per_beat=ticks_per_beat,
separate_tracks_by_layer=separate_tracks_by_layer,
program=program,
)
mid.save(filepath)
def total_duration_beats(notes: List[Note]) -> float:
"""Return the time (in beats) at which the last note ends."""
if not notes:
return 0.0
return max(n.start + n.duration for n in notes)
# ---------------------------------------------------------------------------
# Self-test
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import os
import tempfile
from math_generators import (generate_sequence, build_note_sequence,
MusicParams, RhythmParams, DEFAULT_PARAMS)
seq = generate_sequence("sine", DEFAULT_PARAMS["sine"], 32)
notes = build_note_sequence(seq, MusicParams(), RhythmParams(mode="derived"),
total_beats=16.0, max_notes=64, layer=0)
seq2 = generate_sequence("logistic", DEFAULT_PARAMS["logistic"], 32)
notes2 = build_note_sequence(seq2, MusicParams(octave_min=5, octave_max=6),
RhythmParams(mode="uniform", base_length="1/16"),
total_beats=16.0, max_notes=64, layer=1)
all_notes = notes + notes2
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "test.mid")
export_midi(all_notes, path, bpm=120, time_sig=(4, 4),
separate_tracks_by_layer=True)
size = os.path.getsize(path)
print(f"Wrote {path} ({size} bytes)")
# Read it back to sanity-check structure.
mid = mido.MidiFile(path)
print(f"Type {mid.type}, {len(mid.tracks)} tracks, "
f"ticks_per_beat={mid.ticks_per_beat}")
for i, track in enumerate(mid.tracks):
n_on = sum(1 for m in track if m.type == "note_on")
print(f" Track {i}: {len(track)} events, {n_on} note_on")
print(f"\nTotal duration: {total_duration_beats(all_notes):.2f} beats")