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.
@@ -0,0 +1,351 @@
1
+ """Read-only score measurements and static export checks, without synthesizing audio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._export_rules import (
6
+ MAX_MELODIC_TRACKS,
7
+ MAX_MIDI_TICK,
8
+ MAX_RENDER_SECONDS,
9
+ TICKS_PER_BEAT,
10
+ midi_note_ticks,
11
+ midi_tick,
12
+ note_frame,
13
+ )
14
+ from ._export_rules import midi_export_pitch as _export_pitch
15
+ from .effects import effects_tail
16
+ from .instruments import DRUM_NOTES, get_instrument
17
+ from .model import Song, midi_pitch
18
+
19
+
20
+ def _issue(
21
+ issues: list[dict], code: str, target: str, path: list, message: str, *, blocking: bool = False
22
+ ) -> None:
23
+ issues.append(
24
+ {
25
+ "code": code,
26
+ "severity": "error" if blocking else "warning",
27
+ "target": target,
28
+ "path": path,
29
+ "message": message,
30
+ }
31
+ )
32
+
33
+
34
+ def _polyphony(intervals: list[tuple[float, float]]) -> int:
35
+ events = [event for start, end in intervals if end > start for event in ((start, 1), (end, -1))]
36
+ active = peak = 0
37
+ for _, change in sorted(events):
38
+ active += change
39
+ peak = max(peak, active)
40
+ return peak
41
+
42
+
43
+ def _range(values: list[int]) -> dict | None:
44
+ return {"min": min(values), "max": max(values)} if values else None
45
+
46
+
47
+ def _midi_issues(song: Song, issues: list[dict]) -> int:
48
+ melodic_count = sum(track.instrument not in DRUM_NOTES for track in song.tracks)
49
+ if melodic_count > MAX_MELODIC_TRACKS:
50
+ _issue(
51
+ issues,
52
+ "midi_melodic_channel_limit",
53
+ "midi",
54
+ ["tracks"],
55
+ f"MIDI supports at most {MAX_MELODIC_TRACKS} melodic tracks; "
56
+ "empty and muted tracks also count.",
57
+ blocking=True,
58
+ )
59
+ end_tick = midi_tick(song.beats)
60
+ if not 1 <= end_tick <= MAX_MIDI_TICK:
61
+ _issue(
62
+ issues,
63
+ "midi_duration_out_of_range",
64
+ "midi",
65
+ ["beats"],
66
+ "Song duration cannot be represented on the MIDI tick grid.",
67
+ blocking=True,
68
+ )
69
+ drum_kinds: set[str] = set()
70
+ percussion: dict[int, list[tuple[int, int, int, int]]] = {}
71
+ for track_index, track in enumerate(song.tracks):
72
+ path = ["tracks", track_index]
73
+ is_drum = track.instrument in DRUM_NOTES
74
+ if is_drum:
75
+ if track.instrument in drum_kinds:
76
+ _issue(
77
+ issues,
78
+ "midi_duplicate_drum_instrument",
79
+ "midi",
80
+ [*path, "instrument"],
81
+ "MIDI requires at most one track per drum instrument.",
82
+ blocking=True,
83
+ )
84
+ drum_kinds.add(track.instrument)
85
+ _issue(
86
+ issues,
87
+ "midi_shared_percussion_channel",
88
+ "midi",
89
+ path,
90
+ "Percussion shares channel 10; gain is encoded in velocity, per-track pan "
91
+ "is omitted, and events are grouped in the first percussion track.",
92
+ )
93
+ note_ends: dict[int, int] = {}
94
+ if track.pedal:
95
+ _issue(
96
+ issues,
97
+ "midi_pedal_receiver_behavior",
98
+ "midi",
99
+ [*path, "pedal"],
100
+ "Binary piano pedal is exported as CC64; open pedal lifts at score end. "
101
+ "Damper tails and repeated-pitch behavior depend on the receiving synthesizer.",
102
+ )
103
+ if any(midi_tick(event.beat) / TICKS_PER_BEAT != event.beat for event in track.pedal):
104
+ _issue(
105
+ issues,
106
+ "midi_pedal_timing_quantized",
107
+ "midi",
108
+ [*path, "pedal"],
109
+ "Some pedal events move or collapse together on the 480-tick MIDI grid.",
110
+ )
111
+ quantized = False
112
+ for note_index, note in sorted(enumerate(track.notes), key=lambda item: item[1].start):
113
+ pitch = _export_pitch(track, note.pitch)
114
+ start, end = midi_note_ticks(note, end_tick)
115
+ note_path = [*path, "notes", note_index]
116
+ quantized |= (
117
+ start / TICKS_PER_BEAT != note.start
118
+ or end / TICKS_PER_BEAT != note.start + note.duration
119
+ )
120
+ if end <= start:
121
+ _issue(
122
+ issues,
123
+ "midi_note_below_tick",
124
+ "midi",
125
+ note_path,
126
+ "Note has no positive duration after MIDI tick rounding.",
127
+ blocking=True,
128
+ )
129
+ if start < note_ends.get(pitch, 0):
130
+ _issue(
131
+ issues,
132
+ "midi_same_pitch_overlap",
133
+ "midi",
134
+ note_path,
135
+ f"MIDI pitch {pitch} overlaps an earlier note on this track.",
136
+ blocking=True,
137
+ )
138
+ note_ends[pitch] = max(end, note_ends.get(pitch, 0))
139
+ if is_drum and track.gain and song.master_gain and end > start:
140
+ percussion.setdefault(pitch, []).append((start, end, track_index, note_index))
141
+ if quantized:
142
+ _issue(
143
+ issues,
144
+ "midi_note_timing_quantized",
145
+ "midi",
146
+ [*path, "notes"],
147
+ "Some note boundaries change on the 480-tick-per-beat MIDI grid.",
148
+ )
149
+ if track.tone is not None:
150
+ _issue(
151
+ issues,
152
+ "midi_tone_not_exported",
153
+ "midi",
154
+ [*path, "tone"],
155
+ "Instrument tone controls are not exported to MIDI.",
156
+ )
157
+ if track.release_seconds or any(note.release_seconds for note in track.notes):
158
+ _issue(
159
+ issues,
160
+ "midi_releases_not_exported",
161
+ "midi",
162
+ path,
163
+ "Note releases and audio tails are not exported; MIDI uses written durations.",
164
+ )
165
+ for pitch, intervals in sorted(percussion.items()):
166
+ previous_end, previous_track = 0, -1
167
+ for start, end, track_index, note_index in sorted(intervals):
168
+ if start < previous_end and track_index != previous_track:
169
+ _issue(
170
+ issues,
171
+ "midi_percussion_pitch_overlap",
172
+ "midi",
173
+ ["tracks", track_index, "notes", note_index],
174
+ f"Percussion MIDI pitch {pitch} overlaps track {previous_track} "
175
+ "on shared channel 10.",
176
+ blocking=True,
177
+ )
178
+ if end > previous_end:
179
+ previous_end, previous_track = end, track_index
180
+ for path, owner in [([], song), *[(["tracks", i], t) for i, t in enumerate(song.tracks)]]:
181
+ if owner.automation:
182
+ _issue(
183
+ issues,
184
+ "midi_automation_not_exported",
185
+ "midi",
186
+ [*path, "automation"],
187
+ "MIDI uses static gain and pan; automation is not exported.",
188
+ )
189
+ if owner.effects:
190
+ _issue(
191
+ issues,
192
+ "midi_effects_not_exported",
193
+ "midi",
194
+ [*path, "effects"],
195
+ "Procedural effects and their audio tails are not exported to MIDI.",
196
+ )
197
+ if any(midi_tick(change.beat) / TICKS_PER_BEAT != change.beat for change in song.tempo_map):
198
+ _issue(
199
+ issues,
200
+ "midi_tempo_timing_quantized",
201
+ "midi",
202
+ ["tempo_map"],
203
+ "Some tempo changes move on the 480-tick-per-beat MIDI grid.",
204
+ )
205
+ return melodic_count
206
+
207
+
208
+ def inspect_score(song: Song) -> dict:
209
+ """Return deterministic JSON-safe score facts and static render/MIDI readiness.
210
+
211
+ Invalid models raise Pydantic ValidationError, including unchecked model copies.
212
+ Readiness means the documented static checks pass, not that output quality,
213
+ runtime resources, or destination permissions have been established.
214
+ """
215
+ song = Song.model_validate(song.model_dump())
216
+ issues: list[dict] = []
217
+ duration = song.seconds
218
+ render_duration = song.render_seconds
219
+ frames = round(render_duration * song.sample_rate)
220
+ if render_duration > MAX_RENDER_SECONDS:
221
+ _issue(
222
+ issues,
223
+ "render_duration_limit",
224
+ "render",
225
+ [],
226
+ f"Render duration including releases and effects exceeds {MAX_RENDER_SECONDS} seconds.",
227
+ blocking=True,
228
+ )
229
+ if frames < 1:
230
+ _issue(
231
+ issues,
232
+ "render_below_one_sample",
233
+ "render",
234
+ ["beats"],
235
+ "Song duration rounds to fewer than one audio sample.",
236
+ blocking=True,
237
+ )
238
+ tracks = []
239
+ all_intervals = []
240
+ for track_index, track in enumerate(song.tracks):
241
+ instrument = get_instrument(track.instrument)
242
+ intervals = [(note.start, note.start + note.duration) for note in track.notes]
243
+ all_intervals.extend(intervals)
244
+ starts = [start for start, _ in intervals]
245
+ ends = [end for _, end in intervals]
246
+ dry_end = max(
247
+ (
248
+ song.beat_to_seconds(song.note_gate_end(track, note))
249
+ + song.note_release_seconds(track, note)
250
+ for note in track.notes
251
+ ),
252
+ default=0.0,
253
+ )
254
+ if not track.notes:
255
+ _issue(
256
+ issues,
257
+ "empty_track",
258
+ "score",
259
+ ["tracks", track_index, "notes"],
260
+ "Track contains no notes.",
261
+ )
262
+ for note_index, note in enumerate(track.notes):
263
+ start_frame = note_frame(song, note.start)
264
+ end_frame = note_frame(song, song.note_gate_end(track, note))
265
+ if end_frame <= start_frame:
266
+ _issue(
267
+ issues,
268
+ "render_note_below_sample",
269
+ "render",
270
+ ["tracks", track_index, "notes", note_index],
271
+ "Renderer skips this note because its held duration rounds to zero samples.",
272
+ )
273
+ tracks.append(
274
+ {
275
+ "index": track_index,
276
+ "name": track.name,
277
+ "instrument": track.instrument,
278
+ "family": instrument.family,
279
+ "engine": instrument.engine,
280
+ "instrument_description": instrument.description,
281
+ "note_count": len(track.notes),
282
+ "pedal_event_count": len(track.pedal),
283
+ "pedal_auto_lift": bool(track.pedal and track.pedal[-1].down),
284
+ "pedal_sustained_note_count": sum(
285
+ song.note_gate_end(track, note) > note.start + note.duration
286
+ for note in track.notes
287
+ ),
288
+ "written_pitch_range": _range([midi_pitch(note.pitch) for note in track.notes]),
289
+ "midi_pitch_range": _range(
290
+ [_export_pitch(track, note.pitch) for note in track.notes]
291
+ ),
292
+ "first_note_beat": min(starts) if starts else None,
293
+ "last_note_end_beat": max(ends) if ends else None,
294
+ "first_note_seconds": song.beat_to_seconds(min(starts)) if starts else None,
295
+ "last_note_end_seconds": song.beat_to_seconds(max(ends)) if ends else None,
296
+ "dry_end_seconds": dry_end,
297
+ "effect_end_seconds": dry_end + effects_tail(track.effects),
298
+ "max_note_polyphony": _polyphony(intervals),
299
+ "midi_percussion": track.instrument in DRUM_NOTES,
300
+ }
301
+ )
302
+ melodic_count = _midi_issues(song, issues)
303
+ return {
304
+ "inspection_version": "1",
305
+ "validation": {"valid": True, "schema_version": song.schema_version},
306
+ "summary": {
307
+ "title": song.title,
308
+ "bpm": song.bpm,
309
+ "beats": song.beats,
310
+ "sample_rate": song.sample_rate,
311
+ "seed": song.seed,
312
+ "track_count": len(tracks),
313
+ "note_count": sum(track["note_count"] for track in tracks),
314
+ "max_note_polyphony": _polyphony(all_intervals),
315
+ "duration_seconds": duration,
316
+ "render_duration_seconds": render_duration,
317
+ "tail_seconds": max(0.0, render_duration - duration),
318
+ },
319
+ "tracks": tracks,
320
+ "readiness": {
321
+ "render": {
322
+ "ready": not any(
323
+ i["target"] == "render" and i["severity"] == "error" for i in issues
324
+ ),
325
+ "duration_limit_seconds": MAX_RENDER_SECONDS,
326
+ "frames": frames,
327
+ },
328
+ "midi": {
329
+ "ready": not any(
330
+ i["target"] == "midi" and i["severity"] == "error" for i in issues
331
+ ),
332
+ "melodic_tracks": melodic_count,
333
+ "melodic_track_limit": MAX_MELODIC_TRACKS,
334
+ "ticks_per_beat": TICKS_PER_BEAT,
335
+ },
336
+ },
337
+ "issues": issues,
338
+ "limitations": [
339
+ "Readiness covers static score checks only; no audio or MIDI is generated, "
340
+ "and runtime resources and output paths are not checked.",
341
+ "Polyphony counts written half-open note intervals, including muted notes; "
342
+ "it excludes pedal sustain, releases and effects and does not estimate CPU use "
343
+ "or audible voices.",
344
+ "Piano pedal extends independent note gates until lift; this damper approximation "
345
+ "does not model half-pedaling, sympathetic resonance, or repedaling released tails.",
346
+ "Timing and tail horizons follow score rules, not measured audible duration. "
347
+ "No loudness, clipping, tuning, musical quality, or perceptual realism is measured.",
348
+ "MIDI preserves notes and tempo with tick rounding; sounds depend on the receiving "
349
+ "synthesizer and do not reproduce the procedural audio voices.",
350
+ ],
351
+ }