zynko 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.
zynko/player.py ADDED
@@ -0,0 +1,413 @@
1
+ """Plays audio into a voice connection.
2
+
3
+ Files and links through ffmpeg (any format and stream it opens: mp3, ogg, wav, radio
4
+ streams...), encoded streams, raw PCM, or tones. A queue, pause, skip, volume. Audio goes out
5
+ as 48 kHz stereo 16-bit, in 20 ms frames, paced in real time by the voice connection.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import inspect
12
+ import math
13
+ import os
14
+ import re
15
+ import sys
16
+ from array import array
17
+ from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Callable, Iterable
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Generic, Protocol, TypeVar, Union, overload
20
+
21
+ from ._events import Emitter
22
+
23
+ __all__ = [
24
+ "SAMPLE_RATE",
25
+ "CHANNELS",
26
+ "FRAME_SAMPLES",
27
+ "FRAME_BYTES",
28
+ "PCM",
29
+ "Stream",
30
+ "AudioInput",
31
+ "Track",
32
+ "AudioPlayer",
33
+ "FrameSink",
34
+ "tone",
35
+ "scale_frame",
36
+ "frames",
37
+ ]
38
+
39
+ SAMPLE_RATE = 48_000
40
+ CHANNELS = 2
41
+ #: 20 ms per channel.
42
+ FRAME_SAMPLES = 960
43
+ FRAME_BYTES = FRAME_SAMPLES * CHANNELS * 2
44
+
45
+ _CHUNK = 64 * 1024
46
+
47
+ #: What a byte source may be: bytes, an (async) iterable of bytes, or a file-like object
48
+ #: with ``read`` (a file, ``sys.stdin.buffer``, an ``asyncio.StreamReader``).
49
+ ByteSource = Union[bytes, bytearray, memoryview, Iterable[bytes], AsyncIterable[bytes], Any]
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class PCM:
54
+ """Raw PCM already at 48 kHz, stereo, 16-bit little-endian. Not decoded by ffmpeg."""
55
+
56
+ source: ByteSource
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class Stream:
61
+ """Encoded audio (an mp3 download, a pipe), decoded by ffmpeg."""
62
+
63
+ source: ByteSource
64
+
65
+
66
+ #: What to play: a path or an http(s) link (opened by ffmpeg), a :class:`Stream` or a :class:`PCM`.
67
+ AudioInput = Union[str, "os.PathLike[str]", PCM, Stream]
68
+
69
+ M = TypeVar("M")
70
+ F = TypeVar("F", bound=Callable[..., Any])
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class Track(Generic[M]):
75
+ input: AudioInput
76
+ #: Whatever you want back in events: a title, who asked for it...
77
+ meta: M | None = None
78
+
79
+
80
+ class FrameSink(Protocol):
81
+ """Where the player writes frames: the voice connection."""
82
+
83
+ async def capture(self, frame: bytes) -> None: ...
84
+
85
+ def flush(self) -> None:
86
+ """Drops what is buffered, so a skip or pause is heard at once."""
87
+
88
+
89
+ def tone(hz: float = 440, seconds: float = 1, volume: float = 0.2) -> PCM:
90
+ """A sine wave, for testing: ``player.play(tone(440, 2))``."""
91
+
92
+ def gen() -> Iterable[bytes]:
93
+ total = round(seconds * SAMPLE_RATE)
94
+ step = 2 * math.pi * hz / SAMPLE_RATE
95
+ amp = volume * 32767
96
+ for start in range(0, total, FRAME_SAMPLES):
97
+ n = min(FRAME_SAMPLES, total - start)
98
+ buf = array("h", bytes(n * CHANNELS * 2))
99
+ for i in range(n):
100
+ v = round(math.sin(step * (start + i)) * amp)
101
+ buf[i * 2] = v
102
+ buf[i * 2 + 1] = v
103
+ if sys.byteorder == "big":
104
+ buf.byteswap()
105
+ yield buf.tobytes()
106
+
107
+ return PCM(gen())
108
+
109
+
110
+ def scale_frame(frame: bytes, volume: float) -> bytes:
111
+ """Multiplies 16-bit little-endian samples by ``volume``, clipping."""
112
+ a = array("h")
113
+ a.frombytes(frame)
114
+ if sys.byteorder == "big":
115
+ a.byteswap()
116
+ out = array("h", [max(-32768, min(32767, round(s * volume))) for s in a])
117
+ if sys.byteorder == "big":
118
+ out.byteswap()
119
+ return out.tobytes()
120
+
121
+
122
+ def frames(carry: bytes, chunk: bytes) -> tuple[list[bytes], bytes]:
123
+ """Cuts ``carry + chunk`` into whole 20 ms frames; returns them and what is left over."""
124
+ data = carry + chunk if carry else bytes(chunk)
125
+ n = len(data) // FRAME_BYTES * FRAME_BYTES
126
+ return [data[i : i + FRAME_BYTES] for i in range(0, n, FRAME_BYTES)], data[n:]
127
+
128
+
129
+ async def _iter_bytes(src: ByteSource) -> AsyncIterator[bytes]:
130
+ """Any byte source, as an async iterator of chunks."""
131
+ if isinstance(src, (bytes, bytearray, memoryview)):
132
+ mv = memoryview(src).cast("B")
133
+ for i in range(0, len(mv), _CHUNK):
134
+ yield bytes(mv[i : i + _CHUNK])
135
+ return
136
+ if hasattr(src, "__aiter__"):
137
+ async for chunk in src:
138
+ yield bytes(chunk)
139
+ return
140
+ read = getattr(src, "read", None)
141
+ if read is not None:
142
+ if inspect.iscoroutinefunction(read):
143
+ while chunk := await read(_CHUNK):
144
+ yield bytes(chunk)
145
+ else:
146
+ while chunk := await asyncio.to_thread(read, _CHUNK):
147
+ yield bytes(chunk)
148
+ return
149
+ for chunk in src:
150
+ yield bytes(chunk)
151
+ await asyncio.sleep(0)
152
+
153
+
154
+ class _Run:
155
+ """One track being played: its processes, and whether it was cut short."""
156
+
157
+ def __init__(self) -> None:
158
+ self.stopped = False
159
+ self.procs: list[asyncio.subprocess.Process] = []
160
+ self.tasks: list[asyncio.Task[Any]] = []
161
+
162
+ def abort(self) -> None:
163
+ self.stopped = True
164
+ self.kill()
165
+
166
+ def kill(self) -> None:
167
+ for p in self.procs:
168
+ if p.returncode is None:
169
+ try:
170
+ p.kill()
171
+ except (ProcessLookupError, OSError):
172
+ pass
173
+ for t in self.tasks:
174
+ t.cancel()
175
+
176
+ async def reap(self) -> None:
177
+ """Waits for the killed processes, so their pipes close cleanly."""
178
+ for p in self.procs:
179
+ try:
180
+ # Reading what is left lets the pipe reach its end and close.
181
+ if p.stdout is not None:
182
+ await asyncio.wait_for(_drain(p.stdout), 5)
183
+ await asyncio.wait_for(p.wait(), 5)
184
+ except (asyncio.TimeoutError, ProcessLookupError, OSError):
185
+ pass
186
+ if self.tasks:
187
+ await asyncio.gather(*self.tasks, return_exceptions=True)
188
+
189
+
190
+ class AudioPlayer(Generic[M]):
191
+ """A queue of tracks played one after another.
192
+
193
+ Events: ``start(track)``, ``finish(track)``, ``error(error, track)`` and ``idle()`` (nothing
194
+ left in the queue).
195
+ """
196
+
197
+ def __init__(self, sink: FrameSink, *, ffmpeg: str | None = None, ytdlp: bool | str = False) -> None:
198
+ self._sink = sink
199
+ #: ffmpeg's path. Default: FFMPEG_PATH, or ``ffmpeg`` on the PATH.
200
+ self.ffmpeg = ffmpeg or os.environ.get("FFMPEG_PATH") or "ffmpeg"
201
+ #: Hand http(s) links to yt-dlp first (its path, or True for ``yt-dlp`` on the PATH).
202
+ self.ytdlp = ytdlp
203
+ #: Played after the current one, in order.
204
+ self.queue: list[Track[M]] = []
205
+ #: What is playing now.
206
+ self.current: Track[M] | None = None
207
+ self.paused = False
208
+ self._volume = 1.0
209
+ self._events = Emitter("zynko.player")
210
+ self._run: _Run | None = None
211
+ self._resumed = asyncio.Event()
212
+ self._resumed.set()
213
+ self._task: asyncio.Task[None] | None = None
214
+
215
+ # ---------- events ----------
216
+
217
+ @overload
218
+ def on(self, event: str) -> Callable[[F], F]: ...
219
+ @overload
220
+ def on(self, event: str, handler: F) -> F: ...
221
+ def on(self, event: str, handler: F | None = None) -> Any:
222
+ return self._events.on(event, handler) if handler is not None else self._events.on(event)
223
+
224
+ def off(self, event: str, handler: Callable[..., Any]) -> None:
225
+ self._events.off(event, handler)
226
+
227
+ async def wait_for(self, event: str, check: Callable[..., bool] | None = None, timeout: float | None = None) -> Any:
228
+ return await self._events.wait_for(event, check, timeout)
229
+
230
+ # ---------- controls ----------
231
+
232
+ @property
233
+ def volume(self) -> float:
234
+ """0 (silent) to 2 (twice as loud); 1 is as it came."""
235
+ return self._volume
236
+
237
+ @volume.setter
238
+ def volume(self, v: float) -> None:
239
+ self._volume = max(0.0, min(2.0, float(v)))
240
+
241
+ @property
242
+ def playing(self) -> bool:
243
+ return self._task is not None and not self._task.done()
244
+
245
+ def play(self, input: AudioInput, meta: M | None = None) -> Track[M]:
246
+ """Plays now, in place of what was playing (the queue stays)."""
247
+ track: Track[M] = Track(input, meta)
248
+ self.queue.insert(0, track)
249
+ if self.playing:
250
+ self.skip()
251
+ else:
252
+ self._start()
253
+ return track
254
+
255
+ def enqueue(self, input: AudioInput, meta: M | None = None) -> Track[M]:
256
+ """Adds to the end of the queue; starts if nothing is playing."""
257
+ track: Track[M] = Track(input, meta)
258
+ self.queue.append(track)
259
+ if not self.playing:
260
+ self._start()
261
+ return track
262
+
263
+ def skip(self) -> None:
264
+ """Goes to the next in the queue."""
265
+ if self._run is not None:
266
+ self._run.abort()
267
+ self._sink.flush()
268
+ self.resume()
269
+
270
+ def stop(self) -> None:
271
+ """Stops and empties the queue."""
272
+ self.queue.clear()
273
+ self.skip()
274
+
275
+ def pause(self) -> None:
276
+ if self.paused or self.current is None:
277
+ return
278
+ self.paused = True
279
+ self._resumed.clear()
280
+ self._sink.flush()
281
+
282
+ def resume(self) -> None:
283
+ self.paused = False
284
+ self._resumed.set()
285
+
286
+ # ---------- the loop ----------
287
+
288
+ def _start(self) -> None:
289
+ self._task = asyncio.ensure_future(self._loop())
290
+
291
+ async def _loop(self) -> None:
292
+ try:
293
+ while self.queue:
294
+ track = self.queue.pop(0)
295
+ self.current = track
296
+ self._events.emit("start", track)
297
+ try:
298
+ await self._play_one(track)
299
+ self._events.emit("finish", track)
300
+ except asyncio.CancelledError:
301
+ raise
302
+ except Exception as e: # noqa: BLE001 - reported as an event, the queue goes on
303
+ self._events.emit("error", e, track)
304
+ finally:
305
+ self.current = None
306
+ self.paused = False
307
+ self._resumed.set()
308
+ self._events.emit("idle")
309
+
310
+ async def _play_one(self, track: Track[M]) -> None:
311
+ run = _Run()
312
+ self._run = run
313
+ carry = b""
314
+ pcm = self._open(track.input, run)
315
+ try:
316
+ async for chunk in pcm:
317
+ if run.stopped:
318
+ break
319
+ out, carry = frames(carry, chunk)
320
+ for frame in out:
321
+ while self.paused and not run.stopped:
322
+ await self._resumed.wait()
323
+ if run.stopped:
324
+ break
325
+ await self._send(frame)
326
+ if run.stopped:
327
+ break
328
+ # The last partial frame, padded with silence.
329
+ if not run.stopped and carry:
330
+ await self._send(carry[: len(carry) - len(carry) % 2].ljust(FRAME_BYTES, b"\0"))
331
+ if not run.stopped:
332
+ for p in run.procs:
333
+ code = await p.wait()
334
+ if code != 0 and not run.stopped:
335
+ raise RuntimeError(f"{getattr(p, '_zynko_name', 'processo')} terminou com código {code}")
336
+ finally:
337
+ if self._run is run:
338
+ self._run = None
339
+ run.kill()
340
+ await pcm.aclose()
341
+ await run.reap()
342
+
343
+ async def _send(self, frame: bytes) -> None:
344
+ if self._volume != 1:
345
+ frame = scale_frame(frame, self._volume)
346
+ await self._sink.capture(frame)
347
+
348
+ async def _spawn(self, run: _Run, name: str, args: list[str], stdin: bool) -> asyncio.subprocess.Process:
349
+ try:
350
+ p = await asyncio.create_subprocess_exec(
351
+ *args,
352
+ stdin=asyncio.subprocess.PIPE if stdin else asyncio.subprocess.DEVNULL,
353
+ stdout=asyncio.subprocess.PIPE,
354
+ stderr=None,
355
+ )
356
+ except FileNotFoundError:
357
+ raise RuntimeError(f"{name} não encontrado: instale-o ou indique o caminho") from None
358
+ p._zynko_name = name # type: ignore[attr-defined]
359
+ run.procs.append(p)
360
+ return p
361
+
362
+ async def _open(self, input: AudioInput, run: _Run) -> AsyncGenerator[bytes, None]:
363
+ """The input as raw PCM chunks."""
364
+ if isinstance(input, PCM):
365
+ async for chunk in _iter_bytes(input.source):
366
+ yield chunk
367
+ return
368
+ out = ["-f", "s16le", "-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS), "-loglevel", "error", "pipe:1"]
369
+ feed: ByteSource | asyncio.StreamReader | None = None
370
+ if isinstance(input, Stream):
371
+ feed = input.source
372
+ args = ["-i", "pipe:0", *out]
373
+ else:
374
+ target = os.fspath(input)
375
+ http = re.match(r"^https?://", target, re.I) is not None
376
+ if http and self.ytdlp:
377
+ ytdlp = self.ytdlp if isinstance(self.ytdlp, str) else "yt-dlp"
378
+ y = await self._spawn(
379
+ run, "yt-dlp", [ytdlp, "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", target], False
380
+ )
381
+ assert y.stdout is not None
382
+ feed = y.stdout
383
+ args = ["-i", "pipe:0", *out]
384
+ else:
385
+ reconnect = ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] if http else []
386
+ args = [*reconnect, "-i", target, *out]
387
+ f = await self._spawn(run, "ffmpeg", [self.ffmpeg, "-hide_banner", *([] if feed is not None else ["-nostdin"]), *args], feed is not None)
388
+ if feed is not None:
389
+ run.tasks.append(asyncio.ensure_future(_pipe_into(feed, f)))
390
+ assert f.stdout is not None
391
+ while chunk := await f.stdout.read(_CHUNK):
392
+ yield chunk
393
+
394
+
395
+ async def _drain(reader: asyncio.StreamReader) -> None:
396
+ while await reader.read(_CHUNK):
397
+ pass
398
+
399
+
400
+ async def _pipe_into(source: Any, f: asyncio.subprocess.Process) -> None:
401
+ stdin = f.stdin
402
+ assert stdin is not None
403
+ try:
404
+ async for chunk in _iter_bytes(source):
405
+ stdin.write(chunk)
406
+ await stdin.drain()
407
+ except (ConnectionError, BrokenPipeError, OSError):
408
+ pass # ffmpeg went away (skip)
409
+ finally:
410
+ try:
411
+ stdin.close()
412
+ except (ConnectionError, OSError):
413
+ pass
zynko/py.typed ADDED
File without changes