gingo 1.0.0__cp313-cp313-macosx_11_0_arm64.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.
Binary file
gingo/audio.py ADDED
@@ -0,0 +1,507 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Audio synthesis and playback for gingo objects.
3
+
4
+ Renders Note, Chord, Scale, Field, Tree, Sequence, and event objects
5
+ to audio using simple waveform synthesis. Zero external dependencies
6
+ for synthesis and WAV export; ``simpleaudio`` (optional) for playback.
7
+
8
+ Usage::
9
+
10
+ from gingo import Note, Chord, Scale
11
+ from gingo.audio import play, to_wav, Waveform
12
+
13
+ play(Note("C"))
14
+ play(Chord("Am7"), waveform="square")
15
+ play(Scale("C", "major"), duration=0.3)
16
+ to_wav(Chord("G7"), "g7.wav")
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import struct
23
+ import wave
24
+ from enum import Enum
25
+ from pathlib import Path
26
+ from typing import Any, List, Union
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Waveform
31
+ # ---------------------------------------------------------------------------
32
+
33
+ class Waveform(Enum):
34
+ """Available waveform shapes for synthesis."""
35
+ SINE = "sine"
36
+ SQUARE = "square"
37
+ SAWTOOTH = "sawtooth"
38
+ TRIANGLE = "triangle"
39
+
40
+
41
+ def _sine(phase: float) -> float:
42
+ return math.sin(2.0 * math.pi * phase)
43
+
44
+
45
+ def _square(phase: float) -> float:
46
+ return 1.0 if (phase % 1.0) < 0.5 else -1.0
47
+
48
+
49
+ def _sawtooth(phase: float) -> float:
50
+ return 2.0 * (phase % 1.0) - 1.0
51
+
52
+
53
+ def _triangle(phase: float) -> float:
54
+ t = phase % 1.0
55
+ return 4.0 * t - 1.0 if t < 0.5 else 3.0 - 4.0 * t
56
+
57
+
58
+ _WAVE_FUNCS = {
59
+ Waveform.SINE: _sine,
60
+ Waveform.SQUARE: _square,
61
+ Waveform.SAWTOOTH: _sawtooth,
62
+ Waveform.TRIANGLE: _triangle,
63
+ }
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Envelope
68
+ # ---------------------------------------------------------------------------
69
+
70
+ class Envelope:
71
+ """ADSR amplitude envelope.
72
+
73
+ Args:
74
+ attack: Ramp-up time in seconds.
75
+ decay: Time to drop from peak to sustain level.
76
+ sustain: Sustain amplitude (0.0–1.0).
77
+ release: Ramp-down time in seconds at note end.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ attack: float = 0.01,
83
+ decay: float = 0.08,
84
+ sustain: float = 0.6,
85
+ release: float = 0.2,
86
+ ) -> None:
87
+ self.attack = attack
88
+ self.decay = decay
89
+ self.sustain = sustain
90
+ self.release = release
91
+
92
+ def amplitude(self, t: float, note_duration: float) -> float:
93
+ """Return amplitude at time *t* for a note lasting *note_duration* s."""
94
+ sustain_end = note_duration - self.release
95
+ # Note too short for full ADSR — simple fade in/out
96
+ if sustain_end < self.attack + self.decay:
97
+ half = note_duration / 2.0
98
+ if half <= 0:
99
+ return 0.0
100
+ if t < half:
101
+ return t / half
102
+ return max(0.0, 1.0 - (t - half) / half)
103
+
104
+ if t < self.attack:
105
+ return t / self.attack if self.attack > 0 else 1.0
106
+ if t < self.attack + self.decay:
107
+ frac = (t - self.attack) / self.decay if self.decay > 0 else 1.0
108
+ return 1.0 - (1.0 - self.sustain) * frac
109
+ if t < sustain_end:
110
+ return self.sustain
111
+ if t < note_duration:
112
+ frac = (t - sustain_end) / self.release if self.release > 0 else 1.0
113
+ return self.sustain * (1.0 - frac)
114
+ return 0.0
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Internal rendering
119
+ # ---------------------------------------------------------------------------
120
+
121
+ def _render_tone(
122
+ frequency: float,
123
+ duration_sec: float,
124
+ sample_rate: int,
125
+ waveform: Waveform,
126
+ amplitude: float,
127
+ envelope: Envelope,
128
+ ) -> list[float]:
129
+ """Render a single-frequency tone."""
130
+ n = int(duration_sec * sample_rate)
131
+ fn = _WAVE_FUNCS[waveform]
132
+ inv_sr = 1.0 / sample_rate
133
+ samples = [0.0] * n
134
+ for i in range(n):
135
+ t = i * inv_sr
136
+ samples[i] = fn(frequency * t) * amplitude * envelope.amplitude(t, duration_sec)
137
+ return samples
138
+
139
+
140
+ def _render_chord_tones(
141
+ frequencies: list[float],
142
+ duration_sec: float,
143
+ sample_rate: int,
144
+ waveform: Waveform,
145
+ amplitude: float,
146
+ envelope: Envelope,
147
+ strum: float = 0.0,
148
+ ) -> list[float]:
149
+ """Render several frequencies mixed together (chord).
150
+
151
+ With *strum* > 0 each successive note starts a little later,
152
+ producing a strummed / arpeggiated feel instead of a flat block.
153
+ """
154
+ if not frequencies:
155
+ return [0.0] * int(duration_sec * sample_rate)
156
+ fn = _WAVE_FUNCS[waveform]
157
+ inv_sr = 1.0 / sample_rate
158
+ per_note = amplitude / len(frequencies)
159
+
160
+ # Total buffer includes the extra strum spread
161
+ total_strum = strum * (len(frequencies) - 1)
162
+ total_n = int((duration_sec + total_strum) * sample_rate)
163
+ note_n = int(duration_sec * sample_rate)
164
+ mixed = [0.0] * total_n
165
+
166
+ for idx, freq in enumerate(frequencies):
167
+ offset = int(idx * strum * sample_rate)
168
+ for i in range(note_n):
169
+ pos = offset + i
170
+ if pos >= total_n:
171
+ break
172
+ t = i * inv_sr
173
+ mixed[pos] += fn(freq * t) * per_note * envelope.amplitude(t, duration_sec)
174
+ return mixed
175
+
176
+
177
+ def _render_silence(duration_sec: float, sample_rate: int) -> list[float]:
178
+ return [0.0] * int(duration_sec * sample_rate)
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Chord voicing helpers
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def _chord_frequencies(chord: Any, octave: int, tuning: float) -> list[float]:
186
+ """Compute frequencies for each chord tone with ascending voicing.
187
+
188
+ Notes above the root (by pitch-class) stay in *octave*; notes below
189
+ the root move up one octave so the chord is voiced in close position.
190
+ """
191
+ root_semi = chord.root().semitone()
192
+ freqs: list[float] = []
193
+ for note in chord.notes():
194
+ note_oct = octave if note.semitone() >= root_semi else octave + 1
195
+ freqs.append(note.frequency(note_oct, tuning))
196
+ return freqs
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Object → segments conversion
201
+ # ---------------------------------------------------------------------------
202
+
203
+ # Segment = ("tone", [freq], dur_sec)
204
+ # | ("chord", [freqs], dur_sec)
205
+ # | ("silence", dur_sec)
206
+
207
+ def _obj_to_segments(
208
+ obj: Any,
209
+ octave: int,
210
+ duration: float,
211
+ tuning: float,
212
+ ) -> list[tuple]:
213
+ """Convert any gingo object to a list of audio segments."""
214
+ from gingo._gingo import (
215
+ Note, Chord, Scale, Field, Tree, Sequence,
216
+ NoteEvent, ChordEvent, Rest,
217
+ )
218
+
219
+ if isinstance(obj, Note):
220
+ return [("tone", [obj.frequency(octave, tuning)], duration)]
221
+
222
+ if isinstance(obj, NoteEvent):
223
+ return [("tone", [obj.frequency(tuning)], duration)]
224
+
225
+ if isinstance(obj, Chord):
226
+ return [("chord", _chord_frequencies(obj, octave, tuning), duration)]
227
+
228
+ if isinstance(obj, ChordEvent):
229
+ freqs = [ne.frequency(tuning) for ne in obj.note_events()]
230
+ return [("chord", freqs, duration)]
231
+
232
+ if isinstance(obj, Rest):
233
+ return [("silence", duration)]
234
+
235
+ if isinstance(obj, Scale):
236
+ segs: list[tuple] = []
237
+ for note in obj:
238
+ segs.append(("tone", [note.frequency(octave, tuning)], duration))
239
+ # Complete with the tonic one octave up
240
+ segs.append(("tone", [obj.tonic().frequency(octave + 1, tuning)], duration))
241
+ return segs
242
+
243
+ if isinstance(obj, Field):
244
+ return [
245
+ ("chord", _chord_frequencies(ch, octave, tuning), duration)
246
+ for ch in obj
247
+ ]
248
+
249
+ if isinstance(obj, Tree):
250
+ # Play the diatonic chords via the corresponding Field
251
+ field = Field(obj.tonic().name(), str(obj.type()).rsplit(".", 1)[-1])
252
+ return [
253
+ ("chord", _chord_frequencies(ch, octave, tuning), duration)
254
+ for ch in field
255
+ ]
256
+
257
+ if isinstance(obj, Sequence):
258
+ tempo = obj.tempo()
259
+ segs = []
260
+ for i in range(len(obj)):
261
+ event = obj[i]
262
+ if isinstance(event, NoteEvent):
263
+ segs.append((
264
+ "tone",
265
+ [event.frequency(tuning)],
266
+ tempo.seconds(event.duration()),
267
+ ))
268
+ elif isinstance(event, ChordEvent):
269
+ segs.append((
270
+ "chord",
271
+ [ne.frequency(tuning) for ne in event.note_events()],
272
+ tempo.seconds(event.duration()),
273
+ ))
274
+ elif isinstance(event, Rest):
275
+ segs.append(("silence", tempo.seconds(event.duration())))
276
+ return segs
277
+
278
+ if isinstance(obj, (list, tuple)):
279
+ segs = []
280
+ for item in obj:
281
+ if isinstance(item, str):
282
+ try:
283
+ ch = Chord(item)
284
+ segs.append(("chord", _chord_frequencies(ch, octave, tuning), duration))
285
+ except (ValueError, RuntimeError):
286
+ try:
287
+ n = Note(item)
288
+ segs.append(("tone", [n.frequency(octave, tuning)], duration))
289
+ except (ValueError, RuntimeError):
290
+ pass
291
+ else:
292
+ segs.extend(_obj_to_segments(item, octave, duration, tuning))
293
+ return segs
294
+
295
+ raise TypeError(f"Cannot play object of type {type(obj).__name__}")
296
+
297
+
298
+ def _render_segments(
299
+ segments: list[tuple],
300
+ sample_rate: int,
301
+ waveform: Waveform,
302
+ amplitude: float,
303
+ envelope: Envelope,
304
+ strum: float = 0.0,
305
+ gap: float = 0.0,
306
+ ) -> list[float]:
307
+ """Render all segments into a flat sample buffer.
308
+
309
+ *gap* inserts a short silence between consecutive notes/chords,
310
+ giving each one space to breathe (like lifting fingers between keys).
311
+ """
312
+ samples: list[float] = []
313
+ gap_samples = int(gap * sample_rate) if gap > 0 else 0
314
+ last = len(segments) - 1
315
+
316
+ for i, seg in enumerate(segments):
317
+ kind = seg[0]
318
+ if kind == "tone":
319
+ samples.extend(_render_tone(
320
+ seg[1][0], seg[2], sample_rate, waveform, amplitude, envelope,
321
+ ))
322
+ elif kind == "chord":
323
+ samples.extend(_render_chord_tones(
324
+ seg[1], seg[2], sample_rate, waveform, amplitude, envelope,
325
+ strum,
326
+ ))
327
+ elif kind == "silence":
328
+ samples.extend(_render_silence(seg[1], sample_rate))
329
+
330
+ # Insert gap between segments (not after the last one)
331
+ if gap_samples and i < last and kind != "silence":
332
+ samples.extend([0.0] * gap_samples)
333
+
334
+ return samples
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # WAV output
339
+ # ---------------------------------------------------------------------------
340
+
341
+ def _write_wav(path: str, samples: list[float], sample_rate: int) -> None:
342
+ """Write float samples (–1…+1) to a 16-bit mono WAV file."""
343
+ n = len(samples)
344
+ int_samples = [0] * n
345
+ for i in range(n):
346
+ s = samples[i]
347
+ if s > 1.0:
348
+ s = 1.0
349
+ elif s < -1.0:
350
+ s = -1.0
351
+ int_samples[i] = int(s * 32767)
352
+ data = struct.pack(f"<{n}h", *int_samples)
353
+ with wave.open(path, "w") as wf:
354
+ wf.setnchannels(1)
355
+ wf.setsampwidth(2)
356
+ wf.setframerate(sample_rate)
357
+ wf.writeframes(data)
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Playback
362
+ # ---------------------------------------------------------------------------
363
+
364
+ def _play_samples(samples: list[float], sample_rate: int) -> None:
365
+ """Play rendered samples through the system audio output."""
366
+ n = len(samples)
367
+ int_samples = [0] * n
368
+ for i in range(n):
369
+ s = samples[i]
370
+ if s > 1.0:
371
+ s = 1.0
372
+ elif s < -1.0:
373
+ s = -1.0
374
+ int_samples[i] = int(s * 32767)
375
+ audio_data = struct.pack(f"<{n}h", *int_samples)
376
+
377
+ # Strategy 1: simpleaudio (cross-platform, preferred)
378
+ try:
379
+ import simpleaudio as sa
380
+ play_obj = sa.play_buffer(audio_data, 1, 2, sample_rate)
381
+ play_obj.wait_done()
382
+ return
383
+ except ImportError:
384
+ pass
385
+
386
+ # Strategy 2: temp WAV + system player
387
+ import os
388
+ import subprocess
389
+ import sys
390
+ import tempfile
391
+
392
+ fd, tmp_path = tempfile.mkstemp(suffix=".wav")
393
+ os.close(fd)
394
+ try:
395
+ _write_wav(tmp_path, samples, sample_rate)
396
+ if sys.platform == "darwin":
397
+ subprocess.run(["afplay", tmp_path], check=True, capture_output=True)
398
+ elif sys.platform.startswith("linux"):
399
+ for cmd in (
400
+ ["aplay", tmp_path],
401
+ ["paplay", tmp_path],
402
+ ["ffplay", "-nodisp", "-autoexit", tmp_path],
403
+ ):
404
+ try:
405
+ subprocess.run(cmd, check=True, capture_output=True)
406
+ return
407
+ except (FileNotFoundError, subprocess.CalledProcessError):
408
+ continue
409
+ raise RuntimeError(
410
+ "No audio player found. Install simpleaudio: "
411
+ "pip install gingo[audio]"
412
+ )
413
+ elif sys.platform == "win32":
414
+ import time
415
+ os.startfile(tmp_path) # type: ignore[attr-defined]
416
+ time.sleep(n / sample_rate + 0.5)
417
+ finally:
418
+ try:
419
+ os.unlink(tmp_path)
420
+ except OSError:
421
+ pass
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # Helpers
426
+ # ---------------------------------------------------------------------------
427
+
428
+ def _parse_waveform(waveform: Any) -> Waveform:
429
+ if isinstance(waveform, Waveform):
430
+ return waveform
431
+ if isinstance(waveform, str):
432
+ try:
433
+ return Waveform(waveform.lower())
434
+ except ValueError:
435
+ valid = ", ".join(w.value for w in Waveform)
436
+ raise ValueError(f"Unknown waveform '{waveform}'. Valid: {valid}") from None
437
+ raise TypeError(f"waveform must be Waveform or str, got {type(waveform).__name__}")
438
+
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # Public API
442
+ # ---------------------------------------------------------------------------
443
+
444
+ def play(
445
+ obj: Any,
446
+ *,
447
+ octave: int = 4,
448
+ duration: float = 0.5,
449
+ waveform: Union[Waveform, str] = Waveform.SINE,
450
+ amplitude: float = 0.8,
451
+ envelope: Envelope | None = None,
452
+ strum: float = 0.03,
453
+ gap: float = 0.05,
454
+ tuning: float = 440.0,
455
+ sample_rate: int = 44100,
456
+ ) -> None:
457
+ """Play a gingo object through the system audio output.
458
+
459
+ Accepts Note, Chord, Scale, Field, Tree, Sequence, NoteEvent,
460
+ ChordEvent, Rest, or a list of chord/note names.
461
+
462
+ Args:
463
+ obj: The gingo object to play.
464
+ octave: Base octave (default 4 = middle-C octave).
465
+ duration: Seconds per note/chord (default 0.5).
466
+ Ignored for Sequence (uses its own tempo and durations).
467
+ waveform: ``"sine"``, ``"square"``, ``"sawtooth"``, or ``"triangle"``.
468
+ amplitude: Volume 0.0–1.0 (default 0.8).
469
+ envelope: ADSR envelope. Default: attack=0.01, decay=0.08,
470
+ sustain=0.6, release=0.2.
471
+ strum: Delay in seconds between each note of a chord (default 0.03).
472
+ Creates a strummed / arpeggiated feel. Set to 0 for simultaneous.
473
+ gap: Silence in seconds between consecutive notes/chords (default 0.05).
474
+ Gives each sound space to breathe. Set to 0 for legato.
475
+ tuning: A4 reference in Hz (default 440).
476
+ sample_rate: Samples per second (default 44100).
477
+ """
478
+ wf = _parse_waveform(waveform)
479
+ env = envelope or Envelope()
480
+ segments = _obj_to_segments(obj, octave=octave, duration=duration, tuning=tuning)
481
+ samples = _render_segments(segments, sample_rate, wf, amplitude, env, strum, gap)
482
+ _play_samples(samples, sample_rate)
483
+
484
+
485
+ def to_wav(
486
+ obj: Any,
487
+ path: Union[str, Path],
488
+ *,
489
+ octave: int = 4,
490
+ duration: float = 0.5,
491
+ waveform: Union[Waveform, str] = Waveform.SINE,
492
+ amplitude: float = 0.8,
493
+ envelope: Envelope | None = None,
494
+ strum: float = 0.03,
495
+ gap: float = 0.05,
496
+ tuning: float = 440.0,
497
+ sample_rate: int = 44100,
498
+ ) -> None:
499
+ """Render a gingo object to a WAV audio file.
500
+
501
+ Same object types and parameters as :func:`play`.
502
+ """
503
+ wf = _parse_waveform(waveform)
504
+ env = envelope or Envelope()
505
+ segments = _obj_to_segments(obj, octave=octave, duration=duration, tuning=tuning)
506
+ samples = _render_segments(segments, sample_rate, wf, amplitude, env, strum, gap)
507
+ _write_wav(str(path), samples, sample_rate)
gingo/py.typed ADDED
File without changes