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 |
@@ -8,6 +8,28 @@ TUI Mumble client with voice support and push-to-talk.
|
|||||||
- Voice transmission with Opus codec
|
- Voice transmission with Opus codec
|
||||||
- Push-to-talk via Kitty keyboard protocol, evdev, or toggle
|
- Push-to-talk via Kitty keyboard protocol, evdev, or toggle
|
||||||
- Channel browsing and text chat
|
- 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
|
## Quick Start
|
||||||
|
|
||||||
@@ -29,9 +51,15 @@ mkdir -p ~/.config/tuimble
|
|||||||
host = "mumble.example.com"
|
host = "mumble.example.com"
|
||||||
port = 64738
|
port = 64738
|
||||||
username = "myname"
|
username = "myname"
|
||||||
|
# certfile = "/path/to/cert.pem"
|
||||||
|
# keyfile = "/path/to/key.pem"
|
||||||
|
|
||||||
|
[audio]
|
||||||
|
# output_gain = 1.0
|
||||||
|
# input_gain = 1.0
|
||||||
|
|
||||||
[ptt]
|
[ptt]
|
||||||
key = "space"
|
key = "f4"
|
||||||
mode = "hold"
|
mode = "hold"
|
||||||
backend = "auto"
|
backend = "auto"
|
||||||
```
|
```
|
||||||
|
|||||||
+7
-7
@@ -18,15 +18,15 @@
|
|||||||
## Phase 3 — Polish
|
## Phase 3 — Polish
|
||||||
|
|
||||||
- [x] Responsive terminal layout (adaptive sidebar, truncation, resize)
|
- [x] Responsive terminal layout (adaptive sidebar, truncation, resize)
|
||||||
- [ ] Channel tree navigation
|
- [x] Channel tree navigation
|
||||||
- [ ] User list with status indicators
|
- [x] User list with status indicators
|
||||||
- [ ] Volume control
|
- [x] Volume control
|
||||||
- [ ] Server certificate handling
|
- [x] Server certificate handling
|
||||||
- [ ] Config file hot-reload
|
- [x] Config file hot-reload
|
||||||
|
|
||||||
## Phase 4 — Robustness
|
## Phase 4 — Robustness
|
||||||
|
|
||||||
- [ ] Reconnection handling
|
- [x] Reconnection handling
|
||||||
- [ ] Error recovery
|
- [x] Error recovery
|
||||||
- [ ] Audio device hot-swap
|
- [ ] Audio device hot-swap
|
||||||
- [ ] Comprehensive test suite
|
- [ ] Comprehensive test suite
|
||||||
|
|||||||
+8
-2
@@ -7,8 +7,14 @@
|
|||||||
- [x] Channel tree population from server data
|
- [x] Channel tree population from server data
|
||||||
- [x] Audio pipeline integration with MumbleClient
|
- [x] Audio pipeline integration with MumbleClient
|
||||||
- [x] PTT wiring (key events -> audio.capturing toggle)
|
- [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
|
## Pending
|
||||||
|
|
||||||
- [ ] Channel join/navigation (select channel in tree, move into it)
|
- [ ] Audio device hot-swap
|
||||||
- [ ] Reconnection handling on disconnect
|
|
||||||
|
|||||||
+8
-2
@@ -6,7 +6,13 @@ make run launch tuimble
|
|||||||
make test run tests
|
make test run tests
|
||||||
make lint check code style
|
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
|
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`
|
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
|
### Connection refused
|
||||||
|
|
||||||
- Verify server address and port
|
- Verify server address and port
|
||||||
- Check firewall allows outbound TCP/UDP to port 64738
|
- Check firewall allows outbound TCP/UDP to port 64738
|
||||||
- Test with: `nc -zv <host> 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.
|
||||||
|
|||||||
+57
-3
@@ -11,11 +11,15 @@ tuimble --host mumble.example.com --user myname
|
|||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
|
| `Tab` | Cycle focus (chat input / channel tree) |
|
||||||
| `F1` | Toggle self-deafen |
|
| `F1` | Toggle self-deafen |
|
||||||
|
| `F2` | Cycle output volume |
|
||||||
|
| `F3` | Cycle input volume |
|
||||||
| `F4` | Push-to-talk (configurable) |
|
| `F4` | Push-to-talk (configurable) |
|
||||||
| `Enter` | Send message |
|
| `F5` | Reload config from disk |
|
||||||
| `Up` | Previous sent message (in chat input) |
|
| `Enter` | Send message / join channel (sidebar) |
|
||||||
| `Down` | Next sent message (in chat input) |
|
| `Up` | Previous message (input) / previous channel (sidebar) |
|
||||||
|
| `Down` | Next message (input) / next channel (sidebar) |
|
||||||
| `q` | Quit |
|
| `q` | Quit |
|
||||||
| `Ctrl+C` | 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
|
Note: cProfile captures the main thread only. Background workers
|
||||||
started with `@work(thread=True)` are not included.
|
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
|
## Configuration
|
||||||
|
|
||||||
See `~/.config/tuimble/config.toml`. All fields are optional;
|
See `~/.config/tuimble/config.toml`. All fields are optional;
|
||||||
|
|||||||
+517
-26
@@ -14,12 +14,27 @@ from textual.reactive import reactive
|
|||||||
from textual.widgets import Footer, Header, Input, RichLog, Static
|
from textual.widgets import Footer, Header, Input, RichLog, Static
|
||||||
|
|
||||||
from tuimble.audio import AudioPipeline
|
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.config import Config, load_config
|
||||||
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
|
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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) ----------------------------
|
# -- custom messages (pymumble thread -> Textual) ----------------------------
|
||||||
|
|
||||||
@@ -45,6 +60,14 @@ class ServerStateChanged(Message):
|
|||||||
pass
|
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 -----------------------------------------------------------------
|
# -- widgets -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -53,8 +76,17 @@ class StatusBar(Static):
|
|||||||
|
|
||||||
ptt_active = reactive(False)
|
ptt_active = reactive(False)
|
||||||
connected = reactive(False)
|
connected = reactive(False)
|
||||||
|
reconnecting = reactive(False)
|
||||||
self_deaf = reactive(False)
|
self_deaf = reactive(False)
|
||||||
server_info = reactive("")
|
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:
|
def render(self) -> str:
|
||||||
w = self.content_size.width if self.content_size.width > 0 else 80
|
w = self.content_size.width if self.content_size.width > 0 else 80
|
||||||
@@ -62,6 +94,9 @@ class StatusBar(Static):
|
|||||||
if self.connected:
|
if self.connected:
|
||||||
conn_sym = "[#9ece6a]\u25cf[/]"
|
conn_sym = "[#9ece6a]\u25cf[/]"
|
||||||
conn_full = f"{conn_sym} connected"
|
conn_full = f"{conn_sym} connected"
|
||||||
|
elif self.reconnecting:
|
||||||
|
conn_sym = "[#e0af68]\u25d0[/]"
|
||||||
|
conn_full = f"{conn_sym} reconnecting"
|
||||||
else:
|
else:
|
||||||
conn_sym = "[#f7768e]\u25cb[/]"
|
conn_sym = "[#f7768e]\u25cb[/]"
|
||||||
conn_full = f"{conn_sym} disconnected"
|
conn_full = f"{conn_sym} disconnected"
|
||||||
@@ -80,36 +115,79 @@ class StatusBar(Static):
|
|||||||
return f" {conn_sym} {deaf_sym}{ptt_sym}"
|
return f" {conn_sym} {deaf_sym}{ptt_sym}"
|
||||||
if w < 60:
|
if w < 60:
|
||||||
return f" {conn_full} {deaf_full}{' ' if deaf_full else ''}{ptt_full}"
|
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 ""
|
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):
|
class ChannelTree(Static):
|
||||||
"""Channel and user list."""
|
"""Channel and user list with keyboard navigation."""
|
||||||
|
|
||||||
DEFAULT_WIDTH = 24
|
DEFAULT_WIDTH = 24
|
||||||
|
can_focus = True
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._channels: dict[int, Channel] = {}
|
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(
|
def set_state(
|
||||||
self,
|
self,
|
||||||
channels: dict[int, Channel],
|
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:
|
) -> None:
|
||||||
self._channels = channels
|
self._channels = channels
|
||||||
self._users_by_channel = users_by_channel
|
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()
|
self.refresh()
|
||||||
|
|
||||||
def clear_state(self) -> None:
|
def clear_state(self) -> None:
|
||||||
self._channels = {}
|
self._channels = {}
|
||||||
self._users_by_channel = {}
|
self._users_by_channel = {}
|
||||||
|
self._channel_ids = []
|
||||||
|
self._focused_idx = 0
|
||||||
|
self._my_channel_id = None
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@property
|
def _build_channel_order(self) -> list[int]:
|
||||||
def _available_width(self) -> 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)."""
|
"""Usable character width (content area, excludes padding/border)."""
|
||||||
w = self.content_size.width
|
w = self.content_size.width
|
||||||
if w <= 0:
|
if w <= 0:
|
||||||
@@ -127,13 +205,23 @@ class ChannelTree(Static):
|
|||||||
return text[:max_width]
|
return text[:max_width]
|
||||||
return text[: max_width - 3] + "..."
|
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:
|
def render(self) -> str:
|
||||||
if not self._channels:
|
if not self._channels:
|
||||||
return " Channels\n [dim]\u2514\u2500 (not connected)[/]"
|
return " Channels\n [dim]\u2514\u2500 (not connected)[/]"
|
||||||
|
|
||||||
|
w = self._get_width()
|
||||||
lines = [" [bold]Channels[/]"]
|
lines = [" [bold]Channels[/]"]
|
||||||
root_id = self._find_root()
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _find_root(self) -> int:
|
def _find_root(self) -> int:
|
||||||
@@ -148,18 +236,37 @@ class ChannelTree(Static):
|
|||||||
lines: list[str],
|
lines: list[str],
|
||||||
indent: int,
|
indent: int,
|
||||||
is_last: bool,
|
is_last: bool,
|
||||||
|
w: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
ch = self._channels.get(channel_id)
|
ch = self._channels.get(channel_id)
|
||||||
if ch is None:
|
if ch is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
w = self._available_width
|
|
||||||
prefix = " " * indent
|
prefix = " " * indent
|
||||||
branch = "\u2514\u2500" if is_last else "\u251c\u2500"
|
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)
|
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, [])
|
users = self._users_by_channel.get(channel_id, [])
|
||||||
children = [
|
children = [
|
||||||
@@ -171,12 +278,15 @@ class ChannelTree(Static):
|
|||||||
sub_indent = indent + 2
|
sub_indent = indent + 2
|
||||||
sub_prefix = " " * sub_indent
|
sub_prefix = " " * sub_indent
|
||||||
# sub_indent + 2 bullet chars + 1 space
|
# sub_indent + 2 bullet chars + 1 space
|
||||||
user_max = w - sub_indent - 3
|
user_max = w - sub_indent - 5
|
||||||
for i, user_name in enumerate(users):
|
for i, user in enumerate(users):
|
||||||
is_last_item = i == len(users) - 1 and not children
|
is_last_item = i == len(users) - 1 and not children
|
||||||
bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500"
|
bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500"
|
||||||
uname = self._truncate(user_name, user_max)
|
uname = self._truncate(user.name, user_max)
|
||||||
lines.append(f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]")
|
status = self._user_status(user)
|
||||||
|
lines.append(
|
||||||
|
f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]{status}"
|
||||||
|
)
|
||||||
|
|
||||||
for i, child in enumerate(children):
|
for i, child in enumerate(children):
|
||||||
self._render_tree(
|
self._render_tree(
|
||||||
@@ -184,8 +294,30 @@ class ChannelTree(Static):
|
|||||||
lines,
|
lines,
|
||||||
indent + 2,
|
indent + 2,
|
||||||
is_last=i == len(children) - 1,
|
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):
|
class ChatLog(RichLog):
|
||||||
"""Message log."""
|
"""Message log."""
|
||||||
@@ -215,6 +347,9 @@ class TuimbleApp(App):
|
|||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
#sidebar:focus {
|
||||||
|
border-right: solid #7aa2f7;
|
||||||
|
}
|
||||||
#chat-area {
|
#chat-area {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
}
|
}
|
||||||
@@ -243,6 +378,9 @@ class TuimbleApp(App):
|
|||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
("f1", "toggle_deaf", "Deafen"),
|
("f1", "toggle_deaf", "Deafen"),
|
||||||
|
("f2", "cycle_output_volume", "Vol Out"),
|
||||||
|
("f3", "cycle_input_volume", "Vol In"),
|
||||||
|
("f5", "reload_config", "Reload"),
|
||||||
("q", "quit", "Quit"),
|
("q", "quit", "Quit"),
|
||||||
("ctrl+c", "quit", "Quit"),
|
("ctrl+c", "quit", "Quit"),
|
||||||
]
|
]
|
||||||
@@ -259,6 +397,8 @@ class TuimbleApp(App):
|
|||||||
port=srv.port,
|
port=srv.port,
|
||||||
username=srv.username,
|
username=srv.username,
|
||||||
password=srv.password,
|
password=srv.password,
|
||||||
|
certfile=srv.certfile,
|
||||||
|
keyfile=srv.keyfile,
|
||||||
)
|
)
|
||||||
self._history: list[str] = []
|
self._history: list[str] = []
|
||||||
self._history_idx: int = -1
|
self._history_idx: int = -1
|
||||||
@@ -270,6 +410,13 @@ class TuimbleApp(App):
|
|||||||
input_device=acfg.input_device,
|
input_device=acfg.input_device,
|
||||||
output_device=acfg.output_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:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
@@ -282,6 +429,10 @@ class TuimbleApp(App):
|
|||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
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 = self.query_one("#chatlog", ChatLog)
|
||||||
chatlog.write("[dim]tuimble v0.1.0[/dim]")
|
chatlog.write("[dim]tuimble v0.1.0[/dim]")
|
||||||
srv = self._config.server
|
srv = self._config.server
|
||||||
@@ -290,8 +441,8 @@ class TuimbleApp(App):
|
|||||||
|
|
||||||
# -- server connection (worker thread) -----------------------------------
|
# -- server connection (worker thread) -----------------------------------
|
||||||
|
|
||||||
@work(thread=True)
|
def _wire_client_callbacks(self) -> None:
|
||||||
def _connect_to_server(self) -> None:
|
"""Wire pymumble callbacks to message dispatchers."""
|
||||||
self._client.set_dispatcher(self.call_from_thread)
|
self._client.set_dispatcher(self.call_from_thread)
|
||||||
self._client.on_connected = self._cb_connected
|
self._client.on_connected = self._cb_connected
|
||||||
self._client.on_disconnected = self._cb_disconnected
|
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_channel_update = self._cb_state_changed
|
||||||
self._client.on_sound_received = self._cb_sound_received
|
self._client.on_sound_received = self._cb_sound_received
|
||||||
|
|
||||||
|
@work(thread=True)
|
||||||
|
def _connect_to_server(self) -> None:
|
||||||
|
self._wire_client_callbacks()
|
||||||
try:
|
try:
|
||||||
self._client.connect()
|
self._client.connect()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -324,10 +478,112 @@ class TuimbleApp(App):
|
|||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
chatlog.write(f"[#f7768e]\u2717 {text}[/]")
|
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 ----------------------------------------------------
|
# -- message handlers ----------------------------------------------------
|
||||||
|
|
||||||
def on_server_connected(self, _msg: ServerConnected) -> None:
|
def on_server_connected(self, _msg: ServerConnected) -> None:
|
||||||
|
self._intentional_disconnect = False
|
||||||
|
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
|
status.reconnecting = False
|
||||||
status.connected = True
|
status.connected = True
|
||||||
srv = self._config.server
|
srv = self._config.server
|
||||||
status.server_info = f"{srv.host}:{srv.port}"
|
status.server_info = f"{srv.host}:{srv.port}"
|
||||||
@@ -340,15 +596,24 @@ class TuimbleApp(App):
|
|||||||
self._start_audio()
|
self._start_audio()
|
||||||
|
|
||||||
def on_server_disconnected(self, _msg: ServerDisconnected) -> None:
|
def on_server_disconnected(self, _msg: ServerDisconnected) -> None:
|
||||||
|
self._audio.stop()
|
||||||
|
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
status.connected = False
|
status.connected = False
|
||||||
status.server_info = ""
|
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 = self.query_one("#chatlog", ChatLog)
|
||||||
chatlog.write("[#f7768e]\u2717 disconnected from server[/]")
|
chatlog.write("[#f7768e]\u2717 disconnected from server[/]")
|
||||||
|
|
||||||
tree = self.query_one("#sidebar", ChannelTree)
|
self._reconnecting = True
|
||||||
tree.clear_state()
|
self._reconnect_attempt = 0
|
||||||
|
self._reconnect_loop()
|
||||||
|
|
||||||
def on_text_message_received(self, msg: TextMessageReceived) -> None:
|
def on_text_message_received(self, msg: TextMessageReceived) -> None:
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
@@ -357,7 +622,24 @@ class TuimbleApp(App):
|
|||||||
chatlog.write(f"[#7aa2f7]{msg.sender}[/] {clean}")
|
chatlog.write(f"[#7aa2f7]{msg.sender}[/] {clean}")
|
||||||
|
|
||||||
def on_server_state_changed(self, _msg: ServerStateChanged) -> None:
|
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")
|
@on(Input.Submitted, "#input")
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
@@ -408,13 +690,13 @@ class TuimbleApp(App):
|
|||||||
return
|
return
|
||||||
channels = self._client.channels
|
channels = self._client.channels
|
||||||
users = self._client.users
|
users = self._client.users
|
||||||
users_by_channel: dict[int, list[str]] = {}
|
users_by_channel: dict[int, list[User]] = {}
|
||||||
for u in users.values():
|
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():
|
for lst in users_by_channel.values():
|
||||||
lst.sort()
|
lst.sort(key=lambda u: u.name)
|
||||||
tree = self.query_one("#sidebar", ChannelTree)
|
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 --------------------------------------------------------------
|
# -- deafen --------------------------------------------------------------
|
||||||
|
|
||||||
@@ -431,10 +713,217 @@ class TuimbleApp(App):
|
|||||||
else:
|
else:
|
||||||
chatlog.write("[#9ece6a]\u2713 undeafened[/]")
|
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 -----------------------------------------------------------------
|
# -- PTT -----------------------------------------------------------------
|
||||||
|
|
||||||
def on_key(self, event: events.Key) -> None:
|
def on_key(self, event: events.Key) -> None:
|
||||||
"""Handle input history navigation and PTT key events."""
|
"""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
|
focused = self.focused
|
||||||
if isinstance(focused, Input) and event.key in ("up", "down"):
|
if isinstance(focused, Input) and event.key in ("up", "down"):
|
||||||
inp = focused
|
inp = focused
|
||||||
@@ -494,6 +983,8 @@ class TuimbleApp(App):
|
|||||||
# -- lifecycle -----------------------------------------------------------
|
# -- lifecycle -----------------------------------------------------------
|
||||||
|
|
||||||
def action_quit(self) -> None:
|
def action_quit(self) -> None:
|
||||||
|
self._intentional_disconnect = True
|
||||||
|
self._cancel_reconnect()
|
||||||
self._audio.stop()
|
self._audio.stop()
|
||||||
self._client.set_dispatcher(None)
|
self._client.set_dispatcher(None)
|
||||||
self._client.disconnect()
|
self._client.disconnect()
|
||||||
|
|||||||
+36
-1
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
|
import struct
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,6 +19,17 @@ FRAME_SIZE = 960 # 20ms at 48kHz
|
|||||||
DTYPE = "int16"
|
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:
|
class AudioPipeline:
|
||||||
"""Manages audio input/output streams and Opus codec."""
|
"""Manages audio input/output streams and Opus codec."""
|
||||||
|
|
||||||
@@ -40,6 +52,8 @@ class AudioPipeline:
|
|||||||
self._output_stream = None
|
self._output_stream = None
|
||||||
self._capturing = False
|
self._capturing = False
|
||||||
self._deafened = False
|
self._deafened = False
|
||||||
|
self._input_gain = 1.0
|
||||||
|
self._output_gain = 1.0
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Open audio streams."""
|
"""Open audio streams."""
|
||||||
@@ -93,13 +107,32 @@ class AudioPipeline:
|
|||||||
def deafened(self, value: bool):
|
def deafened(self, value: bool):
|
||||||
self._deafened = value
|
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):
|
def _capture_callback(self, indata, frames, time_info, status):
|
||||||
"""Called by sounddevice when input data is available."""
|
"""Called by sounddevice when input data is available."""
|
||||||
if status:
|
if status:
|
||||||
log.warning("capture status: %s", status)
|
log.warning("capture status: %s", status)
|
||||||
if self._capturing:
|
if self._capturing:
|
||||||
try:
|
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:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -112,6 +145,8 @@ class AudioPipeline:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
pcm = self._playback_queue.get_nowait()
|
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))
|
n = min(len(pcm), len(outdata))
|
||||||
outdata[:n] = pcm[:n]
|
outdata[:n] = pcm[:n]
|
||||||
if n < len(outdata):
|
if n < len(outdata):
|
||||||
|
|||||||
+67
-6
@@ -9,12 +9,26 @@ event loop (e.g. Textual's call_from_thread).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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
|
@dataclass
|
||||||
class User:
|
class User:
|
||||||
session_id: int
|
session_id: int
|
||||||
@@ -48,11 +62,15 @@ class MumbleClient:
|
|||||||
port: int = 64738,
|
port: int = 64738,
|
||||||
username: str = "tuimble-user",
|
username: str = "tuimble-user",
|
||||||
password: str = "",
|
password: str = "",
|
||||||
|
certfile: str = "",
|
||||||
|
keyfile: str = "",
|
||||||
):
|
):
|
||||||
self._host = host
|
self._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
self._username = username
|
self._username = username
|
||||||
self._password = password
|
self._password = password
|
||||||
|
self._certfile = certfile
|
||||||
|
self._keyfile = keyfile
|
||||||
self._mumble = None
|
self._mumble = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._dispatcher: Callable | None = None
|
self._dispatcher: Callable | None = None
|
||||||
@@ -134,15 +152,30 @@ class MumbleClient:
|
|||||||
# -- connection ----------------------------------------------------------
|
# -- connection ----------------------------------------------------------
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
"""Connect to the Mumble server (blocking)."""
|
"""Connect to the Mumble server (blocking).
|
||||||
import pymumble_py3 as pymumble
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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._mumble = pymumble.Mumble(
|
||||||
self._host,
|
self._host,
|
||||||
self._username,
|
self._username,
|
||||||
port=self._port,
|
**kwargs,
|
||||||
password=self._password,
|
|
||||||
reconnect=False,
|
|
||||||
)
|
)
|
||||||
self._mumble.set_codec_profile("audio")
|
self._mumble.set_codec_profile("audio")
|
||||||
self._mumble.set_receive_sound(True)
|
self._mumble.set_receive_sound(True)
|
||||||
@@ -150,8 +183,26 @@ class MumbleClient:
|
|||||||
|
|
||||||
self._mumble.start()
|
self._mumble.start()
|
||||||
self._mumble.is_ready() # blocks until handshake completes
|
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._connected = True
|
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):
|
def disconnect(self):
|
||||||
"""Disconnect from the server."""
|
"""Disconnect from the server."""
|
||||||
@@ -163,6 +214,16 @@ class MumbleClient:
|
|||||||
self._connected = False
|
self._connected = False
|
||||||
log.info("disconnected")
|
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 -------------------------------------------------------------
|
# -- actions -------------------------------------------------------------
|
||||||
|
|
||||||
def send_text(self, message: str):
|
def send_text(self, message: str):
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class ServerConfig:
|
|||||||
username: str = "tuimble-user"
|
username: str = "tuimble-user"
|
||||||
password: str = ""
|
password: str = ""
|
||||||
channel: str = ""
|
channel: str = ""
|
||||||
|
certfile: str = ""
|
||||||
|
keyfile: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -24,6 +26,8 @@ class AudioConfig:
|
|||||||
output_device: int | None = None
|
output_device: int | None = None
|
||||||
sample_rate: int = 48000
|
sample_rate: int = 48000
|
||||||
frame_size: int = 960 # 20ms at 48kHz
|
frame_size: int = 960 # 20ms at 48kHz
|
||||||
|
input_gain: float = 1.0
|
||||||
|
output_gain: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+79
-1
@@ -1,6 +1,8 @@
|
|||||||
"""Tests for AudioPipeline."""
|
"""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():
|
def test_default_construction():
|
||||||
@@ -125,3 +127,79 @@ def test_stop_without_start():
|
|||||||
"""Stop on unstarted pipeline should not raise."""
|
"""Stop on unstarted pipeline should not raise."""
|
||||||
ap = AudioPipeline()
|
ap = AudioPipeline()
|
||||||
ap.stop()
|
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)
|
||||||
|
|||||||
+55
-2
@@ -1,8 +1,8 @@
|
|||||||
"""Tests for MumbleClient dispatcher and callback wiring."""
|
"""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():
|
def test_default_state():
|
||||||
@@ -67,3 +67,56 @@ def test_set_self_deaf_noop_when_disconnected():
|
|||||||
client = MumbleClient(host="localhost")
|
client = MumbleClient(host="localhost")
|
||||||
# Should not raise when not connected
|
# Should not raise when not connected
|
||||||
client.set_self_deaf(True)
|
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
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for configuration module."""
|
"""Tests for configuration module."""
|
||||||
|
|
||||||
from tuimble.config import Config, PttConfig, ServerConfig
|
from tuimble.config import AudioConfig, Config, PttConfig, ServerConfig
|
||||||
|
|
||||||
|
|
||||||
def test_default_config():
|
def test_default_config():
|
||||||
@@ -21,3 +21,27 @@ def test_ptt_config_defaults():
|
|||||||
ptt = PttConfig()
|
ptt = PttConfig()
|
||||||
assert ptt.key == "f4"
|
assert ptt.key == "f4"
|
||||||
assert ptt.backend == "auto"
|
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