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,481 @@
1
+ """Generated instrument prototypes: modal strings, resonators and source/filter tones.
2
+
3
+ No measured spectra or recordings are used. Coefficients are hand-designed;
4
+ bowed and wind voices approximate the resulting spectrum, not a coupled
5
+ nonlinear bow/reed/lip simulation. See docs/orchestra.md for model boundaries.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+
15
+ from ._orchestra_profiles import HELD, RESONATORS, STRINGS
16
+ from ._orchestra_profiles import HeldProfile as HeldProfile
17
+ from ._orchestra_profiles import ResonatorProfile as ResonatorProfile
18
+ from ._orchestra_profiles import StringProfile as StringProfile
19
+ from .acoustics import (
20
+ colored_noise,
21
+ modulate_noise,
22
+ nyquist_gain,
23
+ resonant_body,
24
+ slow_variation,
25
+ struck_mode,
26
+ )
27
+ from .instruments import require_instrument
28
+ from .model import Tone
29
+
30
+ Signal = NDArray[np.float64]
31
+
32
+
33
+ EXTRA_INSTRUMENTS = (
34
+ frozenset(STRINGS)
35
+ | frozenset(HELD)
36
+ | frozenset(RESONATORS)
37
+ | {"piano", "cymbal", "tambourine", "synthesizer"}
38
+ )
39
+
40
+
41
+ def _formant(frequency: float, formants: tuple) -> float:
42
+ return 1 + sum(
43
+ gain * math.exp(-0.5 * ((frequency - center) / width) ** 2)
44
+ for center, width, gain in formants
45
+ )
46
+
47
+
48
+ def _plucked(
49
+ instrument: str,
50
+ frequency: float,
51
+ t: Signal,
52
+ rate: int,
53
+ brightness: float,
54
+ decay: float,
55
+ position: float | None,
56
+ seed: int,
57
+ ) -> Signal:
58
+ profile = STRINGS[instrument]
59
+ position = profile.position if position is None else position
60
+ rng = np.random.Generator(np.random.PCG64(seed))
61
+ position = float(np.clip(position + rng.uniform(-0.004, 0.004), 0.05, 0.45))
62
+ polarization_cents = profile.detune or 0.8
63
+ signal = np.zeros(len(t))
64
+ total = 0.0
65
+ for n in range(1, 49):
66
+ f = frequency * n * math.sqrt((1 + profile.stiffness * n * n) / (1 + profile.stiffness))
67
+ # Initial string displacement and observation position select modes.
68
+ amplitude = math.sin(math.pi * n * position) / n**profile.tilt
69
+ amplitude *= math.exp(-(n - 1) / (3 + 25 * brightness))
70
+ amplitude *= rng.uniform(0.97, 1.03)
71
+ if profile.pickup is not None:
72
+ amplitude *= math.sin(math.pi * n * profile.pickup)
73
+ if instrument == "bass_guitar" and n == 1:
74
+ amplitude *= 1.6
75
+ total += abs(amplitude)
76
+ if f >= rate * 0.49:
77
+ continue
78
+ damping = 1 + profile.damping * (n - 1) ** 0.85
79
+ envelope = np.exp(-math.log(1000) * t * damping / decay)
80
+ partial = 0.82 * envelope * np.sin(2 * np.pi * f * t) * nyquist_gain(f, rate)
81
+ companion = f * 2 ** (polarization_cents / 1200)
82
+ partial += (
83
+ 0.18
84
+ * np.exp(-math.log(1000) * t * damping / (decay * 1.35))
85
+ * np.sin(2 * np.pi * companion * t)
86
+ * nyquist_gain(companion, rate)
87
+ )
88
+ signal += amplitude * partial
89
+ if total:
90
+ signal *= 0.85 / total
91
+ contact = 0.012 if instrument == "harpsichord" else 0.009
92
+ signal += (
93
+ contact
94
+ * brightness
95
+ * colored_noise(len(t), rate, seed + 1, 1400, 8000)
96
+ * np.exp(-t / 0.006)
97
+ )
98
+ modes = tuple((f, min(0.3, lifetime * 6.9), gain) for f, gain, lifetime in profile.body)
99
+ return resonant_body(signal, rate, modes, wet=0.22)
100
+
101
+
102
+ def _piano_poles(
103
+ frequency: float, damping: float, detunes: tuple[float, ...]
104
+ ) -> tuple[NDArray[np.complex128], NDArray[np.complex128]]:
105
+ """Passive narrow-band unisons coupled through a resistive bridge.
106
+
107
+ The Hermitian part is negative definite: intrinsic string loss plus a
108
+ positive rank-one bridge loss. Common motion radiates more strongly than
109
+ differential motion. Small mistuning transfers energy between the two.
110
+ """
111
+ count = len(detunes)
112
+ frequencies = frequency * 2 ** (np.asarray(detunes) / 1200)
113
+ matrix = np.diag(-0.28 * damping + 2j * np.pi * frequencies)
114
+ matrix -= 0.72 * damping * np.ones((count, count)) / count
115
+ poles, modes = np.linalg.eig(matrix)
116
+ residues = np.sum(modes, axis=0) * np.linalg.solve(modes, np.ones(count)) / count
117
+ return poles, residues
118
+
119
+
120
+ def _forced_piano_pole(t: Signal, pole: complex, contact: float) -> NDArray[np.complex128]:
121
+ """Complex modal response to the same finite hammer pulse as struck_mode.
122
+
123
+ Coupled modes have complex residues, so both response quadratures matter.
124
+ Evaluate the three forcing terms only during contact; the tail is one exp.
125
+ """
126
+ attack_frames = int(np.searchsorted(t, contact))
127
+ u = t[:attack_frames]
128
+ response = np.empty(len(t), dtype=np.complex128)
129
+ response[:attack_frames] = 0
130
+ released = 0j
131
+ for weight, offset in ((1, 0), (-0.5, 2 * np.pi / contact), (-0.5, -2 * np.pi / contact)):
132
+ denominator = pole - 1j * offset
133
+ response[:attack_frames] += (
134
+ weight * np.exp(1j * offset * u) * np.expm1(denominator * u) / denominator
135
+ )
136
+ released += (
137
+ weight * np.exp(1j * offset * contact) * np.expm1(denominator * contact) / denominator
138
+ )
139
+ # Reuse the complex output buffer for the long free tail. Keep each
140
+ # operation (and its rounding) in the same order as the analytic expression.
141
+ tail = response[attack_frames:]
142
+ np.multiply(pole, t[attack_frames:] - contact, out=tail)
143
+ np.exp(tail, out=tail)
144
+ np.multiply(released, tail, out=tail)
145
+ response /= contact
146
+ return response
147
+
148
+
149
+ def _piano(
150
+ frequency: float, t: Signal, rate: int, brightness: float, decay: float, seed: int
151
+ ) -> Signal:
152
+ signal = np.zeros(len(t))
153
+ rng = np.random.Generator(np.random.PCG64(seed))
154
+ stiffness = 0.00009 * (1 + (frequency / 500) ** 1.6)
155
+ # Tight unisons avoid mistaking a beating side-lobe for a shifted fundamental.
156
+ # Upper partials still beat faster because their separation scales with pitch.
157
+ detunes = (0,) if frequency < 65 else (-0.6, 0.6) if frequency < 130 else (-0.9, 0, 0.9)
158
+ contact = min(0.65 / frequency, (0.004 - brightness * 0.0028) * (220 / frequency) ** 0.35)
159
+ total = 0.0
160
+ for n in range(1, 41):
161
+ f = frequency * n * math.sqrt((1 + stiffness * n * n) / (1 + stiffness))
162
+ # Hammer location selects modes; finite forcing supplies contact filtering.
163
+ amplitude = abs(math.sin(math.pi * n * 0.137)) / n**1.1
164
+ amplitude *= math.exp(-((n / (3.5 + brightness * 15)) ** 2))
165
+ amplitude *= rng.uniform(0.98, 1.02)
166
+ total += amplitude
167
+ damping = (1 + 0.16 * n**1.25) * (frequency / 220) ** 0.2
168
+ if len(detunes) == 1:
169
+ # Preserve the single bass string's existing two-polarization decay.
170
+ # There is no neighbouring unison to exchange energy with here.
171
+ if f < rate * 0.49:
172
+ response = 0.8 * struck_mode(t, f, 6.9 * damping / decay, contact)
173
+ response += 0.2 * struck_mode(t, f, 6.9 * damping / (decay * 2.2), contact)
174
+ signal += amplitude * response * nyquist_gain(f, rate)
175
+ continue
176
+ poles, residues = _piano_poles(f, 6.9 * damping / decay, detunes)
177
+ for pole, residue in zip(poles, residues, strict=True):
178
+ partial_frequency = pole.imag / (2 * np.pi)
179
+ if partial_frequency >= rate * 0.49:
180
+ continue
181
+ forced = _forced_piano_pole(t, pole, contact)
182
+ np.multiply(residue, forced, out=forced)
183
+ response = forced.imag
184
+ signal += amplitude * response * nyquist_gain(partial_frequency, rate)
185
+ if total:
186
+ signal *= 0.9 / total
187
+ signal += (
188
+ (0.012 + 0.032 * brightness**2)
189
+ * colored_noise(len(t), rate, seed, 700, 7500)
190
+ * np.exp(-t / 0.009)
191
+ )
192
+ return resonant_body(
193
+ signal,
194
+ rate,
195
+ ((95, 0.2, 0.4), (210, 0.14, 0.3), (580, 0.09, 0.2), (1250, 0.06, 0.1)),
196
+ wet=0.17,
197
+ )
198
+
199
+
200
+ def _bowed_transfer(frequency: float, formants: tuple) -> complex:
201
+ """Designed damped body resonances, including their phase response.
202
+
203
+ Each bandpass has its specified peak gain and half-power bandwidth in Hz.
204
+ This is a steady-state body approximation, not measured violin admittance.
205
+ """
206
+ return 1 + sum(
207
+ gain * (1j * frequency * width) / (center**2 - frequency**2 + 1j * frequency * width)
208
+ for center, width, gain in formants
209
+ )
210
+
211
+
212
+ def _held(
213
+ instrument: str,
214
+ frequency: float,
215
+ t: Signal,
216
+ rate: int,
217
+ seed: int,
218
+ brightness: float,
219
+ settings: dict,
220
+ ) -> Signal:
221
+ profile = HELD[instrument]
222
+ bowed = instrument in {"violin", "viola", "cello", "double_bass"}
223
+ brass = instrument in {"trumpet", "trombone", "french_horn", "tuba"}
224
+ acoustic = instrument not in {"organ", "theremin"}
225
+ depth = settings.get("vibrato_depth_cents", 0)
226
+ vibrato_rate = settings.get("vibrato_rate_hz", 5)
227
+ motion = slow_variation(t, seed, 0.7)
228
+ pressure = 1 + (0.025 if acoustic else 0) * motion
229
+ vibrato_phase = 2 * np.pi * vibrato_rate * t + (0.09 if acoustic else 0) * slow_variation(
230
+ t, seed + 1, 0.45
231
+ )
232
+ vibrato = depth * (1 - np.exp(-np.maximum(0, t - 0.07) / 0.24)) * np.sin(vibrato_phase)
233
+ drift = 0.9 * slow_variation(t, seed + 2, 0.4) if acoustic else 0
234
+ settling = (-4 if brass else -1.5) * np.exp(-t / 0.025) if acoustic else 0
235
+ glide = settings.get("glide_semitones", 0) * 100 * np.exp(-t / 0.12)
236
+ frequencies = frequency * 2 ** ((vibrato + glide + drift + settling) / 1200)
237
+ # Trapezoidal phase integration follows the instantaneous pitch without
238
+ # the half-sample frequency error of a right-endpoint sum during glides.
239
+ phase = np.zeros(len(t))
240
+ phase[1:] = 2 * np.pi * np.cumsum((frequencies[:-1] + frequencies[1:]) * 0.5) / rate
241
+ lowest_frequency = float(np.min(frequencies))
242
+ signal = np.zeros(len(t))
243
+ total = 0.0
244
+ partials = list(profile.partials)
245
+ if bowed:
246
+ # Bowed strings need bridge-band energy even in their low registers.
247
+ last = len(partials)
248
+ partials.extend(partials[-1] * (last / n) ** 1.15 for n in range(last + 1, 49))
249
+ bloom_envelope = (
250
+ (0.3 if brass else 0.12 if bowed else 0.08)
251
+ * brightness
252
+ * (1 - np.exp(-t / 0.007))
253
+ * np.exp(-t / 0.1)
254
+ )
255
+ # The pressure exponent is capped at harmonic twelve for every voice.
256
+ upper_evolution = pressure ** (1 + 0.12 * 12) if len(partials) >= 12 else None
257
+ for n, base in enumerate(partials, start=1):
258
+ if bowed:
259
+ # A rounded Helmholtz corner limits absolute bandwidth; tying that
260
+ # cutoff only to harmonic number erased body-band energy down low.
261
+ bandwidth = 900 + 6500 * brightness**2
262
+ transfer = _bowed_transfer(frequency * n, profile.formants)
263
+ amplitude = base * math.exp(-frequency * (n - 1) / bandwidth) * abs(transfer)
264
+ body_phase = math.atan2(transfer.imag, transfer.real)
265
+ else:
266
+ amplitude = base * math.exp(-(n - 1) * (1 - brightness) * 0.28)
267
+ amplitude *= _formant(frequency * n, profile.formants)
268
+ body_phase = 0
269
+ # Normalize against the designed spectrum, so removing an inaudible
270
+ # mode cannot cause a gain jump in all remaining modes.
271
+ total += amplitude
272
+ if lowest_frequency * n >= rate * 0.49:
273
+ continue
274
+ # Upper modes build after the fundamental, giving a played onset.
275
+ attack = profile.attack * (1 + 0.035 * (n - 1)) / (0.7 + 0.6 * brightness)
276
+ onset = 1 - np.exp(-t / attack)
277
+ bloom = 1 + bloom_envelope * (1 - 1 / n)
278
+ evolution = pressure ** (1 + 0.12 * n) if n < 12 else upper_evolution
279
+ signal += (
280
+ amplitude
281
+ * onset
282
+ * bloom
283
+ * evolution
284
+ * nyquist_gain(frequencies * n, rate)
285
+ * np.sin(n * phase + body_phase)
286
+ )
287
+ if total:
288
+ signal *= 0.8 / total
289
+ noise_amount = profile.bow_noise + settings.get("breath", 0) * 0.16
290
+ if noise_amount:
291
+ noise = colored_noise(len(t), rate, seed, *profile.noise_band)
292
+ noise_envelope = (1 - np.exp(-t / 0.006)) * (1 + 0.6 * np.exp(-t / 0.06)) * pressure
293
+ if instrument == "organ":
294
+ noise_envelope *= np.exp(-t / 0.08)
295
+ # Bow friction and breath are colored by the evolving excitation,
296
+ # rather than a constant noise layer pasted over a stationary tone.
297
+ textured_noise = modulate_noise(
298
+ noise,
299
+ phase + 0.4 * motion,
300
+ float(np.max(frequencies)) + 2,
301
+ rate,
302
+ 0.25 if bowed else 0.12,
303
+ )
304
+ signal += textured_noise * noise_amount * noise_envelope
305
+ # Slight amplitude variation accompanies the played vibrato.
306
+ if depth:
307
+ signal *= 1 - 0.025 * (1 - np.cos(2 * np.pi * vibrato_rate * t))
308
+ return signal
309
+
310
+
311
+ def _resonator(
312
+ instrument: str,
313
+ frequency: float,
314
+ t: Signal,
315
+ rate: int,
316
+ seed: int,
317
+ brightness: float,
318
+ decay: float,
319
+ ) -> Signal:
320
+ profile = RESONATORS[instrument]
321
+ rng = np.random.Generator(np.random.PCG64(seed))
322
+ frequency = {"toms": 125, "congas": 210, "bongos": 340}.get(instrument, frequency)
323
+ membrane = instrument in {"toms", "congas", "bongos", "timpani"}
324
+ signal = np.zeros(len(t))
325
+ total = 0.0
326
+ contact = (0.004 if membrane else 0.0025) * (1.3 - 0.85 * brightness)
327
+ if not membrane:
328
+ material_contact = {
329
+ "electric_piano": 0.0015,
330
+ "xylophone": 0.0008,
331
+ "vibraphone": 0.0025,
332
+ "glockenspiel": 0.0005,
333
+ }[instrument]
334
+ contact = min(0.65 / frequency, material_contact * (1.3 - 0.85 * brightness))
335
+ for index, (ratio, amplitude, lifetime) in enumerate(
336
+ zip(profile.ratios, profile.amplitudes, profile.lifetimes, strict=True)
337
+ ):
338
+ f = frequency * ratio
339
+ amplitude *= 1 if index == 0 else 0.2 + 1.6 * brightness
340
+ amplitude *= rng.uniform(0.975, 1.025)
341
+ total += amplitude
342
+ instantaneous_frequency = f * (1 + 0.08 * np.exp(-t / 0.025)) if membrane else f
343
+ if f >= rate * 0.49:
344
+ continue
345
+ # Tension relaxes after a membrane strike; analytic integral avoids rate drift.
346
+ phase = (
347
+ 2 * np.pi * f * (t + 0.08 * 0.025 * (1 - np.exp(-t / 0.025)))
348
+ if membrane
349
+ else 2 * np.pi * f * t
350
+ )
351
+ envelope = np.exp(-math.log(1000) * t / (decay * lifetime))
352
+ partial = np.sin(phase)
353
+ if not membrane:
354
+ partial = struck_mode(t, f, math.log(1000) / (decay * lifetime), contact)
355
+ if instrument in {"vibraphone", "glockenspiel", "electric_piano"}:
356
+ split = f * (1 + 0.0006 * (index + 1))
357
+ partial = 0.85 * partial + 0.15 * struck_mode(
358
+ t, split, math.log(1000) / (decay * lifetime), contact
359
+ ) * nyquist_gain(split, rate)
360
+ signal += (
361
+ amplitude
362
+ * partial
363
+ * (envelope if membrane else 1)
364
+ * nyquist_gain(instantaneous_frequency, rate)
365
+ )
366
+ if total:
367
+ signal *= 0.85 / total
368
+ if membrane:
369
+ signal *= 1 - np.exp(-t / contact)
370
+ signal += (
371
+ profile.strike
372
+ * (0.3 + brightness)
373
+ * colored_noise(len(t), rate, seed, 500, 7000)
374
+ * np.exp(-t / 0.014)
375
+ )
376
+ if profile.tremolo:
377
+ signal *= 1 - profile.tremolo * (0.5 - 0.5 * np.cos(2 * np.pi * 5.5 * t))
378
+ if membrane:
379
+ # A short generated shell/kettle response, driven by the head motion.
380
+ cavity = {"toms": 95, "congas": 145, "bongos": 260, "timpani": frequency * 0.72}[instrument]
381
+ signal = resonant_body(
382
+ signal, rate, ((cavity, 0.12, 1), (cavity * 2.4, 0.055, 0.3)), wet=0.12
383
+ )
384
+ return signal
385
+
386
+
387
+ def _metal(
388
+ instrument: str, t: Signal, rate: int, seed: int, brightness: float, decay: float
389
+ ) -> Signal:
390
+ rng = np.random.Generator(np.random.PCG64(seed))
391
+ signal = np.zeros(len(t))
392
+ cymbal = instrument == "cymbal"
393
+ frequencies = np.geomspace(280 if cymbal else 1600, 19000, 192 if cymbal else 48)
394
+ frequencies *= rng.uniform(0.96, 1.04, len(frequencies))
395
+ collisions = [0.0] if cymbal else [0.0, *np.sort(rng.uniform(0.005, 0.085, 5))]
396
+ total = 0.0
397
+ for index, f in enumerate(frequencies):
398
+ if f >= rate * 0.49:
399
+ continue
400
+ amplitude = (f / frequencies[0]) ** (-0.65 + 0.45 * brightness)
401
+ lifetime = decay / (1 + 0.028 * index)
402
+ phase = rng.uniform(-np.pi, np.pi)
403
+ # Each collision is an analytic resonator excitation, not a sampled hit.
404
+ for collision in collisions:
405
+ age = np.maximum(0, t - collision)
406
+ active = t >= collision
407
+ attack = 1 - np.exp(-age / 0.0008)
408
+ signal += (
409
+ amplitude
410
+ * active
411
+ * attack
412
+ * np.sin(2 * np.pi * f * age + phase)
413
+ * np.exp(-6.9 * age / lifetime)
414
+ / len(collisions)
415
+ )
416
+ total += amplitude**2
417
+ if total:
418
+ signal *= (0.23 if cymbal else 0.34) / math.sqrt(total)
419
+ wash = colored_noise(len(t), rate, seed + 1, 2400 if cymbal else 4000, 15000)
420
+ wash *= (1 - np.exp(-t / 0.001)) * np.exp(-6.9 * t / (decay * 0.35))
421
+ return signal + (0.12 if cymbal else 0.025) * wash * (0.4 + brightness)
422
+
423
+
424
+ def _synthesizer(
425
+ frequency: float, t: Signal, rate: int, brightness: float, settings: dict
426
+ ) -> Signal:
427
+ signal = np.zeros(len(t))
428
+ detune = settings.get("detune_cents", 8)
429
+ total = 0.0
430
+ cutoff = 2 + brightness * (5 + 20 * np.exp(-t / 0.25))
431
+ for n in range(1, 33):
432
+ amplitude = 1 / n
433
+ total += amplitude
434
+ for cents, weight in [(-detune, 0.25), (0, 0.5), (detune, 0.25)]:
435
+ partial_frequency = frequency * n * 2 ** (cents / 1200)
436
+ if partial_frequency >= rate * 0.49:
437
+ continue
438
+ phase = 2 * np.pi * partial_frequency * t
439
+ signal += (
440
+ weight
441
+ * amplitude
442
+ * nyquist_gain(partial_frequency, rate)
443
+ * np.sin(phase)
444
+ / (1 + (n / cutoff) ** 4)
445
+ )
446
+ return signal * (0.9 / total) if total else signal
447
+
448
+
449
+ def synthesize(
450
+ instrument: str,
451
+ frequency: float,
452
+ frames: int,
453
+ rate: int,
454
+ seed: int,
455
+ velocity: float,
456
+ tone: Tone | None,
457
+ ) -> Signal:
458
+ info = require_instrument(instrument)
459
+ if instrument not in EXTRA_INSTRUMENTS:
460
+ raise ValueError(f"No orchestra model for {instrument!r}")
461
+ if frames < 1 or (info.midi_note is None and frequency >= rate / 2):
462
+ return np.zeros(frames)
463
+ tone = tone or Tone()
464
+ settings = dict(info.default_tone)
465
+ settings.update({key: value for key, value in tone.model_dump().items() if value is not None})
466
+ brightness = min(1.0, settings["brightness"] * 0.75 + velocity * 0.25)
467
+ decay = settings.get("decay_seconds", info.default_decay_seconds)
468
+ t = np.arange(frames, dtype=np.float64) / rate
469
+ if instrument in STRINGS:
470
+ return _plucked(
471
+ instrument, frequency, t, rate, brightness, decay, tone.pluck_position, seed
472
+ )
473
+ if instrument == "piano":
474
+ return _piano(frequency, t, rate, brightness, decay, seed)
475
+ if instrument in HELD:
476
+ return _held(instrument, frequency, t, rate, seed, brightness, settings)
477
+ if instrument in RESONATORS:
478
+ return _resonator(instrument, frequency, t, rate, seed, brightness, decay)
479
+ if instrument in {"cymbal", "tambourine"}:
480
+ return _metal(instrument, t, rate, seed, brightness, decay)
481
+ return _synthesizer(frequency, t, rate, brightness, settings)
@@ -0,0 +1,151 @@
1
+ """Small, explicit composition helpers; no DSL or code evaluation required."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+
9
+ from .model import Note, midi_pitch
10
+
11
+ Step = int | str | Sequence[int | str] | None
12
+
13
+
14
+ def _positive(value: float, name: str) -> None:
15
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
16
+ raise ValueError(f"{name} must be a positive finite number")
17
+ if not math.isfinite(value) or value <= 0:
18
+ raise ValueError(f"{name} must be a positive finite number")
19
+
20
+
21
+ def _nonnegative(value: float, name: str) -> None:
22
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
23
+ raise ValueError(f"{name} must be a finite nonnegative number")
24
+ if not math.isfinite(value) or value < 0:
25
+ raise ValueError(f"{name} must be a finite nonnegative number")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Pattern:
30
+ """An immutable phrase with explicit length, including trailing rests."""
31
+
32
+ notes: tuple[Note, ...]
33
+ beats: float
34
+
35
+ def __post_init__(self) -> None:
36
+ _positive(self.beats, "beats")
37
+ object.__setattr__(self, "notes", tuple(self.notes))
38
+ for note in self.notes:
39
+ if not isinstance(note, Note):
40
+ raise ValueError("pattern notes must be Note instances")
41
+ if note.start + note.duration > self.beats + 1e-9:
42
+ raise ValueError("pattern notes must fit inside its length")
43
+
44
+ @classmethod
45
+ def sequence(
46
+ cls, steps: Sequence[Step], *, step: float = 1, gate: float = 0.8, velocity: float = 0.8
47
+ ) -> Pattern:
48
+ """One pitch/chord/rest per step. None is a rest; a list/tuple is a chord."""
49
+ if (
50
+ not isinstance(steps, Sequence)
51
+ or isinstance(steps, (str, bytes, bytearray))
52
+ or not steps
53
+ ):
54
+ raise ValueError("steps must be a nonempty sequence of pitches, chords, or None")
55
+ _positive(step, "step")
56
+ _positive(gate, "gate")
57
+ if gate > 1:
58
+ raise ValueError("gate must be at most 1")
59
+ # Validate even when every step is a rest.
60
+ Note(velocity=velocity)
61
+ notes = []
62
+ for index, item in enumerate(steps):
63
+ if item is None:
64
+ continue
65
+ if not isinstance(item, (int, str)) and (
66
+ not isinstance(item, Sequence) or isinstance(item, (bytes, bytearray))
67
+ ):
68
+ raise ValueError("each step must be a pitch, a sequence of pitches, or None")
69
+ pitches = [item] if isinstance(item, (int, str)) else item
70
+ for pitch in pitches:
71
+ notes.append(
72
+ Note(pitch=pitch, start=index * step, duration=step * gate, velocity=velocity)
73
+ )
74
+ return cls(tuple(notes), len(steps) * step)
75
+
76
+ def repeat(self, times: int) -> Pattern:
77
+ if isinstance(times, bool) or not isinstance(times, int) or times < 1:
78
+ raise ValueError("times must be a positive integer")
79
+ return Pattern(
80
+ tuple(note for index in range(times) for note in self.at(index * self.beats)),
81
+ self.beats * times,
82
+ )
83
+
84
+ def transpose(self, semitones: int) -> Pattern:
85
+ if isinstance(semitones, bool) or not isinstance(semitones, int):
86
+ raise ValueError("semitones must be an integer")
87
+ return Pattern(
88
+ tuple(
89
+ Note(**{**note.model_dump(), "pitch": midi_pitch(note.pitch) + semitones})
90
+ for note in self.notes
91
+ ),
92
+ self.beats,
93
+ )
94
+
95
+ def then(self, other: Pattern) -> Pattern:
96
+ """Append another phrase after this phrase's full length, including rests."""
97
+ if not isinstance(other, Pattern):
98
+ raise ValueError("other must be a Pattern")
99
+ return Pattern(self.notes + other.at(self.beats), self.beats + other.beats)
100
+
101
+ def overlay(self, other: Pattern, *, offset: float = 0) -> Pattern:
102
+ """Layer a phrase at a nonnegative offset; retain both full phrase lengths.
103
+
104
+ Notes remain in this phrase's order followed by the placed layer's order.
105
+ Overlapping and identical notes are retained; no mixing or deduplication occurs.
106
+ """
107
+ if not isinstance(other, Pattern):
108
+ raise ValueError("other must be a Pattern")
109
+ _nonnegative(offset, "offset")
110
+ return Pattern(self.notes + other.at(offset), max(self.beats, offset + other.beats))
111
+
112
+ def stretch(self, factor: float) -> Pattern:
113
+ """Scale beat positions, durations, and phrase length by a positive factor.
114
+
115
+ A factor of 2 doubles the phrase length. Release times stay in seconds.
116
+ """
117
+ _positive(factor, "factor")
118
+ return Pattern(
119
+ tuple(
120
+ Note(
121
+ **{
122
+ **note.model_dump(),
123
+ "start": note.start * factor,
124
+ "duration": note.duration * factor,
125
+ }
126
+ )
127
+ for note in self.notes
128
+ ),
129
+ self.beats * factor,
130
+ )
131
+
132
+ def scale_velocity(self, factor: float) -> Pattern:
133
+ """Multiply velocities by a positive factor, requiring results in (0, 1].
134
+
135
+ Values above 1 raise ValueError rather than clipping away relative accents.
136
+ """
137
+ _positive(factor, "factor")
138
+ return Pattern(
139
+ tuple(
140
+ Note(**{**note.model_dump(), "velocity": note.velocity * factor})
141
+ for note in self.notes
142
+ ),
143
+ self.beats,
144
+ )
145
+
146
+ def at(self, beat: float) -> tuple[Note, ...]:
147
+ """Place a phrase on the score's absolute timeline."""
148
+ _nonnegative(beat, "beat")
149
+ return tuple(
150
+ Note(**{**note.model_dump(), "start": note.start + beat}) for note in self.notes
151
+ )