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/__init__.py +74 -0
- zynko/_events.py +123 -0
- zynko/bits.py +62 -0
- zynko/client.py +471 -0
- zynko/gateway.py +171 -0
- zynko/interaction.py +106 -0
- zynko/message.py +121 -0
- zynko/models.py +387 -0
- zynko/permissions.py +52 -0
- zynko/player.py +413 -0
- zynko/py.typed +0 -0
- zynko/rest.py +309 -0
- zynko/voice.py +298 -0
- zynko-0.1.0.dist-info/METADATA +67 -0
- zynko-0.1.0.dist-info/RECORD +16 -0
- zynko-0.1.0.dist-info/WHEEL +4 -0
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
|