pymumble-rev 0.1.0__tar.gz
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.
- pymumble_rev-0.1.0/.gitignore +11 -0
- pymumble_rev-0.1.0/LICENSE +21 -0
- pymumble_rev-0.1.0/PKG-INFO +251 -0
- pymumble_rev-0.1.0/README.md +225 -0
- pymumble_rev-0.1.0/README.zh-CN.md +35 -0
- pymumble_rev-0.1.0/proto/LICENSE +31 -0
- pymumble_rev-0.1.0/proto/Mumble.proto +621 -0
- pymumble_rev-0.1.0/proto/MumbleUDP.proto +85 -0
- pymumble_rev-0.1.0/pyproject.toml +77 -0
- pymumble_rev-0.1.0/scripts/gen_proto.py +71 -0
- pymumble_rev-0.1.0/src/pymumble_rev/__init__.py +56 -0
- pymumble_rev-0.1.0/src/pymumble_rev/_proto/MumbleUDP_pb2.py +39 -0
- pymumble_rev-0.1.0/src/pymumble_rev/_proto/MumbleUDP_pb2.pyi +42 -0
- pymumble_rev-0.1.0/src/pymumble_rev/_proto/Mumble_pb2.py +113 -0
- pymumble_rev-0.1.0/src/pymumble_rev/_proto/Mumble_pb2.pyi +564 -0
- pymumble_rev-0.1.0/src/pymumble_rev/_proto/__init__.py +0 -0
- pymumble_rev-0.1.0/src/pymumble_rev/audio.py +359 -0
- pymumble_rev-0.1.0/src/pymumble_rev/client.py +449 -0
- pymumble_rev-0.1.0/src/pymumble_rev/connection.py +557 -0
- pymumble_rev-0.1.0/src/pymumble_rev/errors.py +29 -0
- pymumble_rev-0.1.0/src/pymumble_rev/events.py +136 -0
- pymumble_rev-0.1.0/src/pymumble_rev/framing.py +51 -0
- pymumble_rev-0.1.0/src/pymumble_rev/messages.py +67 -0
- pymumble_rev-0.1.0/src/pymumble_rev/opus.py +219 -0
- pymumble_rev-0.1.0/src/pymumble_rev/py.typed +0 -0
- pymumble_rev-0.1.0/src/pymumble_rev/state.py +262 -0
- pymumble_rev-0.1.0/src/pymumble_rev/sync.py +356 -0
- pymumble_rev-0.1.0/tests/__init__.py +0 -0
- pymumble_rev-0.1.0/tests/data/cert.pem +11 -0
- pymumble_rev-0.1.0/tests/data/key.pem +5 -0
- pymumble_rev-0.1.0/tests/fake_server.py +129 -0
- pymumble_rev-0.1.0/tests/helpers.py +69 -0
- pymumble_rev-0.1.0/tests/test_audio.py +328 -0
- pymumble_rev-0.1.0/tests/test_client.py +335 -0
- pymumble_rev-0.1.0/tests/test_connection.py +488 -0
- pymumble_rev-0.1.0/tests/test_framing.py +39 -0
- pymumble_rev-0.1.0/tests/test_integration.py +242 -0
- pymumble_rev-0.1.0/tests/test_opus.py +89 -0
- pymumble_rev-0.1.0/tests/test_sync.py +229 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aiden Wong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pymumble-rev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asyncio Mumble client library for Python — sans-IO protocol core and pull-based Opus audio, built for music and voice bots.
|
|
5
|
+
Project-URL: Repository, https://github.com/AidenOfficial/PyMumble
|
|
6
|
+
Project-URL: Changelog, https://github.com/AidenOfficial/PyMumble/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: Issues, https://github.com/AidenOfficial/PyMumble/issues
|
|
8
|
+
Author: Aiden Wong
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: proto/LICENSE
|
|
12
|
+
Keywords: asyncio,bot,mumble,opus,voip
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Communications :: Conferencing
|
|
21
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: protobuf>=5.29
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pymumble-rev
|
|
28
|
+
|
|
29
|
+
**Asyncio Mumble client library for Python — sans-IO protocol core and pull-based Opus audio, built for music and voice bots.**
|
|
30
|
+
|
|
31
|
+
[简体中文](README.zh-CN.md)
|
|
32
|
+
|
|
33
|
+
[](https://github.com/AidenOfficial/PyMumble/actions/workflows/ci.yml)
|
|
34
|
+
[](https://pypi.org/project/pymumble-rev/)
|
|
35
|
+
[](https://pypi.org/project/pymumble-rev/)
|
|
36
|
+
|
|
37
|
+
> **Status: beta.** Every feature in scope is implemented and tested against a real Mumble
|
|
38
|
+
> server in CI. Until 1.0 a minor release may still change the API; such changes are listed in
|
|
39
|
+
> [CHANGELOG.md](CHANGELOG.md).
|
|
40
|
+
|
|
41
|
+
An asyncio client for the Mumble voice protocol, designed for long-running bots that stream audio.
|
|
42
|
+
|
|
43
|
+
- **Sans-IO core**: the protocol is a pure state machine (`bytes in → events out`, `commands → bytes out`), testable without sockets.
|
|
44
|
+
- **Pull-based audio**: the client asks your source for the next 20 ms frame, so there is no buffer to poll and skips take effect immediately.
|
|
45
|
+
- **Small on purpose**: client only, Opus over the TLS tunnel, Mumble 1.5+. No UDP/OCB2, no ACL editing, no positional audio.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
Requires Python 3.12+. Audio needs the system libopus (`apt install libopus0`,
|
|
50
|
+
`brew install opus`, ...); text-only bots do not.
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
pip install pymumble-rev
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
from pymumble_rev import ClientConfig, MumbleClient, events
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def main() -> None:
|
|
64
|
+
config = ClientConfig(username="dj-bot", client_type="bot")
|
|
65
|
+
async with MumbleClient("mumble.example.org", config) as client:
|
|
66
|
+
if music := client.state.find_channel("Music"):
|
|
67
|
+
await client.move(music.channel_id)
|
|
68
|
+
me = client.state.myself
|
|
69
|
+
await client.send_text(f"hello from {me.name}", channel_ids=[me.channel_id])
|
|
70
|
+
|
|
71
|
+
async for event in client.events():
|
|
72
|
+
match event:
|
|
73
|
+
case events.TextMessage(actor=int(actor), message="!ping"):
|
|
74
|
+
await client.send_text("pong", sessions=[actor])
|
|
75
|
+
case events.UserUpdated(user=user, changed=changed) if "channel_id" in changed:
|
|
76
|
+
print(f"{user.name} moved to {client.state.channels[user.channel_id].name}")
|
|
77
|
+
print("disconnected:", client.close_reason)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `client.state` is an immutable snapshot (users, channels, server limits); reading it from another
|
|
84
|
+
thread is safe.
|
|
85
|
+
- `events()` ends when the connection does, and `close_reason` says why (`kicked: ...`,
|
|
86
|
+
`no data from server for 20s`, ...). The client never reconnects by itself: put
|
|
87
|
+
`async with MumbleClient(...)` in your own retry loop, and each attempt starts from a fresh,
|
|
88
|
+
fully synced state.
|
|
89
|
+
- Commands are coroutines that wait for the socket to drain, so a slow link slows the sender down
|
|
90
|
+
instead of being mistaken for a disconnect. A server that stops answering pings for
|
|
91
|
+
`dead_after` seconds (default 20) is treated as gone.
|
|
92
|
+
- The server certificate is not verified by default, because nearly every Mumble server uses a
|
|
93
|
+
self-signed one. Pass `ssl_context=` to verify it, or
|
|
94
|
+
`default_ssl_context(certfile, keyfile)` to log in with a client certificate.
|
|
95
|
+
|
|
96
|
+
## Playing audio
|
|
97
|
+
|
|
98
|
+
The client pulls 20 ms of PCM (s16le, 48 kHz, mono or stereo) from a source every frame, encodes
|
|
99
|
+
it with the system libopus, and paces packets in real time. `PcmBuffer` lets any thread feed it:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import asyncio, subprocess
|
|
103
|
+
from pymumble_rev import ClientConfig, MumbleClient
|
|
104
|
+
from pymumble_rev.audio import PcmBuffer
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def play_file(client: MumbleClient, path: str) -> None:
|
|
108
|
+
buf = PcmBuffer(channels=2, capacity=0.5)
|
|
109
|
+
|
|
110
|
+
def feed() -> None: # runs in a worker thread
|
|
111
|
+
cmd = ["ffmpeg", "-v", "error", "-i", path, "-f", "s16le", "-ar", "48000", "-ac", "2", "-"]
|
|
112
|
+
with subprocess.Popen(cmd, stdout=subprocess.PIPE) as ff:
|
|
113
|
+
while chunk := ff.stdout.read(3840):
|
|
114
|
+
buf.write(chunk) # blocks while 0.5 s is queued: playback paces the reader
|
|
115
|
+
buf.close() # play what is queued, then end the transmission
|
|
116
|
+
|
|
117
|
+
feeder = asyncio.to_thread(feed)
|
|
118
|
+
await asyncio.gather(feeder, client.play(buf))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def main() -> None:
|
|
122
|
+
async with MumbleClient("mumble.example.org", ClientConfig(username="dj"), channels=2) as c:
|
|
123
|
+
c.volume = 0.6 # applied per frame with a short ramp, so changes are instant and click-free
|
|
124
|
+
await play_file(c, "song.mp3")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- `play(source)` switches immediately and returns a future that resolves when the source ends,
|
|
128
|
+
is replaced, or `stop_audio()` is called. `PcmBuffer.clear()` skips what is queued.
|
|
129
|
+
- Short underruns (a slow download) are bridged with silence for 200 ms; longer ones end the
|
|
130
|
+
transmission cleanly and a new one starts when data returns.
|
|
131
|
+
- The bitrate (default 96 kbit/s) is capped automatically to the server's bandwidth limit,
|
|
132
|
+
counting the TCP tunnel overhead the server charges per packet.
|
|
133
|
+
- libopus is loaded on the first `play()`; set `PYMUMBLE_REV_LIBOPUS` to point at a specific
|
|
134
|
+
library file.
|
|
135
|
+
|
|
136
|
+
### Whisper and shout
|
|
137
|
+
|
|
138
|
+
Audio normally goes to the channel you are in. A *voice target* sends it to specific users or
|
|
139
|
+
channels instead, wherever you sit:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from pymumble_rev import VoiceTarget
|
|
143
|
+
|
|
144
|
+
await client.set_voice_target(1, [VoiceTarget(channel_id=lobby.channel_id, children=True)])
|
|
145
|
+
await client.play(buf, target=1) # shout into the lobby and its sub-channels
|
|
146
|
+
await client.set_voice_target(2, [VoiceTarget(sessions=[user.session])])
|
|
147
|
+
await client.play(buf, target=2) # whisper to one user
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Target IDs are 1–30; `VOICE_TARGET_LOOPBACK` (31) makes the server echo your own audio back,
|
|
151
|
+
which is handy for testing a pipeline without a second client.
|
|
152
|
+
|
|
153
|
+
### Receiving audio
|
|
154
|
+
|
|
155
|
+
With `receive_audio=True`, every tunnelled packet arrives as `events.AudioReceived`. The server
|
|
156
|
+
forwards Opus as is, so decode it per sender with `pymumble_rev.opus.Decoder` (48 kHz s16le):
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from pymumble_rev import opus
|
|
160
|
+
|
|
161
|
+
decoders: dict[int, opus.Decoder] = {}
|
|
162
|
+
async for event in client.events():
|
|
163
|
+
if isinstance(event, events.AudioReceived):
|
|
164
|
+
dec = decoders.setdefault(event.sender_session, opus.Decoder(channels=1))
|
|
165
|
+
pcm = dec.decode(event.opus_data) # 20 ms of PCM per packet
|
|
166
|
+
if event.is_terminator:
|
|
167
|
+
decoders.pop(event.sender_session) # the user stopped talking
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## From threaded code
|
|
171
|
+
|
|
172
|
+
`ThreadedClient` runs the asyncio client on a private loop thread and exposes the same API as
|
|
173
|
+
blocking calls, for applications that are built on threads:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
import threading
|
|
177
|
+
from pymumble_rev import ClientConfig, ThreadedClient, events
|
|
178
|
+
|
|
179
|
+
with ThreadedClient("mumble.example.org", ClientConfig(username="dj"), channels=2) as client:
|
|
180
|
+
buf, done = client.open_stream(capacity=0.5) # a PcmBuffer that is already playing
|
|
181
|
+
|
|
182
|
+
def player() -> None: # e.g. your existing ffmpeg reader thread
|
|
183
|
+
for chunk in pcm_chunks():
|
|
184
|
+
buf.write(chunk) # blocks while 0.5 s is queued
|
|
185
|
+
buf.close()
|
|
186
|
+
|
|
187
|
+
threading.Thread(target=player, daemon=True).start()
|
|
188
|
+
|
|
189
|
+
for event in client.events(): # blocks; ends when the connection ends
|
|
190
|
+
if isinstance(event, events.TextMessage) and event.message == "!skip":
|
|
191
|
+
buf.clear()
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
- Commands (`send_text`, `move`, ...) block until written and raise `TimeoutError` after
|
|
195
|
+
`command_timeout` (10 s). Calling them from the loop thread raises instead of deadlocking.
|
|
196
|
+
- `play()` returns once the switch has happened, so a following `stop_audio()` or `volume`
|
|
197
|
+
change always applies to the new source. Its `concurrent.futures.Future` completes when the
|
|
198
|
+
source ends.
|
|
199
|
+
- `state` is an immutable snapshot and can be read from any thread without locking.
|
|
200
|
+
|
|
201
|
+
## The sans-IO core
|
|
202
|
+
|
|
203
|
+
`Connection` is the protocol without any I/O: feed it the bytes you read with `receive_data()`,
|
|
204
|
+
write what `data_to_send()` returns, and pass time in explicitly (`ping(now)`). `MumbleClient`
|
|
205
|
+
is a thin asyncio driver around it; use `Connection` directly to run the protocol on another
|
|
206
|
+
event loop, in a thread, or in tests.
|
|
207
|
+
|
|
208
|
+
## Why not pymumble?
|
|
209
|
+
|
|
210
|
+
[pymumble](https://codeberg.org/pymumble/pymumble) runs its own thread, calls your callbacks on
|
|
211
|
+
it while holding internal locks, writes to the TLS socket from several threads, and treats a
|
|
212
|
+
congested socket as a disconnect. Those are consequences of its threading model, not individual
|
|
213
|
+
bugs, so this library starts over with a single event loop and a protocol core that does no I/O.
|
|
214
|
+
The API is new and is **not** a drop-in replacement.
|
|
215
|
+
|
|
216
|
+
## Scope
|
|
217
|
+
|
|
218
|
+
| In scope | Out of scope |
|
|
219
|
+
|---|---|
|
|
220
|
+
| TLS control channel, Mumble 1.5+ protocol | UDP voice and OCB2 crypto |
|
|
221
|
+
| Users, channels, text messages, moving, self state | ACL and channel administration |
|
|
222
|
+
| Opus audio tunnelled over TCP (send and receive) | Positional audio, legacy codecs |
|
|
223
|
+
|
|
224
|
+
## Development
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
uv sync --group dev
|
|
228
|
+
uv run pytest
|
|
229
|
+
uv run ruff check && uv run ruff format --check
|
|
230
|
+
uv run mypy
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The protobuf modules in `src/pymumble_rev/_proto` are generated from `proto/*.proto` (taken
|
|
234
|
+
from Mumble v1.5.939) with `uv run --group proto python scripts/gen_proto.py`.
|
|
235
|
+
|
|
236
|
+
`tests/test_integration.py` runs against a real server when `PYMUMBLE_REV_SERVER=host:port` is
|
|
237
|
+
set (CI starts a `mumble-server` from the Ubuntu repositories); see the module docstring for a
|
|
238
|
+
local setup.
|
|
239
|
+
|
|
240
|
+
### Releasing
|
|
241
|
+
|
|
242
|
+
Bump `version` in `pyproject.toml`, run `uv lock`, move the `[Unreleased]` entries in
|
|
243
|
+
`CHANGELOG.md` under the new version, and merge. Then either run the **Release** workflow on
|
|
244
|
+
`main` from the Actions tab, or push a `vX.Y.Z` tag. The workflow checks the version, builds,
|
|
245
|
+
publishes to PyPI via trusted publishing and creates the GitHub release (and, when run by hand,
|
|
246
|
+
the tag) from the changelog section.
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT. The `.proto` files in `proto/` are © The Mumble Developers under the BSD-3-Clause license
|
|
251
|
+
(`proto/LICENSE`).
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# pymumble-rev
|
|
2
|
+
|
|
3
|
+
**Asyncio Mumble client library for Python — sans-IO protocol core and pull-based Opus audio, built for music and voice bots.**
|
|
4
|
+
|
|
5
|
+
[简体中文](README.zh-CN.md)
|
|
6
|
+
|
|
7
|
+
[](https://github.com/AidenOfficial/PyMumble/actions/workflows/ci.yml)
|
|
8
|
+
[](https://pypi.org/project/pymumble-rev/)
|
|
9
|
+
[](https://pypi.org/project/pymumble-rev/)
|
|
10
|
+
|
|
11
|
+
> **Status: beta.** Every feature in scope is implemented and tested against a real Mumble
|
|
12
|
+
> server in CI. Until 1.0 a minor release may still change the API; such changes are listed in
|
|
13
|
+
> [CHANGELOG.md](CHANGELOG.md).
|
|
14
|
+
|
|
15
|
+
An asyncio client for the Mumble voice protocol, designed for long-running bots that stream audio.
|
|
16
|
+
|
|
17
|
+
- **Sans-IO core**: the protocol is a pure state machine (`bytes in → events out`, `commands → bytes out`), testable without sockets.
|
|
18
|
+
- **Pull-based audio**: the client asks your source for the next 20 ms frame, so there is no buffer to poll and skips take effect immediately.
|
|
19
|
+
- **Small on purpose**: client only, Opus over the TLS tunnel, Mumble 1.5+. No UDP/OCB2, no ACL editing, no positional audio.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Requires Python 3.12+. Audio needs the system libopus (`apt install libopus0`,
|
|
24
|
+
`brew install opus`, ...); text-only bots do not.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install pymumble-rev
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from pymumble_rev import ClientConfig, MumbleClient, events
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def main() -> None:
|
|
38
|
+
config = ClientConfig(username="dj-bot", client_type="bot")
|
|
39
|
+
async with MumbleClient("mumble.example.org", config) as client:
|
|
40
|
+
if music := client.state.find_channel("Music"):
|
|
41
|
+
await client.move(music.channel_id)
|
|
42
|
+
me = client.state.myself
|
|
43
|
+
await client.send_text(f"hello from {me.name}", channel_ids=[me.channel_id])
|
|
44
|
+
|
|
45
|
+
async for event in client.events():
|
|
46
|
+
match event:
|
|
47
|
+
case events.TextMessage(actor=int(actor), message="!ping"):
|
|
48
|
+
await client.send_text("pong", sessions=[actor])
|
|
49
|
+
case events.UserUpdated(user=user, changed=changed) if "channel_id" in changed:
|
|
50
|
+
print(f"{user.name} moved to {client.state.channels[user.channel_id].name}")
|
|
51
|
+
print("disconnected:", client.close_reason)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
asyncio.run(main())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `client.state` is an immutable snapshot (users, channels, server limits); reading it from another
|
|
58
|
+
thread is safe.
|
|
59
|
+
- `events()` ends when the connection does, and `close_reason` says why (`kicked: ...`,
|
|
60
|
+
`no data from server for 20s`, ...). The client never reconnects by itself: put
|
|
61
|
+
`async with MumbleClient(...)` in your own retry loop, and each attempt starts from a fresh,
|
|
62
|
+
fully synced state.
|
|
63
|
+
- Commands are coroutines that wait for the socket to drain, so a slow link slows the sender down
|
|
64
|
+
instead of being mistaken for a disconnect. A server that stops answering pings for
|
|
65
|
+
`dead_after` seconds (default 20) is treated as gone.
|
|
66
|
+
- The server certificate is not verified by default, because nearly every Mumble server uses a
|
|
67
|
+
self-signed one. Pass `ssl_context=` to verify it, or
|
|
68
|
+
`default_ssl_context(certfile, keyfile)` to log in with a client certificate.
|
|
69
|
+
|
|
70
|
+
## Playing audio
|
|
71
|
+
|
|
72
|
+
The client pulls 20 ms of PCM (s16le, 48 kHz, mono or stereo) from a source every frame, encodes
|
|
73
|
+
it with the system libopus, and paces packets in real time. `PcmBuffer` lets any thread feed it:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import asyncio, subprocess
|
|
77
|
+
from pymumble_rev import ClientConfig, MumbleClient
|
|
78
|
+
from pymumble_rev.audio import PcmBuffer
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def play_file(client: MumbleClient, path: str) -> None:
|
|
82
|
+
buf = PcmBuffer(channels=2, capacity=0.5)
|
|
83
|
+
|
|
84
|
+
def feed() -> None: # runs in a worker thread
|
|
85
|
+
cmd = ["ffmpeg", "-v", "error", "-i", path, "-f", "s16le", "-ar", "48000", "-ac", "2", "-"]
|
|
86
|
+
with subprocess.Popen(cmd, stdout=subprocess.PIPE) as ff:
|
|
87
|
+
while chunk := ff.stdout.read(3840):
|
|
88
|
+
buf.write(chunk) # blocks while 0.5 s is queued: playback paces the reader
|
|
89
|
+
buf.close() # play what is queued, then end the transmission
|
|
90
|
+
|
|
91
|
+
feeder = asyncio.to_thread(feed)
|
|
92
|
+
await asyncio.gather(feeder, client.play(buf))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def main() -> None:
|
|
96
|
+
async with MumbleClient("mumble.example.org", ClientConfig(username="dj"), channels=2) as c:
|
|
97
|
+
c.volume = 0.6 # applied per frame with a short ramp, so changes are instant and click-free
|
|
98
|
+
await play_file(c, "song.mp3")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- `play(source)` switches immediately and returns a future that resolves when the source ends,
|
|
102
|
+
is replaced, or `stop_audio()` is called. `PcmBuffer.clear()` skips what is queued.
|
|
103
|
+
- Short underruns (a slow download) are bridged with silence for 200 ms; longer ones end the
|
|
104
|
+
transmission cleanly and a new one starts when data returns.
|
|
105
|
+
- The bitrate (default 96 kbit/s) is capped automatically to the server's bandwidth limit,
|
|
106
|
+
counting the TCP tunnel overhead the server charges per packet.
|
|
107
|
+
- libopus is loaded on the first `play()`; set `PYMUMBLE_REV_LIBOPUS` to point at a specific
|
|
108
|
+
library file.
|
|
109
|
+
|
|
110
|
+
### Whisper and shout
|
|
111
|
+
|
|
112
|
+
Audio normally goes to the channel you are in. A *voice target* sends it to specific users or
|
|
113
|
+
channels instead, wherever you sit:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from pymumble_rev import VoiceTarget
|
|
117
|
+
|
|
118
|
+
await client.set_voice_target(1, [VoiceTarget(channel_id=lobby.channel_id, children=True)])
|
|
119
|
+
await client.play(buf, target=1) # shout into the lobby and its sub-channels
|
|
120
|
+
await client.set_voice_target(2, [VoiceTarget(sessions=[user.session])])
|
|
121
|
+
await client.play(buf, target=2) # whisper to one user
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Target IDs are 1–30; `VOICE_TARGET_LOOPBACK` (31) makes the server echo your own audio back,
|
|
125
|
+
which is handy for testing a pipeline without a second client.
|
|
126
|
+
|
|
127
|
+
### Receiving audio
|
|
128
|
+
|
|
129
|
+
With `receive_audio=True`, every tunnelled packet arrives as `events.AudioReceived`. The server
|
|
130
|
+
forwards Opus as is, so decode it per sender with `pymumble_rev.opus.Decoder` (48 kHz s16le):
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from pymumble_rev import opus
|
|
134
|
+
|
|
135
|
+
decoders: dict[int, opus.Decoder] = {}
|
|
136
|
+
async for event in client.events():
|
|
137
|
+
if isinstance(event, events.AudioReceived):
|
|
138
|
+
dec = decoders.setdefault(event.sender_session, opus.Decoder(channels=1))
|
|
139
|
+
pcm = dec.decode(event.opus_data) # 20 ms of PCM per packet
|
|
140
|
+
if event.is_terminator:
|
|
141
|
+
decoders.pop(event.sender_session) # the user stopped talking
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## From threaded code
|
|
145
|
+
|
|
146
|
+
`ThreadedClient` runs the asyncio client on a private loop thread and exposes the same API as
|
|
147
|
+
blocking calls, for applications that are built on threads:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import threading
|
|
151
|
+
from pymumble_rev import ClientConfig, ThreadedClient, events
|
|
152
|
+
|
|
153
|
+
with ThreadedClient("mumble.example.org", ClientConfig(username="dj"), channels=2) as client:
|
|
154
|
+
buf, done = client.open_stream(capacity=0.5) # a PcmBuffer that is already playing
|
|
155
|
+
|
|
156
|
+
def player() -> None: # e.g. your existing ffmpeg reader thread
|
|
157
|
+
for chunk in pcm_chunks():
|
|
158
|
+
buf.write(chunk) # blocks while 0.5 s is queued
|
|
159
|
+
buf.close()
|
|
160
|
+
|
|
161
|
+
threading.Thread(target=player, daemon=True).start()
|
|
162
|
+
|
|
163
|
+
for event in client.events(): # blocks; ends when the connection ends
|
|
164
|
+
if isinstance(event, events.TextMessage) and event.message == "!skip":
|
|
165
|
+
buf.clear()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- Commands (`send_text`, `move`, ...) block until written and raise `TimeoutError` after
|
|
169
|
+
`command_timeout` (10 s). Calling them from the loop thread raises instead of deadlocking.
|
|
170
|
+
- `play()` returns once the switch has happened, so a following `stop_audio()` or `volume`
|
|
171
|
+
change always applies to the new source. Its `concurrent.futures.Future` completes when the
|
|
172
|
+
source ends.
|
|
173
|
+
- `state` is an immutable snapshot and can be read from any thread without locking.
|
|
174
|
+
|
|
175
|
+
## The sans-IO core
|
|
176
|
+
|
|
177
|
+
`Connection` is the protocol without any I/O: feed it the bytes you read with `receive_data()`,
|
|
178
|
+
write what `data_to_send()` returns, and pass time in explicitly (`ping(now)`). `MumbleClient`
|
|
179
|
+
is a thin asyncio driver around it; use `Connection` directly to run the protocol on another
|
|
180
|
+
event loop, in a thread, or in tests.
|
|
181
|
+
|
|
182
|
+
## Why not pymumble?
|
|
183
|
+
|
|
184
|
+
[pymumble](https://codeberg.org/pymumble/pymumble) runs its own thread, calls your callbacks on
|
|
185
|
+
it while holding internal locks, writes to the TLS socket from several threads, and treats a
|
|
186
|
+
congested socket as a disconnect. Those are consequences of its threading model, not individual
|
|
187
|
+
bugs, so this library starts over with a single event loop and a protocol core that does no I/O.
|
|
188
|
+
The API is new and is **not** a drop-in replacement.
|
|
189
|
+
|
|
190
|
+
## Scope
|
|
191
|
+
|
|
192
|
+
| In scope | Out of scope |
|
|
193
|
+
|---|---|
|
|
194
|
+
| TLS control channel, Mumble 1.5+ protocol | UDP voice and OCB2 crypto |
|
|
195
|
+
| Users, channels, text messages, moving, self state | ACL and channel administration |
|
|
196
|
+
| Opus audio tunnelled over TCP (send and receive) | Positional audio, legacy codecs |
|
|
197
|
+
|
|
198
|
+
## Development
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
uv sync --group dev
|
|
202
|
+
uv run pytest
|
|
203
|
+
uv run ruff check && uv run ruff format --check
|
|
204
|
+
uv run mypy
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The protobuf modules in `src/pymumble_rev/_proto` are generated from `proto/*.proto` (taken
|
|
208
|
+
from Mumble v1.5.939) with `uv run --group proto python scripts/gen_proto.py`.
|
|
209
|
+
|
|
210
|
+
`tests/test_integration.py` runs against a real server when `PYMUMBLE_REV_SERVER=host:port` is
|
|
211
|
+
set (CI starts a `mumble-server` from the Ubuntu repositories); see the module docstring for a
|
|
212
|
+
local setup.
|
|
213
|
+
|
|
214
|
+
### Releasing
|
|
215
|
+
|
|
216
|
+
Bump `version` in `pyproject.toml`, run `uv lock`, move the `[Unreleased]` entries in
|
|
217
|
+
`CHANGELOG.md` under the new version, and merge. Then either run the **Release** workflow on
|
|
218
|
+
`main` from the Actions tab, or push a `vX.Y.Z` tag. The workflow checks the version, builds,
|
|
219
|
+
publishes to PyPI via trusted publishing and creates the GitHub release (and, when run by hand,
|
|
220
|
+
the tag) from the changelog section.
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
MIT. The `.proto` files in `proto/` are © The Mumble Developers under the BSD-3-Clause license
|
|
225
|
+
(`proto/LICENSE`).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# pymumble-rev
|
|
2
|
+
|
|
3
|
+
**为音乐和语音 bot 打造的 Python asyncio Mumble 客户端库:sans-IO 协议核心,拉模式 Opus 音频。**
|
|
4
|
+
|
|
5
|
+
[English](README.md)
|
|
6
|
+
|
|
7
|
+
[](https://github.com/AidenOfficial/PyMumble/actions/workflows/ci.yml)
|
|
8
|
+
[](https://pypi.org/project/pymumble-rev/)
|
|
9
|
+
|
|
10
|
+
> **状态:beta。** 范围内的功能全部实现,CI 里对着真实 Mumble 服务器测试。1.0 之前次版本号升级仍可能改 API,
|
|
11
|
+
> 变更都记在 [CHANGELOG.md](CHANGELOG.md)。
|
|
12
|
+
|
|
13
|
+
- **sans-IO 核心**:协议是纯状态机(`字节进 → 事件出`、`命令 → 字节出`),不用 socket 就能测试。
|
|
14
|
+
- **拉模式音频**:客户端每 20 ms 向你的音源要下一帧,不用轮询缓冲区,切歌立即生效。
|
|
15
|
+
- **刻意做小**:只做客户端,Opus 走 TLS 隧道,Mumble 1.5+。不做 UDP/OCB2、ACL 编辑、位置音频。
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
需要 Python 3.12+。放音频需要系统的 libopus(`apt install libopus0`、`brew install opus` 等),纯文字 bot 不需要。
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install pymumble-rev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
用法示例见 [英文 README](README.md#quick-start)。
|
|
26
|
+
|
|
27
|
+
## 为什么不用 pymumble?
|
|
28
|
+
|
|
29
|
+
[pymumble](https://codeberg.org/pymumble/pymumble) 自己起线程,在持有内部锁的情况下在那个线程里调用你的回调,
|
|
30
|
+
从多个线程写同一个 TLS socket,socket 拥塞就当成断线。这些是线程模型带来的,不是零散 bug,所以本库用单事件循环
|
|
31
|
+
和不做 I/O 的协议核心从头写。API 是全新的,**不能**直接替换 pymumble。
|
|
32
|
+
|
|
33
|
+
## 许可证
|
|
34
|
+
|
|
35
|
+
MIT。`proto/` 下的 `.proto` 文件版权属于 The Mumble Developers,BSD-3-Clause 许可(见 `proto/LICENSE`)。
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Copyright (C) 2005-2022 The Mumble Developers
|
|
2
|
+
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions
|
|
7
|
+
are met:
|
|
8
|
+
|
|
9
|
+
- Redistributions of source code must retain the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer.
|
|
11
|
+
- Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
- Neither the name of The Mumble Developers nor the names of its
|
|
15
|
+
contributors may be used to endorse or promote products derived from this
|
|
16
|
+
software without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
19
|
+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
20
|
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
21
|
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
|
|
22
|
+
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
23
|
+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
24
|
+
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
25
|
+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
26
|
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
27
|
+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
28
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
29
|
+
|
|
30
|
+
For questions and inquiries about Mumble's license,
|
|
31
|
+
please contact <license@mumble.info>.
|