ttyengine 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.
ttyengine/sound.py ADDED
@@ -0,0 +1,1439 @@
1
+ import sys, os, re, glob, math, time, struct, random, shutil, queue, atexit, weakref, inspect, tempfile, ctypes, ctypes.util, threading, subprocess, contextlib
2
+ from array import array
3
+
4
+ __all__ = ['Mixer', 'Sound', 'Voice', 'note_freq', 'decoders', 'find_ffmpeg', 'set_ffmpeg']
5
+
6
+ _RATE = 44100
7
+ _TAU = 2.0 * math.pi
8
+ _NOTE_RE = re.compile(r'^([A-Ga-g])([#b]?)(-?\d+)?$')
9
+ _SEMITONES = {'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11}
10
+ _RESTS = ('', 'R', 'REST', '-', '_')
11
+ _FLIP = bytes(b ^ 0x80 for b in range(256))
12
+
13
+
14
+ def _locate(spec):
15
+ if spec is None:
16
+ return None
17
+ if callable(spec):
18
+ return _locate(spec())
19
+ if isinstance(spec, (tuple, list)):
20
+ return float(spec[0]), float(spec[1])
21
+ t = getattr(spec, 'transform', None)
22
+ if t is not None:
23
+ x = t.x + t.vx
24
+ y = t.y + t.vy
25
+ try:
26
+ x, y = t.center()
27
+ except Exception:
28
+ pass
29
+ return float(x), float(y)
30
+ if hasattr(spec, 'camx') and hasattr(spec, 'dimx'):
31
+ return spec.camx + spec.dimx / 2.0, spec.camy - spec.dimy / 2.0
32
+ if hasattr(spec, 'x') and hasattr(spec, 'y'):
33
+ return float(spec.x), float(spec.y)
34
+ raise TypeError(f'cannot derive a position from {spec!r}')
35
+
36
+
37
+ def _call(cb, arg):
38
+ try:
39
+ params = inspect.signature(cb).parameters.values()
40
+ wants = any(p.kind in (p.VAR_POSITIONAL, p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) for p in params)
41
+ except (TypeError, ValueError):
42
+ wants = True
43
+ if wants:
44
+ cb(arg)
45
+ else:
46
+ cb()
47
+
48
+
49
+ def _seconds(value):
50
+ if value is None:
51
+ return None
52
+ return max(0.0, float(value))
53
+
54
+
55
+ class _Session:
56
+ """The desktop user whose audio server a root process should talk to."""
57
+
58
+ def __init__(self, uid, gid, name, home, groups):
59
+ self.uid = uid
60
+ self.gid = gid
61
+ self.name = name
62
+ self.home = home
63
+ self.groups = groups
64
+ self.runtime = f'/run/user/{uid}'
65
+ self.socket = f'{self.runtime}/pulse/native'
66
+ cookie = os.path.join(home, '.config', 'pulse', 'cookie')
67
+ self.cookie = cookie if os.path.isfile(cookie) else None
68
+
69
+ def env(self):
70
+ env = {'XDG_RUNTIME_DIR': self.runtime, 'PULSE_SERVER': 'unix:' + self.socket}
71
+ if self.cookie:
72
+ env['PULSE_COOKIE'] = self.cookie
73
+ return env
74
+
75
+
76
+ def _session_user():
77
+ # Audio servers (PulseAudio / PipeWire) live in the logged-in user's session. A process running as root
78
+ # (sudo, pkexec, a root shell) does not inherit that session, so it can neither find nor authenticate to it.
79
+ # Returns the session owner to connect as, or None when no workaround is needed or possible.
80
+ if sys.platform in ('win32', 'darwin') or not hasattr(os, 'geteuid') or os.geteuid() != 0:
81
+ return None
82
+ if os.environ.get('PULSE_SERVER') or os.path.exists('/run/user/0/pulse/native'):
83
+ return None
84
+ try:
85
+ import pwd
86
+ except ImportError:
87
+ return None
88
+ uids = []
89
+ for var in ('SUDO_UID', 'PKEXEC_UID'):
90
+ try:
91
+ uids.append(int(os.environ[var]))
92
+ except (KeyError, ValueError):
93
+ pass
94
+ for path in sorted(glob.glob('/run/user/*/pulse/native')):
95
+ try:
96
+ uids.append(int(path.split('/')[3]))
97
+ except ValueError:
98
+ pass
99
+ for uid in uids:
100
+ if uid == 0 or not os.path.exists(f'/run/user/{uid}/pulse/native'):
101
+ continue
102
+ try:
103
+ pw = pwd.getpwuid(uid)
104
+ except KeyError:
105
+ continue
106
+ try:
107
+ groups = os.getgrouplist(pw.pw_name, pw.pw_gid)
108
+ except OSError:
109
+ groups = [pw.pw_gid]
110
+ return _Session(uid, pw.pw_gid, pw.pw_name, pw.pw_dir, groups)
111
+ return None
112
+
113
+
114
+ @contextlib.contextmanager
115
+ def _session_env(session):
116
+ if session is None:
117
+ yield
118
+ return
119
+ changes = session.env()
120
+ saved = {k: os.environ.get(k) for k in changes}
121
+ os.environ.update(changes)
122
+ try:
123
+ yield
124
+ finally:
125
+ for k, v in saved.items():
126
+ if v is None:
127
+ os.environ.pop(k, None)
128
+ else:
129
+ os.environ[k] = v
130
+
131
+
132
+ def note_freq(note):
133
+ if isinstance(note, (int, float)):
134
+ return float(note)
135
+ text = str(note).strip()
136
+ if text.upper() in _RESTS:
137
+ return 0.0
138
+ m = _NOTE_RE.match(text)
139
+ if not m:
140
+ raise ValueError(f'invalid note: {note!r}')
141
+ letter, accidental, octave = m.groups()
142
+ semi = _SEMITONES[letter.upper()] + (1 if accidental == '#' else -1 if accidental == 'b' else 0)
143
+ octave = 4 if octave is None else int(octave)
144
+ return 440.0 * 2.0 ** ((12 * (octave + 1) + semi - 69) / 12.0)
145
+
146
+
147
+ def _sine(p): return math.sin(_TAU * p)
148
+ def _square(p): return 1.0 if p < 0.5 else -1.0
149
+ def _saw(p): return 2.0 * p - 1.0
150
+ def _triangle(p): return 1.0 - 4.0 * abs(p - 0.5)
151
+ def _noise(p): return random.random() * 2.0 - 1.0
152
+
153
+ _WAVES = {'sine': _sine, 'square': _square, 'saw': _saw, 'triangle': _triangle, 'noise': _noise}
154
+
155
+
156
+ def _synth(rate, freq, duration, wave, volume, end_freq, attack, release):
157
+ fn = _WAVES.get(wave)
158
+ if fn is None:
159
+ raise ValueError(f'unknown waveform: {wave!r} (choose from {", ".join(_WAVES)})')
160
+ n = max(1, int(duration * rate))
161
+ out = array('h', bytes(2 * n))
162
+ f0 = note_freq(freq)
163
+ f1 = f0 if end_freq is None else note_freq(end_freq)
164
+ if f0 <= 0.0 and f1 <= 0.0:
165
+ return out
166
+ amp = 32767.0 * min(1.0, max(0.0, volume))
167
+ a = max(1, int(attack * rate))
168
+ r = max(1, int(release * rate))
169
+ df = (f1 - f0) / n
170
+ f = f0
171
+ phase = 0.0
172
+ for i in range(n):
173
+ env = i / a if i < a else 1.0
174
+ left = n - i
175
+ if left < r:
176
+ env = min(env, left / r)
177
+ out[i] = int(fn(phase) * amp * env)
178
+ phase += f / rate
179
+ if phase >= 1.0:
180
+ phase -= 1.0
181
+ f += df
182
+ return out
183
+
184
+
185
+ def _shorts(raw):
186
+ out = array('h')
187
+ out.frombytes(bytes(raw[:len(raw) // 2 * 2]))
188
+ if sys.byteorder == 'big':
189
+ out.byteswap()
190
+ return out
191
+
192
+
193
+ def _parse_wav(data):
194
+ if len(data) < 12 or data[:4] != b'RIFF' or data[8:12] != b'WAVE':
195
+ raise ValueError('unsupported audio file (only WAV is supported without external libraries)')
196
+ pos = 12
197
+ size_all = len(data)
198
+ fmt = None
199
+ pcm = None
200
+ while pos + 8 <= size_all:
201
+ cid = data[pos:pos + 4]
202
+ size = struct.unpack_from('<I', data, pos + 4)[0]
203
+ body = pos + 8
204
+ if cid == b'fmt ' and body + 16 <= size_all:
205
+ tag, ch, rate, _, _, bits = struct.unpack_from('<HHIIHH', data, body)
206
+ if tag == 0xFFFE and size >= 26 and body + 26 <= size_all:
207
+ tag = struct.unpack_from('<H', data, body + 24)[0]
208
+ fmt = (tag, ch, rate, bits)
209
+ elif cid == b'data':
210
+ pcm = data[body:min(body + size, size_all)]
211
+ pos = body + size + (size & 1)
212
+ if fmt is None or pcm is None:
213
+ raise ValueError('incomplete WAV file')
214
+ return fmt, pcm
215
+
216
+
217
+ def _decode(fmt, pcm):
218
+ tag, ch, rate, bits = fmt
219
+ if ch < 1 or rate < 1:
220
+ raise ValueError('invalid WAV header')
221
+ if tag == 1 and bits == 8:
222
+ raw = bytearray(2 * len(pcm))
223
+ raw[1::2] = pcm.translate(_FLIP)
224
+ samples = _shorts(raw)
225
+ elif tag == 1 and bits == 16:
226
+ samples = _shorts(pcm)
227
+ elif tag == 1 and bits == 24:
228
+ n = len(pcm) // 3
229
+ raw = bytearray(2 * n)
230
+ raw[0::2] = pcm[1:3 * n:3]
231
+ raw[1::2] = pcm[2:3 * n:3]
232
+ samples = _shorts(raw)
233
+ elif tag == 1 and bits == 32:
234
+ n = len(pcm) // 4
235
+ raw = bytearray(2 * n)
236
+ raw[0::2] = pcm[2:4 * n:4]
237
+ raw[1::2] = pcm[3:4 * n:4]
238
+ samples = _shorts(raw)
239
+ elif tag == 3 and bits in (32, 64):
240
+ floats = array('f' if bits == 32 else 'd')
241
+ width = bits // 8
242
+ floats.frombytes(bytes(pcm[:len(pcm) // width * width]))
243
+ if sys.byteorder == 'big':
244
+ floats.byteswap()
245
+ samples = array('h', [int(max(-1.0, min(1.0, x)) * 32767.0) for x in floats])
246
+ else:
247
+ raise ValueError(f'unsupported WAV encoding (format={tag}, bits={bits})')
248
+ return _from_samples(samples, ch, rate)
249
+
250
+
251
+ def _from_samples(samples, ch, rate):
252
+ frames = len(samples) // ch
253
+ samples = samples[:frames * ch]
254
+ if ch == 1:
255
+ return Sound(samples, None, rate)
256
+ return Sound(samples[0::ch], samples[1::ch], rate)
257
+
258
+
259
+ def _with_file(data, path, run):
260
+ tmp = None
261
+ try:
262
+ if path is None:
263
+ fd, tmp = tempfile.mkstemp(prefix='ttyengine_', suffix='.audio')
264
+ with os.fdopen(fd, 'wb') as f:
265
+ f.write(data)
266
+ path = tmp
267
+ return run(path)
268
+ finally:
269
+ if tmp:
270
+ try:
271
+ os.remove(tmp)
272
+ except OSError:
273
+ pass
274
+
275
+
276
+ _FFMPEG_CONFIG = []
277
+ _FFMPEG_FOUND = []
278
+
279
+
280
+ def _exe_names():
281
+ return ('ffmpeg.exe', 'ffmpeg') if sys.platform == 'win32' else ('ffmpeg',)
282
+
283
+
284
+ def _in_dir(directory):
285
+ for sub in ('', 'bin'):
286
+ for name in _exe_names():
287
+ candidate = os.path.join(directory, sub, name)
288
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
289
+ yield candidate
290
+
291
+
292
+ def _from_setting(value):
293
+ value = os.path.expanduser(os.path.expandvars(str(value)))
294
+ if os.path.isdir(value):
295
+ yield from _in_dir(value)
296
+ else:
297
+ yield shutil.which(value) or value
298
+
299
+
300
+ def _known_dirs():
301
+ home = os.path.expanduser('~')
302
+ if sys.platform == 'win32':
303
+ local = os.environ.get('LOCALAPPDATA', os.path.join(home, 'AppData', 'Local'))
304
+ program = os.environ.get('ProgramFiles', 'C:\\Program Files')
305
+ found = [os.path.join(local, 'Microsoft', 'WinGet', 'Links'), os.path.join(program, 'ffmpeg'), 'C:\\ffmpeg',
306
+ os.path.join(home, 'scoop', 'shims'), 'C:\\ProgramData\\chocolatey\\bin']
307
+ found += glob.glob(os.path.join(local, 'Microsoft', 'WinGet', 'Packages', 'Gyan.FFmpeg*', 'ffmpeg-*', 'bin'))
308
+ return found
309
+ return ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin', '/usr/bin', '/snap/bin', os.path.join(home, '.local', 'bin')]
310
+
311
+
312
+ def _ffmpeg_candidates():
313
+ for value in _FFMPEG_CONFIG:
314
+ yield from _from_setting(value)
315
+ for var in ('TTYENGINE_FFMPEG', 'FFMPEG_BINARY', 'FFMPEG_PATH'):
316
+ if os.environ.get(var):
317
+ yield from _from_setting(os.environ[var])
318
+ found = shutil.which('ffmpeg')
319
+ if found:
320
+ yield found
321
+ try:
322
+ import imageio_ffmpeg
323
+ yield imageio_ffmpeg.get_ffmpeg_exe()
324
+ except Exception:
325
+ pass
326
+ for directory in _known_dirs():
327
+ yield from _in_dir(directory)
328
+ main = os.path.dirname(os.path.abspath(sys.argv[0])) if sys.argv and sys.argv[0] else None
329
+ for directory in (os.path.dirname(os.path.abspath(__file__)), main,
330
+ os.path.join(os.path.expanduser('~'), '.ttyengine')):
331
+ if directory:
332
+ yield from _in_dir(directory)
333
+
334
+
335
+ def _works(exe):
336
+ try:
337
+ proc = subprocess.run([exe, '-version'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10)
338
+ except Exception:
339
+ return False
340
+ return proc.returncode == 0 and b'ffmpeg' in proc.stdout[:200].lower()
341
+
342
+
343
+ def find_ffmpeg():
344
+ if _FFMPEG_FOUND:
345
+ if os.path.exists(_FFMPEG_FOUND[0]):
346
+ return _FFMPEG_FOUND[0]
347
+ _FFMPEG_FOUND.clear()
348
+ seen = set()
349
+ for candidate in _ffmpeg_candidates():
350
+ candidate = os.path.abspath(candidate)
351
+ if candidate in seen:
352
+ continue
353
+ seen.add(candidate)
354
+ if _works(candidate):
355
+ _FFMPEG_FOUND[:] = [candidate]
356
+ return candidate
357
+ return None
358
+
359
+
360
+ def set_ffmpeg(path=None):
361
+ _FFMPEG_CONFIG[:] = [os.fspath(path)] if path else []
362
+ _FFMPEG_FOUND.clear()
363
+ return find_ffmpeg()
364
+
365
+
366
+ def _ffmpeg_hint():
367
+ if sys.platform == 'win32':
368
+ how = 'winget install ffmpeg'
369
+ elif sys.platform == 'darwin':
370
+ how = 'brew install ffmpeg'
371
+ else:
372
+ how = 'sudo apt install ffmpeg, or your distro equivalent'
373
+ return (f'ffmpeg was not found; only WAV can be loaded without it. Install it ({how}; or pip install imageio-ffmpeg), '
374
+ 'put the ffmpeg binary next to sound.py, or point to it with set_ffmpeg(path) / the TTYENGINE_FFMPEG environment variable')
375
+
376
+
377
+ def _via_ffmpeg(data, path, rate):
378
+ exe = find_ffmpeg()
379
+ if not exe:
380
+ raise OSError(_ffmpeg_hint())
381
+ rate = int(rate or _RATE)
382
+
383
+ def run(src):
384
+ proc = subprocess.run([exe, '-v', 'error', '-nostdin', '-i', src, '-vn', '-f', 's16le', '-acodec', 'pcm_s16le',
385
+ '-ac', '2', '-ar', str(rate), 'pipe:1'],
386
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
387
+ if proc.returncode or not proc.stdout:
388
+ raise ValueError((proc.stderr.decode('utf-8', 'replace').strip().splitlines() or ['decode failed'])[-1])
389
+ return _from_samples(_shorts(proc.stdout), 2, rate)
390
+
391
+ return _with_file(data, path, run)
392
+
393
+
394
+ def decoders():
395
+ return {'wav': True, 'ffmpeg': find_ffmpeg()}
396
+
397
+
398
+ def _resample(chan, src, dst):
399
+ if src == dst or len(chan) == 0:
400
+ return chan
401
+ n = len(chan)
402
+ m = max(1, int(n * dst / src))
403
+ step = src / dst
404
+ last = n - 1
405
+ out = array('h', bytes(2 * m))
406
+ for k in range(m):
407
+ p = k * step
408
+ i = int(p)
409
+ if i >= last:
410
+ out[k] = chan[last]
411
+ else:
412
+ out[k] = int(chan[i] + (chan[i + 1] - chan[i]) * (p - i))
413
+ return out
414
+
415
+
416
+ def _clip(values):
417
+ try:
418
+ return array('h', values)
419
+ except OverflowError:
420
+ return array('h', [32767 if s > 32767 else -32768 if s < -32768 else s for s in values])
421
+
422
+
423
+ def _pack(left, right):
424
+ out = array('h', bytes(4 * len(left)))
425
+ out[0::2] = _clip(left)
426
+ out[1::2] = _clip(right)
427
+ if sys.byteorder == 'big':
428
+ out.byteswap()
429
+ return out.tobytes()
430
+
431
+
432
+ class Sound:
433
+ def __init__(self, left, right=None, rate=_RATE):
434
+ self.left = left
435
+ self.right = left if right is None else right
436
+ self.rate = rate
437
+ self._cache = {}
438
+
439
+ @property
440
+ def frames(self):
441
+ return len(self.left)
442
+
443
+ @property
444
+ def duration(self):
445
+ return len(self.left) / float(self.rate)
446
+
447
+ def at_rate(self, rate):
448
+ if rate == self.rate:
449
+ return self
450
+ snd = self._cache.get(rate)
451
+ if snd is None:
452
+ left = _resample(self.left, self.rate, rate)
453
+ right = left if self.right is self.left else _resample(self.right, self.rate, rate)
454
+ snd = self._cache[rate] = Sound(left, right, rate)
455
+ return snd
456
+
457
+ @classmethod
458
+ def load(cls, src, rate=None):
459
+ path = None
460
+ if isinstance(src, (bytes, bytearray, memoryview)):
461
+ data = bytes(src)
462
+ elif hasattr(src, 'read'):
463
+ data = src.read()
464
+ else:
465
+ path = os.fspath(src)
466
+ with open(path, 'rb') as f:
467
+ data = f.read()
468
+ errors = []
469
+ if data[:4] == b'RIFF' and data[8:12] == b'WAVE':
470
+ try:
471
+ snd = _decode(*_parse_wav(data))
472
+ except ValueError as e:
473
+ errors.append(f'wav: {e}')
474
+ else:
475
+ if rate and snd.rate != rate and find_ffmpeg():
476
+ try:
477
+ return _via_ffmpeg(data, path, rate)
478
+ except Exception:
479
+ pass
480
+ return snd
481
+ try:
482
+ return _via_ffmpeg(data, path, rate)
483
+ except Exception as e:
484
+ errors.append(f'ffmpeg: {e}')
485
+ raise ValueError('cannot decode audio (' + '; '.join(errors) + ')')
486
+
487
+ @classmethod
488
+ def tone(cls, freq=440.0, duration=0.2, wave='sine', volume=0.5, end_freq=None, attack=0.005, release=0.02, rate=_RATE):
489
+ return cls(_synth(rate, freq, duration, wave, volume, end_freq, attack, release), None, rate)
490
+
491
+ @classmethod
492
+ def melody(cls, notes, wave='square', volume=0.4, beat=1.0, attack=0.005, release=0.03, rate=_RATE):
493
+ if isinstance(notes, str):
494
+ notes = [t for t in re.split(r'[\s,]+', notes.strip()) if t]
495
+ out = array('h')
496
+ for item in notes:
497
+ if isinstance(item, str):
498
+ name, _, dur = item.partition(':')
499
+ beats = float(dur) if dur else 1.0
500
+ else:
501
+ name, beats = item
502
+ out.extend(_synth(rate, name, beats * beat, wave, volume, None, attack, release))
503
+ return cls(out, None, rate)
504
+
505
+
506
+ class Voice:
507
+ def __init__(self, mixer, sound, volume=1.0, pan=0.0, loop=False, on_end=None, speed=1.0, at=None,
508
+ loop_start=None, loop_end=None):
509
+ self._mixer = mixer
510
+ self._sound = sound
511
+ self._pos = 0
512
+ self._loop_start = _seconds(loop_start)
513
+ self._loop_end = _seconds(loop_end)
514
+ if self._loop_start is not None and self._loop_end is not None and self._loop_end <= self._loop_start:
515
+ raise ValueError('loop_end must be greater than loop_start')
516
+ self._speed = max(0.05, min(32.0, float(speed)))
517
+ self._at = None
518
+ self._hook = None
519
+ self._lastpos = None
520
+ self.pan_width = None
521
+ self.hear_range = None
522
+ self._volume = max(0.0, float(volume))
523
+ self._pan = max(-1.0, min(1.0, float(pan)))
524
+ self._target = self._volume
525
+ self._rate = 0.0
526
+ self._stop_after = False
527
+ self._restore = None
528
+ self._pending = None
529
+ if loop is True:
530
+ self._left = None
531
+ elif loop:
532
+ self._left = max(1, int(loop))
533
+ else:
534
+ self._left = 1
535
+ self._loops = self._left
536
+ self.paused = False
537
+ self.done = False
538
+ self.on_end = on_end
539
+ self._complete = []
540
+ self._finished = False
541
+ if at is not None:
542
+ self.at = at
543
+
544
+ @property
545
+ def speed(self):
546
+ return self._speed
547
+ @speed.setter
548
+ def speed(self, value):
549
+ self._speed = max(0.05, min(32.0, float(value)))
550
+
551
+ @property
552
+ def pitch(self):
553
+ return 12.0 * math.log2(self._speed)
554
+ @pitch.setter
555
+ def pitch(self, semitones):
556
+ self.speed = 2.0 ** (float(semitones) / 12.0)
557
+
558
+ @property
559
+ def at(self):
560
+ return self._at
561
+ @at.setter
562
+ def at(self, value):
563
+ self._detach()
564
+ self._at = value
565
+ hook = getattr(value, 'ondiscard', None)
566
+ if callable(hook) and hasattr(value, 'offdiscard'):
567
+ def gone():
568
+ self._at = self._lastpos
569
+ self._hook = None
570
+ hook(gone)
571
+ self._hook = (value, gone)
572
+
573
+ def _detach(self):
574
+ hook, self._hook = self._hook, None
575
+ if hook is not None:
576
+ try:
577
+ hook[0].offdiscard(hook[1])
578
+ except Exception:
579
+ pass
580
+
581
+ @property
582
+ def volume(self):
583
+ return self._volume
584
+ @volume.setter
585
+ def volume(self, value):
586
+ self._volume = max(0.0, float(value))
587
+ self._rate = 0.0
588
+
589
+ @property
590
+ def pan(self):
591
+ return self._pan
592
+ @pan.setter
593
+ def pan(self, value):
594
+ self._pan = max(-1.0, min(1.0, float(value)))
595
+
596
+ @property
597
+ def playing(self):
598
+ return not self.done and not self.paused
599
+
600
+ @property
601
+ def loop_start(self):
602
+ return self._loop_start
603
+ @loop_start.setter
604
+ def loop_start(self, seconds):
605
+ self._loop_start = _seconds(seconds)
606
+
607
+ @property
608
+ def loop_end(self):
609
+ return self._loop_end
610
+ @loop_end.setter
611
+ def loop_end(self, seconds):
612
+ self._loop_end = _seconds(seconds)
613
+
614
+ def _loop_frames(self, total):
615
+ # (start, end) of the repeated region in frames; an empty or inverted region falls back to the whole sound
616
+ rate = self._sound.rate
617
+ ls = 0 if self._loop_start is None else min(total, int(round(self._loop_start * rate)))
618
+ le = total if self._loop_end is None else min(total, int(round(self._loop_end * rate)))
619
+ if le <= ls:
620
+ return 0, total
621
+ return ls, le
622
+
623
+ @property
624
+ def position(self):
625
+ pending = self._pending
626
+ pos = self._pos if pending is None else pending[0]
627
+ return pos / float(self._sound.rate)
628
+ @position.setter
629
+ def position(self, seconds):
630
+ self.move(seconds)
631
+
632
+ @property
633
+ def duration(self):
634
+ return self._sound.duration
635
+
636
+ def oncomplete(self, callback, once=False):
637
+ if self._finished:
638
+ _call(callback, self)
639
+ return callback
640
+ self._complete.append([callback, bool(once)])
641
+ return callback
642
+
643
+ def offcomplete(self, callback):
644
+ self._complete = [e for e in self._complete if e[0] is not callback]
645
+
646
+ def _emit_complete(self):
647
+ for entry in list(self._complete):
648
+ if entry[1]:
649
+ self._complete = [e for e in self._complete if e is not entry]
650
+ try:
651
+ _call(entry[0], self)
652
+ except Exception:
653
+ pass
654
+
655
+ def fade(self, target, seconds, stop=False):
656
+ target = max(0.0, float(target))
657
+ self._stop_after = stop
658
+ if seconds <= 0 or target == self._volume:
659
+ self._volume = target
660
+ self._rate = 0.0
661
+ if stop:
662
+ self.done = True
663
+ return self
664
+ self._target = target
665
+ self._rate = abs(target - self._volume) / seconds
666
+ return self
667
+
668
+ def kill(self, fade=0.0):
669
+ # ends the voice for good: it is removed from the mixer and on_end fires
670
+ return self.fade(0.0, fade, True)
671
+
672
+ def stop(self, fade=0.0):
673
+ # pauses the voice and returns it to the beginning (and its original loop count and volume);
674
+ # play() or resume() starts it again from the top
675
+ if fade > 0 and self._volume > 0 and not self.paused and not self.done:
676
+ if self._stop_after != 'rewind':
677
+ self._restore = max(self._volume, self._target) if self._rate else self._volume
678
+ return self.fade(0.0, fade, 'rewind')
679
+ self._halt()
680
+ return self
681
+
682
+ def _halt(self):
683
+ restore, self._restore = self._restore, None
684
+ self._stop_after = False
685
+ self._rate = 0.0
686
+ if restore is not None:
687
+ self._volume = self._target = restore
688
+ self.paused = True
689
+ self._set_pending(0.0, True)
690
+
691
+ def play(self):
692
+ # (re)starts from the beginning, also after stop() or after the sound has finished on its own
693
+ mixer = self._mixer
694
+ restore, self._restore = self._restore, None
695
+ self._stop_after = False
696
+ self._rate = 0.0
697
+ if restore is not None:
698
+ self._volume = self._target = restore
699
+ self._finished = False
700
+ self._set_pending(0.0, True)
701
+ revive = self.done
702
+ with mixer._lock:
703
+ self.paused = False
704
+ if revive:
705
+ self.done = False
706
+ if self not in mixer._voices:
707
+ mixer._voices.append(self)
708
+ if revive and self._at is not None:
709
+ self.at = self._at
710
+ mixer._ensure()
711
+ return self
712
+
713
+ def move(self, seconds, relative=False):
714
+ # jumps to a time in the sound, in seconds like position/duration; relative=True offsets from the current position
715
+ seconds = float(seconds)
716
+ if relative:
717
+ seconds += self.position
718
+ frames = min(max(seconds, 0.0) * self._sound.rate, float(self._sound.frames))
719
+ pending = self._pending
720
+ self._set_pending(frames, bool(pending and pending[1]))
721
+ return self
722
+
723
+ def _set_pending(self, frames, reset):
724
+ # applied by the mixer thread at the start of the next block, so it can't race with rendering
725
+ with self._mixer._lock:
726
+ self._pending = (frames, reset)
727
+
728
+ def _apply_pending(self):
729
+ with self._mixer._lock:
730
+ pending, self._pending = self._pending, None
731
+ if pending is not None:
732
+ self._pos = pending[0]
733
+ if pending[1]:
734
+ self._left = self._loops
735
+
736
+ def pause(self):
737
+ self.paused = True
738
+ return self
739
+
740
+ def resume(self):
741
+ self.paused = False
742
+ self._mixer._ensure()
743
+ return self
744
+
745
+
746
+ class _Null:
747
+ name = 'null'
748
+
749
+ def __init__(self, rate, block):
750
+ self.rate = rate
751
+ self.next = time.perf_counter()
752
+
753
+ def write(self, data):
754
+ now = time.perf_counter()
755
+ self.next = max(self.next, now - 0.05) + len(data) / (4.0 * self.rate)
756
+ delay = self.next - now
757
+ if delay > 0:
758
+ time.sleep(delay)
759
+
760
+ def close(self):
761
+ pass
762
+
763
+
764
+ class _WAVEFORMATEX(ctypes.Structure):
765
+ _pack_ = 1
766
+ _fields_ = [('wFormatTag', ctypes.c_ushort), ('nChannels', ctypes.c_ushort),
767
+ ('nSamplesPerSec', ctypes.c_uint32), ('nAvgBytesPerSec', ctypes.c_uint32),
768
+ ('nBlockAlign', ctypes.c_ushort), ('wBitsPerSample', ctypes.c_ushort),
769
+ ('cbSize', ctypes.c_ushort)]
770
+
771
+
772
+ class _WAVEHDR(ctypes.Structure):
773
+ _pack_ = 1
774
+ _fields_ = [('lpData', ctypes.c_void_p), ('dwBufferLength', ctypes.c_uint32),
775
+ ('dwBytesRecorded', ctypes.c_uint32), ('dwUser', ctypes.c_size_t),
776
+ ('dwFlags', ctypes.c_uint32), ('dwLoops', ctypes.c_uint32),
777
+ ('lpNext', ctypes.c_void_p), ('reserved', ctypes.c_size_t)]
778
+
779
+
780
+ class _WinMM:
781
+ name = 'winmm'
782
+ COUNT = 4
783
+
784
+ def __init__(self, rate, block):
785
+ self.h = None
786
+ self.hdrs = []
787
+ self.bufs = []
788
+ lib = self.lib = ctypes.WinDLL('winmm')
789
+ hdr_ptr = ctypes.POINTER(_WAVEHDR)
790
+ for fn in ('waveOutPrepareHeader', 'waveOutWrite', 'waveOutUnprepareHeader'):
791
+ f = getattr(lib, fn)
792
+ f.argtypes = [ctypes.c_void_p, hdr_ptr, ctypes.c_uint]
793
+ f.restype = ctypes.c_uint
794
+ lib.waveOutOpen.argtypes = [ctypes.POINTER(ctypes.c_void_p), ctypes.c_uint, ctypes.POINTER(_WAVEFORMATEX),
795
+ ctypes.c_size_t, ctypes.c_size_t, ctypes.c_uint32]
796
+ lib.waveOutOpen.restype = ctypes.c_uint
797
+ for fn in ('waveOutReset', 'waveOutClose'):
798
+ f = getattr(lib, fn)
799
+ f.argtypes = [ctypes.c_void_p]
800
+ f.restype = ctypes.c_uint
801
+ fmt = _WAVEFORMATEX(1, 2, rate, rate * 4, 4, 16, 0)
802
+ handle = ctypes.c_void_p()
803
+ if lib.waveOutOpen(ctypes.byref(handle), 0xFFFFFFFF, ctypes.byref(fmt), 0, 0, 0):
804
+ raise OSError('waveOutOpen failed')
805
+ self.h = handle
806
+ size = block * 4
807
+ for _ in range(self.COUNT):
808
+ buf = ctypes.create_string_buffer(size)
809
+ hdr = _WAVEHDR(ctypes.addressof(buf), size, 0, 0, 0, 0, None, 0)
810
+ if lib.waveOutPrepareHeader(self.h, ctypes.byref(hdr), ctypes.sizeof(hdr)):
811
+ self.close()
812
+ raise OSError('waveOutPrepareHeader failed')
813
+ self.bufs.append(buf)
814
+ self.hdrs.append(hdr)
815
+ self.busy = [False] * self.COUNT
816
+ self.i = 0
817
+
818
+ def write(self, data):
819
+ i = self.i
820
+ hdr = self.hdrs[i]
821
+ deadline = time.perf_counter() + 2.0
822
+ while self.busy[i] and not (hdr.dwFlags & 1):
823
+ if time.perf_counter() > deadline:
824
+ raise OSError('waveOut stalled')
825
+ time.sleep(0.001)
826
+ n = min(len(data), len(self.bufs[i]))
827
+ ctypes.memmove(self.bufs[i], data, n)
828
+ hdr.dwBufferLength = n
829
+ if self.lib.waveOutWrite(self.h, ctypes.byref(hdr), ctypes.sizeof(hdr)):
830
+ raise OSError('waveOutWrite failed')
831
+ self.busy[i] = True
832
+ self.i = (i + 1) % self.COUNT
833
+
834
+ def close(self):
835
+ if self.h is None:
836
+ return
837
+ try:
838
+ self.lib.waveOutReset(self.h)
839
+ for hdr in self.hdrs:
840
+ self.lib.waveOutUnprepareHeader(self.h, ctypes.byref(hdr), ctypes.sizeof(hdr))
841
+ finally:
842
+ self.lib.waveOutClose(self.h)
843
+ self.h = None
844
+
845
+
846
+ class _ASBD(ctypes.Structure):
847
+ _fields_ = [('mSampleRate', ctypes.c_double), ('mFormatID', ctypes.c_uint32),
848
+ ('mFormatFlags', ctypes.c_uint32), ('mBytesPerPacket', ctypes.c_uint32),
849
+ ('mFramesPerPacket', ctypes.c_uint32), ('mBytesPerFrame', ctypes.c_uint32),
850
+ ('mChannelsPerFrame', ctypes.c_uint32), ('mBitsPerChannel', ctypes.c_uint32),
851
+ ('mReserved', ctypes.c_uint32)]
852
+
853
+
854
+ class _AQBuffer(ctypes.Structure):
855
+ _fields_ = [('mAudioDataBytesCapacity', ctypes.c_uint32), ('mAudioData', ctypes.c_void_p),
856
+ ('mAudioDataByteSize', ctypes.c_uint32), ('mUserData', ctypes.c_void_p),
857
+ ('mPacketDescriptionCapacity', ctypes.c_uint32), ('mPacketDescriptions', ctypes.c_void_p),
858
+ ('mPacketDescriptionCount', ctypes.c_uint32)]
859
+
860
+
861
+ _AQ_CALLBACK = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(_AQBuffer))
862
+
863
+
864
+ class _CoreAudio:
865
+ name = 'coreaudio'
866
+ COUNT = 4
867
+
868
+ def __init__(self, rate, block):
869
+ self.q = None
870
+ path = ctypes.util.find_library('AudioToolbox') or '/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox'
871
+ lib = self.lib = ctypes.CDLL(path)
872
+ buf_ptr = ctypes.POINTER(_AQBuffer)
873
+ lib.AudioQueueNewOutput.argtypes = [ctypes.POINTER(_ASBD), _AQ_CALLBACK, ctypes.c_void_p, ctypes.c_void_p,
874
+ ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p)]
875
+ lib.AudioQueueAllocateBuffer.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(buf_ptr)]
876
+ lib.AudioQueueEnqueueBuffer.argtypes = [ctypes.c_void_p, buf_ptr, ctypes.c_uint32, ctypes.c_void_p]
877
+ lib.AudioQueueStart.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
878
+ lib.AudioQueueStop.argtypes = [ctypes.c_void_p, ctypes.c_ubyte]
879
+ lib.AudioQueueDispose.argtypes = [ctypes.c_void_p, ctypes.c_ubyte]
880
+ for fn in ('AudioQueueNewOutput', 'AudioQueueAllocateBuffer', 'AudioQueueEnqueueBuffer',
881
+ 'AudioQueueStart', 'AudioQueueStop', 'AudioQueueDispose'):
882
+ getattr(lib, fn).restype = ctypes.c_int32
883
+ self.free = queue.Queue()
884
+ self.cb = _AQ_CALLBACK(self._returned)
885
+ fmt = _ASBD(float(rate), 0x6C70636D, 0x0C, 4, 1, 4, 2, 16, 0)
886
+ handle = ctypes.c_void_p()
887
+ if lib.AudioQueueNewOutput(ctypes.byref(fmt), self.cb, None, None, None, 0, ctypes.byref(handle)):
888
+ raise OSError('AudioQueueNewOutput failed')
889
+ self.q = handle
890
+ self.size = block * 4
891
+ self.bufs = []
892
+ for _ in range(self.COUNT):
893
+ buf = buf_ptr()
894
+ if lib.AudioQueueAllocateBuffer(self.q, self.size, ctypes.byref(buf)):
895
+ self.close()
896
+ raise OSError('AudioQueueAllocateBuffer failed')
897
+ self.bufs.append(buf)
898
+ self.free.put(buf)
899
+ self.queued = 0
900
+ self.started = False
901
+
902
+ def _returned(self, user, aq, buf):
903
+ self.free.put(buf)
904
+
905
+ def write(self, data):
906
+ try:
907
+ buf = self.free.get(timeout=1.0)
908
+ except queue.Empty:
909
+ raise OSError('AudioQueue stalled')
910
+ n = min(len(data), self.size)
911
+ ctypes.memmove(buf.contents.mAudioData, data, n)
912
+ buf.contents.mAudioDataByteSize = n
913
+ if self.lib.AudioQueueEnqueueBuffer(self.q, buf, 0, None):
914
+ raise OSError('AudioQueueEnqueueBuffer failed')
915
+ self.queued += 1
916
+ if not self.started and self.queued >= self.COUNT:
917
+ if self.lib.AudioQueueStart(self.q, None):
918
+ raise OSError('AudioQueueStart failed')
919
+ self.started = True
920
+
921
+ def close(self):
922
+ if self.q is None:
923
+ return
924
+ q, self.q = self.q, None
925
+ try:
926
+ self.lib.AudioQueueStop(q, 1)
927
+ finally:
928
+ self.lib.AudioQueueDispose(q, 1)
929
+
930
+
931
+ class _PASpec(ctypes.Structure):
932
+ _fields_ = [('format', ctypes.c_int), ('rate', ctypes.c_uint32), ('channels', ctypes.c_uint8)]
933
+
934
+
935
+ class _PAAttr(ctypes.Structure):
936
+ _fields_ = [('maxlength', ctypes.c_uint32), ('tlength', ctypes.c_uint32), ('prebuf', ctypes.c_uint32),
937
+ ('minreq', ctypes.c_uint32), ('fragsize', ctypes.c_uint32)]
938
+
939
+
940
+ class _Pulse:
941
+ name = 'pulse'
942
+
943
+ def __init__(self, rate, block):
944
+ lib = self.lib = ctypes.CDLL(ctypes.util.find_library('pulse-simple') or 'libpulse-simple.so.0')
945
+ lib.pa_simple_new.restype = ctypes.c_void_p
946
+ lib.pa_simple_new.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p,
947
+ ctypes.c_char_p, ctypes.POINTER(_PASpec), ctypes.c_void_p,
948
+ ctypes.POINTER(_PAAttr), ctypes.POINTER(ctypes.c_int)]
949
+ lib.pa_simple_write.restype = ctypes.c_int
950
+ lib.pa_simple_write.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_int)]
951
+ lib.pa_simple_free.restype = None
952
+ lib.pa_simple_free.argtypes = [ctypes.c_void_p]
953
+ spec = _PASpec(3, rate, 2)
954
+ unset = 0xFFFFFFFF
955
+ attr = _PAAttr(unset, block * 4 * 4, unset, unset, unset)
956
+ err = ctypes.c_int(0)
957
+ self.s = lib.pa_simple_new(None, b'ttyengine', 1, None, b'sound', ctypes.byref(spec), None,
958
+ ctypes.byref(attr), ctypes.byref(err))
959
+ if not self.s:
960
+ raise OSError(f'pulseaudio unavailable ({err.value})')
961
+
962
+ def write(self, data):
963
+ err = ctypes.c_int(0)
964
+ if self.lib.pa_simple_write(self.s, data, len(data), ctypes.byref(err)) < 0:
965
+ raise OSError(f'pulseaudio write failed ({err.value})')
966
+
967
+ def close(self):
968
+ s, self.s = self.s, None
969
+ if s:
970
+ self.lib.pa_simple_free(s)
971
+
972
+
973
+ _ALSA_ERROR = ctypes.CFUNCTYPE(None, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p)
974
+ _alsa_quiet = _ALSA_ERROR(lambda *args: None)
975
+
976
+
977
+ class _Alsa:
978
+ name = 'alsa'
979
+
980
+ def __init__(self, rate, block):
981
+ lib = self.lib = ctypes.CDLL(ctypes.util.find_library('asound') or 'libasound.so.2')
982
+ try:
983
+ lib.snd_lib_error_set_handler.argtypes = [_ALSA_ERROR]
984
+ lib.snd_lib_error_set_handler(_alsa_quiet)
985
+ except AttributeError:
986
+ pass
987
+ lib.snd_pcm_open.argtypes = [ctypes.POINTER(ctypes.c_void_p), ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
988
+ lib.snd_pcm_set_params.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_uint,
989
+ ctypes.c_uint, ctypes.c_int, ctypes.c_uint]
990
+ lib.snd_pcm_writei.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_ulong]
991
+ lib.snd_pcm_writei.restype = ctypes.c_long
992
+ lib.snd_pcm_recover.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int]
993
+ lib.snd_pcm_drop.argtypes = [ctypes.c_void_p]
994
+ lib.snd_pcm_close.argtypes = [ctypes.c_void_p]
995
+ device = os.environ.get('TTYENGINE_ALSA_DEVICE', 'default').encode()
996
+ self.pcm = ctypes.c_void_p()
997
+ if lib.snd_pcm_open(ctypes.byref(self.pcm), device, 0, 0) < 0:
998
+ raise OSError('no ALSA playback device')
999
+ latency = int(block * 4 / float(rate) * 1000000)
1000
+ if lib.snd_pcm_set_params(self.pcm, 2, 3, 2, rate, 1, latency) < 0:
1001
+ lib.snd_pcm_close(self.pcm)
1002
+ raise OSError('ALSA rejected the stream parameters')
1003
+
1004
+ def write(self, data):
1005
+ frames = len(data) // 4
1006
+ buf = ctypes.create_string_buffer(data, len(data))
1007
+ base = ctypes.addressof(buf)
1008
+ done = 0
1009
+ tries = 0
1010
+ while done < frames:
1011
+ n = self.lib.snd_pcm_writei(self.pcm, base + done * 4, frames - done)
1012
+ if n < 0:
1013
+ tries += 1
1014
+ if tries > 4 or self.lib.snd_pcm_recover(self.pcm, int(n), 1) < 0:
1015
+ raise OSError('ALSA write failed')
1016
+ continue
1017
+ done += n
1018
+
1019
+ def close(self):
1020
+ try:
1021
+ self.lib.snd_pcm_drop(self.pcm)
1022
+ finally:
1023
+ self.lib.snd_pcm_close(self.pcm)
1024
+
1025
+
1026
+ def _pipe_commands(rate):
1027
+ r = str(rate)
1028
+ return [
1029
+ ['paplay', '--raw', '--format=s16le', '--rate=' + r, '--channels=2', '--latency-msec=60'],
1030
+ ['aplay', '-q', '-t', 'raw', '-f', 'S16_LE', '-r', r, '-c', '2', '-B', '60000'],
1031
+ ['play', '-q', '-t', 'raw', '-r', r, '-e', 'signed', '-b', '16', '-c', '2', '-'],
1032
+ ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet', '-f', 's16le', '-ar', r, '-ac', '2', '-i', 'pipe:0'],
1033
+ ]
1034
+
1035
+
1036
+ class _Pipe:
1037
+ name = 'pipe'
1038
+
1039
+ def __init__(self, rate, block):
1040
+ self.p = None
1041
+ session = _session_user()
1042
+ env = None
1043
+ extra = {}
1044
+ if session is not None:
1045
+ # run the player as the session user so the audio server accepts it
1046
+ env = dict(os.environ, HOME=session.home, USER=session.name, LOGNAME=session.name, **session.env())
1047
+ extra = {'user': session.uid, 'group': session.gid, 'extra_groups': session.groups}
1048
+ for cmd in _pipe_commands(rate):
1049
+ if not shutil.which(cmd[0]):
1050
+ continue
1051
+ args = dict(stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env)
1052
+ try:
1053
+ try:
1054
+ proc = subprocess.Popen(cmd, **args, **extra)
1055
+ except TypeError:
1056
+ proc = subprocess.Popen(cmd, **args)
1057
+ except OSError:
1058
+ continue
1059
+ time.sleep(0.15)
1060
+ if proc.poll() is None:
1061
+ self.p = proc
1062
+ return
1063
+ raise OSError('no command line audio player found')
1064
+
1065
+ def write(self, data):
1066
+ self.p.stdin.write(data)
1067
+ self.p.stdin.flush()
1068
+
1069
+ def close(self):
1070
+ p, self.p = self.p, None
1071
+ if p is None:
1072
+ return
1073
+ try:
1074
+ p.stdin.close()
1075
+ except Exception:
1076
+ pass
1077
+ try:
1078
+ p.terminate()
1079
+ p.wait(timeout=1.0)
1080
+ except Exception:
1081
+ try:
1082
+ p.kill()
1083
+ except Exception:
1084
+ pass
1085
+
1086
+
1087
+ _BACKENDS = {'winmm': _WinMM, 'coreaudio': _CoreAudio, 'pulse': _Pulse, 'alsa': _Alsa, 'pipe': _Pipe, 'null': _Null}
1088
+
1089
+
1090
+ def _chain():
1091
+ if sys.platform == 'win32':
1092
+ return [_WinMM]
1093
+ if sys.platform == 'darwin':
1094
+ return [_CoreAudio]
1095
+ if _session_user() is not None:
1096
+ # as root, the pipe player (run as the session user) is the most dependable route into the user's audio server
1097
+ return [_Pulse, _Pipe, _Alsa]
1098
+ return [_Pulse, _Alsa, _Pipe]
1099
+
1100
+
1101
+ def _open(spec, rate, block):
1102
+ if callable(spec):
1103
+ return spec(rate, block)
1104
+ if spec in (None, 'auto'):
1105
+ candidates = _chain()
1106
+ elif spec in _BACKENDS:
1107
+ candidates = [_BACKENDS[spec]]
1108
+ else:
1109
+ raise ValueError(f'unknown sound backend: {spec!r}')
1110
+ with _session_env(_session_user()):
1111
+ for cls in candidates:
1112
+ try:
1113
+ return cls(rate, block)
1114
+ except Exception:
1115
+ continue
1116
+ return _Null(rate, block)
1117
+
1118
+
1119
+ def _name(backend):
1120
+ return getattr(backend, 'name', type(backend).__name__)
1121
+
1122
+
1123
+ def _atexit(ref):
1124
+ mixer = ref()
1125
+ if mixer is not None:
1126
+ mixer.close()
1127
+
1128
+
1129
+ class Mixer:
1130
+ def __init__(self, rate=_RATE, block=512, backend=None, volume=1.0, idle=1.0, max_voices=32,
1131
+ listener=None, pan_width=40.0, hear_range=120.0, rolloff=1.0, aspect=2.0, ffmpeg=None):
1132
+ if not 8000 <= rate <= 192000:
1133
+ raise ValueError('rate must be between 8000 and 192000')
1134
+ if block < 64:
1135
+ raise ValueError('block must be at least 64 frames')
1136
+ if not (backend is None or callable(backend) or backend == 'auto' or backend in _BACKENDS):
1137
+ raise ValueError(f'unknown sound backend: {backend!r}')
1138
+ self.rate = int(rate)
1139
+ self.block = int(block)
1140
+ self.idle = float(idle)
1141
+ self.max_voices = int(max_voices)
1142
+ if ffmpeg:
1143
+ set_ffmpeg(ffmpeg)
1144
+ self.listener = listener
1145
+ self.pan_width = pan_width
1146
+ self.hear_range = hear_range
1147
+ self.rolloff = rolloff
1148
+ self.aspect = aspect
1149
+ self._spec = backend
1150
+ self._volume = max(0.0, float(volume))
1151
+ self._voices = []
1152
+ self._cache = {}
1153
+ self._lock = threading.RLock()
1154
+ self._stop = threading.Event()
1155
+ self._thread = None
1156
+ self._backend_name = None
1157
+ self._paused = False
1158
+ self._silence = bytes(self.block * 4)
1159
+ atexit.register(_atexit, weakref.ref(self))
1160
+
1161
+ @property
1162
+ def volume(self):
1163
+ return self._volume
1164
+ @volume.setter
1165
+ def volume(self, value):
1166
+ self._volume = max(0.0, float(value))
1167
+
1168
+ @property
1169
+ def backend(self):
1170
+ return self._backend_name
1171
+
1172
+ @property
1173
+ def voices(self):
1174
+ with self._lock:
1175
+ return [v for v in self._voices if not v.done]
1176
+
1177
+ @property
1178
+ def paused(self):
1179
+ return self._paused
1180
+
1181
+ @property
1182
+ def decoders(self):
1183
+ return decoders()
1184
+
1185
+ def probe(self):
1186
+ if callable(self._spec):
1187
+ return getattr(self._spec, 'name', 'custom')
1188
+ backend = _open(self._spec, self.rate, self.block)
1189
+ name = _name(backend)
1190
+ try:
1191
+ backend.close()
1192
+ except Exception:
1193
+ pass
1194
+ return name
1195
+
1196
+ def load(self, src):
1197
+ key = None
1198
+ if isinstance(src, (str, os.PathLike)):
1199
+ key = os.path.abspath(os.fspath(src))
1200
+ cached = self._cache.get(key)
1201
+ if cached is not None:
1202
+ return cached
1203
+ snd = Sound.load(src, self.rate).at_rate(self.rate)
1204
+ if key is not None:
1205
+ self._cache[key] = snd
1206
+ return snd
1207
+
1208
+ def tone(self, freq=440.0, duration=0.2, wave='sine', volume=0.5, end_freq=None, attack=0.005, release=0.02):
1209
+ return Sound.tone(freq, duration, wave, volume, end_freq, attack, release, self.rate)
1210
+
1211
+ def melody(self, notes, wave='square', volume=0.4, beat=1.0, attack=0.005, release=0.03):
1212
+ return Sound.melody(notes, wave, volume, beat, attack, release, self.rate)
1213
+
1214
+ def play(self, sound, volume=1.0, pan=0.0, loop=False, fade=0.0, on_end=None, speed=1.0, pitch=0.0, at=None,
1215
+ loop_start=None, loop_end=None):
1216
+ snd = sound if isinstance(sound, Sound) else self.load(sound)
1217
+ voice = Voice(self, snd.at_rate(self.rate), volume, pan, loop, on_end, speed * 2.0 ** (pitch / 12.0), at,
1218
+ loop_start, loop_end)
1219
+ if fade > 0:
1220
+ voice._volume = 0.0
1221
+ voice._target = volume
1222
+ voice._rate = max(0.0, float(volume)) / fade
1223
+ with self._lock:
1224
+ self._voices.append(voice)
1225
+ excess = len(self._voices) - self.max_voices
1226
+ if excess > 0:
1227
+ for old in [v for v in self._voices if not v.done and v is not voice][:excess]:
1228
+ old.done = True
1229
+ self._ensure()
1230
+ return voice
1231
+
1232
+ def beep(self, freq=880.0, duration=0.12, wave='square', volume=0.3, **kwargs):
1233
+ play = {k: kwargs.pop(k) for k in ('pan', 'fade', 'on_end', 'speed', 'pitch', 'at', 'loop', 'loop_start', 'loop_end') if k in kwargs}
1234
+ return self.play(self.tone(freq, duration, wave, volume, **kwargs), **play)
1235
+
1236
+ def stop_all(self, fade=0.0):
1237
+ with self._lock:
1238
+ voices = list(self._voices)
1239
+ for v in voices:
1240
+ v.kill(fade)
1241
+
1242
+ def pause(self):
1243
+ self._paused = True
1244
+
1245
+ def resume(self):
1246
+ self._paused = False
1247
+ self._ensure()
1248
+
1249
+ def close(self):
1250
+ with self._lock:
1251
+ thread = self._thread
1252
+ self._thread = None
1253
+ voices = self._voices
1254
+ self._voices = []
1255
+ for v in voices:
1256
+ v.done = True
1257
+ self._stop.set()
1258
+ if thread is not None and thread is not threading.current_thread():
1259
+ thread.join(2.0)
1260
+ self._stop.clear()
1261
+
1262
+ def _ensure(self):
1263
+ with self._lock:
1264
+ thread = self._thread
1265
+ if thread is not None and thread.is_alive():
1266
+ return
1267
+ thread = threading.Thread(target=self._run, name='ttyengine-sound', daemon=True)
1268
+ self._thread = thread
1269
+ thread.start()
1270
+
1271
+ def _run(self):
1272
+ backend = _open(self._spec, self.rate, self.block)
1273
+ self._backend_name = _name(backend)
1274
+ span = self.block / float(self.rate)
1275
+ idle = 0.0
1276
+ try:
1277
+ while not self._stop.is_set():
1278
+ with self._lock:
1279
+ active = not self._paused and any(not v.paused for v in self._voices)
1280
+ if not active and idle >= self.idle:
1281
+ if self._thread is threading.current_thread():
1282
+ self._thread = None
1283
+ break
1284
+ if active:
1285
+ idle = 0.0
1286
+ try:
1287
+ data = self._render()
1288
+ except Exception:
1289
+ data = self._silence
1290
+ else:
1291
+ idle += span
1292
+ data = self._silence
1293
+ try:
1294
+ backend.write(data)
1295
+ except Exception:
1296
+ try:
1297
+ backend.close()
1298
+ except Exception:
1299
+ pass
1300
+ backend = _Null(self.rate, self.block)
1301
+ self._backend_name = 'null'
1302
+ finally:
1303
+ try:
1304
+ backend.close()
1305
+ except Exception:
1306
+ pass
1307
+
1308
+ def _spatial(self, voice):
1309
+ try:
1310
+ src = _locate(voice._at)
1311
+ except Exception:
1312
+ src = voice._lastpos
1313
+ if src is None:
1314
+ return 0.0, 1.0
1315
+ voice._lastpos = src
1316
+ try:
1317
+ lis = _locate(self.listener)
1318
+ except Exception:
1319
+ lis = None
1320
+ if lis is None:
1321
+ lis = (0.0, 0.0)
1322
+ dx = src[0] - lis[0]
1323
+ dy = (src[1] - lis[1]) * self.aspect
1324
+ width = voice.pan_width if voice.pan_width is not None else self.pan_width
1325
+ reach = voice.hear_range if voice.hear_range is not None else self.hear_range
1326
+ pan = max(-1.0, min(1.0, dx / width)) if width else 0.0
1327
+ gain = 1.0
1328
+ if reach:
1329
+ gain = max(0.0, 1.0 - math.hypot(dx, dy) / reach) ** self.rolloff
1330
+ return pan, gain
1331
+
1332
+ def _render(self):
1333
+ frames = self.block
1334
+ with self._lock:
1335
+ voices = list(self._voices)
1336
+ left = [0] * frames
1337
+ right = [0] * frames
1338
+ dt = frames / float(self.rate)
1339
+ master = self._volume
1340
+ ended = []
1341
+ completed = []
1342
+ for v in voices:
1343
+ if v.done:
1344
+ ended.append(v)
1345
+ continue
1346
+ v._apply_pending()
1347
+ if v.paused:
1348
+ continue
1349
+ if v._rate:
1350
+ step = v._rate * dt
1351
+ gap = v._target - v._volume
1352
+ if abs(gap) <= step:
1353
+ v._volume = v._target
1354
+ v._rate = 0.0
1355
+ if v._stop_after == 'rewind':
1356
+ v._halt()
1357
+ v._apply_pending()
1358
+ continue
1359
+ if v._stop_after:
1360
+ v.done = True
1361
+ ended.append(v)
1362
+ continue
1363
+ else:
1364
+ v._volume += step if gap > 0 else -step
1365
+ spatial_pan = 0.0
1366
+ spatial_gain = 1.0
1367
+ if v._at is not None:
1368
+ spatial_pan, spatial_gain = self._spatial(v)
1369
+ gain = v._volume * master * spatial_gain * 256.0
1370
+ pan = max(-1.0, min(1.0, v._pan + spatial_pan))
1371
+ gl = int(gain * (1.0 if pan <= 0 else 1.0 - pan))
1372
+ gr = int(gain * (1.0 if pan >= 0 else 1.0 + pan))
1373
+ speed = v._speed
1374
+ snd = v._sound
1375
+ total = snd.frames
1376
+ ls, le = v._loop_frames(total)
1377
+ n = 0
1378
+ while n < frames:
1379
+ looping = total and (v._left is None or v._left > 1)
1380
+ # while loops remain, a pass ends at the loop end; the final pass plays on to the end of the sound
1381
+ limit = le if looping else total
1382
+ if v._pos >= limit:
1383
+ completed.append(v)
1384
+ if not looping:
1385
+ v._finished = True
1386
+ v.done = True
1387
+ ended.append(v)
1388
+ break
1389
+ if v._left is not None:
1390
+ v._left -= 1
1391
+ v._pos = ls + (v._pos - le) % (le - ls)
1392
+ continue
1393
+ if speed == 1.0:
1394
+ start = int(v._pos)
1395
+ take = min(limit - start, frames - n)
1396
+ end = start + take
1397
+ if gl:
1398
+ left[n:n + take] = [m + ((s * gl) >> 8) for m, s in zip(left[n:n + take], snd.left[start:end])]
1399
+ if gr:
1400
+ right[n:n + take] = [m + ((s * gr) >> 8) for m, s in zip(right[n:n + take], snd.right[start:end])]
1401
+ v._pos = end
1402
+ n += take
1403
+ else:
1404
+ pos = v._pos
1405
+ cnt = int((limit - pos) / speed)
1406
+ if pos + cnt * speed < limit:
1407
+ cnt += 1
1408
+ take = max(1, min(cnt, frames - n))
1409
+ if gl or gr:
1410
+ ps = [pos + k * speed for k in range(take)]
1411
+ ii = [int(q) for q in ps]
1412
+ fr = [q - i for q, i in zip(ps, ii)]
1413
+ last = total - 1
1414
+ jj = [i + 1 if i < last else last for i in ii]
1415
+ if gl:
1416
+ src = snd.left
1417
+ left[n:n + take] = [m + (int(src[i] + (src[j] - src[i]) * f) * gl >> 8)
1418
+ for m, i, j, f in zip(left[n:n + take], ii, jj, fr)]
1419
+ if gr:
1420
+ src = snd.right
1421
+ right[n:n + take] = [m + (int(src[i] + (src[j] - src[i]) * f) * gr >> 8)
1422
+ for m, i, j, f in zip(right[n:n + take], ii, jj, fr)]
1423
+ v._pos = pos + take * speed
1424
+ n += take
1425
+ for v in completed:
1426
+ v._emit_complete()
1427
+ ended = [v for v in ended if v.done]
1428
+ if ended:
1429
+ gone = set(map(id, ended))
1430
+ with self._lock:
1431
+ self._voices = [v for v in self._voices if id(v) not in gone]
1432
+ for v in ended:
1433
+ v._detach()
1434
+ if v.on_end:
1435
+ try:
1436
+ _call(v.on_end, v)
1437
+ except Exception:
1438
+ pass
1439
+ return _pack(left, right)