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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ constraint_synth