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/__init__.py +45 -0
- ttyengine/ansi.py +201 -0
- ttyengine/ascii_styler.py +473 -0
- ttyengine/components.py +4718 -0
- ttyengine/compositor.py +780 -0
- ttyengine/errors.py +181 -0
- ttyengine/figlet-LICENSE.txt +27 -0
- ttyengine/figlet.py +430 -0
- ttyengine/fonts.zip +0 -0
- ttyengine/image_reader.py +813 -0
- ttyengine/input_handler.py +814 -0
- ttyengine/launcher.py +822 -0
- ttyengine/region.py +180 -0
- ttyengine/scene.py +614 -0
- ttyengine/sound.py +1439 -0
- ttyengine/terminal.py +283 -0
- ttyengine-0.1.0.dist-info/METADATA +903 -0
- ttyengine-0.1.0.dist-info/RECORD +21 -0
- ttyengine-0.1.0.dist-info/WHEEL +5 -0
- ttyengine-0.1.0.dist-info/licenses/LICENSE +373 -0
- ttyengine-0.1.0.dist-info/top_level.txt +1 -0
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)
|