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/__init__.py +44 -0
- audio_as_code/__main__.py +3 -0
- audio_as_code/_audio.py +87 -0
- audio_as_code/_export_rules.py +32 -0
- audio_as_code/_orchestra_profiles.py +188 -0
- audio_as_code/_paths.py +36 -0
- audio_as_code/_voices.py +173 -0
- audio_as_code/acoustics.py +126 -0
- audio_as_code/automation.py +40 -0
- audio_as_code/cli.py +171 -0
- audio_as_code/demo.py +106 -0
- audio_as_code/effects.py +85 -0
- audio_as_code/extended.py +283 -0
- audio_as_code/inspection.py +351 -0
- audio_as_code/instruments.py +923 -0
- audio_as_code/midi.py +192 -0
- audio_as_code/model.py +292 -0
- audio_as_code/orchestra.py +481 -0
- audio_as_code/pattern.py +151 -0
- audio_as_code/physical.py +189 -0
- audio_as_code/py.typed +0 -0
- audio_as_code/render.py +230 -0
- audio_as_code-0.1.0.dist-info/METADATA +340 -0
- audio_as_code-0.1.0.dist-info/RECORD +27 -0
- audio_as_code-0.1.0.dist-info/WHEEL +4 -0
- audio_as_code-0.1.0.dist-info/entry_points.txt +2 -0
- audio_as_code-0.1.0.dist-info/licenses/LICENSE +21 -0
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")
|