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,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
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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
+ )
@@ -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}")
@@ -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