constraint-synth 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.
- constraint_synth/__init__.py +19 -0
- constraint_synth/constraint_filter.py +44 -0
- constraint_synth/envelope.py +73 -0
- constraint_synth/flux_bridge.py +521 -0
- constraint_synth/midi_renderer.py +170 -0
- constraint_synth/oscillator.py +127 -0
- constraint_synth/playback.py +55 -0
- constraint_synth/synth.py +241 -0
- constraint_synth-0.1.0.dist-info/METADATA +8 -0
- constraint_synth-0.1.0.dist-info/RECORD +12 -0
- constraint_synth-0.1.0.dist-info/WHEEL +5 -0
- constraint_synth-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Constraint Synthesizer — where waveshape IS lattice geometry."""
|
|
2
|
+
|
|
3
|
+
from .oscillator import LatticeOscillator
|
|
4
|
+
from .envelope import FunnelEnvelope
|
|
5
|
+
from .constraint_filter import ConsonanceFilter
|
|
6
|
+
from .synth import ConstraintSynth, BiquadLowpass, SchroederReverb
|
|
7
|
+
from .playback import AudioPlayer
|
|
8
|
+
from .midi_renderer import MIDIRenderer
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"LatticeOscillator",
|
|
12
|
+
"FunnelEnvelope",
|
|
13
|
+
"ConsonanceFilter",
|
|
14
|
+
"ConstraintSynth",
|
|
15
|
+
"AudioPlayer",
|
|
16
|
+
"MIDIRenderer",
|
|
17
|
+
"BiquadLowpass",
|
|
18
|
+
"SchroederReverb",
|
|
19
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Consonance Filter — passes consonant harmonics, attenuates dissonant ones."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Harmonic series ratios (consonant with fundamental)
|
|
7
|
+
CONSONANT_RATIOS = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConsonanceFilter:
|
|
11
|
+
"""
|
|
12
|
+
A filter that passes consonant harmonics and attenuates dissonant ones.
|
|
13
|
+
|
|
14
|
+
Unlike a traditional frequency filter, this filters by INTERVAL quality.
|
|
15
|
+
|
|
16
|
+
cutoff: 0.0 = only unisons pass, 1.0 = everything passes
|
|
17
|
+
resonance: constraint tightness (higher = sharper rolloff)
|
|
18
|
+
"""
|
|
19
|
+
def __init__(self, cutoff: float = 0.5, resonance: float = 1.0):
|
|
20
|
+
self.cutoff = cutoff
|
|
21
|
+
self.resonance = resonance
|
|
22
|
+
|
|
23
|
+
def apply(self, signal: np.ndarray, fundamental: float, sample_rate: int) -> np.ndarray:
|
|
24
|
+
"""Filter harmonics based on consonance with fundamental."""
|
|
25
|
+
if fundamental <= 0 or len(signal) == 0:
|
|
26
|
+
return signal
|
|
27
|
+
|
|
28
|
+
spectrum = np.fft.rfft(signal)
|
|
29
|
+
freqs = np.fft.rfftfreq(len(signal), 1.0 / sample_rate)
|
|
30
|
+
|
|
31
|
+
for i, freq in enumerate(freqs):
|
|
32
|
+
if freq < 20:
|
|
33
|
+
continue
|
|
34
|
+
ratio = freq / fundamental
|
|
35
|
+
# Distance to nearest consonant ratio
|
|
36
|
+
distances = [abs(ratio - cr) for cr in CONSONANT_RATIOS]
|
|
37
|
+
min_dist = min(distances)
|
|
38
|
+
# Consonance score: 1.0 = perfectly consonant, 0.0 = very dissonant
|
|
39
|
+
consonance = max(0.0, 1.0 - min_dist)
|
|
40
|
+
if consonance < self.cutoff:
|
|
41
|
+
attenuation = (consonance / self.cutoff) ** self.resonance
|
|
42
|
+
spectrum[i] *= attenuation
|
|
43
|
+
|
|
44
|
+
return np.fft.irfft(spectrum, len(signal))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Funnel Envelope — ADSR as deadband funnel lifecycle."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class FunnelEnvelope:
|
|
9
|
+
"""
|
|
10
|
+
ADSR envelope as deadband funnel.
|
|
11
|
+
|
|
12
|
+
Attack = convergence rate, Sustain = equilibrium epsilon,
|
|
13
|
+
Release = divergence. Hold = suspension plateau.
|
|
14
|
+
"""
|
|
15
|
+
attack: float = 0.01 # convergence time (seconds)
|
|
16
|
+
decay: float = 0.1 # relaxation time
|
|
17
|
+
sustain: float = 0.7 # equilibrium epsilon (0-1)
|
|
18
|
+
release: float = 0.3 # divergence time
|
|
19
|
+
hold: float = 0.0 # suspension plateau
|
|
20
|
+
|
|
21
|
+
def apply(self, signal: np.ndarray, sample_rate: int, note_duration: float) -> np.ndarray:
|
|
22
|
+
"""Apply envelope to signal."""
|
|
23
|
+
n_samples = len(signal)
|
|
24
|
+
if n_samples == 0:
|
|
25
|
+
return signal
|
|
26
|
+
|
|
27
|
+
envelope = np.zeros(n_samples)
|
|
28
|
+
|
|
29
|
+
attack_samples = int(self.attack * sample_rate)
|
|
30
|
+
decay_samples = int(self.decay * sample_rate)
|
|
31
|
+
release_samples = int(self.release * sample_rate)
|
|
32
|
+
hold_samples = int(self.hold * sample_rate)
|
|
33
|
+
|
|
34
|
+
sustain_samples = (n_samples
|
|
35
|
+
- attack_samples
|
|
36
|
+
- decay_samples
|
|
37
|
+
- hold_samples
|
|
38
|
+
- release_samples)
|
|
39
|
+
if sustain_samples < 0:
|
|
40
|
+
sustain_samples = 0
|
|
41
|
+
|
|
42
|
+
idx = 0
|
|
43
|
+
|
|
44
|
+
# Attack — convergence ramp (0 → 1)
|
|
45
|
+
if attack_samples > 0:
|
|
46
|
+
end = min(attack_samples, n_samples)
|
|
47
|
+
envelope[idx:end] = np.linspace(0, 1, end - idx)
|
|
48
|
+
idx = end
|
|
49
|
+
|
|
50
|
+
# Hold — suspension plateau at peak
|
|
51
|
+
if hold_samples > 0 and idx < n_samples:
|
|
52
|
+
end = min(idx + hold_samples, n_samples)
|
|
53
|
+
envelope[idx:end] = 1.0
|
|
54
|
+
idx = end
|
|
55
|
+
|
|
56
|
+
# Decay — relaxation to pocket (1 → sustain)
|
|
57
|
+
if decay_samples > 0 and idx < n_samples:
|
|
58
|
+
end = min(idx + decay_samples, n_samples)
|
|
59
|
+
envelope[idx:end] = np.linspace(1, self.sustain, end - idx)
|
|
60
|
+
idx = end
|
|
61
|
+
|
|
62
|
+
# Sustain — pocket equilibrium
|
|
63
|
+
if sustain_samples > 0 and idx < n_samples:
|
|
64
|
+
end = min(idx + sustain_samples, n_samples)
|
|
65
|
+
envelope[idx:end] = self.sustain
|
|
66
|
+
idx = end
|
|
67
|
+
|
|
68
|
+
# Release — divergence (sustain → 0)
|
|
69
|
+
if release_samples > 0 and idx < n_samples:
|
|
70
|
+
remaining = min(release_samples, n_samples - idx)
|
|
71
|
+
envelope[idx:idx + remaining] = np.linspace(self.sustain, 0, remaining)
|
|
72
|
+
|
|
73
|
+
return signal * envelope
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""flux_bridge — Bridge between flux-tensor-midi and constraint-synth.
|
|
2
|
+
|
|
3
|
+
Converts flux-tensor-midi MidiEvent streams and FluxVectors into
|
|
4
|
+
constraint-synth audio, enabling direct synthesis without DawDreamer.
|
|
5
|
+
|
|
6
|
+
This is the synergy: constraint parameters → MIDI (flux) → audio (dawdreamer)
|
|
7
|
+
OR constraint parameters → audio directly (synth). Same source, two renderers.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from constraint_synth.flux_bridge import FluxBridge
|
|
11
|
+
|
|
12
|
+
bridge = FluxBridge(preset="piano_ballad")
|
|
13
|
+
audio = bridge.render_events(midi_events)
|
|
14
|
+
bridge.to_wav(audio, "output.wav")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import struct
|
|
21
|
+
import wave
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from .oscillator import LatticeOscillator
|
|
28
|
+
from .envelope import FunnelEnvelope
|
|
29
|
+
from .constraint_filter import ConsonanceFilter
|
|
30
|
+
from .synth import ConstraintSynth, BiquadLowpass, SchroederReverb
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
# Channel mapping: FluxVector channels → constraint-synth parameters
|
|
35
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
CHANNEL_NAMES = (
|
|
38
|
+
"pitch",
|
|
39
|
+
"dynamics",
|
|
40
|
+
"timbre",
|
|
41
|
+
"brightness",
|
|
42
|
+
"space",
|
|
43
|
+
"tension",
|
|
44
|
+
"noise",
|
|
45
|
+
"snap",
|
|
46
|
+
"weight",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Lattice shape lookup by index (timbre channel)
|
|
50
|
+
LATTICE_SHAPES = ("sine", "saw", "square", "triangle", "eisenstein")
|
|
51
|
+
|
|
52
|
+
# Default note-to-channel mapping (matches flux-tensor-midi NoteName)
|
|
53
|
+
NOTE_CHANNEL_BASE = 60 # C4
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
# MidiEvent shim — works with or without flux-tensor-midi installed
|
|
58
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class MidiEventShim:
|
|
62
|
+
"""Lightweight MidiEvent compatible with flux-tensor-midi's MidiEvent.
|
|
63
|
+
|
|
64
|
+
If flux_tensor_midi is installed, we accept its MidiEvent directly.
|
|
65
|
+
Otherwise, this shim provides the same interface.
|
|
66
|
+
"""
|
|
67
|
+
note: int
|
|
68
|
+
velocity: int
|
|
69
|
+
start_ms: float
|
|
70
|
+
duration_ms: float
|
|
71
|
+
channel: int = 0
|
|
72
|
+
|
|
73
|
+
def __post_init__(self):
|
|
74
|
+
if not 0 <= self.note <= 127:
|
|
75
|
+
raise ValueError(f"note must be 0–127, got {self.note}")
|
|
76
|
+
if not 0 <= self.velocity <= 127:
|
|
77
|
+
raise ValueError(f"velocity must be 0–127, got {self.velocity}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_event(ev: Any) -> MidiEventShim:
|
|
81
|
+
"""Accept any event with note/velocity/start_ms/duration_ms attributes."""
|
|
82
|
+
if isinstance(ev, MidiEventShim):
|
|
83
|
+
return ev
|
|
84
|
+
return MidiEventShim(
|
|
85
|
+
note=ev.note,
|
|
86
|
+
velocity=ev.velocity,
|
|
87
|
+
start_ms=ev.start_ms,
|
|
88
|
+
duration_ms=ev.duration_ms,
|
|
89
|
+
channel=getattr(ev, "channel", 0),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
# FluxVector mapper
|
|
95
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
class FluxVectorMapper:
|
|
98
|
+
"""Maps a 9-channel FluxVector to constraint-synth parameters.
|
|
99
|
+
|
|
100
|
+
Channel semantics:
|
|
101
|
+
0 (pitch) → oscillator frequency via note offset from C4
|
|
102
|
+
1 (dynamics) → envelope sustain (0–1) + velocity scaling
|
|
103
|
+
2 (timbre) → lattice_shape index (0–4)
|
|
104
|
+
3 (brightness) → filter_cutoff (200–8000 Hz)
|
|
105
|
+
4 (space) → reverb_wet (0–1)
|
|
106
|
+
5 (tension) → lattice_stretch (0.9–1.1)
|
|
107
|
+
6 (noise) → noise_floor (0–0.1)
|
|
108
|
+
7 (snap) → snap_threshold (0–1)
|
|
109
|
+
8 (weight) → attack (0.001–0.5) and decay (0.01–1.0)
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
base_note: int = 60,
|
|
115
|
+
velocity_scale: int = 100,
|
|
116
|
+
sample_rate: int = 44100,
|
|
117
|
+
):
|
|
118
|
+
self.base_note = base_note
|
|
119
|
+
self.velocity_scale = velocity_scale
|
|
120
|
+
self.sample_rate = sample_rate
|
|
121
|
+
|
|
122
|
+
def map_to_synth_params(
|
|
123
|
+
self,
|
|
124
|
+
values: Sequence[float],
|
|
125
|
+
salience: Sequence[float] | None = None,
|
|
126
|
+
tolerance: Sequence[float] | None = None,
|
|
127
|
+
) -> dict:
|
|
128
|
+
"""Map FluxVector values to a dict of constraint-synth constructor args.
|
|
129
|
+
|
|
130
|
+
Returns a dict suitable for building a LatticeOscillator, FunnelEnvelope,
|
|
131
|
+
and synth config.
|
|
132
|
+
"""
|
|
133
|
+
if len(values) < 9:
|
|
134
|
+
raise ValueError(f"Expected 9 channels, got {len(values)}")
|
|
135
|
+
|
|
136
|
+
# Channel 0: pitch → semitone offset from base_note
|
|
137
|
+
pitch_offset = values[0] * 12.0 # 0–12 semitones
|
|
138
|
+
note = min(127, max(0, int(self.base_note + pitch_offset)))
|
|
139
|
+
freq = 440.0 * (2 ** ((note - 69) / 12.0))
|
|
140
|
+
|
|
141
|
+
# Channel 1: dynamics → sustain level
|
|
142
|
+
dynamics = max(0.0, min(1.0, values[1]))
|
|
143
|
+
|
|
144
|
+
# Channel 2: timbre → lattice shape index
|
|
145
|
+
shape_idx = max(0, min(len(LATTICE_SHAPES) - 1, int(values[2] * len(LATTICE_SHAPES))))
|
|
146
|
+
lattice_shape = LATTICE_SHAPES[shape_idx]
|
|
147
|
+
|
|
148
|
+
# Channel 3: brightness → filter cutoff
|
|
149
|
+
brightness = max(0.0, min(1.0, values[3]))
|
|
150
|
+
filter_cutoff = 200.0 + brightness * 7800.0 # 200–8000 Hz
|
|
151
|
+
|
|
152
|
+
# Channel 4: space → reverb wet
|
|
153
|
+
space = max(0.0, min(1.0, values[4]))
|
|
154
|
+
reverb_wet = space * 0.8 # cap at 0.8
|
|
155
|
+
|
|
156
|
+
# Channel 5: tension → lattice stretch
|
|
157
|
+
tension = max(0.0, min(1.0, values[5]))
|
|
158
|
+
lattice_stretch = 0.9 + tension * 0.2 # 0.9–1.1
|
|
159
|
+
|
|
160
|
+
# Channel 6: noise → noise floor
|
|
161
|
+
noise = max(0.0, min(1.0, values[6]))
|
|
162
|
+
noise_floor = noise * 0.1
|
|
163
|
+
|
|
164
|
+
# Channel 7: snap → snap threshold
|
|
165
|
+
snap = max(0.0, min(1.0, values[7]))
|
|
166
|
+
|
|
167
|
+
# Channel 8: weight → attack and decay
|
|
168
|
+
weight = max(0.0, min(1.0, values[8]))
|
|
169
|
+
attack = 0.001 + weight * 0.499 # 0.001–0.5
|
|
170
|
+
decay = 0.01 + weight * 0.99 # 0.01–1.0
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"note": note,
|
|
174
|
+
"freq": freq,
|
|
175
|
+
"velocity": int(dynamics * self.velocity_scale),
|
|
176
|
+
"lattice_shape": lattice_shape,
|
|
177
|
+
"lattice_stretch": lattice_stretch,
|
|
178
|
+
"noise_floor": noise_floor,
|
|
179
|
+
"snap_threshold": snap,
|
|
180
|
+
"filter_cutoff": filter_cutoff,
|
|
181
|
+
"reverb_wet": reverb_wet,
|
|
182
|
+
"attack": attack,
|
|
183
|
+
"decay": decay,
|
|
184
|
+
"sustain": dynamics,
|
|
185
|
+
"release": 0.3,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def map_to_synth(self, values: Sequence[float], **overrides) -> ConstraintSynth:
|
|
189
|
+
"""Build a complete ConstraintSynth from FluxVector values."""
|
|
190
|
+
params = self.map_to_synth_params(values)
|
|
191
|
+
params.update(overrides)
|
|
192
|
+
|
|
193
|
+
osc = LatticeOscillator(
|
|
194
|
+
frequency=params["freq"],
|
|
195
|
+
lattice_shape=params["lattice_shape"],
|
|
196
|
+
lattice_stretch=params["lattice_stretch"],
|
|
197
|
+
noise_floor=params["noise_floor"],
|
|
198
|
+
snap_threshold=params["snap_threshold"],
|
|
199
|
+
)
|
|
200
|
+
env = FunnelEnvelope(
|
|
201
|
+
attack=params["attack"],
|
|
202
|
+
decay=params["decay"],
|
|
203
|
+
sustain=params["sustain"],
|
|
204
|
+
release=params["release"],
|
|
205
|
+
)
|
|
206
|
+
return ConstraintSynth(
|
|
207
|
+
oscillator=osc,
|
|
208
|
+
envelope=env,
|
|
209
|
+
filter_cutoff=params["filter_cutoff"],
|
|
210
|
+
reverb_wet=params["reverb_wet"],
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
# FluxBridge — main bridge class
|
|
216
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
class FluxBridge:
|
|
219
|
+
"""Bridge between flux-tensor-midi and constraint-synth.
|
|
220
|
+
|
|
221
|
+
Renders MidiEvent streams as direct audio via ConstraintSynth,
|
|
222
|
+
bypassing DawDreamer entirely while preserving constraint semantics.
|
|
223
|
+
|
|
224
|
+
Parameters
|
|
225
|
+
----------
|
|
226
|
+
preset : str, optional
|
|
227
|
+
ConstraintSynth preset name. If provided, used as default timbre.
|
|
228
|
+
mapper : FluxVectorMapper, optional
|
|
229
|
+
Custom mapper. Defaults to standard channel mapping.
|
|
230
|
+
sample_rate : int
|
|
231
|
+
Audio sample rate (default 44100).
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
preset: str | None = None,
|
|
237
|
+
mapper: FluxVectorMapper | None = None,
|
|
238
|
+
sample_rate: int = 44100,
|
|
239
|
+
):
|
|
240
|
+
self.sample_rate = sample_rate
|
|
241
|
+
self.mapper = mapper or FluxVectorMapper(sample_rate=sample_rate)
|
|
242
|
+
|
|
243
|
+
if preset is not None:
|
|
244
|
+
self._default_synth = ConstraintSynth.from_preset(preset)
|
|
245
|
+
else:
|
|
246
|
+
self._default_synth = ConstraintSynth()
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def synth(self) -> ConstraintSynth:
|
|
250
|
+
"""The default ConstraintSynth instance."""
|
|
251
|
+
return self._default_synth
|
|
252
|
+
|
|
253
|
+
def render_events(
|
|
254
|
+
self,
|
|
255
|
+
events: Sequence[Any],
|
|
256
|
+
spacing_ms: float = 10.0,
|
|
257
|
+
crossfade_samples: int = 64,
|
|
258
|
+
) -> np.ndarray:
|
|
259
|
+
"""Render a list of MidiEvent objects to audio.
|
|
260
|
+
|
|
261
|
+
Accepts flux-tensor-midi MidiEvent objects or MidiEventShim objects.
|
|
262
|
+
Events can be in any order; they are sorted by start time.
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
events : sequence of MidiEvent-like
|
|
267
|
+
Events with .note, .velocity, .start_ms, .duration_ms attributes.
|
|
268
|
+
spacing_ms : float
|
|
269
|
+
Extra silence between overlapping notes (default 10ms).
|
|
270
|
+
crossfade_samples : int
|
|
271
|
+
Crossfade length between consecutive note boundaries.
|
|
272
|
+
|
|
273
|
+
Returns
|
|
274
|
+
-------
|
|
275
|
+
np.ndarray
|
|
276
|
+
Mono audio signal, float64, [-1, 1].
|
|
277
|
+
"""
|
|
278
|
+
if not events:
|
|
279
|
+
return np.array([], dtype=np.float64)
|
|
280
|
+
|
|
281
|
+
normalized = [_normalize_event(e) for e in events]
|
|
282
|
+
normalized.sort(key=lambda e: e.start_ms)
|
|
283
|
+
|
|
284
|
+
# Calculate total duration
|
|
285
|
+
end_ms = max(e.start_ms + e.duration_ms for e in normalized)
|
|
286
|
+
total_seconds = end_ms / 1000.0 + 1.0 # extra for release tail
|
|
287
|
+
total_samples = int(total_seconds * self.sample_rate)
|
|
288
|
+
output = np.zeros(total_samples, dtype=np.float64)
|
|
289
|
+
|
|
290
|
+
for ev in normalized:
|
|
291
|
+
duration_sec = ev.duration_ms / 1000.0
|
|
292
|
+
if duration_sec <= 0:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
# Render note via constraint-synth
|
|
296
|
+
note_audio = self._default_synth.play_note(
|
|
297
|
+
ev.note, ev.velocity, duration_sec
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Place at correct position
|
|
301
|
+
start_sample = int(ev.start_ms / 1000.0 * self.sample_rate)
|
|
302
|
+
end_sample = min(start_sample + len(note_audio), total_samples)
|
|
303
|
+
length = end_sample - start_sample
|
|
304
|
+
|
|
305
|
+
if length > 0:
|
|
306
|
+
# Additive mixing for overlapping notes
|
|
307
|
+
output[start_sample:end_sample] += note_audio[:length]
|
|
308
|
+
|
|
309
|
+
# Normalize
|
|
310
|
+
peak = np.max(np.abs(output))
|
|
311
|
+
if peak > 1.0:
|
|
312
|
+
output = output / peak * 0.9
|
|
313
|
+
|
|
314
|
+
return output
|
|
315
|
+
|
|
316
|
+
def render_flux_vector(
|
|
317
|
+
self,
|
|
318
|
+
values: Sequence[float],
|
|
319
|
+
start_ms: float = 0.0,
|
|
320
|
+
duration_ms: float = 500.0,
|
|
321
|
+
salience: Sequence[float] | None = None,
|
|
322
|
+
tolerance: Sequence[float] | None = None,
|
|
323
|
+
) -> np.ndarray:
|
|
324
|
+
"""Render a single FluxVector to audio via direct constraint mapping.
|
|
325
|
+
|
|
326
|
+
The FluxVector channels control all synth parameters directly —
|
|
327
|
+
no MIDI involved. This is the pure constraint-synthesis path.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
values : sequence of float
|
|
332
|
+
9-channel FluxVector values.
|
|
333
|
+
start_ms : float
|
|
334
|
+
Start time offset (for scheduling within a larger buffer).
|
|
335
|
+
duration_ms : float
|
|
336
|
+
Note duration in milliseconds.
|
|
337
|
+
salience : sequence of float, optional
|
|
338
|
+
Per-channel salience values (0–1).
|
|
339
|
+
tolerance : sequence of float, optional
|
|
340
|
+
Per-channel tolerance values (ms).
|
|
341
|
+
|
|
342
|
+
Returns
|
|
343
|
+
-------
|
|
344
|
+
np.ndarray
|
|
345
|
+
Mono audio signal.
|
|
346
|
+
"""
|
|
347
|
+
params = self.mapper.map_to_synth_params(values, salience, tolerance)
|
|
348
|
+
|
|
349
|
+
# Build a per-vector synth with the mapped parameters
|
|
350
|
+
osc = LatticeOscillator(
|
|
351
|
+
frequency=params["freq"],
|
|
352
|
+
lattice_shape=params["lattice_shape"],
|
|
353
|
+
lattice_stretch=params["lattice_stretch"],
|
|
354
|
+
noise_floor=params["noise_floor"],
|
|
355
|
+
snap_threshold=params["snap_threshold"],
|
|
356
|
+
)
|
|
357
|
+
env = FunnelEnvelope(
|
|
358
|
+
attack=params["attack"],
|
|
359
|
+
decay=params["decay"],
|
|
360
|
+
sustain=params["sustain"],
|
|
361
|
+
release=params["release"],
|
|
362
|
+
)
|
|
363
|
+
synth = ConstraintSynth(
|
|
364
|
+
oscillator=osc,
|
|
365
|
+
envelope=env,
|
|
366
|
+
filter_cutoff=params["filter_cutoff"],
|
|
367
|
+
reverb_wet=params["reverb_wet"],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
duration_sec = duration_ms / 1000.0
|
|
371
|
+
return synth.play_note(params["note"], params["velocity"], duration_sec)
|
|
372
|
+
|
|
373
|
+
def render_flux_sequence(
|
|
374
|
+
self,
|
|
375
|
+
vectors: Sequence[dict],
|
|
376
|
+
spacing_ms: float = 50.0,
|
|
377
|
+
) -> np.ndarray:
|
|
378
|
+
"""Render a sequence of FluxVectors to audio.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
vectors : sequence of dict
|
|
383
|
+
Each dict should have:
|
|
384
|
+
- "values": 9-channel values
|
|
385
|
+
- "start_ms": start time (optional, auto-sequenced)
|
|
386
|
+
- "duration_ms": duration (default 250)
|
|
387
|
+
- "salience": optional salience values
|
|
388
|
+
- "tolerance": optional tolerance values
|
|
389
|
+
spacing_ms : float
|
|
390
|
+
Default spacing between vectors if start_ms not specified.
|
|
391
|
+
|
|
392
|
+
Returns
|
|
393
|
+
-------
|
|
394
|
+
np.ndarray
|
|
395
|
+
Mono audio signal.
|
|
396
|
+
"""
|
|
397
|
+
buffers = []
|
|
398
|
+
auto_start = 0.0
|
|
399
|
+
|
|
400
|
+
for v in vectors:
|
|
401
|
+
values = v["values"]
|
|
402
|
+
duration_ms = v.get("duration_ms", 250.0)
|
|
403
|
+
start_ms = v.get("start_ms", auto_start)
|
|
404
|
+
|
|
405
|
+
audio = self.render_flux_vector(
|
|
406
|
+
values,
|
|
407
|
+
start_ms=0.0, # render relative
|
|
408
|
+
duration_ms=duration_ms,
|
|
409
|
+
salience=v.get("salience"),
|
|
410
|
+
tolerance=v.get("tolerance"),
|
|
411
|
+
)
|
|
412
|
+
buffers.append((start_ms, audio))
|
|
413
|
+
auto_start = start_ms + duration_ms + spacing_ms
|
|
414
|
+
|
|
415
|
+
if not buffers:
|
|
416
|
+
return np.array([], dtype=np.float64)
|
|
417
|
+
|
|
418
|
+
# Calculate total length
|
|
419
|
+
max_end = max(start + len(audio) / self.sample_rate * 1000
|
|
420
|
+
for start, audio in buffers)
|
|
421
|
+
total_samples = int((max_end / 1000.0 + 0.5) * self.sample_rate)
|
|
422
|
+
output = np.zeros(total_samples, dtype=np.float64)
|
|
423
|
+
|
|
424
|
+
for start_ms, audio in buffers:
|
|
425
|
+
start_sample = int(start_ms / 1000.0 * self.sample_rate)
|
|
426
|
+
end_sample = min(start_sample + len(audio), total_samples)
|
|
427
|
+
length = end_sample - start_sample
|
|
428
|
+
if length > 0:
|
|
429
|
+
output[start_sample:end_sample] += audio[:length]
|
|
430
|
+
|
|
431
|
+
peak = np.max(np.abs(output))
|
|
432
|
+
if peak > 1.0:
|
|
433
|
+
output = output / peak * 0.9
|
|
434
|
+
|
|
435
|
+
return output
|
|
436
|
+
|
|
437
|
+
def events_to_constraints(
|
|
438
|
+
self,
|
|
439
|
+
events: Sequence[Any],
|
|
440
|
+
) -> List[dict]:
|
|
441
|
+
"""Extract constraint parameters from a MidiEvent stream.
|
|
442
|
+
|
|
443
|
+
Reverse-maps MIDI events back into constraint parameter space.
|
|
444
|
+
This enables round-tripping: constraints → MIDI → constraints.
|
|
445
|
+
|
|
446
|
+
Parameters
|
|
447
|
+
----------
|
|
448
|
+
events : sequence of MidiEvent-like
|
|
449
|
+
|
|
450
|
+
Returns
|
|
451
|
+
-------
|
|
452
|
+
list of dict
|
|
453
|
+
Each dict contains the inferred constraint parameters per event.
|
|
454
|
+
"""
|
|
455
|
+
results = []
|
|
456
|
+
for ev in events:
|
|
457
|
+
ev = _normalize_event(ev)
|
|
458
|
+
freq = 440.0 * (2 ** ((ev.note - 69) / 12.0))
|
|
459
|
+
|
|
460
|
+
results.append({
|
|
461
|
+
"note": ev.note,
|
|
462
|
+
"freq": freq,
|
|
463
|
+
"velocity": ev.velocity,
|
|
464
|
+
"start_ms": ev.start_ms,
|
|
465
|
+
"duration_ms": ev.duration_ms,
|
|
466
|
+
"channel": ev.channel,
|
|
467
|
+
"amplitude": ev.velocity / 127.0,
|
|
468
|
+
"duration_sec": ev.duration_ms / 1000.0,
|
|
469
|
+
})
|
|
470
|
+
return results
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def to_wav(
|
|
474
|
+
signal: np.ndarray,
|
|
475
|
+
path: str,
|
|
476
|
+
sample_rate: int = 44100,
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Save audio signal as 16-bit WAV."""
|
|
479
|
+
ConstraintSynth.to_wav(signal, path, sample_rate)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
483
|
+
# Comparison utility
|
|
484
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
def compare_renderers(
|
|
487
|
+
events: Sequence[Any],
|
|
488
|
+
preset: str = "piano_ballad",
|
|
489
|
+
output_dir: str = ".",
|
|
490
|
+
) -> dict:
|
|
491
|
+
"""Render the same events via both paths and compare.
|
|
492
|
+
|
|
493
|
+
Returns a dict with:
|
|
494
|
+
- "direct_path": path to constraint-synth WAV
|
|
495
|
+
- "direct_duration_ms": render time
|
|
496
|
+
- "events_count": number of events rendered
|
|
497
|
+
|
|
498
|
+
Note: DawDreamer comparison only available if flux-tensor-midi
|
|
499
|
+
with audio extras is installed.
|
|
500
|
+
"""
|
|
501
|
+
import time
|
|
502
|
+
import os
|
|
503
|
+
|
|
504
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
505
|
+
|
|
506
|
+
bridge = FluxBridge(preset=preset)
|
|
507
|
+
|
|
508
|
+
t0 = time.perf_counter()
|
|
509
|
+
audio = bridge.render_events(events)
|
|
510
|
+
direct_ms = (time.perf_counter() - t0) * 1000
|
|
511
|
+
|
|
512
|
+
direct_path = os.path.join(output_dir, "bridge_direct.wav")
|
|
513
|
+
bridge.to_wav(audio, direct_path)
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
"direct_path": direct_path,
|
|
517
|
+
"direct_duration_ms": round(direct_ms, 2),
|
|
518
|
+
"events_count": len(events),
|
|
519
|
+
"audio_samples": len(audio),
|
|
520
|
+
"audio_duration_sec": len(audio) / bridge.sample_rate,
|
|
521
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Render full MIDI files to audio using ConstraintSynth."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MIDIRenderer:
|
|
7
|
+
"""Render a complete MIDI file to audio using ConstraintSynth."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, synth=None, sample_rate: int = 44100,
|
|
10
|
+
use_biquad: bool = False, use_reverb: bool = False,
|
|
11
|
+
stereo: bool = False):
|
|
12
|
+
from .synth import ConstraintSynth, BiquadLowpass, SchroederReverb
|
|
13
|
+
|
|
14
|
+
self.synth = synth or ConstraintSynth()
|
|
15
|
+
self.sample_rate = sample_rate
|
|
16
|
+
self.use_biquad = use_biquad
|
|
17
|
+
self.use_reverb = use_reverb
|
|
18
|
+
self.stereo = stereo
|
|
19
|
+
|
|
20
|
+
if use_biquad:
|
|
21
|
+
self.biquad = BiquadLowpass(cutoff_hz=2000, sample_rate=sample_rate)
|
|
22
|
+
else:
|
|
23
|
+
self.biquad = None
|
|
24
|
+
|
|
25
|
+
if use_reverb:
|
|
26
|
+
self.reverb = SchroederReverb(sample_rate=sample_rate)
|
|
27
|
+
else:
|
|
28
|
+
self.reverb = None
|
|
29
|
+
|
|
30
|
+
def render(self, midi_path: str) -> np.ndarray:
|
|
31
|
+
"""Render MIDI file to audio numpy array."""
|
|
32
|
+
import mido
|
|
33
|
+
|
|
34
|
+
mid = mido.MidiFile(midi_path)
|
|
35
|
+
ticks_per_beat = mid.ticks_per_beat
|
|
36
|
+
|
|
37
|
+
# Find tempo (default 120 BPM)
|
|
38
|
+
tempo = 500000 # microseconds per beat
|
|
39
|
+
for track in mid.tracks:
|
|
40
|
+
for msg in track:
|
|
41
|
+
if msg.type == "set_tempo":
|
|
42
|
+
tempo = msg.tempo
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
seconds_per_tick = tempo / (ticks_per_beat * 1_000_000)
|
|
46
|
+
|
|
47
|
+
# Calculate total duration
|
|
48
|
+
total_ticks = 0
|
|
49
|
+
for track in mid.tracks:
|
|
50
|
+
track_ticks = sum(msg.time for msg in track)
|
|
51
|
+
total_ticks = max(total_ticks, track_ticks)
|
|
52
|
+
|
|
53
|
+
total_seconds = total_ticks * seconds_per_tick + 2.0 # extra for release
|
|
54
|
+
total_samples = int(total_seconds * self.sample_rate)
|
|
55
|
+
output = np.zeros(total_samples)
|
|
56
|
+
|
|
57
|
+
# Render each track
|
|
58
|
+
for track in mid.tracks:
|
|
59
|
+
abs_tick = 0
|
|
60
|
+
active_notes = {}
|
|
61
|
+
for msg in track:
|
|
62
|
+
abs_tick += msg.time
|
|
63
|
+
abs_time = abs_tick * seconds_per_tick
|
|
64
|
+
sample_pos = int(abs_time * self.sample_rate)
|
|
65
|
+
|
|
66
|
+
if msg.type == "note_on" and msg.velocity > 0:
|
|
67
|
+
active_notes[msg.note] = (sample_pos, msg.velocity, msg.channel)
|
|
68
|
+
elif msg.type == "note_off" or (
|
|
69
|
+
msg.type == "note_on" and msg.velocity == 0
|
|
70
|
+
):
|
|
71
|
+
if msg.note in active_notes:
|
|
72
|
+
start, velocity, channel = active_notes.pop(msg.note)
|
|
73
|
+
duration_samples = sample_pos - start
|
|
74
|
+
duration_sec = duration_samples / self.sample_rate
|
|
75
|
+
|
|
76
|
+
if duration_sec > 0:
|
|
77
|
+
note_audio = self.synth.play_note(
|
|
78
|
+
msg.note, velocity, duration_sec
|
|
79
|
+
)
|
|
80
|
+
end = min(start + len(note_audio), total_samples)
|
|
81
|
+
output[start:end] += note_audio[: end - start]
|
|
82
|
+
|
|
83
|
+
# Apply effects
|
|
84
|
+
if self.biquad is not None:
|
|
85
|
+
output = self.biquad.process(output)
|
|
86
|
+
if self.reverb is not None:
|
|
87
|
+
output = self.reverb.process(output)
|
|
88
|
+
|
|
89
|
+
# Re-normalize after effects
|
|
90
|
+
peak = np.max(np.abs(output))
|
|
91
|
+
if peak > 0:
|
|
92
|
+
output = output / peak * 0.8
|
|
93
|
+
|
|
94
|
+
# Stereo: create right channel with slight detune
|
|
95
|
+
if self.stereo:
|
|
96
|
+
from .oscillator import LatticeOscillator
|
|
97
|
+
# Render a detuned version for the right channel
|
|
98
|
+
detune_factor = 1 + 0.5 / self.synth.oscillator.frequency # +0.5 Hz
|
|
99
|
+
original_freq = self.synth.oscillator.frequency
|
|
100
|
+
# Re-render with detuned oscillator for right channel
|
|
101
|
+
# We do this by re-processing the original MIDI with detuned freq
|
|
102
|
+
right = self._render_detuned(midi_path, detune_factor)
|
|
103
|
+
# Match lengths
|
|
104
|
+
maxlen = max(len(output), len(right))
|
|
105
|
+
left = np.zeros(maxlen)
|
|
106
|
+
right_ch = np.zeros(maxlen)
|
|
107
|
+
left[:len(output)] = output
|
|
108
|
+
right_ch[:len(right)] = right
|
|
109
|
+
output = np.column_stack([left, right_ch])
|
|
110
|
+
|
|
111
|
+
return output
|
|
112
|
+
|
|
113
|
+
def _render_detuned(self, midi_path: str, detune_factor: float) -> np.ndarray:
|
|
114
|
+
"""Render MIDI with a detuned oscillator for stereo effect."""
|
|
115
|
+
import mido
|
|
116
|
+
|
|
117
|
+
mid = mido.MidiFile(midi_path)
|
|
118
|
+
ticks_per_beat = mid.ticks_per_beat
|
|
119
|
+
tempo = 500000
|
|
120
|
+
for track in mid.tracks:
|
|
121
|
+
for msg in track:
|
|
122
|
+
if msg.type == "set_tempo":
|
|
123
|
+
tempo = msg.tempo
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
seconds_per_tick = tempo / (ticks_per_beat * 1_000_000)
|
|
127
|
+
total_ticks = 0
|
|
128
|
+
for track in mid.tracks:
|
|
129
|
+
track_ticks = sum(msg.time for msg in track)
|
|
130
|
+
total_ticks = max(total_ticks, track_ticks)
|
|
131
|
+
total_seconds = total_ticks * seconds_per_tick + 2.0
|
|
132
|
+
total_samples = int(total_seconds * self.sample_rate)
|
|
133
|
+
output = np.zeros(total_samples)
|
|
134
|
+
|
|
135
|
+
original_freq = self.synth.oscillator.frequency
|
|
136
|
+
|
|
137
|
+
for track in mid.tracks:
|
|
138
|
+
abs_tick = 0
|
|
139
|
+
active_notes = {}
|
|
140
|
+
for msg in track:
|
|
141
|
+
abs_tick += msg.time
|
|
142
|
+
abs_time = abs_tick * seconds_per_tick
|
|
143
|
+
sample_pos = int(abs_time * self.sample_rate)
|
|
144
|
+
|
|
145
|
+
if msg.type == "note_on" and msg.velocity > 0:
|
|
146
|
+
active_notes[msg.note] = (sample_pos, msg.velocity, msg.channel)
|
|
147
|
+
elif msg.type == "note_off" or (
|
|
148
|
+
msg.type == "note_on" and msg.velocity == 0
|
|
149
|
+
):
|
|
150
|
+
if msg.note in active_notes:
|
|
151
|
+
start, velocity, channel = active_notes.pop(msg.note)
|
|
152
|
+
duration_samples = sample_pos - start
|
|
153
|
+
duration_sec = duration_samples / self.sample_rate
|
|
154
|
+
|
|
155
|
+
if duration_sec > 0:
|
|
156
|
+
# Apply detune
|
|
157
|
+
base_freq = 440.0 * (2 ** ((msg.note - 69) / 12.0))
|
|
158
|
+
self.synth.oscillator.frequency = base_freq * detune_factor
|
|
159
|
+
note_audio = self.synth.play_note(
|
|
160
|
+
msg.note, velocity, duration_sec
|
|
161
|
+
)
|
|
162
|
+
end = min(start + len(note_audio), total_samples)
|
|
163
|
+
output[start:end] += note_audio[: end - start]
|
|
164
|
+
|
|
165
|
+
self.synth.oscillator.frequency = original_freq
|
|
166
|
+
|
|
167
|
+
peak = np.max(np.abs(output))
|
|
168
|
+
if peak > 0:
|
|
169
|
+
output = output / peak * 0.8
|
|
170
|
+
return output
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Lattice Oscillator — waveshape as lattice geometry."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class LatticeOscillator:
|
|
10
|
+
"""
|
|
11
|
+
An oscillator where waveshape IS lattice geometry.
|
|
12
|
+
|
|
13
|
+
sine: continuous, no snapping (epsilon=inf)
|
|
14
|
+
square: binary snap (Z2 — only 2 directions)
|
|
15
|
+
saw: ramp through lattice (Z — linear interpolation + snap)
|
|
16
|
+
triangle: A2 snap (our Eisenstein lattice in 1D)
|
|
17
|
+
eisenstein: full A2 lattice (hexagonal tiling)
|
|
18
|
+
"""
|
|
19
|
+
frequency: float = 440.0 # Hz
|
|
20
|
+
sample_rate: int = 44100
|
|
21
|
+
lattice_shape: Literal["sine", "square", "saw", "triangle", "eisenstein"] = "sine"
|
|
22
|
+
lattice_stretch: float = 1.0 # 1.0=harmonic, 1.002=piano, 1.5=bell
|
|
23
|
+
noise_floor: float = 0.0 # 0-1, jitter that never converges
|
|
24
|
+
snap_threshold: float = 1.0 # 0=soft snap, 1=hard snap
|
|
25
|
+
use_polyblep: bool = True # enable PolyBLEP anti-aliasing
|
|
26
|
+
|
|
27
|
+
def _polyblep_correct(self, phase_inc: float, sample_phases: np.ndarray,
|
|
28
|
+
direction: Literal["rising", "falling"]) -> np.ndarray:
|
|
29
|
+
"""Apply PolyBLEP correction at phase-wrap discontinuities.
|
|
30
|
+
|
|
31
|
+
For each sample, compute how close the phase is to the wrap point (0/1)
|
|
32
|
+
and apply a 2-point polynomial correction.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
phase_inc: phase increment per sample (= freq / sample_rate)
|
|
36
|
+
sample_phases: normalised phase in [0, 1) per sample
|
|
37
|
+
direction: 'rising' for upward discontinuity, 'falling' for downward
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Correction array (same length as sample_phases)
|
|
41
|
+
"""
|
|
42
|
+
correction = np.zeros_like(sample_phases)
|
|
43
|
+
if phase_inc <= 0:
|
|
44
|
+
return correction
|
|
45
|
+
|
|
46
|
+
for i, p in enumerate(sample_phases):
|
|
47
|
+
# Check samples near the phase-wrap boundary (0 ≡ 1)
|
|
48
|
+
# t = distance from wrap, measured in samples
|
|
49
|
+
|
|
50
|
+
# --- just BEFORE the wrap (phase close to 1, i.e. p close to 1) ---
|
|
51
|
+
if p >= 1.0 - phase_inc:
|
|
52
|
+
t = (p - 1.0) / phase_inc # in (-1, 0]
|
|
53
|
+
val = t + t + t * t # = 2t + t^2
|
|
54
|
+
# --- just AFTER the wrap (phase close to 0) ---
|
|
55
|
+
elif p < phase_inc:
|
|
56
|
+
t = p / phase_inc # in [0, 1)
|
|
57
|
+
val = t + t - t * t # = 2t - t^2
|
|
58
|
+
else:
|
|
59
|
+
val = 0.0
|
|
60
|
+
|
|
61
|
+
if direction == "falling":
|
|
62
|
+
val = -val
|
|
63
|
+
correction[i] = val
|
|
64
|
+
|
|
65
|
+
return correction
|
|
66
|
+
|
|
67
|
+
def generate(self, duration: float) -> np.ndarray:
|
|
68
|
+
"""Generate audio samples."""
|
|
69
|
+
n_samples = int(self.sample_rate * duration)
|
|
70
|
+
if n_samples == 0:
|
|
71
|
+
return np.array([], dtype=np.float64)
|
|
72
|
+
t = np.linspace(0, duration, n_samples, endpoint=False)
|
|
73
|
+
phase = 2 * np.pi * self.frequency * t
|
|
74
|
+
norm_phase = (phase / (2 * np.pi)) % 1.0 # normalised [0, 1)
|
|
75
|
+
phase_inc = self.frequency / self.sample_rate
|
|
76
|
+
|
|
77
|
+
if self.lattice_shape == "sine":
|
|
78
|
+
signal = np.sin(phase)
|
|
79
|
+
elif self.lattice_shape == "square":
|
|
80
|
+
signal = np.sign(np.sin(phase))
|
|
81
|
+
if self.use_polyblep:
|
|
82
|
+
# Rising edge at phase wrap (0→1)
|
|
83
|
+
signal += self._polyblep_correct(phase_inc, norm_phase, "rising")
|
|
84
|
+
# Falling edge at phase 0.5
|
|
85
|
+
shifted = (norm_phase - 0.5) % 1.0
|
|
86
|
+
signal += self._polyblep_correct(phase_inc, shifted, "falling")
|
|
87
|
+
elif self.lattice_shape == "saw":
|
|
88
|
+
signal = 2 * norm_phase - 1
|
|
89
|
+
if self.use_polyblep:
|
|
90
|
+
signal -= self._polyblep_correct(phase_inc, norm_phase, "rising")
|
|
91
|
+
elif self.lattice_shape == "triangle":
|
|
92
|
+
signal = 2 * np.abs(2 * norm_phase - 1) - 1
|
|
93
|
+
elif self.lattice_shape == "eisenstein":
|
|
94
|
+
raw = np.sin(phase)
|
|
95
|
+
signal = self._eisenstein_snap(raw)
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError(f"Unknown lattice_shape: {self.lattice_shape}")
|
|
98
|
+
|
|
99
|
+
# Apply inharmonicity (lattice stretch)
|
|
100
|
+
if self.lattice_stretch != 1.0:
|
|
101
|
+
harmonic = np.sin(phase * 2 * self.lattice_stretch) * 0.3
|
|
102
|
+
signal = signal + harmonic
|
|
103
|
+
|
|
104
|
+
# Apply noise floor (irreducible jitter)
|
|
105
|
+
if self.noise_floor > 0:
|
|
106
|
+
noise = np.random.normal(0, self.noise_floor * 0.1, len(signal))
|
|
107
|
+
signal = signal + noise
|
|
108
|
+
|
|
109
|
+
return signal
|
|
110
|
+
|
|
111
|
+
def _eisenstein_snap(self, values: np.ndarray) -> np.ndarray:
|
|
112
|
+
"""Snap continuous values to Eisenstein lattice directions.
|
|
113
|
+
|
|
114
|
+
Uses the actual A2 lattice snap logic from constraint_theory_core:
|
|
115
|
+
quantize to nearest of the 6 hex directions in 1D projection.
|
|
116
|
+
"""
|
|
117
|
+
# Map values through hexagonal quantization
|
|
118
|
+
# 6 directions at 60° intervals: project onto amplitude axis
|
|
119
|
+
# This creates a staircase-like wave richer than square
|
|
120
|
+
snapped = np.zeros_like(values)
|
|
121
|
+
levels = 6 # hexagonal symmetry
|
|
122
|
+
for i, v in enumerate(values):
|
|
123
|
+
# Quantize to nearest hex level
|
|
124
|
+
level = round(v * levels / 2) / (levels / 2)
|
|
125
|
+
# Soft/hard snap interpolation
|
|
126
|
+
snapped[i] = v + (level - v) * self.snap_threshold
|
|
127
|
+
return snapped
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Real-time audio playback for ConstraintSynth."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import wave
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AudioPlayer:
|
|
10
|
+
"""Play numpy arrays as audio. Zero external deps for WAV, optional sounddevice for realtime."""
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def to_wav_bytes(signal: np.ndarray, sample_rate: int = 44100) -> bytes:
|
|
14
|
+
"""Convert numpy float array to WAV bytes (for IPython, web, etc.)"""
|
|
15
|
+
buf = io.BytesIO()
|
|
16
|
+
with wave.open(buf, "wb") as f:
|
|
17
|
+
f.setnchannels(1)
|
|
18
|
+
f.setsampwidth(2)
|
|
19
|
+
f.setframerate(sample_rate)
|
|
20
|
+
data = (np.clip(signal, -1, 1) * 32767).astype(np.int16)
|
|
21
|
+
f.writeframes(data.tobytes())
|
|
22
|
+
return buf.getvalue()
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def play_ipython(signal: np.ndarray, sample_rate: int = 44100):
|
|
26
|
+
"""Play audio in Jupyter/IPython using IPython.display.Audio"""
|
|
27
|
+
try:
|
|
28
|
+
from IPython.display import Audio, display
|
|
29
|
+
|
|
30
|
+
wav_bytes = AudioPlayer.to_wav_bytes(signal, sample_rate)
|
|
31
|
+
display(Audio(wav_bytes, rate=sample_rate))
|
|
32
|
+
except ImportError:
|
|
33
|
+
print("IPython not available. Save to WAV instead.")
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def play_sounddevice(signal: np.ndarray, sample_rate: int = 44100):
|
|
37
|
+
"""Play audio through speakers using sounddevice."""
|
|
38
|
+
try:
|
|
39
|
+
import sounddevice as sd
|
|
40
|
+
|
|
41
|
+
sd.play(signal, sample_rate)
|
|
42
|
+
sd.wait()
|
|
43
|
+
except ImportError:
|
|
44
|
+
raise RuntimeError("sounddevice not installed. pip install sounddevice")
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def play(signal: np.ndarray, sample_rate: int = 44100):
|
|
48
|
+
"""Auto-detect best playback method."""
|
|
49
|
+
try:
|
|
50
|
+
AudioPlayer.play_sounddevice(signal, sample_rate)
|
|
51
|
+
except Exception:
|
|
52
|
+
try:
|
|
53
|
+
AudioPlayer.play_ipython(signal, sample_rate)
|
|
54
|
+
except Exception:
|
|
55
|
+
print("No audio playback available. Use to_wav_bytes() + save instead.")
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""ConstraintSynth — the full constraint-theory synthesizer."""
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
import wave
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .oscillator import LatticeOscillator
|
|
9
|
+
from .envelope import FunnelEnvelope
|
|
10
|
+
from .constraint_filter import ConsonanceFilter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BiquadLowpass:
|
|
14
|
+
"""Second-order IIR lowpass filter (RBJ Audio EQ Cookbook)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, cutoff_hz: float, sample_rate: int = 44100, Q: float = 0.707):
|
|
17
|
+
from math import cos, pi, sin
|
|
18
|
+
|
|
19
|
+
omega = 2 * pi * cutoff_hz / sample_rate
|
|
20
|
+
alpha = sin(omega) / (2 * Q)
|
|
21
|
+
b0 = (1 - cos(omega)) / 2
|
|
22
|
+
b1 = 1 - cos(omega)
|
|
23
|
+
b2 = (1 - cos(omega)) / 2
|
|
24
|
+
a0 = 1 + alpha
|
|
25
|
+
a1 = -2 * cos(omega)
|
|
26
|
+
a2 = 1 - alpha
|
|
27
|
+
|
|
28
|
+
# Normalize by a0
|
|
29
|
+
self.b = [b0 / a0, b1 / a0, b2 / a0]
|
|
30
|
+
self.a = [a1 / a0, a2 / a0]
|
|
31
|
+
self.w = [0.0, 0.0] # Direct Form II Transposed state
|
|
32
|
+
|
|
33
|
+
def process(self, samples: np.ndarray) -> np.ndarray:
|
|
34
|
+
"""Process a numpy array of samples through the filter."""
|
|
35
|
+
output = np.zeros_like(samples)
|
|
36
|
+
for i, x in enumerate(samples):
|
|
37
|
+
y = self.b[0] * x + self.w[0]
|
|
38
|
+
self.w[0] = self.b[1] * x - self.a[0] * y + self.w[1]
|
|
39
|
+
self.w[1] = self.b[2] * x - self.a[1] * y
|
|
40
|
+
output[i] = y
|
|
41
|
+
return output
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SchroederReverb:
|
|
45
|
+
"""Schroeder reverb: 4 parallel comb filters + 2 series allpass filters."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, sample_rate: int = 44100, feedback: float = 0.84, wet: float = 0.3):
|
|
48
|
+
# Comb filter delay lengths (chosen to be coprime for decorrelation)
|
|
49
|
+
comb_delays = [1557, 1617, 1491, 1422]
|
|
50
|
+
# Allpass delay lengths
|
|
51
|
+
allpass_delays = [225, 556]
|
|
52
|
+
|
|
53
|
+
self.combs = [np.zeros(d, dtype=np.float64) for d in comb_delays]
|
|
54
|
+
self.comb_idx = [0] * 4
|
|
55
|
+
self.allpasses = [np.zeros(d, dtype=np.float64) for d in allpass_delays]
|
|
56
|
+
self.ap_idx = [0] * 2
|
|
57
|
+
self.feedback = feedback
|
|
58
|
+
self.wet = wet
|
|
59
|
+
self.ap_gain = 0.5
|
|
60
|
+
|
|
61
|
+
def _process_combs(self, samples: np.ndarray) -> np.ndarray:
|
|
62
|
+
"""Run 4 parallel comb filters and sum."""
|
|
63
|
+
output = np.zeros_like(samples)
|
|
64
|
+
for k in range(4):
|
|
65
|
+
buf = self.combs[k]
|
|
66
|
+
idx = self.comb_idx[k]
|
|
67
|
+
for i, x in enumerate(samples):
|
|
68
|
+
delayed = buf[idx]
|
|
69
|
+
buf[idx] = x + self.feedback * delayed
|
|
70
|
+
output[i] += delayed
|
|
71
|
+
idx = (idx + 1) % len(buf)
|
|
72
|
+
self.comb_idx[k] = idx
|
|
73
|
+
return output / 4.0
|
|
74
|
+
|
|
75
|
+
def _process_allpasses(self, samples: np.ndarray) -> np.ndarray:
|
|
76
|
+
"""Run 2 series allpass filters."""
|
|
77
|
+
signal = samples
|
|
78
|
+
for k in range(2):
|
|
79
|
+
buf = self.allpasses[k]
|
|
80
|
+
idx = self.ap_idx[k]
|
|
81
|
+
out = np.zeros_like(signal)
|
|
82
|
+
for i, x in enumerate(signal):
|
|
83
|
+
delayed = buf[idx]
|
|
84
|
+
buf[idx] = x + self.ap_gain * delayed
|
|
85
|
+
out[i] = delayed - self.ap_gain * x
|
|
86
|
+
idx = (idx + 1) % len(buf)
|
|
87
|
+
self.ap_idx[k] = idx
|
|
88
|
+
signal = out
|
|
89
|
+
return signal
|
|
90
|
+
|
|
91
|
+
def process(self, samples: np.ndarray) -> np.ndarray:
|
|
92
|
+
"""Process samples: parallel combs → series allpasses → dry/wet mix."""
|
|
93
|
+
comb_out = self._process_combs(samples)
|
|
94
|
+
reverb = self._process_allpasses(comb_out)
|
|
95
|
+
return samples * (1.0 - self.wet) + reverb * self.wet
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ConstraintSynth:
|
|
99
|
+
"""Lattice oscillator + funnel envelope + consonance filter + lowpass + reverb."""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
oscillator: LatticeOscillator | None = None,
|
|
104
|
+
envelope: FunnelEnvelope | None = None,
|
|
105
|
+
filter: ConsonanceFilter | None = None,
|
|
106
|
+
filter_cutoff: float = 2000.0,
|
|
107
|
+
reverb_wet: float = 0.3,
|
|
108
|
+
):
|
|
109
|
+
self.oscillator = oscillator or LatticeOscillator()
|
|
110
|
+
self.envelope = envelope or FunnelEnvelope()
|
|
111
|
+
self.filter = filter
|
|
112
|
+
self._lowpass = BiquadLowpass(filter_cutoff, self.oscillator.sample_rate)
|
|
113
|
+
self._reverb = SchroederReverb(self.oscillator.sample_rate, wet=reverb_wet)
|
|
114
|
+
self.filter_cutoff = filter_cutoff
|
|
115
|
+
self.reverb_wet = reverb_wet
|
|
116
|
+
|
|
117
|
+
def play_note(self, pitch: int, velocity: int, duration: float) -> np.ndarray:
|
|
118
|
+
"""Generate a single note from MIDI parameters."""
|
|
119
|
+
# MIDI pitch → frequency
|
|
120
|
+
freq = 440.0 * (2 ** ((pitch - 69) / 12.0))
|
|
121
|
+
self.oscillator.frequency = freq
|
|
122
|
+
|
|
123
|
+
signal = self.oscillator.generate(duration)
|
|
124
|
+
signal = self.envelope.apply(signal, self.oscillator.sample_rate, duration)
|
|
125
|
+
signal *= velocity / 127.0
|
|
126
|
+
|
|
127
|
+
if self.filter is not None:
|
|
128
|
+
signal = self.filter.apply(signal, freq, self.oscillator.sample_rate)
|
|
129
|
+
|
|
130
|
+
# Apply lowpass filter
|
|
131
|
+
if self.filter_cutoff > 0:
|
|
132
|
+
signal = self._lowpass.process(signal)
|
|
133
|
+
|
|
134
|
+
# Apply reverb
|
|
135
|
+
if self.reverb_wet > 0:
|
|
136
|
+
signal = self._reverb.process(signal)
|
|
137
|
+
|
|
138
|
+
return signal
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Presets
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
PRESETS = {
|
|
144
|
+
"bop_sax": dict(
|
|
145
|
+
oscillator=dict(lattice_shape="saw", lattice_stretch=1.0, use_polyblep=True),
|
|
146
|
+
envelope=dict(attack=0.005, decay=0.08, sustain=0.75, release=0.15, hold=0.0),
|
|
147
|
+
consonance_filter=dict(cutoff=0.5, resonance=1.0),
|
|
148
|
+
filter_cutoff=3500.0,
|
|
149
|
+
reverb_wet=0.2,
|
|
150
|
+
),
|
|
151
|
+
"blues_guitar": dict(
|
|
152
|
+
oscillator=dict(lattice_shape="square", lattice_stretch=1.0, noise_floor=0.02),
|
|
153
|
+
envelope=dict(attack=0.02, decay=0.15, sustain=0.6, release=0.4, hold=0.0),
|
|
154
|
+
consonance_filter=dict(cutoff=0.4, resonance=1.2),
|
|
155
|
+
filter_cutoff=1800.0,
|
|
156
|
+
reverb_wet=0.45,
|
|
157
|
+
),
|
|
158
|
+
"techno_bass": dict(
|
|
159
|
+
oscillator=dict(lattice_shape="saw", lattice_stretch=1.0),
|
|
160
|
+
envelope=dict(attack=0.001, decay=0.3, sustain=0.0, release=0.1, hold=0.0),
|
|
161
|
+
consonance_filter=None,
|
|
162
|
+
filter_cutoff=800.0,
|
|
163
|
+
reverb_wet=0.0,
|
|
164
|
+
),
|
|
165
|
+
"piano_ballad": dict(
|
|
166
|
+
oscillator=dict(lattice_shape="triangle", lattice_stretch=1.002),
|
|
167
|
+
envelope=dict(attack=0.008, decay=0.5, sustain=0.4, release=0.8, hold=0.0),
|
|
168
|
+
consonance_filter=dict(cutoff=0.6, resonance=0.8),
|
|
169
|
+
filter_cutoff=4000.0,
|
|
170
|
+
reverb_wet=0.5,
|
|
171
|
+
),
|
|
172
|
+
"808_kick": dict(
|
|
173
|
+
oscillator=dict(lattice_shape="sine", lattice_stretch=1.0),
|
|
174
|
+
envelope=dict(attack=0.001, decay=0.0, sustain=1.0, release=0.35, hold=0.0),
|
|
175
|
+
consonance_filter=None,
|
|
176
|
+
filter_cutoff=400.0,
|
|
177
|
+
reverb_wet=0.0,
|
|
178
|
+
),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def from_preset(cls, name: str) -> "ConstraintSynth":
|
|
183
|
+
"""Create a ConstraintSynth from a named preset."""
|
|
184
|
+
if name not in cls.PRESETS:
|
|
185
|
+
raise ValueError(f"Unknown preset '{name}'. Available: {list(cls.PRESETS)}")
|
|
186
|
+
cfg = cls.PRESETS[name]
|
|
187
|
+
osc = LatticeOscillator(**cfg["oscillator"])
|
|
188
|
+
env = FunnelEnvelope(**cfg["envelope"])
|
|
189
|
+
filt = ConsonanceFilter(**cfg["consonance_filter"]) if cfg.get("consonance_filter") else None
|
|
190
|
+
return cls(oscillator=osc, envelope=env, filter=filt,
|
|
191
|
+
filter_cutoff=cfg["filter_cutoff"], reverb_wet=cfg["reverb_wet"])
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _crossfade(tail: np.ndarray, head: np.ndarray, samples: int = 64) -> tuple[np.ndarray, np.ndarray]:
|
|
195
|
+
"""Apply a short crossfade between the tail of one note and the head of the next."""
|
|
196
|
+
samples = min(samples, len(tail), len(head))
|
|
197
|
+
if samples <= 0:
|
|
198
|
+
return tail, head
|
|
199
|
+
fade_out = np.linspace(1, 0, samples)
|
|
200
|
+
fade_in = np.linspace(0, 1, samples)
|
|
201
|
+
tail[-samples:] *= fade_out
|
|
202
|
+
head[:samples] = (head[:samples] * fade_in) + (tail[-samples:] * (1 - fade_in))
|
|
203
|
+
return tail, head
|
|
204
|
+
|
|
205
|
+
def render_melody(
|
|
206
|
+
self,
|
|
207
|
+
notes: list[tuple[int, int, float]], # (pitch, velocity, duration)
|
|
208
|
+
spacing: float = 0.05,
|
|
209
|
+
crossfade_samples: int = 64,
|
|
210
|
+
) -> np.ndarray:
|
|
211
|
+
"""Render a sequence of notes into a single audio buffer."""
|
|
212
|
+
buffers: list[np.ndarray] = []
|
|
213
|
+
for pitch, velocity, duration in notes:
|
|
214
|
+
note = self.play_note(pitch, velocity, duration)
|
|
215
|
+
# Crossfade with previous note to avoid click artifacts at boundaries
|
|
216
|
+
if buffers and crossfade_samples > 0:
|
|
217
|
+
prev = buffers[-1]
|
|
218
|
+
prev, note = self._crossfade(prev, note, crossfade_samples)
|
|
219
|
+
buffers[-1] = prev
|
|
220
|
+
buffers.append(note)
|
|
221
|
+
if spacing > 0:
|
|
222
|
+
silence = np.zeros(int(self.oscillator.sample_rate * spacing))
|
|
223
|
+
buffers.append(silence)
|
|
224
|
+
return np.concatenate(buffers) if buffers else np.array([])
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def to_wav(signal: np.ndarray, path: str, sample_rate: int = 44100) -> None:
|
|
228
|
+
"""Save a numpy array as a 16-bit WAV file (mono or stereo)."""
|
|
229
|
+
signal = np.clip(signal, -1.0, 1.0)
|
|
230
|
+
if signal.ndim == 1:
|
|
231
|
+
nchannels = 1
|
|
232
|
+
data = (signal * 32767).astype(np.int16)
|
|
233
|
+
else:
|
|
234
|
+
# shape (N, 2) for stereo
|
|
235
|
+
nchannels = signal.shape[1]
|
|
236
|
+
data = (signal * 32767).astype(np.int16)
|
|
237
|
+
with wave.open(path, "w") as f:
|
|
238
|
+
f.setnchannels(nchannels)
|
|
239
|
+
f.setsampwidth(2)
|
|
240
|
+
f.setframerate(sample_rate)
|
|
241
|
+
f.writeframes(data.tobytes())
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: constraint-synth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Constraint-theory synthesizer prototype — waveshape as lattice geometry
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: numpy>=1.24
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
constraint_synth/__init__.py,sha256=DRzeWNueewH_BOt7kjIxabwrI9lPjaesaHlLEghaPvA,531
|
|
2
|
+
constraint_synth/constraint_filter.py,sha256=ZjDUM_K8Y_gQr5c8iWsMek6cYcCuqvn9QYGWYc3xiO0,1645
|
|
3
|
+
constraint_synth/envelope.py,sha256=qHCL8uXdEiKDw-wtnOzcNH3gyJM5juUHNWpiPvA1pH4,2519
|
|
4
|
+
constraint_synth/flux_bridge.py,sha256=FErJpl_pdzVF3v0Jof8IOo_aFtTpIu0u1faGjYAgLAU,18915
|
|
5
|
+
constraint_synth/midi_renderer.py,sha256=XFRifFc1GllSuIiQw7FKhdt_gU-CZ2OHyPcYQlN4wI0,6697
|
|
6
|
+
constraint_synth/oscillator.py,sha256=Rt1oBrwrXR1liTORTEjUQXwj-dCiM0AnSdajyd050Zs,5253
|
|
7
|
+
constraint_synth/playback.py,sha256=mKadFhkiHVdkexBs24E-FiNZw4Xps_OQmKU9vJIl8fI,1951
|
|
8
|
+
constraint_synth/synth.py,sha256=jwjyQKpUWo9mr2SgjFh_3H59-hgLVV2AR4lCnyMbvjM,9588
|
|
9
|
+
constraint_synth-0.1.0.dist-info/METADATA,sha256=-TG-3ifRRKOOcjpnglIISgCnKgB7a_nu_YU1AgkQmKI,257
|
|
10
|
+
constraint_synth-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
constraint_synth-0.1.0.dist-info/top_level.txt,sha256=81a-bO4roFt5nqNIloBStP-nRorH3JLEZYYE0G12lHU,17
|
|
12
|
+
constraint_synth-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
constraint_synth
|