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
|
@@ -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)
|
audio_as_code/pattern.py
ADDED
|
@@ -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
|
+
)
|