Compare commits
18 Commits
931c6d4776
...
be6574ae79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be6574ae79 | ||
|
|
57f4559a38 | ||
|
|
351b980b42 | ||
|
|
6f590ede38 | ||
|
|
e443facd3b | ||
|
|
d02bb5239a | ||
|
|
a041069cc9 | ||
|
|
0b186f1f0c | ||
|
|
0b178d371e | ||
|
|
0bc41d1a46 | ||
|
|
e9726da401 | ||
|
|
5e44ee9e38 | ||
|
|
6467f5fe32 | ||
|
|
e9944c88eb | ||
|
|
eb98165370 | ||
|
|
0856ab3c55 | ||
|
|
b852459eff | ||
|
|
ec81fac507 |
30
README.md
30
README.md
@@ -8,6 +8,28 @@ TUI Mumble client with voice support and push-to-talk.
|
||||
- Voice transmission with Opus codec
|
||||
- Push-to-talk via Kitty keyboard protocol, evdev, or toggle
|
||||
- Channel browsing and text chat
|
||||
- Chat input history (Up/Down arrow navigation)
|
||||
- Self-deafen toggle
|
||||
- Volume control (input/output gain)
|
||||
- Client certificate authentication
|
||||
- Auto-reconnect on network loss
|
||||
- Config hot-reload (F5)
|
||||
|
||||
## Key Bindings
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Tab` | Cycle focus (chat input / channel tree) |
|
||||
| `F1` | Toggle self-deafen |
|
||||
| `F2` | Cycle output volume |
|
||||
| `F3` | Cycle input volume |
|
||||
| `F4` | Push-to-talk (configurable) |
|
||||
| `F5` | Reload config from disk |
|
||||
| `Enter` | Send message / join channel (sidebar) |
|
||||
| `Up` | Previous message (input) / previous channel (sidebar) |
|
||||
| `Down` | Next message (input) / next channel (sidebar) |
|
||||
| `q` | Quit |
|
||||
| `Ctrl+C` | Quit |
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -29,9 +51,15 @@ mkdir -p ~/.config/tuimble
|
||||
host = "mumble.example.com"
|
||||
port = 64738
|
||||
username = "myname"
|
||||
# certfile = "/path/to/cert.pem"
|
||||
# keyfile = "/path/to/key.pem"
|
||||
|
||||
[audio]
|
||||
# output_gain = 1.0
|
||||
# input_gain = 1.0
|
||||
|
||||
[ptt]
|
||||
key = "space"
|
||||
key = "f4"
|
||||
mode = "hold"
|
||||
backend = "auto"
|
||||
```
|
||||
|
||||
14
ROADMAP.md
14
ROADMAP.md
@@ -18,15 +18,15 @@
|
||||
## Phase 3 — Polish
|
||||
|
||||
- [x] Responsive terminal layout (adaptive sidebar, truncation, resize)
|
||||
- [ ] Channel tree navigation
|
||||
- [ ] User list with status indicators
|
||||
- [ ] Volume control
|
||||
- [ ] Server certificate handling
|
||||
- [ ] Config file hot-reload
|
||||
- [x] Channel tree navigation
|
||||
- [x] User list with status indicators
|
||||
- [x] Volume control
|
||||
- [x] Server certificate handling
|
||||
- [x] Config file hot-reload
|
||||
|
||||
## Phase 4 — Robustness
|
||||
|
||||
- [ ] Reconnection handling
|
||||
- [ ] Error recovery
|
||||
- [x] Reconnection handling
|
||||
- [x] Error recovery
|
||||
- [ ] Audio device hot-swap
|
||||
- [ ] Comprehensive test suite
|
||||
|
||||
10
TASKLIST.md
10
TASKLIST.md
@@ -7,8 +7,14 @@
|
||||
- [x] Channel tree population from server data
|
||||
- [x] Audio pipeline integration with MumbleClient
|
||||
- [x] PTT wiring (key events -> audio.capturing toggle)
|
||||
- [x] Chat input history navigation (up/down arrows)
|
||||
- [x] Channel navigation and join from sidebar
|
||||
- [x] User status indicators (mute/deaf)
|
||||
- [x] Volume control (F2/F3, gain 0.0-2.0, status bar display)
|
||||
- [x] Server certificate handling (certfile/keyfile config)
|
||||
- [x] Config file hot-reload (F5, safe/restart change detection)
|
||||
- [x] Reconnection handling with auto-retry and backoff
|
||||
|
||||
## Pending
|
||||
|
||||
- [ ] Channel join/navigation (select channel in tree, move into it)
|
||||
- [ ] Reconnection handling on disconnect
|
||||
- [ ] Audio device hot-swap
|
||||
|
||||
@@ -6,7 +6,13 @@ make run launch tuimble
|
||||
make test run tests
|
||||
make lint check code style
|
||||
|
||||
F1 toggle self-deafen
|
||||
F2 cycle output volume
|
||||
F3 cycle input volume
|
||||
F4 push-to-talk
|
||||
F5 reload config
|
||||
Tab cycle focus
|
||||
Enter send message / join channel
|
||||
Up/Down history / channel navigation
|
||||
q quit
|
||||
space push-to-talk (hold)
|
||||
Enter send message
|
||||
```
|
||||
|
||||
@@ -38,8 +38,46 @@ groups # should include 'input'
|
||||
|
||||
Add user to `input` group: `sudo usermod -aG input $USER`
|
||||
|
||||
### Certificate errors
|
||||
|
||||
If the server requires client certificates:
|
||||
|
||||
```sh
|
||||
# Verify cert/key pair
|
||||
openssl x509 -in cert.pem -noout -subject -dates
|
||||
openssl rsa -in key.pem -check -noout
|
||||
```
|
||||
|
||||
- Both `certfile` and `keyfile` must be PEM-encoded
|
||||
- Paths must be absolute or relative to the working directory
|
||||
- Key must not be passphrase-protected (pymumble limitation)
|
||||
- If the server uses a self-signed CA, pymumble verifies by default
|
||||
|
||||
### Connection refused
|
||||
|
||||
- Verify server address and port
|
||||
- Check firewall allows outbound TCP/UDP to port 64738
|
||||
- Test with: `nc -zv <host> 64738`
|
||||
|
||||
### Connection drops / reconnection
|
||||
|
||||
When tuimble loses connection, it retries automatically with backoff.
|
||||
The chatlog shows each attempt:
|
||||
|
||||
```
|
||||
✗ disconnected from server
|
||||
reconnecting in 2s (attempt 1/10)...
|
||||
✗ attempt 1: network error: [Errno 111] Connection refused
|
||||
reconnecting in 4s (attempt 2/10)...
|
||||
✓ connected as myname
|
||||
```
|
||||
|
||||
Common causes:
|
||||
|
||||
- **Server restart** — normal, reconnect succeeds when server is back
|
||||
- **Network loss** — check connectivity, tuimble retries automatically
|
||||
- **Auth timeout** — server may drop idle connections; reconnect handles this
|
||||
- **"rejected" with no retry** — wrong password or certificate issue
|
||||
|
||||
If reconnection fails after 10 attempts, press `F5` to retry manually.
|
||||
Update `config.toml` if server details changed.
|
||||
|
||||
@@ -11,11 +11,15 @@ tuimble --host mumble.example.com --user myname
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Tab` | Cycle focus (chat input / channel tree) |
|
||||
| `F1` | Toggle self-deafen |
|
||||
| `F2` | Cycle output volume |
|
||||
| `F3` | Cycle input volume |
|
||||
| `F4` | Push-to-talk (configurable) |
|
||||
| `Enter` | Send message |
|
||||
| `Up` | Previous sent message (in chat input) |
|
||||
| `Down` | Next sent message (in chat input) |
|
||||
| `F5` | Reload config from disk |
|
||||
| `Enter` | Send message / join channel (sidebar) |
|
||||
| `Up` | Previous message (input) / previous channel (sidebar) |
|
||||
| `Down` | Next message (input) / next channel (sidebar) |
|
||||
| `q` | Quit |
|
||||
| `Ctrl+C` | Quit |
|
||||
|
||||
@@ -44,6 +48,56 @@ python3 -m pstats /tmp/tuimble.prof # interactive explorer
|
||||
Note: cProfile captures the main thread only. Background workers
|
||||
started with `@work(thread=True)` are not included.
|
||||
|
||||
## Volume Control
|
||||
|
||||
`F2` and `F3` cycle through volume steps: 0%, 25%, 50%, 75%, 100%,
|
||||
125%, 150%, 200%, then back to 0%. Volume is shown in the status bar
|
||||
as block indicators.
|
||||
|
||||
```toml
|
||||
[audio]
|
||||
output_gain = 0.75 # 0.0 to 2.0 (default 1.0)
|
||||
input_gain = 1.5 # 0.0 to 2.0 (default 1.0)
|
||||
```
|
||||
|
||||
## Client Certificates
|
||||
|
||||
For servers requiring client certificate authentication:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
certfile = "/path/to/client-cert.pem"
|
||||
keyfile = "/path/to/client-key.pem"
|
||||
```
|
||||
|
||||
Both must be PEM-encoded. Leave empty to connect without a certificate.
|
||||
|
||||
## Config Reload
|
||||
|
||||
`F5` reloads `~/.config/tuimble/config.toml` from disk.
|
||||
|
||||
**Safe changes** (applied immediately): PTT key/mode/backend, audio
|
||||
gain values.
|
||||
|
||||
**Restart-requiring changes** (server settings, audio device/rate):
|
||||
shown as warnings. Press `F5` again to confirm reconnect, or any
|
||||
other key to cancel.
|
||||
|
||||
## Reconnection
|
||||
|
||||
On unexpected disconnect (server restart, network loss), tuimble
|
||||
automatically retries with exponential backoff:
|
||||
|
||||
- Initial delay: 2 seconds, doubling each attempt (2, 4, 8, 16, 30...)
|
||||
- Maximum delay: 30 seconds
|
||||
- Maximum attempts: 10
|
||||
|
||||
The status bar shows `◐ reconnecting` during retry. If the server
|
||||
rejects authentication, retries stop immediately.
|
||||
|
||||
After all attempts are exhausted, press `F5` to retry manually.
|
||||
`F5` also cancels an in-progress reconnect and starts a fresh attempt.
|
||||
|
||||
## Configuration
|
||||
|
||||
See `~/.config/tuimble/config.toml`. All fields are optional;
|
||||
|
||||
@@ -14,12 +14,27 @@ from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Header, Input, RichLog, Static
|
||||
|
||||
from tuimble.audio import AudioPipeline
|
||||
from tuimble.client import Channel, MumbleClient
|
||||
from tuimble.client import Channel, ConnectionFailed, MumbleClient, User
|
||||
from tuimble.config import Config, load_config
|
||||
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
VOLUME_STEPS = (0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0)
|
||||
|
||||
RECONNECT_INITIAL = 2 # seconds before first retry
|
||||
RECONNECT_MAX = 30 # maximum backoff delay
|
||||
RECONNECT_RETRIES = 10 # attempts before giving up
|
||||
TREE_DEBOUNCE = 0.1 # seconds to coalesce state changes
|
||||
|
||||
|
||||
def _next_volume(current: float) -> float:
|
||||
"""Cycle through VOLUME_STEPS, wrapping to 0.0 after max."""
|
||||
for step in VOLUME_STEPS:
|
||||
if step > current + 0.01:
|
||||
return step
|
||||
return VOLUME_STEPS[0]
|
||||
|
||||
|
||||
# -- custom messages (pymumble thread -> Textual) ----------------------------
|
||||
|
||||
@@ -45,6 +60,14 @@ class ServerStateChanged(Message):
|
||||
pass
|
||||
|
||||
|
||||
class ChannelSelected(Message):
|
||||
"""User selected a channel to join."""
|
||||
|
||||
def __init__(self, channel_id: int) -> None:
|
||||
super().__init__()
|
||||
self.channel_id = channel_id
|
||||
|
||||
|
||||
# -- widgets -----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -53,8 +76,17 @@ class StatusBar(Static):
|
||||
|
||||
ptt_active = reactive(False)
|
||||
connected = reactive(False)
|
||||
reconnecting = reactive(False)
|
||||
self_deaf = reactive(False)
|
||||
server_info = reactive("")
|
||||
output_vol = reactive(100)
|
||||
input_vol = reactive(100)
|
||||
|
||||
@staticmethod
|
||||
def _vol_bar(pct: int) -> str:
|
||||
"""Compact 4-char volume indicator using block chars."""
|
||||
filled = round(pct / 25)
|
||||
return "\u2588" * filled + "\u2591" * (4 - filled)
|
||||
|
||||
def render(self) -> str:
|
||||
w = self.content_size.width if self.content_size.width > 0 else 80
|
||||
@@ -62,6 +94,9 @@ class StatusBar(Static):
|
||||
if self.connected:
|
||||
conn_sym = "[#9ece6a]\u25cf[/]"
|
||||
conn_full = f"{conn_sym} connected"
|
||||
elif self.reconnecting:
|
||||
conn_sym = "[#e0af68]\u25d0[/]"
|
||||
conn_full = f"{conn_sym} reconnecting"
|
||||
else:
|
||||
conn_sym = "[#f7768e]\u25cb[/]"
|
||||
conn_full = f"{conn_sym} disconnected"
|
||||
@@ -80,36 +115,79 @@ class StatusBar(Static):
|
||||
return f" {conn_sym} {deaf_sym}{ptt_sym}"
|
||||
if w < 60:
|
||||
return f" {conn_full} {deaf_full}{' ' if deaf_full else ''}{ptt_full}"
|
||||
|
||||
vol = (
|
||||
f" [dim]out[/]{self._vol_bar(self.output_vol)}"
|
||||
f" [dim]in[/]{self._vol_bar(self.input_vol)}"
|
||||
)
|
||||
info = f" [dim]{self.server_info}[/]" if self.server_info else ""
|
||||
return f" {conn_full} {deaf_full}{' ' if deaf_full else ''}{ptt_full}{info}"
|
||||
deaf = f"{deaf_full} " if deaf_full else ""
|
||||
return f" {conn_full} {deaf}{ptt_full}{vol}{info}"
|
||||
|
||||
|
||||
class ChannelTree(Static):
|
||||
"""Channel and user list."""
|
||||
"""Channel and user list with keyboard navigation."""
|
||||
|
||||
DEFAULT_WIDTH = 24
|
||||
can_focus = True
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._channels: dict[int, Channel] = {}
|
||||
self._users_by_channel: dict[int, list[str]] = {}
|
||||
self._users_by_channel: dict[int, list[User]] = {}
|
||||
self._channel_ids: list[int] = []
|
||||
self._focused_idx: int = 0
|
||||
self._my_channel_id: int | None = None
|
||||
|
||||
def set_state(
|
||||
self,
|
||||
channels: dict[int, Channel],
|
||||
users_by_channel: dict[int, list[str]],
|
||||
users_by_channel: dict[int, list[User]],
|
||||
my_channel_id: int | None = None,
|
||||
) -> None:
|
||||
self._channels = channels
|
||||
self._users_by_channel = users_by_channel
|
||||
self._my_channel_id = my_channel_id
|
||||
self._channel_ids = self._build_channel_order()
|
||||
if self._channel_ids:
|
||||
self._focused_idx = max(
|
||||
0, min(self._focused_idx, len(self._channel_ids) - 1)
|
||||
)
|
||||
else:
|
||||
self._focused_idx = 0
|
||||
self.refresh()
|
||||
|
||||
def clear_state(self) -> None:
|
||||
self._channels = {}
|
||||
self._users_by_channel = {}
|
||||
self._channel_ids = []
|
||||
self._focused_idx = 0
|
||||
self._my_channel_id = None
|
||||
self.refresh()
|
||||
|
||||
@property
|
||||
def _available_width(self) -> int:
|
||||
def _build_channel_order(self) -> list[int]:
|
||||
"""Build flat list of channel IDs in tree order."""
|
||||
if not self._channels:
|
||||
return []
|
||||
order: list[int] = []
|
||||
root_id = self._find_root()
|
||||
self._collect_order(root_id, order)
|
||||
return order
|
||||
|
||||
def _collect_order(self, channel_id: int, order: list[int]) -> None:
|
||||
ch = self._channels.get(channel_id)
|
||||
if ch is None:
|
||||
return
|
||||
order.append(channel_id)
|
||||
children = sorted(
|
||||
(c for c in self._channels.values()
|
||||
if c.parent_id == channel_id and c.channel_id != channel_id),
|
||||
key=lambda c: c.name,
|
||||
)
|
||||
for child in children:
|
||||
self._collect_order(child.channel_id, order)
|
||||
|
||||
def _get_width(self) -> int:
|
||||
"""Usable character width (content area, excludes padding/border)."""
|
||||
w = self.content_size.width
|
||||
if w <= 0:
|
||||
@@ -127,13 +205,23 @@ class ChannelTree(Static):
|
||||
return text[:max_width]
|
||||
return text[: max_width - 3] + "..."
|
||||
|
||||
@staticmethod
|
||||
def _user_status(user: User) -> str:
|
||||
"""Return status indicator for a user."""
|
||||
if user.self_deaf or user.deaf:
|
||||
return " [#f7768e]\u2298[/]"
|
||||
if user.self_mute or user.mute:
|
||||
return " [#e0af68]\u2715[/]"
|
||||
return ""
|
||||
|
||||
def render(self) -> str:
|
||||
if not self._channels:
|
||||
return " Channels\n [dim]\u2514\u2500 (not connected)[/]"
|
||||
|
||||
w = self._get_width()
|
||||
lines = [" [bold]Channels[/]"]
|
||||
root_id = self._find_root()
|
||||
self._render_tree(root_id, lines, indent=1, is_last=True)
|
||||
self._render_tree(root_id, lines, indent=1, is_last=True, w=w)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _find_root(self) -> int:
|
||||
@@ -148,18 +236,37 @@ class ChannelTree(Static):
|
||||
lines: list[str],
|
||||
indent: int,
|
||||
is_last: bool,
|
||||
w: int,
|
||||
) -> None:
|
||||
ch = self._channels.get(channel_id)
|
||||
if ch is None:
|
||||
return
|
||||
|
||||
w = self._available_width
|
||||
prefix = " " * indent
|
||||
branch = "\u2514\u2500" if is_last else "\u251c\u2500"
|
||||
# indent + 2 branch chars + 1 space
|
||||
name_max = w - indent - 3
|
||||
|
||||
is_current = channel_id == self._my_channel_id
|
||||
is_focused = (
|
||||
self.has_focus
|
||||
and self._channel_ids
|
||||
and self._focused_idx < len(self._channel_ids)
|
||||
and self._channel_ids[self._focused_idx] == channel_id
|
||||
)
|
||||
|
||||
marker = "\u25cf " if is_current else " "
|
||||
# indent + 2 branch + 1 space + 2 marker
|
||||
name_max = w - indent - 5
|
||||
name = self._truncate(ch.name, name_max)
|
||||
lines.append(f"{prefix}{branch} [bold]{name}[/]")
|
||||
|
||||
if is_focused:
|
||||
lines.append(
|
||||
f"{prefix}{branch} {marker}[reverse bold]{name}[/]"
|
||||
)
|
||||
elif is_current:
|
||||
lines.append(
|
||||
f"{prefix}{branch} {marker}[bold #9ece6a]{name}[/]"
|
||||
)
|
||||
else:
|
||||
lines.append(f"{prefix}{branch} {marker}[bold]{name}[/]")
|
||||
|
||||
users = self._users_by_channel.get(channel_id, [])
|
||||
children = [
|
||||
@@ -171,12 +278,15 @@ class ChannelTree(Static):
|
||||
sub_indent = indent + 2
|
||||
sub_prefix = " " * sub_indent
|
||||
# sub_indent + 2 bullet chars + 1 space
|
||||
user_max = w - sub_indent - 3
|
||||
for i, user_name in enumerate(users):
|
||||
user_max = w - sub_indent - 5
|
||||
for i, user in enumerate(users):
|
||||
is_last_item = i == len(users) - 1 and not children
|
||||
bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500"
|
||||
uname = self._truncate(user_name, user_max)
|
||||
lines.append(f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]")
|
||||
uname = self._truncate(user.name, user_max)
|
||||
status = self._user_status(user)
|
||||
lines.append(
|
||||
f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]{status}"
|
||||
)
|
||||
|
||||
for i, child in enumerate(children):
|
||||
self._render_tree(
|
||||
@@ -184,8 +294,30 @@ class ChannelTree(Static):
|
||||
lines,
|
||||
indent + 2,
|
||||
is_last=i == len(children) - 1,
|
||||
w=w,
|
||||
)
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
if not self._channel_ids:
|
||||
return
|
||||
if event.key == "up":
|
||||
self._focused_idx = max(0, self._focused_idx - 1)
|
||||
self.refresh()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "down":
|
||||
self._focused_idx = min(
|
||||
len(self._channel_ids) - 1, self._focused_idx + 1
|
||||
)
|
||||
self.refresh()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "enter":
|
||||
cid = self._channel_ids[self._focused_idx]
|
||||
self.post_message(ChannelSelected(cid))
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
|
||||
class ChatLog(RichLog):
|
||||
"""Message log."""
|
||||
@@ -215,6 +347,9 @@ class TuimbleApp(App):
|
||||
padding: 0 1;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#sidebar:focus {
|
||||
border-right: solid #7aa2f7;
|
||||
}
|
||||
#chat-area {
|
||||
width: 1fr;
|
||||
}
|
||||
@@ -243,6 +378,9 @@ class TuimbleApp(App):
|
||||
|
||||
BINDINGS = [
|
||||
("f1", "toggle_deaf", "Deafen"),
|
||||
("f2", "cycle_output_volume", "Vol Out"),
|
||||
("f3", "cycle_input_volume", "Vol In"),
|
||||
("f5", "reload_config", "Reload"),
|
||||
("q", "quit", "Quit"),
|
||||
("ctrl+c", "quit", "Quit"),
|
||||
]
|
||||
@@ -259,6 +397,8 @@ class TuimbleApp(App):
|
||||
port=srv.port,
|
||||
username=srv.username,
|
||||
password=srv.password,
|
||||
certfile=srv.certfile,
|
||||
keyfile=srv.keyfile,
|
||||
)
|
||||
self._history: list[str] = []
|
||||
self._history_idx: int = -1
|
||||
@@ -270,6 +410,13 @@ class TuimbleApp(App):
|
||||
input_device=acfg.input_device,
|
||||
output_device=acfg.output_device,
|
||||
)
|
||||
self._audio.input_gain = acfg.input_gain
|
||||
self._audio.output_gain = acfg.output_gain
|
||||
self._pending_reload: Config | None = None
|
||||
self._tree_refresh_timer = None
|
||||
self._reconnecting: bool = False
|
||||
self._reconnect_attempt: int = 0
|
||||
self._intentional_disconnect: bool = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
@@ -282,6 +429,10 @@ class TuimbleApp(App):
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.output_vol = int(self._audio.output_gain * 100)
|
||||
status.input_vol = int(self._audio.input_gain * 100)
|
||||
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write("[dim]tuimble v0.1.0[/dim]")
|
||||
srv = self._config.server
|
||||
@@ -290,8 +441,8 @@ class TuimbleApp(App):
|
||||
|
||||
# -- server connection (worker thread) -----------------------------------
|
||||
|
||||
@work(thread=True)
|
||||
def _connect_to_server(self) -> None:
|
||||
def _wire_client_callbacks(self) -> None:
|
||||
"""Wire pymumble callbacks to message dispatchers."""
|
||||
self._client.set_dispatcher(self.call_from_thread)
|
||||
self._client.on_connected = self._cb_connected
|
||||
self._client.on_disconnected = self._cb_disconnected
|
||||
@@ -300,6 +451,9 @@ class TuimbleApp(App):
|
||||
self._client.on_channel_update = self._cb_state_changed
|
||||
self._client.on_sound_received = self._cb_sound_received
|
||||
|
||||
@work(thread=True)
|
||||
def _connect_to_server(self) -> None:
|
||||
self._wire_client_callbacks()
|
||||
try:
|
||||
self._client.connect()
|
||||
except Exception as exc:
|
||||
@@ -324,10 +478,112 @@ class TuimbleApp(App):
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write(f"[#f7768e]\u2717 {text}[/]")
|
||||
|
||||
# -- auto-reconnect ------------------------------------------------------
|
||||
|
||||
@work(thread=True)
|
||||
def _reconnect_loop(self) -> None:
|
||||
"""Retry connection with exponential backoff."""
|
||||
while self._reconnecting:
|
||||
self._reconnect_attempt += 1
|
||||
delay = min(
|
||||
RECONNECT_INITIAL * (2 ** (self._reconnect_attempt - 1)),
|
||||
RECONNECT_MAX,
|
||||
)
|
||||
self.call_from_thread(
|
||||
self._log_reconnect, self._reconnect_attempt, delay,
|
||||
)
|
||||
|
||||
elapsed = 0.0
|
||||
while elapsed < delay and self._reconnecting:
|
||||
time.sleep(0.5)
|
||||
elapsed += 0.5
|
||||
|
||||
if not self._reconnecting:
|
||||
break
|
||||
|
||||
try:
|
||||
self._client.disconnect()
|
||||
srv = self._config.server
|
||||
self._client = MumbleClient(
|
||||
host=srv.host,
|
||||
port=srv.port,
|
||||
username=srv.username,
|
||||
password=srv.password,
|
||||
certfile=srv.certfile,
|
||||
keyfile=srv.keyfile,
|
||||
)
|
||||
self._wire_client_callbacks()
|
||||
self._client.connect()
|
||||
|
||||
self._reconnecting = False
|
||||
self._reconnect_attempt = 0
|
||||
self.call_from_thread(self._on_reconnect_success)
|
||||
return
|
||||
except ConnectionFailed as exc:
|
||||
if not exc.retryable:
|
||||
self.call_from_thread(
|
||||
self._show_error, f"rejected: {exc}",
|
||||
)
|
||||
self._reconnecting = False
|
||||
self.call_from_thread(self._on_reconnect_exhausted)
|
||||
return
|
||||
self.call_from_thread(
|
||||
self._show_error,
|
||||
f"attempt {self._reconnect_attempt}: {exc}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self.call_from_thread(
|
||||
self._show_error,
|
||||
f"attempt {self._reconnect_attempt}: {exc}",
|
||||
)
|
||||
|
||||
if self._reconnect_attempt >= RECONNECT_RETRIES:
|
||||
self._reconnecting = False
|
||||
self.call_from_thread(self._on_reconnect_exhausted)
|
||||
return
|
||||
|
||||
def _log_reconnect(self, attempt: int, delay: int) -> None:
|
||||
"""Log reconnection attempt to chatlog."""
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.reconnecting = True
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write(
|
||||
f"[dim]reconnecting in {delay}s "
|
||||
f"(attempt {attempt}/{RECONNECT_RETRIES})...[/dim]"
|
||||
)
|
||||
|
||||
def _on_reconnect_success(self) -> None:
|
||||
"""Handle successful reconnection."""
|
||||
self.post_message(ServerConnected())
|
||||
|
||||
def _on_reconnect_exhausted(self) -> None:
|
||||
"""Handle all reconnection attempts exhausted."""
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.reconnecting = False
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write(
|
||||
f"[#f7768e]reconnection failed after "
|
||||
f"{RECONNECT_RETRIES} attempts[/]"
|
||||
)
|
||||
chatlog.write("[dim]press F5 to retry manually[/dim]")
|
||||
|
||||
def _cancel_reconnect(self) -> None:
|
||||
"""Cancel an in-progress reconnect loop."""
|
||||
self._reconnecting = False
|
||||
self._reconnect_attempt = 0
|
||||
try:
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.reconnecting = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- message handlers ----------------------------------------------------
|
||||
|
||||
def on_server_connected(self, _msg: ServerConnected) -> None:
|
||||
self._intentional_disconnect = False
|
||||
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.reconnecting = False
|
||||
status.connected = True
|
||||
srv = self._config.server
|
||||
status.server_info = f"{srv.host}:{srv.port}"
|
||||
@@ -340,15 +596,24 @@ class TuimbleApp(App):
|
||||
self._start_audio()
|
||||
|
||||
def on_server_disconnected(self, _msg: ServerDisconnected) -> None:
|
||||
self._audio.stop()
|
||||
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.connected = False
|
||||
status.server_info = ""
|
||||
|
||||
tree = self.query_one("#sidebar", ChannelTree)
|
||||
tree.clear_state()
|
||||
|
||||
if self._intentional_disconnect or self._reconnecting:
|
||||
return
|
||||
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write("[#f7768e]\u2717 disconnected from server[/]")
|
||||
|
||||
tree = self.query_one("#sidebar", ChannelTree)
|
||||
tree.clear_state()
|
||||
self._reconnecting = True
|
||||
self._reconnect_attempt = 0
|
||||
self._reconnect_loop()
|
||||
|
||||
def on_text_message_received(self, msg: TextMessageReceived) -> None:
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
@@ -357,7 +622,24 @@ class TuimbleApp(App):
|
||||
chatlog.write(f"[#7aa2f7]{msg.sender}[/] {clean}")
|
||||
|
||||
def on_server_state_changed(self, _msg: ServerStateChanged) -> None:
|
||||
self._refresh_channel_tree()
|
||||
if self._tree_refresh_timer is not None:
|
||||
self._tree_refresh_timer.stop()
|
||||
self._tree_refresh_timer = self.set_timer(
|
||||
TREE_DEBOUNCE, self._refresh_channel_tree,
|
||||
)
|
||||
|
||||
def on_channel_selected(self, msg: ChannelSelected) -> None:
|
||||
if not self._client.connected:
|
||||
return
|
||||
ch = self._client.channels.get(msg.channel_id)
|
||||
name = ch.name if ch else str(msg.channel_id)
|
||||
try:
|
||||
self._client.join_channel(msg.channel_id)
|
||||
except Exception as exc:
|
||||
self._show_error(f"join failed: {exc}")
|
||||
return
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write(f"[dim]\u2192 joined {name}[/dim]")
|
||||
|
||||
@on(Input.Submitted, "#input")
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
@@ -408,13 +690,13 @@ class TuimbleApp(App):
|
||||
return
|
||||
channels = self._client.channels
|
||||
users = self._client.users
|
||||
users_by_channel: dict[int, list[str]] = {}
|
||||
users_by_channel: dict[int, list[User]] = {}
|
||||
for u in users.values():
|
||||
users_by_channel.setdefault(u.channel_id, []).append(u.name)
|
||||
users_by_channel.setdefault(u.channel_id, []).append(u)
|
||||
for lst in users_by_channel.values():
|
||||
lst.sort()
|
||||
lst.sort(key=lambda u: u.name)
|
||||
tree = self.query_one("#sidebar", ChannelTree)
|
||||
tree.set_state(channels, users_by_channel)
|
||||
tree.set_state(channels, users_by_channel, self._client.my_channel_id)
|
||||
|
||||
# -- deafen --------------------------------------------------------------
|
||||
|
||||
@@ -431,10 +713,217 @@ class TuimbleApp(App):
|
||||
else:
|
||||
chatlog.write("[#9ece6a]\u2713 undeafened[/]")
|
||||
|
||||
# -- volume ---------------------------------------------------------------
|
||||
|
||||
def action_cycle_output_volume(self) -> None:
|
||||
"""Cycle output volume through preset steps."""
|
||||
vol = _next_volume(self._audio.output_gain)
|
||||
self._audio.output_gain = vol
|
||||
pct = int(vol * 100)
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.output_vol = pct
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write(f"[dim]output volume {pct}%[/dim]")
|
||||
|
||||
def action_cycle_input_volume(self) -> None:
|
||||
"""Cycle input volume through preset steps."""
|
||||
vol = _next_volume(self._audio.input_gain)
|
||||
self._audio.input_gain = vol
|
||||
pct = int(vol * 100)
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.input_vol = pct
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write(f"[dim]input volume {pct}%[/dim]")
|
||||
|
||||
# -- config reload --------------------------------------------------------
|
||||
|
||||
def _detect_config_changes(
|
||||
self, old: Config, new: Config,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Compare configs, return (safe_changes, restart_changes)."""
|
||||
safe: list[str] = []
|
||||
restart: list[str] = []
|
||||
|
||||
if old.ptt.key != new.ptt.key:
|
||||
safe.append(f"ptt key: {old.ptt.key} -> {new.ptt.key}")
|
||||
if old.ptt.mode != new.ptt.mode:
|
||||
safe.append(f"ptt mode: {old.ptt.mode} -> {new.ptt.mode}")
|
||||
if old.ptt.backend != new.ptt.backend:
|
||||
safe.append(
|
||||
f"ptt backend: {old.ptt.backend} -> {new.ptt.backend}"
|
||||
)
|
||||
if old.audio.input_gain != new.audio.input_gain:
|
||||
safe.append(
|
||||
f"input gain: {old.audio.input_gain} -> "
|
||||
f"{new.audio.input_gain}"
|
||||
)
|
||||
if old.audio.output_gain != new.audio.output_gain:
|
||||
safe.append(
|
||||
f"output gain: {old.audio.output_gain} -> "
|
||||
f"{new.audio.output_gain}"
|
||||
)
|
||||
|
||||
o_srv, n_srv = old.server, new.server
|
||||
for attr in ("host", "port", "username", "password",
|
||||
"certfile", "keyfile"):
|
||||
ov, nv = getattr(o_srv, attr), getattr(n_srv, attr)
|
||||
if ov != nv:
|
||||
label = "password" if attr == "password" else attr
|
||||
restart.append(f"server.{label} changed")
|
||||
|
||||
o_aud, n_aud = old.audio, new.audio
|
||||
for attr in ("input_device", "output_device", "sample_rate"):
|
||||
ov, nv = getattr(o_aud, attr), getattr(n_aud, attr)
|
||||
if ov != nv:
|
||||
restart.append(f"audio.{attr} changed")
|
||||
|
||||
return safe, restart
|
||||
|
||||
def _apply_safe_changes(self, new: Config) -> None:
|
||||
"""Apply hot-reload-safe config changes immediately."""
|
||||
self._config.ptt = new.ptt
|
||||
self._ptt = detect_backend(
|
||||
self._on_ptt_change, new.ptt.backend
|
||||
)
|
||||
|
||||
self._audio.input_gain = new.audio.input_gain
|
||||
self._audio.output_gain = new.audio.output_gain
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.input_vol = int(new.audio.input_gain * 100)
|
||||
status.output_vol = int(new.audio.output_gain * 100)
|
||||
|
||||
def _apply_restart_changes(self, new: Config) -> None:
|
||||
"""Apply changes that require reconnect/audio restart."""
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
old = self._config
|
||||
|
||||
server_changed = (
|
||||
old.server.host != new.server.host
|
||||
or old.server.port != new.server.port
|
||||
or old.server.username != new.server.username
|
||||
or old.server.password != new.server.password
|
||||
or old.server.certfile != new.server.certfile
|
||||
or old.server.keyfile != new.server.keyfile
|
||||
)
|
||||
audio_hw_changed = (
|
||||
old.audio.input_device != new.audio.input_device
|
||||
or old.audio.output_device != new.audio.output_device
|
||||
or old.audio.sample_rate != new.audio.sample_rate
|
||||
)
|
||||
|
||||
if server_changed:
|
||||
self._intentional_disconnect = True
|
||||
self._cancel_reconnect()
|
||||
self._audio.stop()
|
||||
self._client.set_dispatcher(None)
|
||||
self._client.disconnect()
|
||||
tree = self.query_one("#sidebar", ChannelTree)
|
||||
tree.clear_state()
|
||||
|
||||
srv = new.server
|
||||
self._client = MumbleClient(
|
||||
host=srv.host,
|
||||
port=srv.port,
|
||||
username=srv.username,
|
||||
password=srv.password,
|
||||
certfile=srv.certfile,
|
||||
keyfile=srv.keyfile,
|
||||
)
|
||||
self._config = new
|
||||
chatlog.write(
|
||||
f"[dim]reconnecting to {srv.host}:{srv.port}...[/]"
|
||||
)
|
||||
self._connect_to_server()
|
||||
elif audio_hw_changed:
|
||||
self._audio.stop()
|
||||
acfg = new.audio
|
||||
self._audio = AudioPipeline(
|
||||
sample_rate=acfg.sample_rate,
|
||||
frame_size=acfg.frame_size,
|
||||
input_device=acfg.input_device,
|
||||
output_device=acfg.output_device,
|
||||
)
|
||||
self._audio.input_gain = acfg.input_gain
|
||||
self._audio.output_gain = acfg.output_gain
|
||||
self._config = new
|
||||
if self._client.connected:
|
||||
self._start_audio()
|
||||
chatlog.write("[dim]audio pipeline restarted[/dim]")
|
||||
|
||||
def action_reload_config(self) -> None:
|
||||
"""Reload config from disk (F5).
|
||||
|
||||
Also serves as manual reconnect when disconnected.
|
||||
"""
|
||||
if self._reconnecting:
|
||||
self._cancel_reconnect()
|
||||
|
||||
if not self._client.connected:
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
srv = self._config.server
|
||||
self._client = MumbleClient(
|
||||
host=srv.host,
|
||||
port=srv.port,
|
||||
username=srv.username,
|
||||
password=srv.password,
|
||||
certfile=srv.certfile,
|
||||
keyfile=srv.keyfile,
|
||||
)
|
||||
chatlog.write(
|
||||
f"[dim]connecting to {srv.host}:{srv.port}...[/dim]"
|
||||
)
|
||||
self._intentional_disconnect = False
|
||||
self._connect_to_server()
|
||||
return
|
||||
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
|
||||
if self._pending_reload is not None:
|
||||
new = self._pending_reload
|
||||
self._pending_reload = None
|
||||
self._apply_restart_changes(new)
|
||||
return
|
||||
|
||||
try:
|
||||
new = load_config()
|
||||
except Exception as exc:
|
||||
self._show_error(f"config reload: {exc}")
|
||||
return
|
||||
|
||||
safe, restart = self._detect_config_changes(self._config, new)
|
||||
|
||||
if not safe and not restart:
|
||||
chatlog.write("[dim]config unchanged[/dim]")
|
||||
return
|
||||
|
||||
self._apply_safe_changes(new)
|
||||
self._config.audio.input_gain = new.audio.input_gain
|
||||
self._config.audio.output_gain = new.audio.output_gain
|
||||
|
||||
if safe:
|
||||
for change in safe:
|
||||
chatlog.write(f"[dim]\u2713 {change}[/dim]")
|
||||
|
||||
if restart:
|
||||
for change in restart:
|
||||
chatlog.write(f"[#e0af68]\u26a0 {change}[/]")
|
||||
chatlog.write(
|
||||
"[dim]press F5 again to apply, "
|
||||
"or any key to cancel[/dim]"
|
||||
)
|
||||
self._pending_reload = new
|
||||
else:
|
||||
chatlog.write("[dim]\u2713 config reloaded[/dim]")
|
||||
|
||||
# -- PTT -----------------------------------------------------------------
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
"""Handle input history navigation and PTT key events."""
|
||||
if self._pending_reload is not None and event.key != "f5":
|
||||
self._pending_reload = None
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write("[dim]reload cancelled[/dim]")
|
||||
|
||||
focused = self.focused
|
||||
if isinstance(focused, Input) and event.key in ("up", "down"):
|
||||
inp = focused
|
||||
@@ -494,6 +983,8 @@ class TuimbleApp(App):
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def action_quit(self) -> None:
|
||||
self._intentional_disconnect = True
|
||||
self._cancel_reconnect()
|
||||
self._audio.stop()
|
||||
self._client.set_dispatcher(None)
|
||||
self._client.disconnect()
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import struct
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,6 +19,17 @@ FRAME_SIZE = 960 # 20ms at 48kHz
|
||||
DTYPE = "int16"
|
||||
|
||||
|
||||
def _apply_gain(pcm: bytes, gain: float) -> bytes:
|
||||
"""Scale int16 PCM samples by gain factor with clipping."""
|
||||
n = len(pcm) // 2
|
||||
if n == 0:
|
||||
return pcm
|
||||
fmt = f"<{n}h"
|
||||
samples = struct.unpack(fmt, pcm[: n * 2])
|
||||
scaled = [max(-32768, min(32767, int(s * gain))) for s in samples]
|
||||
return struct.pack(fmt, *scaled)
|
||||
|
||||
|
||||
class AudioPipeline:
|
||||
"""Manages audio input/output streams and Opus codec."""
|
||||
|
||||
@@ -40,6 +52,8 @@ class AudioPipeline:
|
||||
self._output_stream = None
|
||||
self._capturing = False
|
||||
self._deafened = False
|
||||
self._input_gain = 1.0
|
||||
self._output_gain = 1.0
|
||||
|
||||
def start(self):
|
||||
"""Open audio streams."""
|
||||
@@ -93,13 +107,32 @@ class AudioPipeline:
|
||||
def deafened(self, value: bool):
|
||||
self._deafened = value
|
||||
|
||||
@property
|
||||
def input_gain(self) -> float:
|
||||
return self._input_gain
|
||||
|
||||
@input_gain.setter
|
||||
def input_gain(self, value: float):
|
||||
self._input_gain = max(0.0, min(2.0, value))
|
||||
|
||||
@property
|
||||
def output_gain(self) -> float:
|
||||
return self._output_gain
|
||||
|
||||
@output_gain.setter
|
||||
def output_gain(self, value: float):
|
||||
self._output_gain = max(0.0, min(2.0, value))
|
||||
|
||||
def _capture_callback(self, indata, frames, time_info, status):
|
||||
"""Called by sounddevice when input data is available."""
|
||||
if status:
|
||||
log.warning("capture status: %s", status)
|
||||
if self._capturing:
|
||||
try:
|
||||
self._capture_queue.put_nowait(bytes(indata))
|
||||
pcm = bytes(indata)
|
||||
if self._input_gain != 1.0:
|
||||
pcm = _apply_gain(pcm, self._input_gain)
|
||||
self._capture_queue.put_nowait(pcm)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
@@ -112,6 +145,8 @@ class AudioPipeline:
|
||||
return
|
||||
try:
|
||||
pcm = self._playback_queue.get_nowait()
|
||||
if self._output_gain != 1.0:
|
||||
pcm = _apply_gain(pcm, self._output_gain)
|
||||
n = min(len(pcm), len(outdata))
|
||||
outdata[:n] = pcm[:n]
|
||||
if n < len(outdata):
|
||||
|
||||
@@ -9,12 +9,26 @@ event loop (e.g. Textual's call_from_thread).
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionFailed(Exception):
|
||||
"""Connection attempt failed.
|
||||
|
||||
Attributes:
|
||||
retryable: Whether the caller should attempt reconnection.
|
||||
False for authentication rejections, True for network errors.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, retryable: bool = True):
|
||||
super().__init__(message)
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
session_id: int
|
||||
@@ -48,11 +62,15 @@ class MumbleClient:
|
||||
port: int = 64738,
|
||||
username: str = "tuimble-user",
|
||||
password: str = "",
|
||||
certfile: str = "",
|
||||
keyfile: str = "",
|
||||
):
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._certfile = certfile
|
||||
self._keyfile = keyfile
|
||||
self._mumble = None
|
||||
self._connected = False
|
||||
self._dispatcher: Callable | None = None
|
||||
@@ -134,24 +152,57 @@ class MumbleClient:
|
||||
# -- connection ----------------------------------------------------------
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the Mumble server (blocking)."""
|
||||
"""Connect to the Mumble server (blocking).
|
||||
|
||||
Raises:
|
||||
ConnectionFailed: On any connection failure. Check the
|
||||
``retryable`` attribute to decide whether to retry.
|
||||
"""
|
||||
import pymumble_py3 as pymumble
|
||||
import pymumble_py3.constants as const
|
||||
|
||||
self._mumble = pymumble.Mumble(
|
||||
self._host,
|
||||
self._username,
|
||||
port=self._port,
|
||||
password=self._password,
|
||||
reconnect=False,
|
||||
)
|
||||
self._mumble.set_codec_profile("audio")
|
||||
self._mumble.set_receive_sound(True)
|
||||
self._register_callbacks()
|
||||
kwargs = {
|
||||
"port": self._port,
|
||||
"password": self._password,
|
||||
"reconnect": False,
|
||||
}
|
||||
if self._certfile:
|
||||
kwargs["certfile"] = self._certfile
|
||||
if self._keyfile:
|
||||
kwargs["keyfile"] = self._keyfile
|
||||
|
||||
try:
|
||||
self._mumble = pymumble.Mumble(
|
||||
self._host,
|
||||
self._username,
|
||||
**kwargs,
|
||||
)
|
||||
self._mumble.set_codec_profile("audio")
|
||||
self._mumble.set_receive_sound(True)
|
||||
self._register_callbacks()
|
||||
|
||||
self._mumble.start()
|
||||
self._mumble.is_ready() # blocks until handshake completes
|
||||
except (socket.error, OSError) as exc:
|
||||
self._connected = False
|
||||
raise ConnectionFailed(
|
||||
f"network error: {exc}", retryable=True,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
self._connected = False
|
||||
raise ConnectionFailed(str(exc), retryable=True) from exc
|
||||
|
||||
if self._mumble.connected != const.PYMUMBLE_CONN_STATE_CONNECTED:
|
||||
self._connected = False
|
||||
raise ConnectionFailed(
|
||||
"server rejected connection", retryable=False,
|
||||
)
|
||||
|
||||
self._mumble.start()
|
||||
self._mumble.is_ready() # blocks until handshake completes
|
||||
self._connected = True
|
||||
log.info("connected to %s:%d as %s", self._host, self._port, self._username)
|
||||
log.info(
|
||||
"connected to %s:%d as %s",
|
||||
self._host, self._port, self._username,
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the server."""
|
||||
@@ -163,6 +214,16 @@ class MumbleClient:
|
||||
self._connected = False
|
||||
log.info("disconnected")
|
||||
|
||||
def reconnect(self):
|
||||
"""Disconnect and reconnect to the same server.
|
||||
|
||||
Raises:
|
||||
ConnectionFailed: On connection failure (see ``connect``).
|
||||
"""
|
||||
self.disconnect()
|
||||
self._mumble = None
|
||||
self.connect()
|
||||
|
||||
# -- actions -------------------------------------------------------------
|
||||
|
||||
def send_text(self, message: str):
|
||||
|
||||
@@ -16,6 +16,8 @@ class ServerConfig:
|
||||
username: str = "tuimble-user"
|
||||
password: str = ""
|
||||
channel: str = ""
|
||||
certfile: str = ""
|
||||
keyfile: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -24,6 +26,8 @@ class AudioConfig:
|
||||
output_device: int | None = None
|
||||
sample_rate: int = 48000
|
||||
frame_size: int = 960 # 20ms at 48kHz
|
||||
input_gain: float = 1.0
|
||||
output_gain: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for AudioPipeline."""
|
||||
|
||||
from tuimble.audio import FRAME_SIZE, SAMPLE_RATE, AudioPipeline
|
||||
import struct
|
||||
|
||||
from tuimble.audio import FRAME_SIZE, SAMPLE_RATE, AudioPipeline, _apply_gain
|
||||
|
||||
|
||||
def test_default_construction():
|
||||
@@ -125,3 +127,79 @@ def test_stop_without_start():
|
||||
"""Stop on unstarted pipeline should not raise."""
|
||||
ap = AudioPipeline()
|
||||
ap.stop()
|
||||
|
||||
|
||||
# -- _apply_gain tests -------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_gain_unity():
|
||||
"""Gain 1.0 returns identical samples."""
|
||||
pcm = struct.pack("<4h", 100, -200, 32767, -32768)
|
||||
assert _apply_gain(pcm, 1.0) == pcm
|
||||
|
||||
|
||||
def test_apply_gain_double():
|
||||
"""Gain 2.0 doubles sample values."""
|
||||
pcm = struct.pack("<2h", 100, -100)
|
||||
result = struct.unpack("<2h", _apply_gain(pcm, 2.0))
|
||||
assert result == (200, -200)
|
||||
|
||||
|
||||
def test_apply_gain_clips():
|
||||
"""Values exceeding int16 range are clipped."""
|
||||
pcm = struct.pack("<2h", 20000, -20000)
|
||||
result = struct.unpack("<2h", _apply_gain(pcm, 2.0))
|
||||
assert result == (32767, -32768)
|
||||
|
||||
|
||||
def test_apply_gain_zero():
|
||||
"""Gain 0.0 produces silence."""
|
||||
pcm = struct.pack("<2h", 1000, -1000)
|
||||
result = struct.unpack("<2h", _apply_gain(pcm, 0.0))
|
||||
assert result == (0, 0)
|
||||
|
||||
|
||||
def test_apply_gain_empty():
|
||||
"""Empty buffer returns empty."""
|
||||
assert _apply_gain(b"", 2.0) == b""
|
||||
|
||||
|
||||
# -- gain property tests ------------------------------------------------------
|
||||
|
||||
|
||||
def test_gain_defaults():
|
||||
ap = AudioPipeline()
|
||||
assert ap.input_gain == 1.0
|
||||
assert ap.output_gain == 1.0
|
||||
|
||||
|
||||
def test_gain_clamping():
|
||||
ap = AudioPipeline()
|
||||
ap.input_gain = 3.0
|
||||
assert ap.input_gain == 2.0
|
||||
ap.output_gain = -1.0
|
||||
assert ap.output_gain == 0.0
|
||||
|
||||
|
||||
def test_capture_callback_applies_input_gain():
|
||||
"""Input gain is applied to captured PCM."""
|
||||
ap = AudioPipeline()
|
||||
ap.capturing = True
|
||||
ap.input_gain = 0.5
|
||||
pcm = struct.pack("<2h", 1000, -1000)
|
||||
ap._capture_callback(pcm, 2, None, None)
|
||||
frame = ap.get_capture_frame()
|
||||
result = struct.unpack("<2h", frame)
|
||||
assert result == (500, -500)
|
||||
|
||||
|
||||
def test_playback_callback_applies_output_gain():
|
||||
"""Output gain is applied during playback."""
|
||||
ap = AudioPipeline()
|
||||
ap.output_gain = 0.5
|
||||
pcm = struct.pack("<2h", 1000, -1000)
|
||||
ap.queue_playback(pcm)
|
||||
outdata = bytearray(4)
|
||||
ap._playback_callback(outdata, 2, None, None)
|
||||
result = struct.unpack("<2h", bytes(outdata))
|
||||
assert result == (500, -500)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Tests for MumbleClient dispatcher and callback wiring."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tuimble.client import MumbleClient
|
||||
from tuimble.client import ConnectionFailed, MumbleClient
|
||||
|
||||
|
||||
def test_default_state():
|
||||
@@ -67,3 +67,56 @@ def test_set_self_deaf_noop_when_disconnected():
|
||||
client = MumbleClient(host="localhost")
|
||||
# Should not raise when not connected
|
||||
client.set_self_deaf(True)
|
||||
|
||||
|
||||
def test_cert_defaults():
|
||||
client = MumbleClient(host="localhost")
|
||||
assert client._certfile == ""
|
||||
assert client._keyfile == ""
|
||||
|
||||
|
||||
def test_cert_custom():
|
||||
client = MumbleClient(
|
||||
host="localhost",
|
||||
certfile="/path/cert.pem",
|
||||
keyfile="/path/key.pem",
|
||||
)
|
||||
assert client._certfile == "/path/cert.pem"
|
||||
assert client._keyfile == "/path/key.pem"
|
||||
|
||||
|
||||
def test_connection_failed_retryable():
|
||||
exc = ConnectionFailed("network error", retryable=True)
|
||||
assert str(exc) == "network error"
|
||||
assert exc.retryable is True
|
||||
|
||||
|
||||
def test_connection_failed_not_retryable():
|
||||
exc = ConnectionFailed("auth rejected", retryable=False)
|
||||
assert str(exc) == "auth rejected"
|
||||
assert exc.retryable is False
|
||||
|
||||
|
||||
def test_connection_failed_default_retryable():
|
||||
exc = ConnectionFailed("something broke")
|
||||
assert exc.retryable is True
|
||||
|
||||
|
||||
def test_reconnect_resets_state():
|
||||
client = MumbleClient(host="localhost")
|
||||
client._connected = True
|
||||
client._mumble = MagicMock()
|
||||
|
||||
with patch.object(client, "connect"):
|
||||
client.reconnect()
|
||||
|
||||
# disconnect should have cleared _mumble before connect
|
||||
assert client._connected is False
|
||||
|
||||
|
||||
def test_disconnect_clears_connected():
|
||||
client = MumbleClient(host="localhost")
|
||||
client._connected = True
|
||||
client._mumble = MagicMock()
|
||||
client.disconnect()
|
||||
assert client.connected is False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for configuration module."""
|
||||
|
||||
from tuimble.config import Config, PttConfig, ServerConfig
|
||||
from tuimble.config import AudioConfig, Config, PttConfig, ServerConfig
|
||||
|
||||
|
||||
def test_default_config():
|
||||
@@ -21,3 +21,27 @@ def test_ptt_config_defaults():
|
||||
ptt = PttConfig()
|
||||
assert ptt.key == "f4"
|
||||
assert ptt.backend == "auto"
|
||||
|
||||
|
||||
def test_audio_gain_defaults():
|
||||
acfg = AudioConfig()
|
||||
assert acfg.input_gain == 1.0
|
||||
assert acfg.output_gain == 1.0
|
||||
|
||||
|
||||
def test_audio_gain_custom():
|
||||
acfg = AudioConfig(input_gain=0.5, output_gain=1.5)
|
||||
assert acfg.input_gain == 0.5
|
||||
assert acfg.output_gain == 1.5
|
||||
|
||||
|
||||
def test_server_cert_defaults():
|
||||
srv = ServerConfig()
|
||||
assert srv.certfile == ""
|
||||
assert srv.keyfile == ""
|
||||
|
||||
|
||||
def test_server_cert_custom():
|
||||
srv = ServerConfig(certfile="/path/cert.pem", keyfile="/path/key.pem")
|
||||
assert srv.certfile == "/path/cert.pem"
|
||||
assert srv.keyfile == "/path/key.pem"
|
||||
|
||||
Reference in New Issue
Block a user