Compare commits

...

18 Commits

Author SHA1 Message Date
Username
be6574ae79 app: debounce channel tree and cache render width 2026-02-24 15:32:57 +01:00
Username
57f4559a38 update roadmap and tasklist 2026-02-24 14:55:03 +01:00
Username
351b980b42 docs: add reconnection and error recovery docs 2026-02-24 14:54:40 +01:00
Username
6f590ede38 app: add reconnection status to status bar 2026-02-24 14:54:07 +01:00
Username
e443facd3b app: add auto-reconnect with backoff 2026-02-24 14:43:29 +01:00
Username
d02bb5239a app: stop audio on disconnect 2026-02-24 14:40:24 +01:00
Username
a041069cc9 client: add ConnectionFailed and reconnect method 2026-02-24 14:34:40 +01:00
Username
0b186f1f0c update roadmap and tasklist 2026-02-24 14:19:48 +01:00
Username
0b178d371e docs: add volume, certificate, and reload docs 2026-02-24 14:19:15 +01:00
Username
0bc41d1a46 app: add config reload on F5 2026-02-24 14:18:19 +01:00
Username
e9726da401 app: add volume key bindings and status display 2026-02-24 14:17:02 +01:00
Username
5e44ee9e38 app: wire volume and certificate config 2026-02-24 14:15:42 +01:00
Username
6467f5fe32 client: add certificate parameters 2026-02-24 14:15:24 +01:00
Username
e9944c88eb audio: add gain control to capture and playback 2026-02-24 14:14:52 +01:00
Username
eb98165370 config: add gain and certificate fields 2026-02-24 14:13:18 +01:00
Username
0856ab3c55 docs: update tasklist with completed items 2026-02-24 13:58:02 +01:00
Username
b852459eff feat: add channel navigation and user status indicators
Tab cycles focus to sidebar where Up/Down navigates channels
and Enter joins the selected one. Muted/deafened users show
status symbols in the channel tree.
2026-02-24 13:57:57 +01:00
Username
ec81fac507 docs: add key bindings and fix ptt key in readme 2026-02-24 13:31:56 +01:00
13 changed files with 938 additions and 60 deletions

View File

@@ -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"
```

View File

@@ -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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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;

View File

@@ -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()

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"