audio-as-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
audio_as_code/midi.py ADDED
@@ -0,0 +1,192 @@
1
+ """Standard MIDI type-1 export with tempo, programs, pan, and percussion mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import mido
8
+
9
+ from ._export_rules import (
10
+ MAX_MELODIC_TRACKS,
11
+ MAX_MIDI_TICK,
12
+ TICKS_PER_BEAT,
13
+ midi_export_pitch,
14
+ midi_note_ticks,
15
+ midi_tick,
16
+ )
17
+ from .instruments import DRUM_NOTES, PROGRAMS
18
+ from .model import Song
19
+
20
+
21
+ def _export_warnings(song: Song) -> list[str]:
22
+ messages = ["MIDI preserves notes and tempo; sounds depend on the receiving synthesizer."]
23
+ if any(track.instrument in DRUM_NOTES for track in song.tracks):
24
+ messages.append(
25
+ "Percussion uses shared channel 10, with gain in velocity and no per-track pan. "
26
+ "Percussion note events are grouped in the first percussion track; "
27
+ "other percussion track names are retained as metadata."
28
+ )
29
+ if any(track.tone is not None for track in song.tracks):
30
+ messages.append("Instrument tone controls are not exported.")
31
+ if song.tempo_map:
32
+ messages.append("Tempo changes are rounded to the 480-tick beat grid.")
33
+ if any(track.pedal for track in song.tracks):
34
+ messages.append(
35
+ "Piano pedal events use binary CC64 on the 480-tick beat grid; an open pedal "
36
+ "lifts at score end. Damper tails and repeated-pitch behavior depend on the receiver."
37
+ )
38
+ if song.automation or any(track.automation for track in song.tracks):
39
+ messages.append(
40
+ "Gain/pan automation is not exported; MIDI uses static track/master gain and pan."
41
+ )
42
+ if song.effects or any(track.effects for track in song.tracks):
43
+ messages.append("Procedural effects and their audio tails are not exported.")
44
+ if any(
45
+ track.release_seconds or any(note.release_seconds for note in track.notes)
46
+ for track in song.tracks
47
+ ):
48
+ messages.append("Note releases are not exported; MIDI uses written note durations.")
49
+ return [" ".join(messages)]
50
+
51
+
52
+ def export_midi(song: Song, path: str | Path) -> dict:
53
+ song = Song.model_validate(song.model_dump())
54
+ melodic = [track for track in song.tracks if track.instrument not in DRUM_NOTES]
55
+ if len(melodic) > MAX_MELODIC_TRACKS:
56
+ raise ValueError(
57
+ f"MIDI export supports at most {MAX_MELODIC_TRACKS} melodic tracks "
58
+ "(channel 10 is percussion)"
59
+ )
60
+ drum_kinds = [track.instrument for track in song.tracks if track.instrument in DRUM_NOTES]
61
+ if len(set(drum_kinds)) != len(drum_kinds):
62
+ raise ValueError("MIDI export requires at most one track per drum instrument")
63
+ midi = mido.MidiFile(type=1, ticks_per_beat=TICKS_PER_BEAT)
64
+ end_tick = midi_tick(song.beats)
65
+ if not 1 <= end_tick <= MAX_MIDI_TICK:
66
+ raise ValueError("song duration cannot be represented on the MIDI tick grid")
67
+ tempo = mido.MidiTrack()
68
+ tempo.append(mido.MetaMessage("track_name", name="Tempo"))
69
+ tempo.append(mido.MetaMessage("set_tempo", tempo=mido.bpm2tempo(song.bpm)))
70
+ previous_tempo_tick = 0
71
+ for change in song.tempo_map:
72
+ tick = midi_tick(change.beat)
73
+ tempo.append(
74
+ mido.MetaMessage(
75
+ "set_tempo", tempo=mido.bpm2tempo(change.bpm), time=tick - previous_tempo_tick
76
+ )
77
+ )
78
+ previous_tempo_tick = tick
79
+ tempo.append(mido.MetaMessage("end_of_track", time=end_tick - previous_tempo_tick))
80
+ midi.tracks.append(tempo)
81
+ channels = iter(channel for channel in range(16) if channel != 9)
82
+ percussion_intervals: dict[int, list[tuple[int, int, str]]] = {}
83
+ track_events = []
84
+ percussion_events = None
85
+ for track in song.tracks:
86
+ percussion = track.instrument in DRUM_NOTES
87
+ channel = 9 if percussion else next(channels)
88
+ output = mido.MidiTrack()
89
+ # UTF-8 metadata is explicitly selected for titles outside Latin-1.
90
+ output.append(mido.MetaMessage("track_name", name=track.name))
91
+ if not percussion:
92
+ output.append(
93
+ mido.Message("program_change", channel=channel, program=PROGRAMS[track.instrument])
94
+ )
95
+ output.append(
96
+ mido.Message(
97
+ "control_change",
98
+ channel=channel,
99
+ control=7,
100
+ value=round(track.gain * song.master_gain * 127),
101
+ )
102
+ )
103
+ output.append(
104
+ mido.Message(
105
+ "control_change",
106
+ channel=channel,
107
+ control=10,
108
+ value=round((track.pan + 1) * 63.5),
109
+ )
110
+ )
111
+ events = []
112
+ for pedal in track.pedal:
113
+ events.append(
114
+ (
115
+ midi_tick(pedal.beat),
116
+ -1,
117
+ mido.Message(
118
+ "control_change",
119
+ channel=channel,
120
+ control=64,
121
+ value=127 if pedal.down else 0,
122
+ ),
123
+ )
124
+ )
125
+ if track.pedal and track.pedal[-1].down:
126
+ events.append(
127
+ (end_tick, -1, mido.Message("control_change", channel=channel, control=64, value=0))
128
+ )
129
+ note_ends: dict[int, int] = {}
130
+ for note in sorted(track.notes, key=lambda note: note.start):
131
+ pitch = midi_export_pitch(track, note.pitch)
132
+ start, end = midi_note_ticks(note, end_tick)
133
+ if end <= start:
134
+ raise ValueError(
135
+ f"track {track.name!r}: note at beat {note.start} is shorter than one MIDI tick"
136
+ )
137
+ if start < note_ends.get(pitch, 0):
138
+ raise ValueError(
139
+ f"track {track.name!r}: overlapping MIDI pitch {pitch}; "
140
+ "shorten or split the notes"
141
+ )
142
+ note_ends[pitch] = end
143
+ if track.gain == 0 or song.master_gain == 0:
144
+ continue
145
+ if percussion:
146
+ percussion_intervals.setdefault(pitch, []).append((start, end, track.name))
147
+ velocity = note.velocity * (track.gain * song.master_gain if percussion else 1)
148
+ velocity = max(1, round(velocity * 127))
149
+ events.append(
150
+ (start, 1, mido.Message("note_on", channel=channel, note=pitch, velocity=velocity))
151
+ )
152
+ events.append(
153
+ (end, 0, mido.Message("note_off", channel=channel, note=pitch, velocity=0))
154
+ )
155
+ if percussion:
156
+ # All percussion shares one channel. Sorting within separate MIDI tracks
157
+ # cannot guarantee that a retrigger follows the previous track's note-off.
158
+ if percussion_events is None:
159
+ percussion_events = events
160
+ else:
161
+ percussion_events.extend(events)
162
+ events = []
163
+ track_events.append((output, events))
164
+ midi.tracks.append(output)
165
+ for pitch, intervals in percussion_intervals.items():
166
+ previous_end = 0
167
+ previous_track = ""
168
+ for start, end, track_name in sorted(intervals):
169
+ if start < previous_end:
170
+ raise ValueError(
171
+ f"overlapping percussion MIDI pitch {pitch} across tracks "
172
+ f"{previous_track!r} and {track_name!r}; channel 10 is shared"
173
+ )
174
+ previous_end, previous_track = end, track_name
175
+ for output, events in track_events:
176
+ previous = 0
177
+ # Pedal changes precede note-offs, then note-ons at the same tick.
178
+ for tick, _, event in sorted(events, key=lambda item: (item[0], item[1])):
179
+ output.append(event.copy(time=tick - previous))
180
+ previous = tick
181
+ output.append(mido.MetaMessage("end_of_track", time=end_tick - previous))
182
+ midi.charset = "utf-8"
183
+ destination = Path(path)
184
+ destination.parent.mkdir(parents=True, exist_ok=True)
185
+ midi.save(str(destination))
186
+ return {
187
+ "output": str(destination),
188
+ "tracks": len(song.tracks),
189
+ "ticks_per_beat": TICKS_PER_BEAT,
190
+ "duration_seconds": midi.length,
191
+ "warnings": _export_warnings(song),
192
+ }
audio_as_code/model.py ADDED
@@ -0,0 +1,292 @@
1
+ """Versioned, portable musical data. All time values are quarter-note beats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from bisect import bisect_right
8
+ from pathlib import Path
9
+ from typing import Annotated, Literal
10
+
11
+ from pydantic import (
12
+ BaseModel,
13
+ ConfigDict,
14
+ Field,
15
+ StrictBool,
16
+ StrictInt,
17
+ field_validator,
18
+ model_validator,
19
+ )
20
+
21
+ from .instruments import KIT_NOTES, Instrument, get_instrument, require_instrument
22
+
23
+ Number = Annotated[float, Field(strict=True, allow_inf_nan=False)]
24
+ MidiPitch = Annotated[StrictInt, Field(ge=0, le=127)]
25
+ NoteName = Annotated[str, Field(strict=True, pattern=r"^[A-Ga-g](?:#|b)?-?[0-9]$")]
26
+ Pitch = MidiPitch | NoteName
27
+
28
+
29
+ def midi_pitch(value: int | str) -> int:
30
+ """Convert scientific pitch notation (C4 = 60, A4 = 69) to MIDI."""
31
+ if isinstance(value, bool):
32
+ raise ValueError("pitch must be a MIDI integer or a note name, not a boolean")
33
+ if isinstance(value, int):
34
+ result = value
35
+ elif isinstance(value, str):
36
+ match = re.fullmatch(r"([A-Ga-g])([#b]?)(-?[0-9])", value)
37
+ if not match:
38
+ raise ValueError(f"invalid pitch {value!r}; use a name such as C4, F#3, or Bb2")
39
+ letter, accidental, octave = match.groups()
40
+ pitch_class = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
41
+ result = (int(octave) + 1) * 12 + pitch_class[letter.upper()]
42
+ result += {"": 0, "#": 1, "b": -1}[accidental]
43
+ else:
44
+ raise ValueError("pitch must be a MIDI integer or a note name")
45
+ if not 0 <= result <= 127:
46
+ raise ValueError(f"pitch {value!r} falls outside MIDI range 0..127")
47
+ return result
48
+
49
+
50
+ class ScoreModel(BaseModel):
51
+ model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True)
52
+
53
+
54
+ class TempoChange(ScoreModel):
55
+ beat: Annotated[Number, Field(ge=0)]
56
+ bpm: Annotated[Number, Field(ge=20, le=300)]
57
+
58
+
59
+ class PedalEvent(ScoreModel):
60
+ """Binary piano damper pedal; events apply before note-offs at the same beat."""
61
+
62
+ beat: Annotated[Number, Field(ge=0)]
63
+ down: StrictBool
64
+
65
+
66
+ class AutomationPoint(ScoreModel):
67
+ beat: Annotated[Number, Field(ge=0)]
68
+ value: Annotated[Number, Field(ge=-1, le=1)]
69
+
70
+
71
+ class Automation(ScoreModel):
72
+ parameter: Literal["gain", "pan", "master_gain"]
73
+ points: Annotated[tuple[AutomationPoint, ...], Field(min_length=1, max_length=1024)]
74
+ interpolation: Literal["linear", "step"] = "linear"
75
+
76
+ @model_validator(mode="after")
77
+ def validate_points(self) -> Automation:
78
+ if any(a.beat >= b.beat for a, b in zip(self.points, self.points[1:], strict=False)):
79
+ raise ValueError("automation points must have strictly increasing beats")
80
+ if self.parameter != "pan" and any(point.value < 0 for point in self.points):
81
+ raise ValueError("gain automation values must be between 0 and 1")
82
+ return self
83
+
84
+
85
+ class Delay(ScoreModel):
86
+ """Finite echo train: first echo at full wet gain, successive echoes decay."""
87
+
88
+ type: Literal["delay"] = "delay"
89
+ time_seconds: Annotated[Number, Field(ge=0.01, le=2)] = 0.25
90
+ feedback: Annotated[Number, Field(ge=0, le=0.85)] = 0.4
91
+ repeats: Annotated[StrictInt, Field(ge=1, le=16)] = 4
92
+ mix: Annotated[Number, Field(ge=0, le=1)] = 0.2
93
+
94
+
95
+ class Reverb(ScoreModel):
96
+ """Generated stereo comb network with diffusion; no measured impulse response."""
97
+
98
+ type: Literal["reverb"] = "reverb"
99
+ decay_seconds: Annotated[Number, Field(ge=0.1, le=5)] = 1.2
100
+ mix: Annotated[Number, Field(ge=0, le=1)] = 0.2
101
+
102
+
103
+ Effect = Annotated[Delay | Reverb, Field(discriminator="type")]
104
+
105
+
106
+ def _validate_lanes(lanes: tuple[Automation, ...], allowed: set[str]) -> None:
107
+ names = [lane.parameter for lane in lanes]
108
+ if len(names) != len(set(names)) or not set(names) <= allowed:
109
+ raise ValueError(f"automation parameters must be unique and drawn from {sorted(allowed)}")
110
+
111
+
112
+ class Note(ScoreModel):
113
+ pitch: Pitch = "C4"
114
+ start: Annotated[Number, Field(ge=0)] = 0
115
+ duration: Annotated[Number, Field(gt=0)] = 1
116
+ velocity: Annotated[Number, Field(gt=0, le=1)] = 0.8
117
+ release_seconds: Annotated[Number, Field(ge=0, le=10)] | None = None
118
+
119
+ @field_validator("pitch")
120
+ @classmethod
121
+ def valid_pitch(cls, value: int | str) -> int | str:
122
+ midi_pitch(value)
123
+ return value
124
+
125
+
126
+ class Tone(ScoreModel):
127
+ """Instrument-specific generated tone controls; never refers to an audio asset."""
128
+
129
+ brightness: Annotated[Number, Field(ge=0, le=1)] = 0.5
130
+ decay_seconds: Annotated[Number, Field(ge=0.1, le=20)] | None = None
131
+ pluck_position: Annotated[Number, Field(ge=0.05, le=0.45)] | None = None
132
+ breath: Annotated[Number, Field(ge=0, le=1)] | None = None
133
+ vibrato_depth_cents: Annotated[Number, Field(ge=0, le=100)] | None = None
134
+ vibrato_rate_hz: Annotated[Number, Field(ge=0.1, le=12)] | None = None
135
+ glide_semitones: Annotated[Number, Field(ge=-24, le=24)] | None = None
136
+ detune_cents: Annotated[Number, Field(ge=0, le=40)] | None = None
137
+
138
+
139
+ class Track(ScoreModel):
140
+ name: Annotated[str, Field(strict=True, min_length=1, max_length=80)]
141
+ instrument: Instrument = "pluck"
142
+ gain: Annotated[Number, Field(ge=0, le=1)] = 0.6
143
+ pan: Annotated[Number, Field(ge=-1, le=1)] = 0
144
+ tone: Tone | None = None
145
+ notes: tuple[Note, ...] = ()
146
+ release_seconds: Annotated[Number, Field(ge=0, le=10)] = 0
147
+ automation: Annotated[tuple[Automation, ...], Field(max_length=2)] = ()
148
+ effects: Annotated[tuple[Effect, ...], Field(max_length=4)] = ()
149
+
150
+ pedal: Annotated[tuple[PedalEvent, ...], Field(max_length=1024)] = ()
151
+
152
+ @field_validator("instrument", mode="before")
153
+ @classmethod
154
+ def available_instrument(cls, value: object) -> object:
155
+ if isinstance(value, str):
156
+ require_instrument(value)
157
+ return value
158
+
159
+ @model_validator(mode="after")
160
+ def validate_tone(self) -> Track:
161
+ _validate_lanes(self.automation, {"gain", "pan"})
162
+ if self.pedal:
163
+ if self.instrument != "piano":
164
+ raise ValueError("pedal events are supported only by piano")
165
+ if any(a.beat >= b.beat for a, b in zip(self.pedal, self.pedal[1:], strict=False)):
166
+ raise ValueError("pedal events must have strictly increasing beats")
167
+ if any(event.down != (index % 2 == 0) for index, event in enumerate(self.pedal)):
168
+ raise ValueError("pedal events must alternate down/up, starting with down")
169
+ if self.instrument == "drum_machine":
170
+ for note in self.notes:
171
+ if midi_pitch(note.pitch) not in KIT_NOTES:
172
+ raise ValueError(f"drum_machine pitch must be one of {sorted(KIT_NOTES)}")
173
+ if self.tone is not None:
174
+ supported = get_instrument(self.instrument).tone_controls
175
+ if not supported:
176
+ raise ValueError(f"tone controls are not supported by {self.instrument}")
177
+ for name, value in self.tone.model_dump().items():
178
+ if value is not None and name not in supported:
179
+ raise ValueError(f"{name} is not supported by {self.instrument}")
180
+ return self
181
+
182
+ @field_validator("name")
183
+ @classmethod
184
+ def valid_name(cls, value: str) -> str:
185
+ if not value.strip():
186
+ raise ValueError("track name must contain a visible character")
187
+ return value
188
+
189
+
190
+ class Song(ScoreModel):
191
+ schema_version: Literal["1"] = "1"
192
+ title: Annotated[str, Field(strict=True, min_length=1, max_length=200)] = "Untitled"
193
+ bpm: Annotated[Number, Field(ge=20, le=300)] = 120
194
+ beats: Annotated[Number, Field(gt=0, le=65536)] = 16
195
+ sample_rate: Literal[22050, 44100, 48000] = 44100
196
+ seed: Annotated[StrictInt, Field(ge=0, le=4294967295)] = 0
197
+ master_gain: Annotated[Number, Field(ge=0, le=1)] = 0.8
198
+ tracks: Annotated[tuple[Track, ...], Field(min_length=1, max_length=64)]
199
+ tempo_map: Annotated[tuple[TempoChange, ...], Field(max_length=1024)] = ()
200
+ automation: Annotated[tuple[Automation, ...], Field(max_length=1)] = ()
201
+ effects: Annotated[tuple[Effect, ...], Field(max_length=4)] = ()
202
+
203
+ @model_validator(mode="after")
204
+ def validate_arrangement(self) -> Song:
205
+ _validate_lanes(self.automation, {"master_gain"})
206
+ if any(a.beat >= b.beat for a, b in zip(self.tempo_map, self.tempo_map[1:], strict=False)):
207
+ raise ValueError("tempo_map must have strictly increasing beats")
208
+ if any(change.beat >= self.beats for change in self.tempo_map):
209
+ raise ValueError("tempo changes must occur before the song ends")
210
+ for owner in (self, *self.tracks):
211
+ for lane in owner.automation:
212
+ if lane.points[-1].beat > self.beats:
213
+ raise ValueError("automation points must not exceed the song's beats")
214
+ names: set[str] = set()
215
+ count = 0
216
+ for track in self.tracks:
217
+ if track.pedal and track.pedal[-1].beat > self.beats:
218
+ raise ValueError("pedal events must not exceed the song's beats")
219
+ if track.name in names:
220
+ raise ValueError(f"duplicate track name: {track.name!r}")
221
+ names.add(track.name)
222
+ count += len(track.notes)
223
+ for index, note in enumerate(track.notes):
224
+ end = note.start + note.duration
225
+ if not math.isfinite(end) or end > self.beats + 1e-9:
226
+ raise ValueError(
227
+ f"track {track.name!r}, note {index}: ends at beat {end}; "
228
+ f"song ends at {self.beats}"
229
+ )
230
+ if count > 100000:
231
+ raise ValueError("a score may contain at most 100000 notes")
232
+ return self
233
+
234
+ @property
235
+ def seconds(self) -> float:
236
+ return self.beat_to_seconds(self.beats)
237
+
238
+ def beat_to_seconds(self, beat: float) -> float:
239
+ """Integrate piecewise-constant BPM; the last tempo continues beyond the score."""
240
+ if not math.isfinite(beat) or beat < 0:
241
+ raise ValueError("beat must be finite and nonnegative")
242
+ if not self.tempo_map:
243
+ return beat * 60 / self.bpm
244
+ seconds, previous, bpm = 0.0, 0.0, self.bpm
245
+ for change in self.tempo_map:
246
+ if change.beat > beat:
247
+ break
248
+ seconds += (change.beat - previous) * 60 / bpm
249
+ previous, bpm = change.beat, change.bpm
250
+ return seconds + (beat - previous) * 60 / bpm
251
+
252
+ @property
253
+ def render_seconds(self) -> float:
254
+ from .effects import effects_tail
255
+
256
+ end = self.seconds
257
+ for track in self.tracks:
258
+ dry_end = max(
259
+ (
260
+ self.beat_to_seconds(self.note_gate_end(track, n))
261
+ + self.note_release_seconds(track, n)
262
+ for n in track.notes
263
+ ),
264
+ default=0.0,
265
+ )
266
+ end = max(end, dry_end + effects_tail(track.effects))
267
+ return end + effects_tail(self.effects)
268
+
269
+ def note_gate_end(self, track: Track, note: Note) -> float:
270
+ """Written key release, extended to pedal lift when caught by the damper pedal."""
271
+ end = note.start + note.duration
272
+ index = bisect_right(track.pedal, end, key=lambda event: event.beat)
273
+ if index and track.pedal[index - 1].down:
274
+ return track.pedal[index].beat if index < len(track.pedal) else max(end, self.beats)
275
+ return end
276
+
277
+ def note_release_seconds(self, track: Track, note: Note) -> float:
278
+ """A caught piano note defaults to a 120ms damper tail after pedal lift."""
279
+ if note.release_seconds is not None:
280
+ return note.release_seconds
281
+ if track.release_seconds:
282
+ return track.release_seconds
283
+ return 0.12 if self.note_gate_end(track, note) > note.start + note.duration else 0.0
284
+
285
+ @classmethod
286
+ def load(cls, path: str | Path) -> Song:
287
+ return cls.model_validate_json(Path(path).read_text(encoding="utf-8-sig"))
288
+
289
+ def save(self, path: str | Path) -> None:
290
+ destination = Path(path)
291
+ destination.parent.mkdir(parents=True, exist_ok=True)
292
+ destination.write_text(self.model_dump_json(indent=2) + "\n", encoding="utf-8")