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,40 @@
1
+ """Beat-domain automation evaluated on the audio sample clock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from .model import Automation, Song
8
+
9
+
10
+ def _point_times(song: Song, beats: np.ndarray) -> np.ndarray:
11
+ """Convert ordered knots in one tempo sweep, preserving integration order."""
12
+ changes = iter(song.tempo_map)
13
+ change = next(changes, None)
14
+ seconds, previous, bpm = 0.0, 0.0, song.bpm
15
+ times = []
16
+ for beat in beats:
17
+ while change is not None and change.beat <= beat:
18
+ seconds += (change.beat - previous) * 60 / bpm
19
+ previous, bpm = change.beat, change.bpm
20
+ change = next(changes, None)
21
+ times.append(seconds + (beat - previous) * 60 / bpm)
22
+ return np.array(times)
23
+
24
+
25
+ def automation_values(song: Song, lane: Automation, start: int, stop: int) -> np.ndarray:
26
+ """Absolute values, linear in beats or stepped; endpoints hold before/after the lane."""
27
+ points = lane.points
28
+ beats = np.array([point.beat for point in points])
29
+ values = np.array([point.value for point in points])
30
+ times = np.arange(start, stop, dtype=np.float64) / song.sample_rate
31
+ if lane.interpolation == "step":
32
+ point_times = _point_times(song, beats)
33
+ indices = np.searchsorted(point_times, times, side="right") - 1
34
+ return values[np.maximum(indices, 0)]
35
+ # Add tempo boundaries as interpolation knots so a straight line in beats
36
+ # keeps its musical slope when the number of seconds per beat changes.
37
+ knots = np.unique(np.concatenate((beats, [change.beat for change in song.tempo_map])))
38
+ knot_values = np.interp(knots, beats, values)
39
+ knot_times = _point_times(song, knots)
40
+ return np.interp(times, knot_times, knot_values)
audio_as_code/cli.py ADDED
@@ -0,0 +1,171 @@
1
+ """Every successful command emits JSON. Failures emit JSON to stderr and exit 2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import wave
9
+ from pathlib import Path
10
+
11
+ from pydantic import ValidationError
12
+
13
+ from . import __version__
14
+ from ._paths import check_paths as _check_paths
15
+ from .demo import demo_song
16
+ from .inspection import inspect_score
17
+ from .instruments import ENGINES, FAMILIES, instrument_catalog
18
+ from .midi import export_midi
19
+ from .model import Song
20
+ from .render import analyze_wav, render
21
+
22
+
23
+ class Parser(argparse.ArgumentParser):
24
+ def error(self, message: str) -> None:
25
+ raise ArgumentError(f"{self.prog}: {message}")
26
+
27
+
28
+ class ArgumentError(ValueError):
29
+ """An invalid CLI invocation, with the existing operation_failed contract."""
30
+
31
+
32
+ def _parser() -> argparse.ArgumentParser:
33
+ parser = Parser(
34
+ prog="aac",
35
+ description="Audio as Code: compose, render, and inspect music locally.",
36
+ epilog="Success: JSON on stdout, exit 0. Errors: JSON on stderr, exit 2. "
37
+ "Run 'aac COMMAND --help' for command options.",
38
+ )
39
+ parser.add_argument(
40
+ "--version",
41
+ action="version",
42
+ version=json.dumps({"version": __version__}),
43
+ help="Print the installed engine version as JSON and exit",
44
+ )
45
+ commands = parser.add_subparsers(dest="command", required=True)
46
+ instruments = commands.add_parser("instruments", help="Discover voices, families, and engines")
47
+ instruments.add_argument("--all", action="store_true", help="Include planned instruments")
48
+ instruments.add_argument("--family", choices=[family.id for family in FAMILIES])
49
+ instruments.add_argument("--engine", choices=[engine.id for engine in ENGINES])
50
+ demo = commands.add_parser("demo", help="Write an example JSON score")
51
+ demo.add_argument("-o", "--output", default="demo.json", help="Score path (default: demo.json)")
52
+ schema = commands.add_parser("schema", help="Print the versioned JSON Schema")
53
+ schema.add_argument("-o", "--output", help="Write schema to a file instead of stdout")
54
+ validate = commands.add_parser("validate", help="Validate a JSON score")
55
+ validate.add_argument("score", help="Path to a UTF-8 JSON score")
56
+ inspect = commands.add_parser("inspect", help="Inspect score facts and static export readiness")
57
+ inspect.add_argument("score", help="Path to a UTF-8 JSON score; does not render or write files")
58
+ wav = commands.add_parser("render", help="Render a score to stereo PCM WAV")
59
+ wav.add_argument("score", help="Path to a UTF-8 JSON score (render limit: 300 seconds)")
60
+ wav.add_argument("-o", "--output", required=True, help="Destination 16-bit stereo WAV")
61
+ wav.add_argument("--stems", metavar="DIRECTORY", help="Write numbered per-track WAV files")
62
+ wav.add_argument(
63
+ "--no-normalize", action="store_true", help="Disable peak attenuation; loud mixes may clip"
64
+ )
65
+ wav.add_argument("--report", help="Also save the JSON render report to this path")
66
+ midi = commands.add_parser("midi", help="Export a score as a type-1 MIDI file")
67
+ midi.add_argument("score", help="Path to a UTF-8 JSON score")
68
+ midi.add_argument("-o", "--output", required=True, help="Destination type-1 MIDI file")
69
+ analyze = commands.add_parser("analyze", help="Measure a 16-bit PCM WAV file")
70
+ analyze.add_argument("audio", help="Path to an existing 16-bit PCM WAV file")
71
+ return parser
72
+
73
+
74
+ def _write_json(path: str, data: dict) -> None:
75
+ destination = Path(path)
76
+ destination.parent.mkdir(parents=True, exist_ok=True)
77
+ destination.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n", encoding="utf-8")
78
+
79
+
80
+ def _error_hint(error: Exception) -> str:
81
+ if isinstance(error, ArgumentError):
82
+ return "Run aac --help or aac COMMAND --help to check arguments."
83
+ if isinstance(error, FileNotFoundError):
84
+ return "Check the input path and current working directory; quote paths containing spaces."
85
+ if isinstance(error, PermissionError):
86
+ return "Choose a writable output directory and close applications holding the file open."
87
+ if isinstance(error, (wave.Error, EOFError)):
88
+ return "Use an intact 16-bit PCM WAV file, such as one produced by aac render."
89
+ return "Check the message and command options; for score inputs, run aac validate first."
90
+
91
+
92
+ def main(argv: list[str] | None = None) -> int:
93
+ try:
94
+ args = _parser().parse_args(argv)
95
+ if args.command == "instruments":
96
+ result = instrument_catalog(
97
+ family=args.family, engine=args.engine, include_planned=args.all
98
+ )
99
+ elif args.command == "demo":
100
+ _check_paths([args.output])
101
+ song = demo_song()
102
+ song.save(args.output)
103
+ result = {"output": args.output, "title": song.title}
104
+ elif args.command == "schema":
105
+ result = Song.model_json_schema()
106
+ if args.output:
107
+ _check_paths([args.output])
108
+ _write_json(args.output, result)
109
+ result = {"output": args.output, "schema_version": "1"}
110
+ elif args.command == "analyze":
111
+ result = analyze_wav(args.audio)
112
+ else:
113
+ song = Song.load(args.score)
114
+ if args.command == "validate":
115
+ result = {
116
+ "valid": True,
117
+ "schema_version": song.schema_version,
118
+ "title": song.title,
119
+ "bpm": song.bpm,
120
+ "beats": song.beats,
121
+ "duration_seconds": song.seconds,
122
+ "render_duration_seconds": song.render_seconds,
123
+ "tracks": len(song.tracks),
124
+ "notes": sum(len(t.notes) for t in song.tracks),
125
+ }
126
+ elif args.command == "inspect":
127
+ result = inspect_score(song)
128
+ elif args.command == "midi":
129
+ _check_paths([args.score, args.output])
130
+ result = export_midi(song, args.output)
131
+ else:
132
+ paths = [args.score, args.output]
133
+ if args.report:
134
+ paths.append(args.report)
135
+ if args.stems:
136
+ paths.extend(
137
+ Path(args.stems) / f"{i + 1:02d}.wav" for i in range(len(song.tracks))
138
+ )
139
+ _check_paths(paths, [args.stems] if args.stems else [])
140
+ result = render(
141
+ song, args.output, normalize=not args.no_normalize, stems_dir=args.stems
142
+ )
143
+ if args.report:
144
+ _write_json(args.report, result)
145
+ print(json.dumps(result, indent=2, ensure_ascii=True, allow_nan=False))
146
+ return 0
147
+ except ValidationError as error:
148
+ issues = [
149
+ {"path": list(item["loc"]), "message": item["msg"], "type": item["type"]}
150
+ for item in error.errors(include_url=False)
151
+ ]
152
+ print(
153
+ json.dumps(
154
+ {
155
+ "error": "invalid_score",
156
+ "issues": issues,
157
+ "hint": "Correct the issue paths, then run aac validate again. "
158
+ "Use aac schema for the score contract.",
159
+ }
160
+ ),
161
+ file=sys.stderr,
162
+ )
163
+ return 2
164
+ except (OSError, ValueError, wave.Error, EOFError) as error:
165
+ print(
166
+ json.dumps(
167
+ {"error": "operation_failed", "message": str(error), "hint": _error_hint(error)}
168
+ ),
169
+ file=sys.stderr,
170
+ )
171
+ return 2
audio_as_code/demo.py ADDED
@@ -0,0 +1,106 @@
1
+ """A small, original arrangement generated entirely from the public score API."""
2
+
3
+ from .model import Song, Track
4
+ from .pattern import Pattern
5
+
6
+
7
+ def demo_song() -> Song:
8
+ # Eight bars: Am7 / Fmaj7 / Cmaj7 / G, then a melodic variation.
9
+ chords = [
10
+ ["A3", "C4", "E4", "G4"],
11
+ ["F3", "A3", "C4", "E4"],
12
+ ["C3", "E3", "G3", "B3"],
13
+ ["G3", "B3", "D4", "G4"],
14
+ ]
15
+ pads = Pattern.sequence(chords, step=4, gate=0.95, velocity=0.55).repeat(2)
16
+ bass = Pattern.sequence(
17
+ [
18
+ "A2",
19
+ None,
20
+ "E3",
21
+ "A2",
22
+ "F2",
23
+ None,
24
+ "C3",
25
+ "F2",
26
+ "C2",
27
+ None,
28
+ "G2",
29
+ "C3",
30
+ "G2",
31
+ None,
32
+ "D3",
33
+ "G2",
34
+ ],
35
+ gate=0.72,
36
+ velocity=0.8,
37
+ ).repeat(2)
38
+ melody = Pattern.sequence(
39
+ [
40
+ "E5",
41
+ None,
42
+ "C5",
43
+ "B4",
44
+ "A4",
45
+ None,
46
+ "C5",
47
+ "E5",
48
+ "A4",
49
+ "G4",
50
+ None,
51
+ "E4",
52
+ "G4",
53
+ None,
54
+ "B4",
55
+ "D5",
56
+ ],
57
+ step=0.5,
58
+ gate=0.65,
59
+ velocity=0.7,
60
+ )
61
+ variation = Pattern.sequence(
62
+ [
63
+ "E5",
64
+ "G5",
65
+ None,
66
+ "E5",
67
+ "C5",
68
+ "B4",
69
+ "A4",
70
+ None,
71
+ "G4",
72
+ None,
73
+ "A4",
74
+ "B4",
75
+ "D5",
76
+ "B4",
77
+ "G4",
78
+ None,
79
+ ],
80
+ step=0.5,
81
+ gate=0.7,
82
+ velocity=0.74,
83
+ )
84
+ kick = Pattern.sequence([36, None, 36, None], gate=0.4, velocity=0.9).repeat(8)
85
+ snare = Pattern.sequence([None, 38, None, 38], gate=0.28, velocity=0.7).repeat(8)
86
+ hat = Pattern.sequence([42, 42, 42, 42], step=0.5, gate=0.16, velocity=0.45).repeat(16)
87
+ return Song(
88
+ title="First Light",
89
+ bpm=104,
90
+ beats=32,
91
+ seed=7,
92
+ tracks=(
93
+ Track(name="Warm chords", instrument="pad", gain=0.34, pan=-0.25, notes=pads.notes),
94
+ Track(name="Bass", instrument="bass", gain=0.65, notes=bass.notes),
95
+ Track(
96
+ name="Melody",
97
+ instrument="pluck",
98
+ gain=0.52,
99
+ pan=0.3,
100
+ notes=melody.at(8) + variation.at(24),
101
+ ),
102
+ Track(name="Kick", instrument="kick", gain=0.85, notes=kick.notes),
103
+ Track(name="Snare", instrument="snare", gain=0.48, pan=-0.08, notes=snare.notes),
104
+ Track(name="Hi-hat", instrument="hat", gain=0.34, pan=0.35, notes=hat.notes),
105
+ ),
106
+ )
@@ -0,0 +1,85 @@
1
+ """Bounded, deterministic effects made entirely from code-generated delay networks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+
10
+ from .model import Delay, Effect, Reverb
11
+
12
+ Audio = NDArray[np.float32]
13
+
14
+
15
+ def effects_tail(effects: tuple[Effect, ...]) -> float:
16
+ return sum(
17
+ (
18
+ effect.time_seconds * (effect.repeats if effect.feedback else 1)
19
+ if isinstance(effect, Delay)
20
+ else effect.decay_seconds + 0.1
21
+ )
22
+ for effect in effects
23
+ if effect.mix
24
+ )
25
+
26
+
27
+ def _reverb_kernel(effect: Reverb, rate: int, channel: int) -> NDArray[np.float64]:
28
+ """Four decaying combs and two short finite all-pass diffusers, analytically generated."""
29
+ frames = math.ceil((effect.decay_seconds + 0.1) * rate) + 1
30
+ impulse = np.zeros(frames)
31
+ for seconds in (0.0297, 0.0371, 0.0411, 0.0437):
32
+ delay = max(1, round((seconds + channel * 0.0013) * rate))
33
+ positions = np.arange(delay, frames, delay)
34
+ gains = np.power(10.0, -3 * positions / (rate * effect.decay_seconds))
35
+ impulse[positions] += gains / 4
36
+ # Finite expansion of (-g + z^-d)/(1 - g*z^-d); twelve repeats per diffuser.
37
+ for seconds in (0.0031, 0.0053):
38
+ delay = max(1, round((seconds + channel * 0.0002) * rate))
39
+ source = impulse.copy()
40
+ impulse *= -0.5
41
+ for repeat in range(1, 13):
42
+ offset = repeat * delay
43
+ if offset >= frames:
44
+ break
45
+ impulse[offset:] += source[:-offset] * (0.75 * 0.5 ** (repeat - 1))
46
+ fade = min(frames, max(2, round(0.01 * rate)))
47
+ impulse[-fade:] *= np.linspace(1, 0, fade)
48
+ return impulse
49
+
50
+
51
+ def _convolve(source: Audio, kernel: NDArray[np.float64]) -> Audio:
52
+ """Overlap-add convolution keeps working memory independent of song duration."""
53
+ block = 1 << max(16, (len(kernel) - 1).bit_length())
54
+ size = block * 2
55
+ spectrum = np.fft.rfft(kernel, n=size)
56
+ result = np.zeros(len(source), dtype=np.float32)
57
+ for start in range(0, len(source), block):
58
+ chunk = source[start : start + block]
59
+ filtered = np.fft.irfft(np.fft.rfft(chunk, n=size) * spectrum, n=size)
60
+ count = min(len(source) - start, len(chunk) + len(kernel) - 1)
61
+ result[start : start + count] += filtered[:count].astype(np.float32)
62
+ return result
63
+
64
+
65
+ def apply_effects(audio: Audio, effects: tuple[Effect, ...], rate: int) -> Audio:
66
+ """Apply a serial chain to an already tail-padded stereo buffer."""
67
+ for effect in effects:
68
+ if effect.mix == 0:
69
+ continue
70
+ wet = np.zeros_like(audio)
71
+ if isinstance(effect, Delay):
72
+ for repeat in range(1, effect.repeats + 1):
73
+ if repeat > 1 and effect.feedback == 0:
74
+ break
75
+ offset = round(effect.time_seconds * repeat * rate)
76
+ if offset >= len(audio):
77
+ break
78
+ wet[offset:] += audio[:-offset] * effect.feedback ** (repeat - 1)
79
+ else:
80
+ for channel in range(2):
81
+ wet[:, channel] = _convolve(
82
+ audio[:, channel], _reverb_kernel(effect, rate, channel)
83
+ )
84
+ audio = audio * (1 - effect.mix) + wet * effect.mix
85
+ return audio
@@ -0,0 +1,283 @@
1
+ """Paired strings, cantilever lamellae, struck bars and a recorder jet spectrum.
2
+
3
+ All excitations and resonator responses are generated from equations. Parameters
4
+ are designed rather than fitted to recordings. See docs/extended-instruments.md
5
+ for the physical motivation and the deliberately limited 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 .acoustics import (
16
+ colored_noise,
17
+ modulate_noise,
18
+ nyquist_gain,
19
+ resonant_body,
20
+ slow_variation,
21
+ struck_mode,
22
+ )
23
+ from .instruments import require_instrument
24
+ from .model import Tone
25
+
26
+ Signal = NDArray[np.float64]
27
+ EXTENDED_INSTRUMENTS = frozenset({"mandolin", "kalimba", "celesta", "recorder"})
28
+
29
+
30
+ def _mandolin(
31
+ frequency: float,
32
+ t: Signal,
33
+ rate: int,
34
+ seed: int,
35
+ brightness: float,
36
+ decay: float,
37
+ position: float,
38
+ detune: float,
39
+ ) -> Signal:
40
+ """Two-string modal pairs with shared bridge stiffness and radiation loss.
41
+
42
+ The symmetric stiffness matrix gives stable real normal modes. Damping is
43
+ projected onto each mode; off-diagonal dissipative coupling is neglected.
44
+ detune is the TOTAL uncoupled-string separation in cents, not +/- detune.
45
+ """
46
+ rng = np.random.Generator(np.random.PCG64(seed))
47
+ signal = np.zeros(len(t))
48
+ excitation = np.array([1.0, rng.uniform(0.88, 0.96)])
49
+ radiation = np.array([1.0, 1.0])
50
+ total = 0.0
51
+ stiffness = 0.000018 * (1 + frequency / 600)
52
+ for harmonic in range(1, 33):
53
+ center = frequency * harmonic * math.sqrt((1 + stiffness * harmonic**2) / (1 + stiffness))
54
+ uncoupled = np.array([2 ** (-detune / 2400), 2 ** (detune / 2400)])
55
+ # A small common-bridge restoring term separates bright and dark modes.
56
+ coupling = 0.00035 / (1 + harmonic * 0.08)
57
+ eigenvalues, vectors = np.linalg.eigh(np.diag(uncoupled**2) + coupling * np.ones((2, 2)))
58
+ ratios = np.sqrt(eigenvalues)
59
+ # Preserve the written pitch as the geometric center of the mode pair.
60
+ ratios /= math.sqrt(ratios[0] * ratios[1])
61
+ pluck = math.sin(math.pi * harmonic * position) / harmonic**1.2
62
+ pluck *= math.exp(-(harmonic - 1) / (3.0 + 15 * brightness))
63
+ pluck *= rng.uniform(0.98, 1.02)
64
+ for index, ratio in enumerate(ratios):
65
+ mode = vectors[:, index]
66
+ bridge = float(mode @ radiation)
67
+ amplitude = pluck * float(mode @ excitation) * bridge
68
+ total += abs(amplitude)
69
+ f = center * ratio
70
+ if f >= rate * 0.49:
71
+ continue
72
+ loss = (0.45 + 0.42 * bridge**2) * (1 + 0.12 * (harmonic - 1) ** 1.2)
73
+ signal += (
74
+ amplitude
75
+ * np.sin(2 * np.pi * f * t)
76
+ * np.exp(-math.log(1000) * loss * t / decay)
77
+ * nyquist_gain(f, rate)
78
+ )
79
+ if total:
80
+ signal *= 0.86 / total
81
+ # A very short generated pick scrape drives the same body as the strings.
82
+ signal += (
83
+ 0.018 * brightness * colored_noise(len(t), rate, seed + 1, 1700, 8000) * np.exp(-t / 0.005)
84
+ )
85
+ return resonant_body(
86
+ signal,
87
+ rate,
88
+ ((310, 0.11, 0.45), (650, 0.075, 0.35), (1550, 0.045, 0.2)),
89
+ wet=0.18,
90
+ )
91
+
92
+
93
+ def _kalimba(
94
+ frequency: float,
95
+ t: Signal,
96
+ rate: int,
97
+ seed: int,
98
+ brightness: float,
99
+ decay: float,
100
+ ) -> Signal:
101
+ """Finite thumb-contact forcing of fixed-free lamella modes and a wooden box."""
102
+ # Squared roots of cos(beta)*cosh(beta)=-1, relative to the first root.
103
+ roots = (1.87510407, 4.69409113, 7.85475744, 10.99554073)
104
+ amplitudes = (1.0, 0.26, 0.095, 0.035)
105
+ lifetimes = (1.0, 0.22, 0.085, 0.04)
106
+ contact = min(0.42 / frequency, 0.00055 * (1.3 - 0.9 * brightness))
107
+ rng = np.random.Generator(np.random.PCG64(seed))
108
+ signal = np.zeros(len(t))
109
+ total = 0.0
110
+ for index, (root, amplitude, lifetime) in enumerate(
111
+ zip(roots, amplitudes, lifetimes, strict=True)
112
+ ):
113
+ f = frequency * (root / roots[0]) ** 2
114
+ amplitude *= (1 if index == 0 else 0.3 + 1.4 * brightness) * rng.uniform(0.98, 1.02)
115
+ total += amplitude
116
+ if f < rate * 0.49:
117
+ signal += (
118
+ amplitude
119
+ * struck_mode(
120
+ t,
121
+ f,
122
+ math.log(1000) / (decay * lifetime),
123
+ contact,
124
+ )
125
+ * nyquist_gain(f, rate)
126
+ )
127
+ signal *= 0.86 / total
128
+ # A thumb/metal contact transient, not a sampled click or a buzz attachment.
129
+ signal += (
130
+ 0.012 * brightness * colored_noise(len(t), rate, seed + 1, 900, 6500) * np.exp(-t / 0.003)
131
+ )
132
+ return resonant_body(
133
+ signal,
134
+ rate,
135
+ ((185, 0.18, 0.45), (430, 0.10, 0.35), (1100, 0.045, 0.2)),
136
+ wet=0.22,
137
+ )
138
+
139
+
140
+ def _celesta(
141
+ frequency: float,
142
+ t: Signal,
143
+ rate: int,
144
+ seed: int,
145
+ brightness: float,
146
+ decay: float,
147
+ ) -> Signal:
148
+ """Felt-hammer forcing of free-free metal-bar modes and a pitch-matched box."""
149
+ # First four non-rigid roots of cos(beta)*cosh(beta)=1.
150
+ roots = (4.73004074, 7.85320462, 10.99560784, 14.13716549)
151
+ amplitudes = (1.0, 0.48, 0.23, 0.11)
152
+ lifetimes = (1.0, 0.62, 0.30, 0.16)
153
+ contact = min(0.55 / frequency, 0.0034 * (1.2 - 0.85 * brightness))
154
+ rng = np.random.Generator(np.random.PCG64(seed))
155
+ signal = np.zeros(len(t))
156
+ total = 0.0
157
+ for index, (root, amplitude, lifetime) in enumerate(
158
+ zip(roots, amplitudes, lifetimes, strict=True)
159
+ ):
160
+ f = frequency * (root / roots[0]) ** 2
161
+ amplitude *= (1 if index == 0 else 0.35 + 1.3 * brightness) * rng.uniform(0.985, 1.015)
162
+ total += amplitude
163
+ if f < rate * 0.49:
164
+ signal += (
165
+ amplitude
166
+ * struck_mode(
167
+ t,
168
+ f,
169
+ math.log(1000) / (decay * lifetime),
170
+ contact,
171
+ )
172
+ * nyquist_gain(f, rate)
173
+ )
174
+ signal *= 0.9 / total
175
+ # The resonator is driven by bar motion. It is an analytic one-way response,
176
+ # not an independent ringing oscillator or measured instrument response.
177
+ return resonant_body(
178
+ signal,
179
+ rate,
180
+ ((frequency, 0.22, 1.0), (frequency * 2.02, 0.085, 0.16)),
181
+ wet=0.24,
182
+ )
183
+
184
+
185
+ def _recorder(
186
+ frequency: float,
187
+ t: Signal,
188
+ rate: int,
189
+ seed: int,
190
+ brightness: float,
191
+ settings: dict,
192
+ ) -> Signal:
193
+ """Band-limited jet-source harmonics with an idealized bore/radiation filter.
194
+
195
+ The jet waveform is Fourier-analyzed on a phase grid, then reconstructed
196
+ only with audible partials. No nonlinear audio-rate waveshaper can alias.
197
+ This is a prescribed source/filter model, not a self-oscillating jet solver.
198
+ """
199
+ depth = settings["vibrato_depth_cents"]
200
+ vibrato_rate = settings["vibrato_rate_hz"]
201
+ motion = slow_variation(t, seed, 0.6)
202
+ vibrato = (
203
+ depth * (1 - np.exp(-np.maximum(t - 0.08, 0) / 0.22)) * np.sin(2 * np.pi * vibrato_rate * t)
204
+ )
205
+ settling = -2.0 * np.exp(-t / 0.018)
206
+ frequencies = frequency * 2 ** ((vibrato + settling + 0.35 * motion) / 1200)
207
+ phase = np.zeros(len(t))
208
+ phase[1:] = 2 * np.pi * np.cumsum((frequencies[:-1] + frequencies[1:]) * 0.5) / rate
209
+ theta = 2 * np.pi * np.arange(2048) / 2048
210
+ drive = 0.85 + 1.6 * brightness
211
+ offset = 0.10 + 0.18 * brightness
212
+ jet = np.tanh(drive * (np.sin(theta) + offset))
213
+ coefficients = np.fft.rfft(jet) / len(theta)
214
+ signal = np.zeros(len(t))
215
+ total = 0.0
216
+ pressure = 1 + 0.018 * motion
217
+ # The pitch-scaled bore roll-off and fixed radiation shelf are designed
218
+ # envelopes, without fingering-dependent impedances or measured formants.
219
+ cutoff = 3.0 + 5.0 * brightness
220
+ for harmonic in range(1, 21):
221
+ coefficient = 2 * coefficients[harmonic]
222
+ transfer = harmonic**0.35 / math.sqrt(1 + (harmonic / cutoff) ** 4)
223
+ transfer /= math.sqrt(1 + (frequency * harmonic / 8500) ** 2)
224
+ amplitude = abs(coefficient) * transfer
225
+ total += amplitude
226
+ if float(np.min(frequencies)) * harmonic >= rate * 0.49:
227
+ continue
228
+ onset = 1 - np.exp(-t / (0.012 + harmonic * 0.0015))
229
+ signal += (
230
+ amplitude
231
+ * onset
232
+ * pressure
233
+ * np.cos(harmonic * phase + np.angle(coefficient))
234
+ * nyquist_gain(frequencies * harmonic, rate)
235
+ )
236
+ if total:
237
+ signal *= 0.79 / total
238
+ breath = settings["breath"]
239
+ if breath:
240
+ noise = colored_noise(len(t), rate, seed + 1, 1200, 7500)
241
+ noise = modulate_noise(noise, phase, float(np.max(frequencies)) + 2, rate, 0.18)
242
+ envelope = (1 - np.exp(-t / 0.003)) * (0.55 + 0.8 * np.exp(-t / 0.045))
243
+ signal += 0.15 * breath * noise * envelope * pressure
244
+ return signal
245
+
246
+
247
+ def synthesize(
248
+ instrument: str,
249
+ frequency: float,
250
+ frames: int,
251
+ rate: int,
252
+ seed: int,
253
+ velocity: float,
254
+ tone: Tone | None,
255
+ ) -> Signal:
256
+ """Return generated float64 mono; the renderer applies note gates and gain."""
257
+ if instrument not in EXTENDED_INSTRUMENTS:
258
+ raise ValueError(f"No extended model for {instrument!r}")
259
+ info = require_instrument(instrument)
260
+ if frames < 1 or frequency >= rate / 2:
261
+ return np.zeros(frames, dtype=np.float64)
262
+ tone = tone or Tone()
263
+ settings = dict(info.default_tone)
264
+ settings.update({key: value for key, value in tone.model_dump().items() if value is not None})
265
+ brightness = min(1.0, settings["brightness"] * 0.75 + velocity * 0.25)
266
+ decay = settings.get("decay_seconds", info.default_decay_seconds)
267
+ t = np.arange(frames, dtype=np.float64) / rate
268
+ if instrument == "mandolin":
269
+ return _mandolin(
270
+ frequency,
271
+ t,
272
+ rate,
273
+ seed,
274
+ brightness,
275
+ decay,
276
+ settings["pluck_position"],
277
+ settings["detune_cents"],
278
+ )
279
+ if instrument == "kalimba":
280
+ return _kalimba(frequency, t, rate, seed, brightness, decay)
281
+ if instrument == "celesta":
282
+ return _celesta(frequency, t, rate, seed, brightness, decay)
283
+ return _recorder(frequency, t, rate, seed, brightness, settings)