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,44 @@
|
|
|
1
|
+
"""Compose, validate, render, and inspect music as code."""
|
|
2
|
+
|
|
3
|
+
from .inspection import inspect_score
|
|
4
|
+
from .instruments import get_instrument, instrument_catalog, list_instruments
|
|
5
|
+
from .midi import export_midi
|
|
6
|
+
from .model import (
|
|
7
|
+
Automation,
|
|
8
|
+
AutomationPoint,
|
|
9
|
+
Delay,
|
|
10
|
+
Note,
|
|
11
|
+
PedalEvent,
|
|
12
|
+
Reverb,
|
|
13
|
+
Song,
|
|
14
|
+
TempoChange,
|
|
15
|
+
Tone,
|
|
16
|
+
Track,
|
|
17
|
+
midi_pitch,
|
|
18
|
+
)
|
|
19
|
+
from .pattern import Pattern
|
|
20
|
+
from .render import analyze_wav, render, render_audio
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Automation",
|
|
25
|
+
"AutomationPoint",
|
|
26
|
+
"Delay",
|
|
27
|
+
"Note",
|
|
28
|
+
"Pattern",
|
|
29
|
+
"PedalEvent",
|
|
30
|
+
"Reverb",
|
|
31
|
+
"Song",
|
|
32
|
+
"TempoChange",
|
|
33
|
+
"Tone",
|
|
34
|
+
"Track",
|
|
35
|
+
"analyze_wav",
|
|
36
|
+
"export_midi",
|
|
37
|
+
"get_instrument",
|
|
38
|
+
"inspect_score",
|
|
39
|
+
"instrument_catalog",
|
|
40
|
+
"list_instruments",
|
|
41
|
+
"midi_pitch",
|
|
42
|
+
"render",
|
|
43
|
+
"render_audio",
|
|
44
|
+
]
|
audio_as_code/_audio.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""PCM WAV encoding and signal measurements, independent of score and synthesis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import wave
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
Audio = NDArray[np.float32]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _metrics(audio: Audio, rate: int) -> dict:
|
|
16
|
+
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
|
17
|
+
rms = float(np.sqrt(np.mean(np.square(audio, dtype=np.float64)))) if audio.size else 0.0
|
|
18
|
+
return {
|
|
19
|
+
"sample_rate": rate,
|
|
20
|
+
"channels": audio.shape[1],
|
|
21
|
+
"frames": len(audio),
|
|
22
|
+
"duration_seconds": len(audio) / rate,
|
|
23
|
+
"peak": peak,
|
|
24
|
+
"rms": rms,
|
|
25
|
+
"peak_dbfs": 20 * math.log10(peak) if peak > 0 else None,
|
|
26
|
+
"rms_dbfs": 20 * math.log10(rms) if rms > 0 else None,
|
|
27
|
+
"clipped_samples": int(np.count_nonzero(np.abs(audio) >= 1)),
|
|
28
|
+
"silent": peak == 0,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write_wav(path: Path, audio: Audio, rate: int) -> None:
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
pcm = np.clip(np.rint(audio * 32768), -32768, 32767).astype("<i2")
|
|
35
|
+
with wave.open(str(path), "wb") as stream:
|
|
36
|
+
stream.setnchannels(2)
|
|
37
|
+
stream.setsampwidth(2)
|
|
38
|
+
stream.setframerate(rate)
|
|
39
|
+
stream.writeframes(pcm.tobytes())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def analyze_wav(path: str | Path) -> dict:
|
|
43
|
+
"""Measure a 16-bit PCM WAV in blocks. These are signal checks, not music criticism."""
|
|
44
|
+
peak = 0
|
|
45
|
+
squares = 0.0
|
|
46
|
+
count = 0
|
|
47
|
+
clipped = 0
|
|
48
|
+
try:
|
|
49
|
+
stream = wave.open(str(path), "rb")
|
|
50
|
+
except RuntimeError as error:
|
|
51
|
+
# The stdlib RIFF parser can raise RuntimeError when an ancillary
|
|
52
|
+
# chunk declares a seek beyond its enclosing chunk.
|
|
53
|
+
raise wave.Error("WAV is malformed: invalid RIFF chunk layout") from error
|
|
54
|
+
with stream:
|
|
55
|
+
if stream.getsampwidth() != 2 or stream.getcomptype() != "NONE":
|
|
56
|
+
raise ValueError("analysis supports uncompressed 16-bit PCM WAV files")
|
|
57
|
+
channels, rate, frames = stream.getnchannels(), stream.getframerate(), stream.getnframes()
|
|
58
|
+
if rate <= 0:
|
|
59
|
+
raise ValueError("WAV sample rate must be positive")
|
|
60
|
+
# A frame contains one sample per channel, and the channel count comes
|
|
61
|
+
# from the file. Bound bytes as well as frames before allocating float64
|
|
62
|
+
# analysis buffers; retain the existing mono/stereo block sizes.
|
|
63
|
+
block_frames = min(65536, max(1, 131072 // channels))
|
|
64
|
+
while data := stream.readframes(block_frames):
|
|
65
|
+
if len(data) % (2 * channels):
|
|
66
|
+
raise ValueError("WAV is truncated or malformed: incomplete PCM frame")
|
|
67
|
+
pcm = np.frombuffer(data, dtype="<i2").astype(np.float64)
|
|
68
|
+
count += pcm.size
|
|
69
|
+
peak = max(peak, float(np.max(np.abs(pcm))))
|
|
70
|
+
squares += float(np.sum(pcm * pcm))
|
|
71
|
+
clipped += int(np.count_nonzero((pcm >= 32767) | (pcm <= -32768)))
|
|
72
|
+
if count != frames * channels:
|
|
73
|
+
raise ValueError("WAV is truncated: actual samples do not match the file header")
|
|
74
|
+
rms = math.sqrt(squares / count) / 32768 if count else 0.0
|
|
75
|
+
amplitude = peak / 32768
|
|
76
|
+
return {
|
|
77
|
+
"sample_rate": rate,
|
|
78
|
+
"channels": channels,
|
|
79
|
+
"frames": frames,
|
|
80
|
+
"duration_seconds": frames / rate,
|
|
81
|
+
"peak": amplitude,
|
|
82
|
+
"rms": rms,
|
|
83
|
+
"peak_dbfs": 20 * math.log10(amplitude) if amplitude else None,
|
|
84
|
+
"rms_dbfs": 20 * math.log10(rms) if rms else None,
|
|
85
|
+
"full_scale_samples": clipped,
|
|
86
|
+
"silent": peak == 0,
|
|
87
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared output limits and event mapping for inspection and exporters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .instruments import DRUM_NOTES
|
|
6
|
+
from .model import Note, Song, Track, midi_pitch
|
|
7
|
+
|
|
8
|
+
MAX_RENDER_SECONDS = 300
|
|
9
|
+
TICKS_PER_BEAT = 480
|
|
10
|
+
MAX_MIDI_TICK = 0x0FFFFFFF
|
|
11
|
+
MAX_MELODIC_TRACKS = 15
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def note_frame(song: Song, beat: float) -> int:
|
|
15
|
+
# Retain the original arithmetic order on legacy rounding boundaries.
|
|
16
|
+
if not song.tempo_map:
|
|
17
|
+
return round(beat * (song.sample_rate * 60 / song.bpm))
|
|
18
|
+
return round(song.beat_to_seconds(beat) * song.sample_rate)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def midi_tick(beat: float) -> int:
|
|
22
|
+
return round(beat * TICKS_PER_BEAT)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def midi_note_ticks(note: Note, end_tick: int) -> tuple[int, int]:
|
|
26
|
+
return midi_tick(note.start), min(end_tick, midi_tick(note.start + note.duration))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def midi_export_pitch(track: Track, pitch: int | str) -> int:
|
|
30
|
+
if track.instrument in DRUM_NOTES and track.instrument != "drum_machine":
|
|
31
|
+
return DRUM_NOTES[track.instrument]
|
|
32
|
+
return midi_pitch(pitch)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Hand-designed coefficients for the shared orchestra synthesis models.
|
|
2
|
+
|
|
3
|
+
Keep playable IDs and discovery metadata in instruments.py. These immutable
|
|
4
|
+
profiles supply the spectra, resonances and damping used by orchestra.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class StringProfile:
|
|
15
|
+
position: float
|
|
16
|
+
stiffness: float
|
|
17
|
+
damping: float
|
|
18
|
+
tilt: float
|
|
19
|
+
body: tuple[tuple[float, float, float], ...]
|
|
20
|
+
pickup: float | None = None
|
|
21
|
+
detune: float = 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
STRINGS = MappingProxyType(
|
|
25
|
+
{
|
|
26
|
+
"electric_guitar": StringProfile(0.16, 0.000025, 0.13, 1.1, (), 0.18),
|
|
27
|
+
"bass_guitar": StringProfile(0.24, 0.00007, 0.2, 1.3, (), 0.27),
|
|
28
|
+
"harp": StringProfile(
|
|
29
|
+
0.32, 0.000015, 0.09, 1.55, ((190, 0.05, 0.07), (420, 0.025, 0.04)), detune=1.5
|
|
30
|
+
),
|
|
31
|
+
"ukulele": StringProfile(
|
|
32
|
+
0.25, 0.000004, 0.3, 1.7, ((270, 0.07, 0.04), (590, 0.035, 0.025))
|
|
33
|
+
),
|
|
34
|
+
"banjo": StringProfile(
|
|
35
|
+
0.12, 0.00004, 0.27, 0.9, ((410, 0.09, 0.06), (660, 0.06, 0.035), (1080, 0.035, 0.02))
|
|
36
|
+
),
|
|
37
|
+
"harpsichord": StringProfile(0.09, 0.00002, 0.12, 0.85, ((230, 0.025, 0.025),), detune=2.5),
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class HeldProfile:
|
|
44
|
+
partials: tuple[float, ...]
|
|
45
|
+
attack: float
|
|
46
|
+
formants: tuple[tuple[float, float, float], ...]
|
|
47
|
+
noise_band: tuple[float, float]
|
|
48
|
+
bow_noise: float = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Formants are center Hz, bandwidth Hz, and gain. These are designed spectra,
|
|
52
|
+
# not fitted body responses. Instruments retain different spectra and onsets.
|
|
53
|
+
HELD = MappingProxyType(
|
|
54
|
+
{
|
|
55
|
+
"violin": HeldProfile(
|
|
56
|
+
(1, 0.7, 0.55, 0.42, 0.3, 0.26, 0.2, 0.16, 0.13, 0.1, 0.08, 0.06),
|
|
57
|
+
0.065,
|
|
58
|
+
((470, 230, 0.6), (2800, 900, 1.5)),
|
|
59
|
+
(900, 6500),
|
|
60
|
+
0.025,
|
|
61
|
+
),
|
|
62
|
+
"viola": HeldProfile(
|
|
63
|
+
(1, 0.75, 0.45, 0.34, 0.3, 0.18, 0.15, 0.1, 0.08),
|
|
64
|
+
0.085,
|
|
65
|
+
((320, 170, 0.75), (2200, 700, 1.2)),
|
|
66
|
+
(600, 5000),
|
|
67
|
+
0.022,
|
|
68
|
+
),
|
|
69
|
+
"cello": HeldProfile(
|
|
70
|
+
(1, 0.85, 0.45, 0.28, 0.25, 0.16, 0.12, 0.09, 0.07),
|
|
71
|
+
0.105,
|
|
72
|
+
((180, 100, 0.8), (900, 380, 0.7), (1800, 700, 0.65)),
|
|
73
|
+
(400, 4200),
|
|
74
|
+
0.025,
|
|
75
|
+
),
|
|
76
|
+
"double_bass": HeldProfile(
|
|
77
|
+
(1, 0.65, 0.35, 0.3, 0.18, 0.12, 0.1, 0.07),
|
|
78
|
+
0.14,
|
|
79
|
+
((95, 65, 0.75), (600, 300, 0.8)),
|
|
80
|
+
(220, 2700),
|
|
81
|
+
0.035,
|
|
82
|
+
),
|
|
83
|
+
"flute": HeldProfile(
|
|
84
|
+
(1, 0.17, 0.07, 0.03, 0.012), 0.07, ((1500, 1200, 0.12),), (900, 6500)
|
|
85
|
+
),
|
|
86
|
+
"clarinet": HeldProfile(
|
|
87
|
+
(1, 0.055, 0.65, 0.045, 0.35, 0.035, 0.17, 0.025, 0.08, 0.01, 0.03),
|
|
88
|
+
0.035,
|
|
89
|
+
((1400, 900, 0.25),),
|
|
90
|
+
(1400, 6500),
|
|
91
|
+
),
|
|
92
|
+
"saxophone": HeldProfile(
|
|
93
|
+
(1, 0.85, 0.58, 0.42, 0.29, 0.2, 0.14, 0.1, 0.07, 0.04),
|
|
94
|
+
0.045,
|
|
95
|
+
((900, 600, 0.8), (2600, 900, 0.4)),
|
|
96
|
+
(800, 5500),
|
|
97
|
+
),
|
|
98
|
+
"oboe": HeldProfile(
|
|
99
|
+
(1, 1.1, 0.95, 0.8, 0.6, 0.38, 0.25, 0.18, 0.1, 0.07),
|
|
100
|
+
0.045,
|
|
101
|
+
((1500, 650, 1.3),),
|
|
102
|
+
(1700, 7000),
|
|
103
|
+
),
|
|
104
|
+
"bassoon": HeldProfile(
|
|
105
|
+
(1, 0.9, 0.7, 0.45, 0.28, 0.19, 0.13, 0.08),
|
|
106
|
+
0.065,
|
|
107
|
+
((500, 300, 1.2), (1500, 650, 0.3)),
|
|
108
|
+
(700, 4200),
|
|
109
|
+
),
|
|
110
|
+
"trumpet": HeldProfile(
|
|
111
|
+
(1, 0.85, 0.8, 0.72, 0.55, 0.4, 0.28, 0.19, 0.12, 0.08, 0.04, 0.02),
|
|
112
|
+
0.035,
|
|
113
|
+
((1800, 1200, 0.55),),
|
|
114
|
+
(900, 5500),
|
|
115
|
+
),
|
|
116
|
+
"trombone": HeldProfile(
|
|
117
|
+
(1, 0.85, 0.67, 0.48, 0.35, 0.25, 0.17, 0.1, 0.07),
|
|
118
|
+
0.055,
|
|
119
|
+
((800, 650, 0.6),),
|
|
120
|
+
(500, 4000),
|
|
121
|
+
),
|
|
122
|
+
"french_horn": HeldProfile(
|
|
123
|
+
(1, 0.6, 0.28, 0.14, 0.08, 0.04, 0.025), 0.085, ((650, 450, 0.3),), (400, 3200)
|
|
124
|
+
),
|
|
125
|
+
"tuba": HeldProfile(
|
|
126
|
+
(1, 0.7, 0.42, 0.23, 0.13, 0.07, 0.04), 0.115, ((330, 280, 0.6),), (250, 2500)
|
|
127
|
+
),
|
|
128
|
+
"organ": HeldProfile(
|
|
129
|
+
(1, 0.55, 0.18, 0.32, 0.04, 0.08, 0.02, 0.12), 0.022, (), (1200, 6000)
|
|
130
|
+
),
|
|
131
|
+
"theremin": HeldProfile((1, 0.16, 0.045, 0.012), 0.08, (), (1000, 3000)),
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class ResonatorProfile:
|
|
138
|
+
ratios: tuple[float, ...]
|
|
139
|
+
amplitudes: tuple[float, ...]
|
|
140
|
+
lifetimes: tuple[float, ...]
|
|
141
|
+
strike: float
|
|
142
|
+
tremolo: float = 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
RESONATORS = MappingProxyType(
|
|
146
|
+
{
|
|
147
|
+
"electric_piano": ResonatorProfile(
|
|
148
|
+
(1, 2, 4.01, 7.03, 10.1),
|
|
149
|
+
(1, 0.28, 0.22, 0.09, 0.025),
|
|
150
|
+
(1, 0.65, 0.2, 0.1, 0.055),
|
|
151
|
+
0.006,
|
|
152
|
+
0.08,
|
|
153
|
+
),
|
|
154
|
+
"xylophone": ResonatorProfile(
|
|
155
|
+
(1, 3, 6, 10), (1, 0.45, 0.2, 0.08), (1, 0.45, 0.23, 0.12), 0.03
|
|
156
|
+
),
|
|
157
|
+
"vibraphone": ResonatorProfile(
|
|
158
|
+
(1, 4, 10, 16.8), (1, 0.22, 0.065, 0.02), (1, 0.7, 0.35, 0.2), 0.008, 0.24
|
|
159
|
+
),
|
|
160
|
+
"glockenspiel": ResonatorProfile(
|
|
161
|
+
(1, 2.756, 5.404, 8.933, 13.34),
|
|
162
|
+
(1, 0.4, 0.23, 0.12, 0.05),
|
|
163
|
+
(1, 0.7, 0.48, 0.3, 0.18),
|
|
164
|
+
0.018,
|
|
165
|
+
),
|
|
166
|
+
"toms": ResonatorProfile(
|
|
167
|
+
(1, 1.594, 2.136, 2.296, 2.653, 2.918),
|
|
168
|
+
(1, 0.4, 0.22, 0.12, 0.09, 0.07),
|
|
169
|
+
(1, 0.72, 0.52, 0.37, 0.28, 0.2),
|
|
170
|
+
0.055,
|
|
171
|
+
),
|
|
172
|
+
"congas": ResonatorProfile(
|
|
173
|
+
(1, 1.5, 2.05, 2.65, 3.4), (1, 0.55, 0.27, 0.13, 0.08), (1, 0.8, 0.5, 0.28, 0.17), 0.085
|
|
174
|
+
),
|
|
175
|
+
"bongos": ResonatorProfile(
|
|
176
|
+
(1, 1.59, 2.14, 2.65, 3.12),
|
|
177
|
+
(1, 0.48, 0.25, 0.15, 0.08),
|
|
178
|
+
(1, 0.6, 0.42, 0.26, 0.14),
|
|
179
|
+
0.11,
|
|
180
|
+
),
|
|
181
|
+
"timpani": ResonatorProfile(
|
|
182
|
+
(1, 1.5, 2, 2.48, 2.95, 3.48),
|
|
183
|
+
(1, 0.55, 0.3, 0.18, 0.1, 0.07),
|
|
184
|
+
(1, 0.85, 0.6, 0.42, 0.28, 0.18),
|
|
185
|
+
0.035,
|
|
186
|
+
),
|
|
187
|
+
}
|
|
188
|
+
)
|
audio_as_code/_paths.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Shared, read-only path checks before CLI and Python exports write artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_paths(
|
|
10
|
+
paths: Sequence[str | Path],
|
|
11
|
+
directories: Sequence[str | Path] = (),
|
|
12
|
+
*,
|
|
13
|
+
conflict_message: str = "score input, output, report, and stem paths must be distinct",
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Reject known conflicts without creating files or output directories.
|
|
16
|
+
|
|
17
|
+
These checks do not reserve paths or guarantee later filesystem writes succeed.
|
|
18
|
+
"""
|
|
19
|
+
resolved = [Path(path).resolve() for path in paths]
|
|
20
|
+
for index, path in enumerate(resolved):
|
|
21
|
+
if path.is_dir():
|
|
22
|
+
raise ValueError(f"expected a file path, got a directory: {path}")
|
|
23
|
+
for other in resolved[:index]:
|
|
24
|
+
if path == other or (path.exists() and other.exists() and path.samefile(other)):
|
|
25
|
+
raise ValueError(conflict_message)
|
|
26
|
+
if path in other.parents or other in path.parents:
|
|
27
|
+
raise ValueError("a file path cannot also be an output directory")
|
|
28
|
+
for directory in [
|
|
29
|
+
*(path.parent for path in resolved),
|
|
30
|
+
*(Path(path).resolve() for path in directories),
|
|
31
|
+
]:
|
|
32
|
+
if directory in resolved:
|
|
33
|
+
raise ValueError("a file path cannot also be an output directory")
|
|
34
|
+
for parent in [directory, *directory.parents]:
|
|
35
|
+
if parent.exists() and not parent.is_dir():
|
|
36
|
+
raise ValueError(f"output directory is blocked by an existing file: {parent}")
|
audio_as_code/_voices.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Instrument dispatch and note envelopes; scheduling and mixing live in render."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import NDArray
|
|
7
|
+
|
|
8
|
+
from ._audio import Audio
|
|
9
|
+
from .acoustics import colored_noise, nyquist_gain
|
|
10
|
+
from .extended import EXTENDED_INSTRUMENTS
|
|
11
|
+
from .extended import synthesize as synthesize_extended
|
|
12
|
+
from .instruments import KIT_NOTES, PHYSICAL_INSTRUMENTS
|
|
13
|
+
from .model import Tone
|
|
14
|
+
from .orchestra import EXTRA_INSTRUMENTS
|
|
15
|
+
from .orchestra import synthesize as synthesize_orchestra
|
|
16
|
+
from .physical import synthesize
|
|
17
|
+
|
|
18
|
+
_HARMONICS = {
|
|
19
|
+
"sine": [(1, 1)],
|
|
20
|
+
"triangle": [(n, (-1) ** ((n - 1) // 2) / n**2) for n in range(1, 16, 2)],
|
|
21
|
+
"pluck": [(n, 1 / n**1.6) for n in range(1, 9)],
|
|
22
|
+
"bass": [(1, 1), (2, 0.35), (3, 0.12)],
|
|
23
|
+
"pad": [(1, 1), (2, 0.25), (3, 0.12), (4, 0.06)],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_ATTACK_SECONDS = {
|
|
27
|
+
"pad": 0.08,
|
|
28
|
+
"guitar": 0.001,
|
|
29
|
+
"marimba": 0.0005,
|
|
30
|
+
"piano": 0.0005,
|
|
31
|
+
"xylophone": 0.0005,
|
|
32
|
+
"glockenspiel": 0.0005,
|
|
33
|
+
"mandolin": 0.001,
|
|
34
|
+
"kalimba": 0.0005,
|
|
35
|
+
"celesta": 0.0005,
|
|
36
|
+
"recorder": 0.003,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_RELEASE_SECONDS = {
|
|
40
|
+
"pad": 0.15,
|
|
41
|
+
"guitar": 0.065,
|
|
42
|
+
"electric_guitar": 0.055,
|
|
43
|
+
"bass_guitar": 0.07,
|
|
44
|
+
"harp": 0.12,
|
|
45
|
+
"ukulele": 0.045,
|
|
46
|
+
"banjo": 0.035,
|
|
47
|
+
"harpsichord": 0.04,
|
|
48
|
+
"piano": 0.12,
|
|
49
|
+
"electric_piano": 0.09,
|
|
50
|
+
"marimba": 0.055,
|
|
51
|
+
"bell": 0.12,
|
|
52
|
+
"xylophone": 0.035,
|
|
53
|
+
"vibraphone": 0.12,
|
|
54
|
+
"glockenspiel": 0.1,
|
|
55
|
+
"violin": 0.09,
|
|
56
|
+
"viola": 0.11,
|
|
57
|
+
"cello": 0.13,
|
|
58
|
+
"double_bass": 0.16,
|
|
59
|
+
"flute": 0.08,
|
|
60
|
+
"clarinet": 0.055,
|
|
61
|
+
"saxophone": 0.075,
|
|
62
|
+
"oboe": 0.065,
|
|
63
|
+
"bassoon": 0.08,
|
|
64
|
+
"trumpet": 0.055,
|
|
65
|
+
"trombone": 0.075,
|
|
66
|
+
"french_horn": 0.1,
|
|
67
|
+
"tuba": 0.13,
|
|
68
|
+
"organ": 0.06,
|
|
69
|
+
"theremin": 0.1,
|
|
70
|
+
"timpani": 0.12,
|
|
71
|
+
"cymbal": 0.1,
|
|
72
|
+
"tambourine": 0.045,
|
|
73
|
+
"mandolin": 0.06,
|
|
74
|
+
"kalimba": 0.08,
|
|
75
|
+
"celesta": 0.1,
|
|
76
|
+
"recorder": 0.055,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _electronic_voice(
|
|
81
|
+
instrument: str, frequency: float, frames: int, rate: int, seed: int, velocity: float
|
|
82
|
+
) -> NDArray[np.float64]:
|
|
83
|
+
"""Generate the original harmonic voices and electronic drum sounds."""
|
|
84
|
+
t = np.arange(frames, dtype=np.float64) / rate
|
|
85
|
+
if instrument == "kick":
|
|
86
|
+
# Analytic integral of an exponential pitch sweep; independent of sample rate.
|
|
87
|
+
sweep = 60 + 60 * velocity
|
|
88
|
+
phase = 2 * np.pi * (48 * t + sweep * (1 - np.exp(-35 * t)) / 35)
|
|
89
|
+
signal = np.sin(phase) * np.exp(-9 * t)
|
|
90
|
+
signal += (
|
|
91
|
+
0.035 * velocity * colored_noise(frames, rate, seed, 1800, 9000) * np.exp(-t / 0.006)
|
|
92
|
+
)
|
|
93
|
+
elif instrument in {"snare", "hat"}:
|
|
94
|
+
if instrument == "snare":
|
|
95
|
+
noise = colored_noise(frames, rate, seed, 1100, 9500)
|
|
96
|
+
body = 0.32 * np.sin(2 * np.pi * 185 * t) * np.exp(-t / 0.045) + 0.14 * np.sin(
|
|
97
|
+
2 * np.pi * 330 * t
|
|
98
|
+
) * np.exp(-t / 0.028)
|
|
99
|
+
rattle = (
|
|
100
|
+
0.72
|
|
101
|
+
* noise
|
|
102
|
+
* (1 + 0.18 * np.sin(2 * np.pi * 83 * t))
|
|
103
|
+
* np.exp(-t / (0.055 + 0.025 * velocity))
|
|
104
|
+
)
|
|
105
|
+
signal = body + rattle
|
|
106
|
+
else:
|
|
107
|
+
noise = colored_noise(frames, rate, seed, 4500, 16000)
|
|
108
|
+
metal = (
|
|
109
|
+
sum(
|
|
110
|
+
np.sin(2 * np.pi * f * t) * nyquist_gain(f, rate)
|
|
111
|
+
for f in (3170, 4211, 5783, 7139, 9323)
|
|
112
|
+
)
|
|
113
|
+
/ 5
|
|
114
|
+
)
|
|
115
|
+
signal = (0.65 * noise + 0.12 * metal) * np.exp(-t / (0.018 + 0.01 * velocity))
|
|
116
|
+
else:
|
|
117
|
+
# Finite harmonic sums avoid the unbounded harmonics of naive square/saw waves.
|
|
118
|
+
phase = 2 * np.pi * frequency * t
|
|
119
|
+
harmonics = _HARMONICS[instrument]
|
|
120
|
+
signal = np.zeros(frames)
|
|
121
|
+
weight = 0.0
|
|
122
|
+
for harmonic, amplitude in harmonics:
|
|
123
|
+
if frequency * harmonic >= rate / 2:
|
|
124
|
+
continue
|
|
125
|
+
partial = np.sin(phase * harmonic) * amplitude
|
|
126
|
+
if instrument == "pluck":
|
|
127
|
+
partial *= np.exp(-t * (2.5 + harmonic * 0.8))
|
|
128
|
+
signal += partial
|
|
129
|
+
weight += abs(amplitude)
|
|
130
|
+
if weight:
|
|
131
|
+
signal /= weight
|
|
132
|
+
if instrument == "bass":
|
|
133
|
+
signal *= 0.65 + 0.35 * np.exp(-6 * t)
|
|
134
|
+
return signal
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _voice(
|
|
138
|
+
instrument: str,
|
|
139
|
+
pitch: int,
|
|
140
|
+
frames: int,
|
|
141
|
+
rate: int,
|
|
142
|
+
seed: int,
|
|
143
|
+
velocity: float = 0.8,
|
|
144
|
+
tone: Tone | None = None,
|
|
145
|
+
held_frames: int | None = None,
|
|
146
|
+
) -> Audio:
|
|
147
|
+
if instrument == "drum_machine":
|
|
148
|
+
return _voice(
|
|
149
|
+
KIT_NOTES[pitch], pitch, frames, rate, seed, velocity, held_frames=held_frames
|
|
150
|
+
)
|
|
151
|
+
frequency = 440 * 2 ** ((pitch - 69) / 12)
|
|
152
|
+
if instrument in EXTENDED_INSTRUMENTS:
|
|
153
|
+
signal = synthesize_extended(instrument, frequency, frames, rate, seed, velocity, tone)
|
|
154
|
+
elif instrument in EXTRA_INSTRUMENTS:
|
|
155
|
+
signal = synthesize_orchestra(instrument, frequency, frames, rate, seed, velocity, tone)
|
|
156
|
+
elif instrument in PHYSICAL_INSTRUMENTS:
|
|
157
|
+
signal = synthesize(instrument, frequency, frames, rate, seed, velocity, tone)
|
|
158
|
+
else:
|
|
159
|
+
signal = _electronic_voice(instrument, frequency, frames, rate, seed, velocity)
|
|
160
|
+
|
|
161
|
+
# Every voice begins and ends at zero. Envelope fits even very short notes.
|
|
162
|
+
attack_seconds = _ATTACK_SECONDS.get(instrument, 0.003)
|
|
163
|
+
release_seconds = _RELEASE_SECONDS.get(instrument, 0.02)
|
|
164
|
+
attack = min(max(1, round(attack_seconds * rate)), max(1, (held_frames or frames) // 3))
|
|
165
|
+
release = (
|
|
166
|
+
frames - held_frames
|
|
167
|
+
if held_frames is not None
|
|
168
|
+
else min(max(1, round(release_seconds * rate)), max(1, frames // 3))
|
|
169
|
+
)
|
|
170
|
+
signal[:attack] *= np.linspace(0, 1, attack)
|
|
171
|
+
release_curve = 0.5 + 0.5 * np.cos(np.linspace(0, np.pi, release)) if release > 1 else 0
|
|
172
|
+
signal[-release:] *= release_curve
|
|
173
|
+
return signal.astype(np.float32)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Small deterministic DSP helpers. Every response is calculated from equations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import NDArray
|
|
10
|
+
|
|
11
|
+
Signal = NDArray[np.float64]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def struck_mode(t: Signal, frequency: float, damping: float, contact: float) -> Signal:
|
|
15
|
+
"""Damped sine driven by a unit-area, finite raised-cosine force pulse.
|
|
16
|
+
|
|
17
|
+
Integrate continuously, then sample: even sub-sample contact times retain
|
|
18
|
+
their area. After contact the mode rings freely at its original frequency.
|
|
19
|
+
Positive unit-area forcing keeps the result bounded by the impulse response.
|
|
20
|
+
This is prescribed one-way forcing, not a nonlinear hammer/contact solver.
|
|
21
|
+
"""
|
|
22
|
+
pole = complex(-damping, 2 * np.pi * frequency)
|
|
23
|
+
pulse_frequency = 2 * np.pi / contact
|
|
24
|
+
attacking = t < contact
|
|
25
|
+
u = t[attacking]
|
|
26
|
+
response = np.zeros(len(t), dtype=np.complex128)
|
|
27
|
+
released = 0j
|
|
28
|
+
for weight, offset in ((1, 0), (-0.5, pulse_frequency), (-0.5, -pulse_frequency)):
|
|
29
|
+
denominator = pole - 1j * offset
|
|
30
|
+
# expm1 avoids cancellation for short contacts and low modes. All real
|
|
31
|
+
# exponents are nonpositive, including very short decay settings.
|
|
32
|
+
integral = np.expm1(denominator * u) / denominator
|
|
33
|
+
response[attacking] += weight * np.exp(1j * offset * u) * integral
|
|
34
|
+
released += (
|
|
35
|
+
weight * np.exp(1j * offset * contact) * np.expm1(denominator * contact) / denominator
|
|
36
|
+
)
|
|
37
|
+
response[~attacking] = released * np.exp(pole * (t[~attacking] - contact))
|
|
38
|
+
return response.imag / contact
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def nyquist_gain(frequency: Signal | float, rate: int) -> Signal | float:
|
|
42
|
+
"""Cosine shoulder leaves room for envelope/modulation sidebands."""
|
|
43
|
+
fraction = np.clip((frequency / rate - 0.45) / 0.04, 0, 1)
|
|
44
|
+
return 0.5 + 0.5 * np.cos(np.pi * fraction)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def colored_noise(frames: int, rate: int, seed: int, low: float, high: float) -> Signal:
|
|
48
|
+
if frames == 0:
|
|
49
|
+
return np.zeros(0)
|
|
50
|
+
noise = np.random.Generator(np.random.PCG64(seed)).standard_normal(frames)
|
|
51
|
+
frequencies = np.fft.rfftfreq(frames, 1 / rate)
|
|
52
|
+
shape = (frequencies / max(low, 1)) ** 2
|
|
53
|
+
shape = shape / (1 + shape) / (1 + (frequencies / high) ** 4)
|
|
54
|
+
shape *= nyquist_gain(frequencies, rate)
|
|
55
|
+
return np.fft.irfft(np.fft.rfft(noise) * shape, n=frames) * 0.3
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def modulate_noise(
|
|
59
|
+
noise: Signal, phase: Signal, maximum_frequency: float, rate: int, depth: float
|
|
60
|
+
) -> Signal:
|
|
61
|
+
"""Add pitch-synchronous texture without folding the noise sidebands.
|
|
62
|
+
|
|
63
|
+
Only the modulated component is low-passed. The original breath/friction
|
|
64
|
+
noise keeps its full bandwidth. A cosine shoulder below the sideband limit
|
|
65
|
+
leaves space for the slowly evolving phase used by acoustic voices.
|
|
66
|
+
"""
|
|
67
|
+
cutoff = 0.49 * rate - maximum_frequency
|
|
68
|
+
if not len(noise) or cutoff <= 0 or depth == 0:
|
|
69
|
+
return noise
|
|
70
|
+
bins = np.fft.rfftfreq(len(noise), 1 / rate)
|
|
71
|
+
shoulder = np.clip((bins / cutoff - 0.9) / 0.1, 0, 1)
|
|
72
|
+
gain = 0.5 + 0.5 * np.cos(np.pi * shoulder)
|
|
73
|
+
band_limited = np.fft.irfft(np.fft.rfft(noise) * gain, n=len(noise))
|
|
74
|
+
return noise + depth * band_limited * np.sin(phase)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def slow_variation(t: Signal, seed: int, speed: float = 1) -> Signal:
|
|
78
|
+
"""Bounded smooth variation with a duration-independent seeded trajectory."""
|
|
79
|
+
rng = np.random.Generator(np.random.PCG64(seed))
|
|
80
|
+
frequencies = rng.uniform([0.37, 0.91, 1.9], [0.7, 1.5, 2.7]) * speed
|
|
81
|
+
phases = rng.uniform(-np.pi, np.pi, 3)
|
|
82
|
+
return sum(
|
|
83
|
+
weight * np.sin(2 * np.pi * f * t + phase)
|
|
84
|
+
for weight, f, phase in zip((0.55, 0.3, 0.15), frequencies, phases, strict=True)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@lru_cache(maxsize=48)
|
|
89
|
+
def _body_kernel(rate: int, modes: tuple[tuple[float, float, float], ...]) -> Signal:
|
|
90
|
+
"""Analytic damped modes: frequency Hz, T60 seconds, relative gain."""
|
|
91
|
+
frames = max(2, math.ceil(min(0.35, max(mode[1] for mode in modes)) * rate))
|
|
92
|
+
t = np.arange(frames) / rate
|
|
93
|
+
kernel = np.zeros(frames)
|
|
94
|
+
for frequency, decay, gain in modes:
|
|
95
|
+
if frequency < 0.49 * rate:
|
|
96
|
+
mode = np.sin(2 * np.pi * frequency * t) * np.exp(-math.log(1000) * t / decay)
|
|
97
|
+
mode /= max(float(np.sum(np.abs(mode))), 1e-12)
|
|
98
|
+
kernel += gain * mode
|
|
99
|
+
fade = min(frames, max(2, round(0.01 * rate)))
|
|
100
|
+
kernel[-fade:] *= 0.5 + 0.5 * np.cos(np.linspace(0, np.pi, fade))
|
|
101
|
+
kernel /= max(float(np.sum(np.abs(kernel))), 1e-12)
|
|
102
|
+
kernel.flags.writeable = False
|
|
103
|
+
return kernel
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resonant_body(
|
|
107
|
+
signal: Signal, rate: int, modes: tuple[tuple[float, float, float], ...], wet: float = 0.2
|
|
108
|
+
) -> Signal:
|
|
109
|
+
"""Causal, bounded coloration driven by the voice, with no recorded IR.
|
|
110
|
+
|
|
111
|
+
Block overlap-add computes linear convolution; it never wraps the end of
|
|
112
|
+
a note onto its attack. The score's note gate still limits the output tail.
|
|
113
|
+
"""
|
|
114
|
+
if not modes or not len(signal) or wet == 0:
|
|
115
|
+
return signal
|
|
116
|
+
kernel = _body_kernel(rate, modes)
|
|
117
|
+
size = 1 << (max(4096, 2 * len(kernel)) - 1).bit_length()
|
|
118
|
+
block = size - len(kernel) + 1
|
|
119
|
+
response = np.fft.rfft(kernel, n=size)
|
|
120
|
+
result = np.zeros(len(signal))
|
|
121
|
+
for start in range(0, len(signal), block):
|
|
122
|
+
part = signal[start : start + block]
|
|
123
|
+
convolved = np.fft.irfft(np.fft.rfft(part, n=size) * response, n=size)
|
|
124
|
+
end = min(len(signal), start + len(part) + len(kernel) - 1)
|
|
125
|
+
result[start:end] += convolved[: end - start]
|
|
126
|
+
return (1 - wet) * signal + wet * result
|