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.
Files changed (39) hide show
  1. pymumble_rev-0.1.0/.gitignore +11 -0
  2. pymumble_rev-0.1.0/LICENSE +21 -0
  3. pymumble_rev-0.1.0/PKG-INFO +251 -0
  4. pymumble_rev-0.1.0/README.md +225 -0
  5. pymumble_rev-0.1.0/README.zh-CN.md +35 -0
  6. pymumble_rev-0.1.0/proto/LICENSE +31 -0
  7. pymumble_rev-0.1.0/proto/Mumble.proto +621 -0
  8. pymumble_rev-0.1.0/proto/MumbleUDP.proto +85 -0
  9. pymumble_rev-0.1.0/pyproject.toml +77 -0
  10. pymumble_rev-0.1.0/scripts/gen_proto.py +71 -0
  11. pymumble_rev-0.1.0/src/pymumble_rev/__init__.py +56 -0
  12. pymumble_rev-0.1.0/src/pymumble_rev/_proto/MumbleUDP_pb2.py +39 -0
  13. pymumble_rev-0.1.0/src/pymumble_rev/_proto/MumbleUDP_pb2.pyi +42 -0
  14. pymumble_rev-0.1.0/src/pymumble_rev/_proto/Mumble_pb2.py +113 -0
  15. pymumble_rev-0.1.0/src/pymumble_rev/_proto/Mumble_pb2.pyi +564 -0
  16. pymumble_rev-0.1.0/src/pymumble_rev/_proto/__init__.py +0 -0
  17. pymumble_rev-0.1.0/src/pymumble_rev/audio.py +359 -0
  18. pymumble_rev-0.1.0/src/pymumble_rev/client.py +449 -0
  19. pymumble_rev-0.1.0/src/pymumble_rev/connection.py +557 -0
  20. pymumble_rev-0.1.0/src/pymumble_rev/errors.py +29 -0
  21. pymumble_rev-0.1.0/src/pymumble_rev/events.py +136 -0
  22. pymumble_rev-0.1.0/src/pymumble_rev/framing.py +51 -0
  23. pymumble_rev-0.1.0/src/pymumble_rev/messages.py +67 -0
  24. pymumble_rev-0.1.0/src/pymumble_rev/opus.py +219 -0
  25. pymumble_rev-0.1.0/src/pymumble_rev/py.typed +0 -0
  26. pymumble_rev-0.1.0/src/pymumble_rev/state.py +262 -0
  27. pymumble_rev-0.1.0/src/pymumble_rev/sync.py +356 -0
  28. pymumble_rev-0.1.0/tests/__init__.py +0 -0
  29. pymumble_rev-0.1.0/tests/data/cert.pem +11 -0
  30. pymumble_rev-0.1.0/tests/data/key.pem +5 -0
  31. pymumble_rev-0.1.0/tests/fake_server.py +129 -0
  32. pymumble_rev-0.1.0/tests/helpers.py +69 -0
  33. pymumble_rev-0.1.0/tests/test_audio.py +328 -0
  34. pymumble_rev-0.1.0/tests/test_client.py +335 -0
  35. pymumble_rev-0.1.0/tests/test_connection.py +488 -0
  36. pymumble_rev-0.1.0/tests/test_framing.py +39 -0
  37. pymumble_rev-0.1.0/tests/test_integration.py +242 -0
  38. pymumble_rev-0.1.0/tests/test_opus.py +89 -0
  39. pymumble_rev-0.1.0/tests/test_sync.py +229 -0
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
@@ -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
+ [![CI](https://github.com/AidenOfficial/PyMumble/actions/workflows/ci.yml/badge.svg)](https://github.com/AidenOfficial/PyMumble/actions/workflows/ci.yml)
34
+ [![PyPI](https://img.shields.io/pypi/v/pymumble-rev)](https://pypi.org/project/pymumble-rev/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/pymumble-rev)](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
+ [![CI](https://github.com/AidenOfficial/PyMumble/actions/workflows/ci.yml/badge.svg)](https://github.com/AidenOfficial/PyMumble/actions/workflows/ci.yml)
8
+ [![PyPI](https://img.shields.io/pypi/v/pymumble-rev)](https://pypi.org/project/pymumble-rev/)
9
+ [![Python](https://img.shields.io/pypi/pyversions/pymumble-rev)](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
+ [![CI](https://github.com/AidenOfficial/PyMumble/actions/workflows/ci.yml/badge.svg)](https://github.com/AidenOfficial/PyMumble/actions/workflows/ci.yml)
8
+ [![PyPI](https://img.shields.io/pypi/v/pymumble-rev)](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>.