Compare commits
35 Commits
be6574ae79
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67467c846c | ||
|
|
2e70e73086 | ||
|
|
4bf8adc5e2 | ||
|
|
15fbf0040a | ||
|
|
2533e43391 | ||
|
|
f94f94907d | ||
|
|
26695e6e70 | ||
|
|
e8f34b4d80 | ||
|
|
b62993459a | ||
|
|
76c9c494a7 | ||
|
|
d9373f8a3b | ||
|
|
3dbd126239 | ||
|
|
9e6c11e588 | ||
|
|
df6f2ff354 | ||
|
|
85f373a8b5 | ||
|
|
0f476a25d5 | ||
|
|
d4a8f34dac | ||
|
|
aa17159f7e | ||
|
|
bbd28e2840 | ||
|
|
0ae0e77814 | ||
|
|
c0be5f164e | ||
|
|
d117576449 | ||
|
|
65de74193a | ||
|
|
7c57e03e6d | ||
|
|
4c1a545a8b | ||
|
|
e2039558d7 | ||
|
|
0cf3702c8f | ||
|
|
216a4be4fd | ||
|
|
a6380b53f7 | ||
|
|
bfa79eadcb | ||
|
|
7a2c8e3a5d | ||
|
|
88e8d4d923 | ||
|
|
897c5b1f6c | ||
|
|
44da57d084 | ||
|
|
8be475f23f |
@@ -11,6 +11,7 @@ TUI Mumble client with voice support and push-to-talk.
|
|||||||
- Chat input history (Up/Down arrow navigation)
|
- Chat input history (Up/Down arrow navigation)
|
||||||
- Self-deafen toggle
|
- Self-deafen toggle
|
||||||
- Volume control (input/output gain)
|
- Volume control (input/output gain)
|
||||||
|
- Voice pitch shifting (deeper/higher)
|
||||||
- Client certificate authentication
|
- Client certificate authentication
|
||||||
- Auto-reconnect on network loss
|
- Auto-reconnect on network loss
|
||||||
- Config hot-reload (F5)
|
- Config hot-reload (F5)
|
||||||
@@ -25,6 +26,8 @@ TUI Mumble client with voice support and push-to-talk.
|
|||||||
| `F3` | Cycle input volume |
|
| `F3` | Cycle input volume |
|
||||||
| `F4` | Push-to-talk (configurable) |
|
| `F4` | Push-to-talk (configurable) |
|
||||||
| `F5` | Reload config from disk |
|
| `F5` | Reload config from disk |
|
||||||
|
| `F6` | Pitch down (1 semitone) |
|
||||||
|
| `F7` | Pitch up (1 semitone) |
|
||||||
| `Enter` | Send message / join channel (sidebar) |
|
| `Enter` | Send message / join channel (sidebar) |
|
||||||
| `Up` | Previous message (input) / previous channel (sidebar) |
|
| `Up` | Previous message (input) / previous channel (sidebar) |
|
||||||
| `Down` | Next message (input) / next channel (sidebar) |
|
| `Down` | Next message (input) / next channel (sidebar) |
|
||||||
@@ -57,6 +60,7 @@ username = "myname"
|
|||||||
[audio]
|
[audio]
|
||||||
# output_gain = 1.0
|
# output_gain = 1.0
|
||||||
# input_gain = 1.0
|
# input_gain = 1.0
|
||||||
|
# pitch = 0 # semitones, -12 to +12
|
||||||
|
|
||||||
[ptt]
|
[ptt]
|
||||||
key = "f4"
|
key = "f4"
|
||||||
|
|||||||
@@ -28,5 +28,5 @@
|
|||||||
|
|
||||||
- [x] Reconnection handling
|
- [x] Reconnection handling
|
||||||
- [x] Error recovery
|
- [x] Error recovery
|
||||||
- [ ] Audio device hot-swap
|
- [x] Audio device hot-swap
|
||||||
- [ ] Comprehensive test suite
|
- [x] Comprehensive test suite
|
||||||
|
|||||||
55
TASKLIST.md
55
TASKLIST.md
@@ -1,7 +1,58 @@
|
|||||||
# Task List
|
# Task List
|
||||||
|
|
||||||
|
## In Progress
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
|
|
||||||
|
### Phase 4 -- Robustness
|
||||||
|
|
||||||
|
- [x] Audio device hot-swap (DeviceMonitor polling + pipeline rebuild)
|
||||||
|
- [x] Widget tests (41 tests: StatusBar breakpoints, ChannelTree navigation, volume/truncate helpers)
|
||||||
|
|
||||||
|
|
||||||
|
### Phase E -- Polish
|
||||||
|
|
||||||
|
- [x] Deduplicate `_detect_config_changes` with `dataclasses.asdict()`
|
||||||
|
- [x] Suppress PTT chatlog spam for hold-mode
|
||||||
|
- [x] Add `-> None` return annotations throughout
|
||||||
|
- [x] Cache `users`/`channels` in `MumbleClient` with dirty flag
|
||||||
|
|
||||||
|
### Phase D -- Test Coverage
|
||||||
|
|
||||||
|
- [x] Add `ReconnectManager` unit tests (8 tests)
|
||||||
|
- [x] Add `InputHistory` unit tests (11 tests)
|
||||||
|
- [x] Add `_strip_html` edge case tests (13 tests)
|
||||||
|
- [x] Add config `_load_section` / `load_config` tests (5 tests)
|
||||||
|
- [x] Refactor `test_audio.py` to use public interfaces
|
||||||
|
- [x] Add `stop()` queue drain test
|
||||||
|
|
||||||
|
### Phase C -- Architecture
|
||||||
|
|
||||||
|
- [x] Cache `_find_root()` result in `ChannelTree.set_state()`
|
||||||
|
- [x] Extract `InputHistory` class
|
||||||
|
- [x] Extract `_make_client()` factory (4 duplicate sites)
|
||||||
|
- [x] Extract `ReconnectManager` to `reconnect.py` (`threading.Event`)
|
||||||
|
|
||||||
|
### Phase A -- Safety
|
||||||
|
|
||||||
|
- [x] Replace `_strip_html` regex with `html.parser` stripper
|
||||||
|
- [x] Add defensive config loading (filter unknown TOML keys)
|
||||||
|
- [x] Guard `join_channel`/`send_text` against `KeyError`
|
||||||
|
|
||||||
|
### Phase B -- Performance
|
||||||
|
|
||||||
|
- [x] Replace `_apply_gain` struct with `array` module (numpy unavailable)
|
||||||
|
- [x] Replace `time.sleep(0.005)` polling with `queue.get(timeout=)`
|
||||||
|
- [x] Drain audio queues on `stop()`
|
||||||
|
|
||||||
|
### Earlier Work
|
||||||
|
|
||||||
- [x] Wire TUI to MumbleClient (connect on startup, display state)
|
- [x] Wire TUI to MumbleClient (connect on startup, display state)
|
||||||
- [x] Implement text message send/receive in chat log
|
- [x] Implement text message send/receive in chat log
|
||||||
- [x] Channel tree population from server data
|
- [x] Channel tree population from server data
|
||||||
@@ -14,7 +65,3 @@
|
|||||||
- [x] Server certificate handling (certfile/keyfile config)
|
- [x] Server certificate handling (certfile/keyfile config)
|
||||||
- [x] Config file hot-reload (F5, safe/restart change detection)
|
- [x] Config file hot-reload (F5, safe/restart change detection)
|
||||||
- [x] Reconnection handling with auto-retry and backoff
|
- [x] Reconnection handling with auto-retry and backoff
|
||||||
|
|
||||||
## Pending
|
|
||||||
|
|
||||||
- [ ] Audio device hot-swap
|
|
||||||
|
|||||||
@@ -17,12 +17,24 @@ tuimble --host mumble.example.com --user myname
|
|||||||
| `F3` | Cycle input volume |
|
| `F3` | Cycle input volume |
|
||||||
| `F4` | Push-to-talk (configurable) |
|
| `F4` | Push-to-talk (configurable) |
|
||||||
| `F5` | Reload config from disk |
|
| `F5` | Reload config from disk |
|
||||||
|
| `F6` | Pitch down (1 semitone) |
|
||||||
|
| `F7` | Pitch up (1 semitone) |
|
||||||
| `Enter` | Send message / join channel (sidebar) |
|
| `Enter` | Send message / join channel (sidebar) |
|
||||||
| `Up` | Previous message (input) / previous channel (sidebar) |
|
| `Up` | Previous message (input) / previous channel (sidebar) |
|
||||||
| `Down` | Next message (input) / next channel (sidebar) |
|
| `Down` | Next message (input) / next channel (sidebar) |
|
||||||
| `q` | Quit |
|
| `q` | Quit |
|
||||||
| `Ctrl+C` | Quit |
|
| `Ctrl+C` | Quit |
|
||||||
|
|
||||||
|
## Slash Commands
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `/help` | List available commands |
|
||||||
|
| `/deafen` | Toggle self-deafen |
|
||||||
|
| `/mute` | Toggle self-mute (suppresses PTT) |
|
||||||
|
| `/unmute` | Unmute yourself |
|
||||||
|
| `/register` | Register your identity on the server |
|
||||||
|
|
||||||
## Push-to-Talk Modes
|
## Push-to-Talk Modes
|
||||||
|
|
||||||
- **toggle** — press to start, press again to stop (default)
|
- **toggle** — press to start, press again to stop (default)
|
||||||
@@ -45,8 +57,8 @@ python3 -m pstats /tmp/tuimble.prof # interactive explorer
|
|||||||
# pip install snakeviz && snakeviz /tmp/tuimble.prof
|
# pip install snakeviz && snakeviz /tmp/tuimble.prof
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: cProfile captures the main thread only. Background workers
|
Profiling covers both the main thread and all worker threads
|
||||||
started with `@work(thread=True)` are not included.
|
(including the audio send loop where pitch shifting runs).
|
||||||
|
|
||||||
## Volume Control
|
## Volume Control
|
||||||
|
|
||||||
@@ -60,6 +72,21 @@ output_gain = 0.75 # 0.0 to 2.0 (default 1.0)
|
|||||||
input_gain = 1.5 # 0.0 to 2.0 (default 1.0)
|
input_gain = 1.5 # 0.0 to 2.0 (default 1.0)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Voice Pitch
|
||||||
|
|
||||||
|
`F6` and `F7` adjust outgoing voice pitch in 1-semitone steps,
|
||||||
|
ranging from -12 to +12. Negative values make the voice deeper,
|
||||||
|
positive values make it higher. The current setting is shown in the
|
||||||
|
status bar when non-zero.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[audio]
|
||||||
|
pitch = 3 # semitones, -12 to +12 (default 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
When pitch is 0, the processing step is skipped entirely (no CPU
|
||||||
|
overhead).
|
||||||
|
|
||||||
## Client Certificates
|
## Client Certificates
|
||||||
|
|
||||||
For servers requiring client certificate authentication:
|
For servers requiring client certificate authentication:
|
||||||
@@ -77,7 +104,7 @@ Both must be PEM-encoded. Leave empty to connect without a certificate.
|
|||||||
`F5` reloads `~/.config/tuimble/config.toml` from disk.
|
`F5` reloads `~/.config/tuimble/config.toml` from disk.
|
||||||
|
|
||||||
**Safe changes** (applied immediately): PTT key/mode/backend, audio
|
**Safe changes** (applied immediately): PTT key/mode/backend, audio
|
||||||
gain values.
|
gain values, pitch.
|
||||||
|
|
||||||
**Restart-requiring changes** (server settings, audio device/rate):
|
**Restart-requiring changes** (server settings, audio device/rate):
|
||||||
shown as warnings. Press `F5` again to confirm reconnect, or any
|
shown as warnings. Press `F5` again to confirm reconnect, or any
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ dependencies = [
|
|||||||
"textual>=1.0.0",
|
"textual>=1.0.0",
|
||||||
"pymumble>=1.6",
|
"pymumble>=1.6",
|
||||||
"sounddevice>=0.5.0",
|
"sounddevice>=0.5.0",
|
||||||
|
"numpy>=1.24.0",
|
||||||
"tomli>=2.0.0;python_version<'3.11'",
|
"tomli>=2.0.0;python_version<'3.11'",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -38,3 +39,4 @@ select = ["E", "F", "W", "I"]
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "strict"
|
||||||
|
|||||||
142
reports/code-review-2026-02-24-response.md
Normal file
142
reports/code-review-2026-02-24-response.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Code Review Response
|
||||||
|
|
||||||
|
**Original review:** `code-review-2026-02-24.md`
|
||||||
|
**Date applied:** 2026-02-24
|
||||||
|
**Commits:** 14 functional + 4 tasklist updates (be6574a..d4a8f34)
|
||||||
|
**Test suite:** 83 tests, all passing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
All 17 items in the priority matrix were evaluated. 15 were applied across five
|
||||||
|
phases (A--E). Two were deferred to the backlog with rationale.
|
||||||
|
|
||||||
|
### Disposition by severity
|
||||||
|
|
||||||
|
| Severity | Total | Applied | Deferred | Notes |
|
||||||
|
|----------|-------|---------|----------|--------------------------------|
|
||||||
|
| Critical | 4 | 3 | 1 | Widget tests deferred (backlog)|
|
||||||
|
| Important| 9 | 9 | 0 | |
|
||||||
|
| Nice | 4 | 3 | 1 | Frozen config deferred |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A -- Safety
|
||||||
|
|
||||||
|
| # | Item | Resolution |
|
||||||
|
|---|------|------------|
|
||||||
|
| 2 | Replace regex HTML stripping | Replaced with `html.parser.HTMLParser` subclass. Handles malformed tags, comments, nested markup, entities. 13 edge-case tests added. |
|
||||||
|
| 3 | Validate TOML config keys | Added `_load_section()` that filters unknown keys via `dataclasses.fields()` before construction. Logs warnings for typos. 5 tests added. |
|
||||||
|
| 10 | Guard `join_channel`/`send_text` | `join_channel` uses `.get()` + `ValueError`. `send_text` wraps lookup in `try/except (KeyError, AttributeError)`. |
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
```
|
||||||
|
8be475f app: replace regex html stripping with stdlib parser
|
||||||
|
44da57d config: filter unknown toml keys before dataclass init
|
||||||
|
897c5b1 client: guard join_channel and send_text against stale ids
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B -- Performance
|
||||||
|
|
||||||
|
| # | Item | Resolution |
|
||||||
|
|---|------|------------|
|
||||||
|
| 5 | Use numpy for `_apply_gain` | **Deviation:** numpy is NOT a transitive dep of sounddevice (only cffi). Used stdlib `array.array("h")` instead -- eliminates struct pack/unpack overhead without adding a dependency. |
|
||||||
|
| 6 | Replace polling sleep with `queue.get(timeout=)` | `get_capture_frame()` now accepts `timeout` param. Send loop uses `timeout=0.02`. No polling, instant wake on data. |
|
||||||
|
| 15 | Drain audio queues on `stop()` | Both capture and playback queues drained after stream close. Prevents stale frames leaking on restart. Test added. |
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
```
|
||||||
|
7a2c8e3 audio: replace struct pack/unpack with array module in _apply_gain
|
||||||
|
bfa79ea app: replace audio send polling with blocking queue.get
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C -- Architecture
|
||||||
|
|
||||||
|
| # | Item | Resolution |
|
||||||
|
|---|------|------------|
|
||||||
|
| 1 | Extract reconnect manager | Extracted to `src/tuimble/reconnect.py` (93 lines). Uses `threading.Event` for cancellation (item #7 resolved simultaneously). Instant cancel, no polling. |
|
||||||
|
| 7 | `threading.Event` for reconnect | Resolved as part of item #1 above. `Event.wait(timeout=delay)` replaces the 0.5s sleep loop. |
|
||||||
|
| 8 | Extract `_make_client()` factory | Single factory method replaces 4 duplicate 7-line instantiation blocks. |
|
||||||
|
| 9 | Compile `_strip_html` regex | Superseded by item #2 (HTMLParser replacement). No regex remains. |
|
||||||
|
| 13 | Extract `InputHistory` class | Extracted to standalone class in `app.py` (29 lines). Clean `push`/`up`/`down` interface. 11 tests added. |
|
||||||
|
| -- | Cache `_find_root()` | `ChannelTree.set_state()` now caches `_root_id`. Eliminates redundant traversal during render. |
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
```
|
||||||
|
216a4be app: extract InputHistory, _make_client factory, cache _find_root
|
||||||
|
0cf3702 app: extract ReconnectManager to reconnect.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase D -- Test Coverage
|
||||||
|
|
||||||
|
| # | Item | Resolution |
|
||||||
|
|---|------|------------|
|
||||||
|
| 4 | Widget/app tests | **Partially addressed.** Extracted components (ReconnectManager, InputHistory, _strip_html, config loading) are now fully tested without the TUI harness. Widget-level tests (StatusBar breakpoints, ChannelTree navigation) deferred to backlog -- requires Textual `App.run_test()`. |
|
||||||
|
| -- | Refactor test_audio.py | Replaced private attribute access with public interface calls (`_capture_callback` + `get_capture_frame` instead of direct queue manipulation). |
|
||||||
|
|
||||||
|
**Test additions:**
|
||||||
|
- `test_reconnect.py` -- 8 tests (backoff formula, cancellation, exhaustion, non-retryable abort)
|
||||||
|
- `test_history.py` -- 11 tests (navigation, draft preservation, push reset, full cycle)
|
||||||
|
- `test_strip_html.py` -- 13 tests (nested tags, entities, comments, malformed HTML)
|
||||||
|
- `test_config.py` -- 5 new tests (_load_section filtering, missing file, unknown keys)
|
||||||
|
- `test_audio.py` -- 1 new test (stop drains queues), existing tests refactored
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
```
|
||||||
|
4c1a545 test: add ReconnectManager unit tests
|
||||||
|
7c57e03 test: add InputHistory unit tests
|
||||||
|
65de741 test: add _strip_html edge cases and config validation tests
|
||||||
|
d117576 test: refactor audio tests to use public interfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase E -- Polish
|
||||||
|
|
||||||
|
| # | Item | Resolution |
|
||||||
|
|---|------|------------|
|
||||||
|
| 12 | Deduplicate config change detection | `_detect_config_changes` and `_apply_restart_changes` now use `dataclasses.asdict()` for comparison. New config fields are automatically picked up. |
|
||||||
|
| 11 | Suppress PTT chatlog spam | `_on_ptt_change` skips chatlog writes when `ptt.mode == "hold"`. Status bar indicator still updates in real time. |
|
||||||
|
| 16 | Add `-> None` return annotations | Added to 21 methods across `client.py` and `audio.py`. |
|
||||||
|
| -- | Cache `users`/`channels` properties | Properties now cache with dirty flags. Invalidated on connect, disconnect, and user/channel event callbacks. |
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
```
|
||||||
|
0ae0e77 app: deduplicate config detection, suppress hold-mode PTT spam
|
||||||
|
bbd28e2 audio: add -> None return annotations
|
||||||
|
aa17159 client: add return annotations and cache users/channels properties
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred Items
|
||||||
|
|
||||||
|
| # | Item | Reason |
|
||||||
|
|---|------|--------|
|
||||||
|
| 4 | Widget tests (StatusBar, ChannelTree) | Requires Textual async test harness (`App.run_test()`). Extracted logic is tested; widget integration tests are in the backlog. |
|
||||||
|
| 14 | Inline callback wrappers with lambdas | Low priority. Current named methods aid debugging (stack traces show `_cb_connected` vs `<lambda>`). |
|
||||||
|
| 17 | Frozen dataclasses for Config | Requires refactoring `_apply_safe_changes` which mutates `self._config.ptt`. Scope exceeds incremental polish. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
| | Before | After | Delta |
|
||||||
|
|----------------|--------|--------|--------|
|
||||||
|
| Source LOC | ~1,750 | 1,866 | +116 |
|
||||||
|
| Test LOC | ~480 | 818 | +338 |
|
||||||
|
| Test count | 45 | 83 | +38 |
|
||||||
|
| Modules | 7 | 8 | +1 |
|
||||||
|
| `app.py` LOC | ~999 | 965 | -34 |
|
||||||
|
|
||||||
|
New module: `src/tuimble/reconnect.py` (93 lines).
|
||||||
|
Net source growth is primarily from the extracted reconnect module and cached
|
||||||
|
property logic in `client.py`. `app.py` shrank despite gaining the
|
||||||
|
`InputHistory` class and `dataclasses` import.
|
||||||
738
reports/code-review-2026-02-24.md
Normal file
738
reports/code-review-2026-02-24.md
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
# tuimble Code Review
|
||||||
|
|
||||||
|
**Date:** 2026-02-24
|
||||||
|
**Scope:** Full codebase review (1,750 LOC across 7 modules)
|
||||||
|
**Constraints:** No theme/layout redesign. Targeted, incremental improvements only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture & Design
|
||||||
|
|
||||||
|
### [CRITICAL] `app.py` is a 999-line monolith
|
||||||
|
|
||||||
|
`TuimbleApp` directly owns connection lifecycle, audio management, reconnection
|
||||||
|
logic, config reload state machine, input history, PTT wiring, and all UI
|
||||||
|
updates. This makes it hard to test, hard to reason about, and hard to extend.
|
||||||
|
|
||||||
|
**Specific extractions worth making:**
|
||||||
|
|
||||||
|
#### a) Reconnection manager
|
||||||
|
|
||||||
|
The reconnect loop, backoff logic, attempt counting, and cancellation form a
|
||||||
|
self-contained state machine currently scattered across `_reconnect_loop`,
|
||||||
|
`_log_reconnect`, `_on_reconnect_success`, `_on_reconnect_exhausted`,
|
||||||
|
`_cancel_reconnect`, plus three boolean flags (`_reconnecting`,
|
||||||
|
`_reconnect_attempt`, `_intentional_disconnect`).
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/tuimble/reconnect.py
|
||||||
|
from __future__ import annotations
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INITIAL_DELAY = 2
|
||||||
|
MAX_DELAY = 30
|
||||||
|
MAX_RETRIES = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReconnectState:
|
||||||
|
active: bool = False
|
||||||
|
attempt: int = 0
|
||||||
|
intentional: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ReconnectManager:
|
||||||
|
"""Exponential backoff reconnection with cancellation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
connect_fn: Callable[[], None],
|
||||||
|
on_attempt: Callable[[int, float], None],
|
||||||
|
on_success: Callable[[], None],
|
||||||
|
on_failure: Callable[[int, str], None],
|
||||||
|
on_exhausted: Callable[[], None],
|
||||||
|
):
|
||||||
|
self._connect = connect_fn
|
||||||
|
self._on_attempt = on_attempt
|
||||||
|
self._on_success = on_success
|
||||||
|
self._on_failure = on_failure
|
||||||
|
self._on_exhausted = on_exhausted
|
||||||
|
self.state = ReconnectState()
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
self.state.active = False
|
||||||
|
self.state.attempt = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delay(self) -> float:
|
||||||
|
return min(
|
||||||
|
INITIAL_DELAY * (2 ** (self.state.attempt - 1)),
|
||||||
|
MAX_DELAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Blocking reconnect loop -- run in a worker thread."""
|
||||||
|
self.state.active = True
|
||||||
|
self.state.attempt = 0
|
||||||
|
while self.state.active:
|
||||||
|
self.state.attempt += 1
|
||||||
|
d = self.delay
|
||||||
|
self._on_attempt(self.state.attempt, d)
|
||||||
|
|
||||||
|
elapsed = 0.0
|
||||||
|
while elapsed < d and self.state.active:
|
||||||
|
time.sleep(0.5)
|
||||||
|
elapsed += 0.5
|
||||||
|
|
||||||
|
if not self.state.active:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._connect()
|
||||||
|
self.state.active = False
|
||||||
|
self.state.attempt = 0
|
||||||
|
self._on_success()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
self._on_failure(self.state.attempt, str(exc))
|
||||||
|
|
||||||
|
if self.state.attempt >= MAX_RETRIES:
|
||||||
|
self.state.active = False
|
||||||
|
self._on_exhausted()
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
Removes ~80 lines from `app.py` and makes the backoff logic unit-testable
|
||||||
|
without a TUI.
|
||||||
|
|
||||||
|
#### b) Input history
|
||||||
|
|
||||||
|
The history navigation in `on_key` (lines 928-953) is a classic "small feature
|
||||||
|
that grew legs." Extract to a reusable class:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class InputHistory:
|
||||||
|
def __init__(self):
|
||||||
|
self._entries: list[str] = []
|
||||||
|
self._idx: int = -1
|
||||||
|
self._draft: str = ""
|
||||||
|
|
||||||
|
def push(self, text: str) -> None:
|
||||||
|
self._entries.append(text)
|
||||||
|
self._idx = -1
|
||||||
|
|
||||||
|
def up(self, current: str) -> str | None:
|
||||||
|
if not self._entries:
|
||||||
|
return None
|
||||||
|
if self._idx == -1:
|
||||||
|
self._draft = current
|
||||||
|
self._idx = len(self._entries) - 1
|
||||||
|
elif self._idx > 0:
|
||||||
|
self._idx -= 1
|
||||||
|
return self._entries[self._idx]
|
||||||
|
|
||||||
|
def down(self) -> str | None:
|
||||||
|
if self._idx == -1:
|
||||||
|
return None
|
||||||
|
if self._idx < len(self._entries) - 1:
|
||||||
|
self._idx += 1
|
||||||
|
return self._entries[self._idx]
|
||||||
|
self._idx = -1
|
||||||
|
return self._draft
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] Thread safety gaps in `_reconnect_loop`
|
||||||
|
|
||||||
|
`self._reconnecting` and `self._reconnect_attempt` are plain booleans/ints
|
||||||
|
accessed from both the main thread (via `_cancel_reconnect`,
|
||||||
|
`action_reload_config`) and the worker thread (`_reconnect_loop`). Python's GIL
|
||||||
|
makes this "mostly safe" for simple booleans, but it's fragile and not
|
||||||
|
guaranteed for compound operations.
|
||||||
|
|
||||||
|
`app.py:486-488`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
while self._reconnecting: # read on worker thread
|
||||||
|
self._reconnect_attempt += 1 # read-modify-write on worker thread
|
||||||
|
```
|
||||||
|
|
||||||
|
Meanwhile `_cancel_reconnect` at line 572 writes both from the main thread.
|
||||||
|
Use `threading.Event` for the cancellation flag:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._reconnect_cancel = threading.Event()
|
||||||
|
|
||||||
|
# in _reconnect_loop:
|
||||||
|
while not self._reconnect_cancel.is_set():
|
||||||
|
...
|
||||||
|
# interruptible sleep:
|
||||||
|
if self._reconnect_cancel.wait(timeout=delay):
|
||||||
|
break
|
||||||
|
|
||||||
|
# in _cancel_reconnect:
|
||||||
|
self._reconnect_cancel.set()
|
||||||
|
```
|
||||||
|
|
||||||
|
This also eliminates the 0.5s sleep polling loop (lines 496-499), making
|
||||||
|
cancellation instant instead of up to 500ms delayed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] `MumbleClient` creates `User`/`Channel` objects on every property access
|
||||||
|
|
||||||
|
`client.py:112-141` -- The `users` and `channels` properties rebuild full
|
||||||
|
dictionaries from pymumble's internal state on every call.
|
||||||
|
`_refresh_channel_tree` in `app.py:688-699` calls both, then iterates all
|
||||||
|
users. During a debounced state update, this means three full traversals.
|
||||||
|
|
||||||
|
Consider caching with a dirty flag set by callbacks:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def users(self) -> dict[int, User]:
|
||||||
|
if self._users_dirty or self._users_cache is None:
|
||||||
|
self._users_cache = self._build_users()
|
||||||
|
self._users_dirty = False
|
||||||
|
return self._users_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [NICE] Callback wiring pattern
|
||||||
|
|
||||||
|
`client.py:79-84` uses bare `None`-able attributes for callbacks:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.on_connected = None
|
||||||
|
self.on_disconnected = None
|
||||||
|
```
|
||||||
|
|
||||||
|
This works but lacks type safety and discoverability. Consider a typed protocol
|
||||||
|
or simple typed attributes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
class ClientCallbacks(Protocol):
|
||||||
|
def on_connected(self) -> None: ...
|
||||||
|
def on_disconnected(self) -> None: ...
|
||||||
|
def on_text_message(self, sender: str, text: str) -> None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Not urgent -- current approach is pragmatic for the project size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. TUI/UX Best Practices
|
||||||
|
|
||||||
|
### [IMPORTANT] `_strip_html` recompiles regex on every message
|
||||||
|
|
||||||
|
`app.py:994-999`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _strip_html(text: str) -> str:
|
||||||
|
import re
|
||||||
|
clean = re.sub(r"<[^>]+>", "", text)
|
||||||
|
return html.unescape(clean)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `re` import inside the function is fine (cached by Python), but the pattern
|
||||||
|
is recompiled on every call. Compile once at module level:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
def _strip_html(text: str) -> str:
|
||||||
|
return html.unescape(_HTML_TAG_RE.sub("", text))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] PTT state logging is noisy
|
||||||
|
|
||||||
|
`app.py:969-973` -- Every PTT toggle writes to the chatlog. For toggle mode
|
||||||
|
this is acceptable, but if someone later implements hold-mode (evdev),
|
||||||
|
pressing/releasing space would flood the log with "transmitting"/"stopped
|
||||||
|
transmitting" on every keypress.
|
||||||
|
|
||||||
|
Add a config option or suppress log for hold-mode, or use the status bar alone:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _on_ptt_change(self, transmitting: bool) -> None:
|
||||||
|
self._audio.capturing = transmitting
|
||||||
|
status = self.query_one("#status", StatusBar)
|
||||||
|
status.ptt_active = transmitting
|
||||||
|
# Only log for toggle mode; hold-mode updates too frequently
|
||||||
|
if self._config.ptt.mode == "toggle":
|
||||||
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
|
if transmitting:
|
||||||
|
chatlog.write("[#e0af68] transmitting[/]")
|
||||||
|
else:
|
||||||
|
chatlog.write("[dim] stopped transmitting[/dim]")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [NICE] `on_resize` sets sidebar width but doesn't refresh the channel tree
|
||||||
|
|
||||||
|
`app.py:977-981` -- After resize, the sidebar width changes but
|
||||||
|
`ChannelTree.render()` uses `self.content_size.width` which may not update
|
||||||
|
until the next render pass. The tree truncation logic depends on width. Call
|
||||||
|
`tree.refresh()` explicitly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def on_resize(self, event: events.Resize) -> None:
|
||||||
|
sidebar = self.query_one("#sidebar", ChannelTree)
|
||||||
|
sidebar.styles.width = max(16, min(32, event.size.width // 4))
|
||||||
|
sidebar.refresh()
|
||||||
|
self.query_one("#status", StatusBar).refresh()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Code Quality
|
||||||
|
|
||||||
|
### [CRITICAL] `load_config` passes raw TOML dicts to dataclass constructors without validation
|
||||||
|
|
||||||
|
`config.py:62-67`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if "server" in data:
|
||||||
|
cfg.server = ServerConfig(**data["server"])
|
||||||
|
```
|
||||||
|
|
||||||
|
If the TOML file contains an unexpected key (`ServerConfig(host="x",
|
||||||
|
typo_field="y")`), this raises an opaque `TypeError`. If a value has the wrong
|
||||||
|
type (`port = "abc"`), it silently accepts it and fails later.
|
||||||
|
|
||||||
|
Add defensive loading:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _load_section(cls, data: dict, section: str):
|
||||||
|
"""Load a dataclass section, ignoring unknown keys."""
|
||||||
|
raw = data.get(section, {})
|
||||||
|
import dataclasses
|
||||||
|
valid_keys = {f.name for f in dataclasses.fields(cls)}
|
||||||
|
filtered = {k: v for k, v in raw.items() if k in valid_keys}
|
||||||
|
return cls(**filtered)
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
cfg.server = _load_section(ServerConfig, data, "server")
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents crashes from typos in config files -- critical for a user-facing
|
||||||
|
tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] Duplicate logic for creating `MumbleClient`
|
||||||
|
|
||||||
|
`MumbleClient` is instantiated in four places with the same pattern:
|
||||||
|
|
||||||
|
1. `__init__` (line 395)
|
||||||
|
2. `_reconnect_loop` (line 507)
|
||||||
|
3. `action_reload_config` disconnected path (line 864)
|
||||||
|
4. `_apply_restart_changes` (line 824)
|
||||||
|
|
||||||
|
Extract a factory method:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _make_client(self, srv: ServerConfig | None = None) -> MumbleClient:
|
||||||
|
srv = srv or self._config.server
|
||||||
|
return MumbleClient(
|
||||||
|
host=srv.host,
|
||||||
|
port=srv.port,
|
||||||
|
username=srv.username,
|
||||||
|
password=srv.password,
|
||||||
|
certfile=srv.certfile,
|
||||||
|
keyfile=srv.keyfile,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] `ChannelTree._find_root` is called twice per render
|
||||||
|
|
||||||
|
`render()` at line 223 calls `_find_root()`, and `_build_channel_order()` at
|
||||||
|
line 173 also calls `_find_root()`. Both execute during `set_state()` then
|
||||||
|
`render()`. Cache the root ID:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def set_state(self, channels, users_by_channel, my_channel_id=None):
|
||||||
|
self._channels = channels
|
||||||
|
self._users_by_channel = users_by_channel
|
||||||
|
self._my_channel_id = my_channel_id
|
||||||
|
self._root_id = self._find_root() if channels else 0
|
||||||
|
self._channel_ids = self._build_channel_order()
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] `_detect_config_changes` duplicates field-level comparison
|
||||||
|
|
||||||
|
`app.py:740-780` manually compares each field. If you add a new server field,
|
||||||
|
you must remember to add it to both `_detect_config_changes` AND
|
||||||
|
`_apply_restart_changes`. Use `dataclasses.asdict()` for comparison:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
def _detect_config_changes(self, old: Config, new: Config):
|
||||||
|
safe, restart = [], []
|
||||||
|
for attr in ("key", "mode", "backend"):
|
||||||
|
if getattr(old.ptt, attr) != getattr(new.ptt, attr):
|
||||||
|
safe.append(
|
||||||
|
f"ptt.{attr}: {getattr(old.ptt, attr)} -> "
|
||||||
|
f"{getattr(new.ptt, attr)}"
|
||||||
|
)
|
||||||
|
for attr in ("input_gain", "output_gain"):
|
||||||
|
if getattr(old.audio, attr) != getattr(new.audio, attr):
|
||||||
|
safe.append(
|
||||||
|
f"audio.{attr}: {getattr(old.audio, attr)} -> "
|
||||||
|
f"{getattr(new.audio, attr)}"
|
||||||
|
)
|
||||||
|
if asdict(old.server) != asdict(new.server):
|
||||||
|
for k in asdict(old.server):
|
||||||
|
if getattr(old.server, k) != getattr(new.server, k):
|
||||||
|
label = "password" if k == "password" else k
|
||||||
|
restart.append(f"server.{label} changed")
|
||||||
|
for attr in ("input_device", "output_device", "sample_rate"):
|
||||||
|
if getattr(old.audio, attr) != getattr(new.audio, attr):
|
||||||
|
restart.append(f"audio.{attr} changed")
|
||||||
|
return safe, restart
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [NICE] Type annotations missing on several methods
|
||||||
|
|
||||||
|
- `client.py:86` `set_dispatcher(self, fn: Callable)` -- should be
|
||||||
|
`Callable | None`
|
||||||
|
- `client.py:93` `_dispatch(self, callback, *args)` -- no return type, no
|
||||||
|
`callback` type
|
||||||
|
- `client.py:154` `connect(self)` -- no return type annotation
|
||||||
|
- `audio.py:58` `start(self)` -- no return type
|
||||||
|
|
||||||
|
Add `-> None` where appropriate. Minor but compounds into unclear interfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Performance
|
||||||
|
|
||||||
|
### [IMPORTANT] `_apply_gain` uses Python-level sample iteration
|
||||||
|
|
||||||
|
`audio.py:22-30` -- Unpacking, scaling, and repacking every sample via a list
|
||||||
|
comprehension in Python is slow for real-time audio (960 samples = 1920 bytes
|
||||||
|
per 20ms frame):
|
||||||
|
|
||||||
|
```python
|
||||||
|
samples = struct.unpack(fmt, pcm[: n * 2])
|
||||||
|
scaled = [max(-32768, min(32767, int(s * gain))) for s in samples]
|
||||||
|
return struct.pack(fmt, *scaled)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use numpy (already a transitive dependency via sounddevice):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def _apply_gain(pcm: bytes, gain: float) -> bytes:
|
||||||
|
if not pcm:
|
||||||
|
return pcm
|
||||||
|
samples = np.frombuffer(pcm, dtype=np.int16)
|
||||||
|
scaled = np.clip(samples * gain, -32768, 32767).astype(np.int16)
|
||||||
|
return scaled.tobytes()
|
||||||
|
```
|
||||||
|
|
||||||
|
This is ~50-100x faster for a 960-sample frame. Since sounddevice already
|
||||||
|
pulls in numpy, there's no new dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] `_audio_send_loop` polls with `time.sleep(0.005)`
|
||||||
|
|
||||||
|
`app.py:676-684`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
while self._client.connected:
|
||||||
|
frame = self._audio.get_capture_frame()
|
||||||
|
if frame is not None:
|
||||||
|
self._client.send_audio(frame)
|
||||||
|
else:
|
||||||
|
time.sleep(0.005)
|
||||||
|
```
|
||||||
|
|
||||||
|
5ms polling means up to 5ms of added latency on every frame, plus unnecessary
|
||||||
|
CPU wake-ups when idle. Use `queue.Queue.get(timeout=...)` instead:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_capture_frame(self, timeout: float = 0.02) -> bytes | None:
|
||||||
|
try:
|
||||||
|
return self._capture_queue.get(timeout=timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the send loop becomes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
while self._client.connected:
|
||||||
|
frame = self._audio.get_capture_frame(timeout=0.02)
|
||||||
|
if frame is not None:
|
||||||
|
self._client.send_audio(frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
No polling, no wasted cycles, deterministic wake on data arrival.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [NICE] `ChannelTree.render()` rebuilds the entire tree string every refresh
|
||||||
|
|
||||||
|
For small channel lists (typical Mumble servers have 5-30 channels), this is
|
||||||
|
fine. But `_render_tree` is recursive and allocates many string fragments. If
|
||||||
|
performance becomes an issue, cache the rendered string and invalidate on
|
||||||
|
`set_state()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Scalability & Extensibility
|
||||||
|
|
||||||
|
### [IMPORTANT] No structured event bus
|
||||||
|
|
||||||
|
All communication between components flows through Textual's message system,
|
||||||
|
which is good. But the `_cb_*` callback wrappers in `app.py:462-475` are thin
|
||||||
|
boilerplate. Consider a mapping-driven approach:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _wire_client_callbacks(self) -> None:
|
||||||
|
self._client.set_dispatcher(self.call_from_thread)
|
||||||
|
self._client.on_connected = lambda: self.post_message(ServerConnected())
|
||||||
|
self._client.on_disconnected = lambda: self.post_message(ServerDisconnected())
|
||||||
|
self._client.on_text_message = (
|
||||||
|
lambda s, t: self.post_message(TextMessageReceived(s, t))
|
||||||
|
)
|
||||||
|
self._client.on_user_update = lambda: self.post_message(ServerStateChanged())
|
||||||
|
self._client.on_channel_update = (
|
||||||
|
lambda: self.post_message(ServerStateChanged())
|
||||||
|
)
|
||||||
|
self._client.on_sound_received = (
|
||||||
|
lambda _u, pcm: self._audio.queue_playback(pcm)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This eliminates six one-line methods. The audio callback bypasses the message
|
||||||
|
system correctly (it's hot-path, no UI update needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [NICE] `Config` is mutable
|
||||||
|
|
||||||
|
Config dataclasses are mutable, and `_apply_safe_changes` directly mutates
|
||||||
|
`self._config.ptt`. Consider frozen dataclasses with a `replace()` pattern to
|
||||||
|
prevent accidental state corruption:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PttConfig:
|
||||||
|
key: str = "f4"
|
||||||
|
mode: str = "toggle"
|
||||||
|
backend: str = "auto"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: `self._config = dataclasses.replace(self._config, ptt=new.ptt)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
### [CRITICAL] No tests for `TuimbleApp` or any widget
|
||||||
|
|
||||||
|
The test suite covers `audio.py`, `client.py`, `ptt.py`, and `config.py` --
|
||||||
|
all non-UI modules. There are zero tests for:
|
||||||
|
|
||||||
|
- `StatusBar` rendering (connection states, volume bars, responsive breakpoints)
|
||||||
|
- `ChannelTree` rendering (tree structure, truncation, focus navigation)
|
||||||
|
- `TuimbleApp` message handling (connect/disconnect/state change flows)
|
||||||
|
- Config reload state machine
|
||||||
|
- Input history navigation
|
||||||
|
|
||||||
|
Textual provides `App.run_test()` for async widget testing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from textual.testing import AppTest
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_bar_connected():
|
||||||
|
app = TuimbleApp()
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
status = app.query_one("#status", StatusBar)
|
||||||
|
status.connected = True
|
||||||
|
await pilot.pause()
|
||||||
|
rendered = status.render()
|
||||||
|
assert "connected" in rendered
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority tests to add:**
|
||||||
|
|
||||||
|
1. `StatusBar.render()` at each width breakpoint (< 40, < 60, >= 60)
|
||||||
|
2. `ChannelTree` keyboard navigation (up/down/enter)
|
||||||
|
3. `_strip_html` with edge cases (nested tags, malformed HTML, entities)
|
||||||
|
4. `_next_volume` wraparound behavior
|
||||||
|
5. Config reload with unknown TOML keys (crash test)
|
||||||
|
6. `_detect_config_changes` safe vs restart classification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] Tests access private attributes
|
||||||
|
|
||||||
|
`test_audio.py` directly accesses `ap._capture_queue`, `ap._playback_queue`,
|
||||||
|
`ap._sample_rate`. This couples tests to implementation. Where possible, test
|
||||||
|
through public interfaces:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Instead of: ap._capture_queue.put(b"\x01\x02\x03")
|
||||||
|
# Use the actual capture callback:
|
||||||
|
ap.capturing = True
|
||||||
|
ap._capture_callback(b"\x01\x02\x03", 1, None, None)
|
||||||
|
assert ap.get_capture_frame() == b"\x01\x02\x03"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Security & Robustness
|
||||||
|
|
||||||
|
### [CRITICAL] `_strip_html` regex is insufficient for Mumble HTML
|
||||||
|
|
||||||
|
`app.py:996-999` uses `re.sub(r"<[^>]+>", "", text)` which fails on:
|
||||||
|
|
||||||
|
- Malformed tags: `<img src="x" onerror="alert(1)"` (no closing `>`)
|
||||||
|
- Nested markup: `<a href="<script>">`
|
||||||
|
- CDATA/comments: `<!-- <script> -->`
|
||||||
|
|
||||||
|
While Textual's Rich markup won't execute scripts, malformed input could break
|
||||||
|
Rich's parser or produce garbled output. Consider using `html.parser` from the
|
||||||
|
stdlib:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
class _HTMLStripper(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._parts: list[str] = []
|
||||||
|
|
||||||
|
def handle_data(self, data: str):
|
||||||
|
self._parts.append(data)
|
||||||
|
|
||||||
|
def get_text(self) -> str:
|
||||||
|
return "".join(self._parts)
|
||||||
|
|
||||||
|
def _strip_html(text: str) -> str:
|
||||||
|
stripper = _HTMLStripper()
|
||||||
|
stripper.feed(text)
|
||||||
|
return html.unescape(stripper.get_text())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] `join_channel` doesn't handle missing channel IDs
|
||||||
|
|
||||||
|
`client.py:240-243`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def join_channel(self, channel_id: int):
|
||||||
|
if self._mumble and self._connected:
|
||||||
|
self._mumble.channels[channel_id].move_in()
|
||||||
|
```
|
||||||
|
|
||||||
|
If `channel_id` is stale (channel was removed between tree render and user
|
||||||
|
pressing Enter), this raises `KeyError`. Add a guard:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def join_channel(self, channel_id: int):
|
||||||
|
if self._mumble and self._connected:
|
||||||
|
ch = self._mumble.channels.get(channel_id)
|
||||||
|
if ch is None:
|
||||||
|
raise ValueError(f"channel {channel_id} not found")
|
||||||
|
ch.move_in()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [IMPORTANT] `send_text` silently fails on missing channel
|
||||||
|
|
||||||
|
`client.py:229-233`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def send_text(self, message: str):
|
||||||
|
if self._mumble and self._connected:
|
||||||
|
ch = self._mumble.channels[self._mumble.users.myself["channel_id"]]
|
||||||
|
ch.send_text_message(message)
|
||||||
|
```
|
||||||
|
|
||||||
|
If `myself` has no `channel_id` key (edge case during connection setup), this
|
||||||
|
crashes with `KeyError`. Guard with try/except or a `my_channel_id` check
|
||||||
|
first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [NICE] `audio.py` `stop()` doesn't drain queues
|
||||||
|
|
||||||
|
After `stop()`, the capture and playback queues still hold stale frames. If
|
||||||
|
`start()` is called again (audio device hot-swap), old frames leak into the new
|
||||||
|
session:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def stop(self):
|
||||||
|
for stream in (self._input_stream, self._output_stream):
|
||||||
|
if stream is not None:
|
||||||
|
stream.stop()
|
||||||
|
stream.close()
|
||||||
|
self._input_stream = None
|
||||||
|
self._output_stream = None
|
||||||
|
# Drain stale frames
|
||||||
|
while not self._capture_queue.empty():
|
||||||
|
try:
|
||||||
|
self._capture_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
while not self._playback_queue.empty():
|
||||||
|
try:
|
||||||
|
self._playback_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Matrix
|
||||||
|
|
||||||
|
| # | Area | Severity | Item |
|
||||||
|
|----|---------------|-----------|------------------------------------------------------|
|
||||||
|
| 1 | Architecture | Critical | Extract reconnect manager from app.py |
|
||||||
|
| 2 | Security | Critical | Replace regex HTML stripping with proper parser |
|
||||||
|
| 3 | Robustness | Critical | Validate TOML config keys before dataclass init |
|
||||||
|
| 4 | Testing | Critical | Add widget/app tests using Textual's test harness |
|
||||||
|
| 5 | Performance | Important | Use numpy for `_apply_gain` (transitive dep exists) |
|
||||||
|
| 6 | Performance | Important | Replace polling sleep with `queue.get(timeout=)` |
|
||||||
|
| 7 | Thread safety | Important | Use `threading.Event` for reconnect cancellation |
|
||||||
|
| 8 | Code quality | Important | Extract `_make_client()` factory (4 dup sites) |
|
||||||
|
| 9 | Code quality | Important | Compile `_strip_html` regex at module level |
|
||||||
|
| 10 | Robustness | Important | Guard `join_channel`/`send_text` against `KeyError` |
|
||||||
|
| 11 | UX | Important | Suppress PTT chatlog spam for hold-mode |
|
||||||
|
| 12 | Code quality | Important | Deduplicate config change detection logic |
|
||||||
|
| 13 | Architecture | Nice | Extract `InputHistory` class |
|
||||||
|
| 14 | Architecture | Nice | Inline callback wrappers with lambdas |
|
||||||
|
| 15 | Robustness | Nice | Drain audio queues on `stop()` |
|
||||||
|
| 16 | Code quality | Nice | Add return type annotations throughout |
|
||||||
|
| 17 | Scalability | Nice | Consider frozen dataclasses for Config |
|
||||||
@@ -31,10 +31,17 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
def _run_profiled(app, dest):
|
def _run_profiled(app, dest):
|
||||||
"""Run the app under cProfile with periodic 30s dumps."""
|
"""Run the app under cProfile with periodic 30s dumps.
|
||||||
|
|
||||||
|
Profiles both the main thread and all worker threads (including
|
||||||
|
the audio send loop where PitchShifter.process runs). Each
|
||||||
|
thread gets its own cProfile.Profile; stats are merged on dump.
|
||||||
|
"""
|
||||||
import cProfile
|
import cProfile
|
||||||
|
import pstats
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Event, Thread
|
from threading import Event, Lock, Thread
|
||||||
|
|
||||||
if dest is None:
|
if dest is None:
|
||||||
from tuimble.config import CONFIG_DIR
|
from tuimble.config import CONFIG_DIR
|
||||||
@@ -47,10 +54,34 @@ def _run_profiled(app, dest):
|
|||||||
|
|
||||||
prof = cProfile.Profile()
|
prof = cProfile.Profile()
|
||||||
stop = Event()
|
stop = Event()
|
||||||
|
thread_profiles: list[cProfile.Profile] = []
|
||||||
|
lock = Lock()
|
||||||
|
|
||||||
|
def _thread_bootstrap(frame, event, arg):
|
||||||
|
"""Installed via threading.setprofile; creates a per-thread profiler.
|
||||||
|
|
||||||
|
Called once per new thread as the profile function. Immediately
|
||||||
|
creates and enables a cProfile.Profile whose C-level hook
|
||||||
|
replaces this Python-level one for the remainder of the thread.
|
||||||
|
"""
|
||||||
|
tp = cProfile.Profile()
|
||||||
|
with lock:
|
||||||
|
thread_profiles.append(tp)
|
||||||
|
tp.enable()
|
||||||
|
|
||||||
|
threading.setprofile(_thread_bootstrap)
|
||||||
|
|
||||||
|
def _dump_all():
|
||||||
|
"""Merge main + thread profiles and write to disk."""
|
||||||
|
stats = pstats.Stats(prof)
|
||||||
|
with lock:
|
||||||
|
for tp in thread_profiles:
|
||||||
|
stats.add(tp)
|
||||||
|
stats.dump_stats(str(dest))
|
||||||
|
|
||||||
def _periodic_dump():
|
def _periodic_dump():
|
||||||
while not stop.wait(30):
|
while not stop.wait(30):
|
||||||
prof.dump_stats(str(dest))
|
_dump_all()
|
||||||
|
|
||||||
dumper = Thread(target=_periodic_dump, daemon=True)
|
dumper = Thread(target=_periodic_dump, daemon=True)
|
||||||
dumper.start()
|
dumper.start()
|
||||||
@@ -60,8 +91,12 @@ def _run_profiled(app, dest):
|
|||||||
app.run()
|
app.run()
|
||||||
finally:
|
finally:
|
||||||
prof.disable()
|
prof.disable()
|
||||||
|
threading.setprofile(None)
|
||||||
|
with lock:
|
||||||
|
for tp in thread_profiles:
|
||||||
|
tp.disable()
|
||||||
stop.set()
|
stop.set()
|
||||||
prof.dump_stats(str(dest))
|
_dump_all()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import time
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
from textual import events, on, work
|
from textual import events, on, work
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
@@ -13,21 +14,51 @@ from textual.message import Message
|
|||||||
from textual.reactive import reactive
|
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, DeviceMonitor
|
||||||
from tuimble.client import Channel, ConnectionFailed, MumbleClient, User
|
from tuimble.client import Channel, 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
|
||||||
|
from tuimble.reconnect import ReconnectManager
|
||||||
|
|
||||||
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)
|
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
|
TREE_DEBOUNCE = 0.1 # seconds to coalesce state changes
|
||||||
|
|
||||||
|
|
||||||
|
class InputHistory:
|
||||||
|
"""Navigable command history for the chat input."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._entries: list[str] = []
|
||||||
|
self._idx: int = -1
|
||||||
|
self._draft: str = ""
|
||||||
|
|
||||||
|
def push(self, text: str) -> None:
|
||||||
|
self._entries.append(text)
|
||||||
|
self._idx = -1
|
||||||
|
|
||||||
|
def up(self, current: str) -> str | None:
|
||||||
|
if not self._entries:
|
||||||
|
return None
|
||||||
|
if self._idx == -1:
|
||||||
|
self._draft = current
|
||||||
|
self._idx = len(self._entries) - 1
|
||||||
|
elif self._idx > 0:
|
||||||
|
self._idx -= 1
|
||||||
|
return self._entries[self._idx]
|
||||||
|
|
||||||
|
def down(self) -> str | None:
|
||||||
|
if self._idx == -1:
|
||||||
|
return None
|
||||||
|
if self._idx < len(self._entries) - 1:
|
||||||
|
self._idx += 1
|
||||||
|
return self._entries[self._idx]
|
||||||
|
self._idx = -1
|
||||||
|
return self._draft
|
||||||
|
|
||||||
|
|
||||||
def _next_volume(current: float) -> float:
|
def _next_volume(current: float) -> float:
|
||||||
"""Cycle through VOLUME_STEPS, wrapping to 0.0 after max."""
|
"""Cycle through VOLUME_STEPS, wrapping to 0.0 after max."""
|
||||||
for step in VOLUME_STEPS:
|
for step in VOLUME_STEPS:
|
||||||
@@ -60,6 +91,12 @@ class ServerStateChanged(Message):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AudioDeviceChanged(Message):
|
||||||
|
"""Audio device list changed (hot-swap detected)."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChannelSelected(Message):
|
class ChannelSelected(Message):
|
||||||
"""User selected a channel to join."""
|
"""User selected a channel to join."""
|
||||||
|
|
||||||
@@ -78,9 +115,11 @@ class StatusBar(Static):
|
|||||||
connected = reactive(False)
|
connected = reactive(False)
|
||||||
reconnecting = reactive(False)
|
reconnecting = reactive(False)
|
||||||
self_deaf = reactive(False)
|
self_deaf = reactive(False)
|
||||||
|
self_mute = reactive(False)
|
||||||
server_info = reactive("")
|
server_info = reactive("")
|
||||||
output_vol = reactive(100)
|
output_vol = reactive(100)
|
||||||
input_vol = reactive(100)
|
input_vol = reactive(100)
|
||||||
|
pitch = reactive(0)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _vol_bar(pct: int) -> str:
|
def _vol_bar(pct: int) -> str:
|
||||||
@@ -110,19 +149,27 @@ class StatusBar(Static):
|
|||||||
|
|
||||||
deaf_sym = "[#f7768e]\u2298[/]" if self.self_deaf else ""
|
deaf_sym = "[#f7768e]\u2298[/]" if self.self_deaf else ""
|
||||||
deaf_full = "[#f7768e]\u2298[/] deaf" if self.self_deaf else ""
|
deaf_full = "[#f7768e]\u2298[/] deaf" if self.self_deaf else ""
|
||||||
|
mute_sym = "[#e0af68]\u2715[/]" if self.self_mute else ""
|
||||||
|
mute_full = "[#e0af68]\u2715[/] mute" if self.self_mute else ""
|
||||||
|
|
||||||
if w < 40:
|
if w < 40:
|
||||||
return f" {conn_sym} {deaf_sym}{ptt_sym}"
|
return f" {conn_sym} {deaf_sym}{mute_sym}{ptt_sym}"
|
||||||
if w < 60:
|
if w < 60:
|
||||||
return f" {conn_full} {deaf_full}{' ' if deaf_full else ''}{ptt_full}"
|
flags = f"{deaf_full}{' ' if deaf_full else ''}{mute_full}{' ' if mute_full else ''}"
|
||||||
|
return f" {conn_full} {flags}{ptt_full}"
|
||||||
|
|
||||||
vol = (
|
vol = (
|
||||||
f" [dim]out[/]{self._vol_bar(self.output_vol)}"
|
f" [dim]out[/]{self._vol_bar(self.output_vol)}"
|
||||||
f" [dim]in[/]{self._vol_bar(self.input_vol)}"
|
f" [dim]in[/]{self._vol_bar(self.input_vol)}"
|
||||||
)
|
)
|
||||||
|
if self.pitch != 0:
|
||||||
|
sign = "+" if self.pitch > 0 else ""
|
||||||
|
pitch_str = f" [dim]pitch[/]{sign}{self.pitch}"
|
||||||
|
else:
|
||||||
|
pitch_str = ""
|
||||||
info = f" [dim]{self.server_info}[/]" if self.server_info else ""
|
info = f" [dim]{self.server_info}[/]" if self.server_info else ""
|
||||||
deaf = f"{deaf_full} " if deaf_full else ""
|
flags = f"{deaf_full}{' ' if deaf_full else ''}{mute_full}{' ' if mute_full else ''}"
|
||||||
return f" {conn_full} {deaf}{ptt_full}{vol}{info}"
|
return f" {conn_full} {flags}{ptt_full}{vol}{pitch_str}{info}"
|
||||||
|
|
||||||
|
|
||||||
class ChannelTree(Static):
|
class ChannelTree(Static):
|
||||||
@@ -138,6 +185,7 @@ class ChannelTree(Static):
|
|||||||
self._channel_ids: list[int] = []
|
self._channel_ids: list[int] = []
|
||||||
self._focused_idx: int = 0
|
self._focused_idx: int = 0
|
||||||
self._my_channel_id: int | None = None
|
self._my_channel_id: int | None = None
|
||||||
|
self._root_id: int = 0
|
||||||
|
|
||||||
def set_state(
|
def set_state(
|
||||||
self,
|
self,
|
||||||
@@ -148,6 +196,7 @@ class ChannelTree(Static):
|
|||||||
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._my_channel_id = my_channel_id
|
||||||
|
self._root_id = self._find_root() if channels else 0
|
||||||
self._channel_ids = self._build_channel_order()
|
self._channel_ids = self._build_channel_order()
|
||||||
if self._channel_ids:
|
if self._channel_ids:
|
||||||
self._focused_idx = max(
|
self._focused_idx = max(
|
||||||
@@ -170,8 +219,7 @@ class ChannelTree(Static):
|
|||||||
if not self._channels:
|
if not self._channels:
|
||||||
return []
|
return []
|
||||||
order: list[int] = []
|
order: list[int] = []
|
||||||
root_id = self._find_root()
|
self._collect_order(self._root_id, order)
|
||||||
self._collect_order(root_id, order)
|
|
||||||
return order
|
return order
|
||||||
|
|
||||||
def _collect_order(self, channel_id: int, order: list[int]) -> None:
|
def _collect_order(self, channel_id: int, order: list[int]) -> None:
|
||||||
@@ -180,8 +228,11 @@ class ChannelTree(Static):
|
|||||||
return
|
return
|
||||||
order.append(channel_id)
|
order.append(channel_id)
|
||||||
children = sorted(
|
children = sorted(
|
||||||
(c for c in self._channels.values()
|
(
|
||||||
if c.parent_id == channel_id and c.channel_id != channel_id),
|
c
|
||||||
|
for c in self._channels.values()
|
||||||
|
if c.parent_id == channel_id and c.channel_id != channel_id
|
||||||
|
),
|
||||||
key=lambda c: c.name,
|
key=lambda c: c.name,
|
||||||
)
|
)
|
||||||
for child in children:
|
for child in children:
|
||||||
@@ -220,8 +271,7 @@ class ChannelTree(Static):
|
|||||||
|
|
||||||
w = self._get_width()
|
w = self._get_width()
|
||||||
lines = [" [bold]Channels[/]"]
|
lines = [" [bold]Channels[/]"]
|
||||||
root_id = self._find_root()
|
self._render_tree(self._root_id, lines, indent=1, is_last=True, w=w)
|
||||||
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:
|
||||||
@@ -258,13 +308,9 @@ class ChannelTree(Static):
|
|||||||
name = self._truncate(ch.name, name_max)
|
name = self._truncate(ch.name, name_max)
|
||||||
|
|
||||||
if is_focused:
|
if is_focused:
|
||||||
lines.append(
|
lines.append(f"{prefix}{branch} {marker}[reverse bold]{name}[/]")
|
||||||
f"{prefix}{branch} {marker}[reverse bold]{name}[/]"
|
|
||||||
)
|
|
||||||
elif is_current:
|
elif is_current:
|
||||||
lines.append(
|
lines.append(f"{prefix}{branch} {marker}[bold #9ece6a]{name}[/]")
|
||||||
f"{prefix}{branch} {marker}[bold #9ece6a]{name}[/]"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
lines.append(f"{prefix}{branch} {marker}[bold]{name}[/]")
|
lines.append(f"{prefix}{branch} {marker}[bold]{name}[/]")
|
||||||
|
|
||||||
@@ -284,9 +330,7 @@ class ChannelTree(Static):
|
|||||||
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)
|
||||||
status = self._user_status(user)
|
status = self._user_status(user)
|
||||||
lines.append(
|
lines.append(f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]{status}")
|
||||||
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(
|
||||||
@@ -306,9 +350,7 @@ class ChannelTree(Static):
|
|||||||
event.prevent_default()
|
event.prevent_default()
|
||||||
event.stop()
|
event.stop()
|
||||||
elif event.key == "down":
|
elif event.key == "down":
|
||||||
self._focused_idx = min(
|
self._focused_idx = min(len(self._channel_ids) - 1, self._focused_idx + 1)
|
||||||
len(self._channel_ids) - 1, self._focused_idx + 1
|
|
||||||
)
|
|
||||||
self.refresh()
|
self.refresh()
|
||||||
event.prevent_default()
|
event.prevent_default()
|
||||||
event.stop()
|
event.stop()
|
||||||
@@ -359,9 +401,8 @@ class TuimbleApp(App):
|
|||||||
scrollbar-size: 1 1;
|
scrollbar-size: 1 1;
|
||||||
}
|
}
|
||||||
#input {
|
#input {
|
||||||
dock: bottom;
|
|
||||||
height: 3;
|
height: 3;
|
||||||
border-top: solid #292e42;
|
border: none;
|
||||||
}
|
}
|
||||||
#status {
|
#status {
|
||||||
dock: bottom;
|
dock: bottom;
|
||||||
@@ -381,6 +422,8 @@ class TuimbleApp(App):
|
|||||||
("f2", "cycle_output_volume", "Vol Out"),
|
("f2", "cycle_output_volume", "Vol Out"),
|
||||||
("f3", "cycle_input_volume", "Vol In"),
|
("f3", "cycle_input_volume", "Vol In"),
|
||||||
("f5", "reload_config", "Reload"),
|
("f5", "reload_config", "Reload"),
|
||||||
|
("f6", "pitch_down", "Pitch-"),
|
||||||
|
("f7", "pitch_up", "Pitch+"),
|
||||||
("q", "quit", "Quit"),
|
("q", "quit", "Quit"),
|
||||||
("ctrl+c", "quit", "Quit"),
|
("ctrl+c", "quit", "Quit"),
|
||||||
]
|
]
|
||||||
@@ -388,21 +431,9 @@ class TuimbleApp(App):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._config: Config = load_config()
|
self._config: Config = load_config()
|
||||||
self._ptt = detect_backend(
|
self._ptt = detect_backend(self._on_ptt_change, self._config.ptt.backend)
|
||||||
self._on_ptt_change, self._config.ptt.backend
|
self._client = self._make_client()
|
||||||
)
|
self._history = InputHistory()
|
||||||
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._history: list[str] = []
|
|
||||||
self._history_idx: int = -1
|
|
||||||
self._history_draft: str = ""
|
|
||||||
acfg = self._config.audio
|
acfg = self._config.audio
|
||||||
self._audio = AudioPipeline(
|
self._audio = AudioPipeline(
|
||||||
sample_rate=acfg.sample_rate,
|
sample_rate=acfg.sample_rate,
|
||||||
@@ -412,12 +443,34 @@ class TuimbleApp(App):
|
|||||||
)
|
)
|
||||||
self._audio.input_gain = acfg.input_gain
|
self._audio.input_gain = acfg.input_gain
|
||||||
self._audio.output_gain = acfg.output_gain
|
self._audio.output_gain = acfg.output_gain
|
||||||
|
self._audio.pitch = acfg.pitch
|
||||||
|
self._device_monitor = DeviceMonitor(self._on_device_change)
|
||||||
self._pending_reload: Config | None = None
|
self._pending_reload: Config | None = None
|
||||||
self._tree_refresh_timer = None
|
self._tree_refresh_timer = None
|
||||||
self._reconnecting: bool = False
|
self._resize_timer = None
|
||||||
self._reconnect_attempt: int = 0
|
self._pending_width: int = 80
|
||||||
|
self._reconnect = ReconnectManager(
|
||||||
|
connect_fn=self._reconnect_connect,
|
||||||
|
on_attempt=self._reconnect_on_attempt,
|
||||||
|
on_success=self._reconnect_on_success,
|
||||||
|
on_failure=self._reconnect_on_failure,
|
||||||
|
on_exhausted=self._reconnect_on_exhausted,
|
||||||
|
)
|
||||||
|
self._muted: bool = False
|
||||||
self._intentional_disconnect: bool = False
|
self._intentional_disconnect: bool = False
|
||||||
|
|
||||||
|
def _make_client(self, srv=None) -> MumbleClient:
|
||||||
|
"""Create a MumbleClient from the current (or given) server config."""
|
||||||
|
srv = srv or self._config.server
|
||||||
|
return MumbleClient(
|
||||||
|
host=srv.host,
|
||||||
|
port=srv.port,
|
||||||
|
username=srv.username,
|
||||||
|
password=srv.password,
|
||||||
|
certfile=srv.certfile,
|
||||||
|
keyfile=srv.keyfile,
|
||||||
|
)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
with Horizontal(id="main"):
|
with Horizontal(id="main"):
|
||||||
@@ -432,6 +485,7 @@ class TuimbleApp(App):
|
|||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
status.output_vol = int(self._audio.output_gain * 100)
|
status.output_vol = int(self._audio.output_gain * 100)
|
||||||
status.input_vol = int(self._audio.input_gain * 100)
|
status.input_vol = int(self._audio.input_gain * 100)
|
||||||
|
status.pitch = int(self._audio.pitch)
|
||||||
|
|
||||||
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]")
|
||||||
@@ -480,97 +534,54 @@ class TuimbleApp(App):
|
|||||||
|
|
||||||
# -- auto-reconnect ------------------------------------------------------
|
# -- auto-reconnect ------------------------------------------------------
|
||||||
|
|
||||||
@work(thread=True)
|
def _reconnect_connect(self) -> None:
|
||||||
def _reconnect_loop(self) -> None:
|
"""Called by ReconnectManager to attempt a new connection."""
|
||||||
"""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()
|
self._client.disconnect()
|
||||||
srv = self._config.server
|
self._client = self._make_client()
|
||||||
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._wire_client_callbacks()
|
||||||
self._client.connect()
|
self._client.connect()
|
||||||
|
|
||||||
self._reconnecting = False
|
def _reconnect_on_attempt(self, attempt: int, delay: float) -> None:
|
||||||
self._reconnect_attempt = 0
|
self.call_from_thread(self._log_reconnect, attempt, delay)
|
||||||
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:
|
def _reconnect_on_success(self) -> None:
|
||||||
self._reconnecting = False
|
self.call_from_thread(self.post_message, ServerConnected())
|
||||||
self.call_from_thread(self._on_reconnect_exhausted)
|
|
||||||
return
|
|
||||||
|
|
||||||
def _log_reconnect(self, attempt: int, delay: int) -> None:
|
def _reconnect_on_failure(self, attempt: int, error: str) -> None:
|
||||||
|
self.call_from_thread(self._show_error, f"attempt {attempt}: {error}")
|
||||||
|
|
||||||
|
def _reconnect_on_exhausted(self) -> None:
|
||||||
|
self.call_from_thread(self._show_reconnect_exhausted)
|
||||||
|
|
||||||
|
def _log_reconnect(self, attempt: int, delay: float) -> None:
|
||||||
"""Log reconnection attempt to chatlog."""
|
"""Log reconnection attempt to chatlog."""
|
||||||
|
from tuimble.reconnect import MAX_RETRIES
|
||||||
|
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
status.reconnecting = True
|
status.reconnecting = True
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
chatlog.write(
|
chatlog.write(
|
||||||
f"[dim]reconnecting in {delay}s "
|
f"[dim]reconnecting in {delay}s (attempt {attempt}/{MAX_RETRIES})...[/dim]"
|
||||||
f"(attempt {attempt}/{RECONNECT_RETRIES})...[/dim]"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_reconnect_success(self) -> None:
|
def _show_reconnect_exhausted(self) -> None:
|
||||||
"""Handle successful reconnection."""
|
|
||||||
self.post_message(ServerConnected())
|
|
||||||
|
|
||||||
def _on_reconnect_exhausted(self) -> None:
|
|
||||||
"""Handle all reconnection attempts exhausted."""
|
"""Handle all reconnection attempts exhausted."""
|
||||||
|
from tuimble.reconnect import MAX_RETRIES
|
||||||
|
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
status.reconnecting = False
|
status.reconnecting = False
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
chatlog.write(
|
chatlog.write(f"[#f7768e]reconnection failed after {MAX_RETRIES} attempts[/]")
|
||||||
f"[#f7768e]reconnection failed after "
|
|
||||||
f"{RECONNECT_RETRIES} attempts[/]"
|
|
||||||
)
|
|
||||||
chatlog.write("[dim]press F5 to retry manually[/dim]")
|
chatlog.write("[dim]press F5 to retry manually[/dim]")
|
||||||
|
|
||||||
|
@work(thread=True)
|
||||||
|
def _start_reconnect(self) -> None:
|
||||||
|
"""Run the reconnect manager in a worker thread."""
|
||||||
|
self._reconnect.run()
|
||||||
|
|
||||||
def _cancel_reconnect(self) -> None:
|
def _cancel_reconnect(self) -> None:
|
||||||
"""Cancel an in-progress reconnect loop."""
|
"""Cancel an in-progress reconnect loop."""
|
||||||
self._reconnecting = False
|
self._reconnect.cancel()
|
||||||
self._reconnect_attempt = 0
|
|
||||||
try:
|
try:
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
status.reconnecting = False
|
status.reconnecting = False
|
||||||
@@ -581,21 +592,22 @@ class TuimbleApp(App):
|
|||||||
|
|
||||||
def on_server_connected(self, _msg: ServerConnected) -> None:
|
def on_server_connected(self, _msg: ServerConnected) -> None:
|
||||||
self._intentional_disconnect = False
|
self._intentional_disconnect = False
|
||||||
|
self._muted = False
|
||||||
|
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
status.reconnecting = False
|
status.reconnecting = False
|
||||||
status.connected = True
|
status.connected = True
|
||||||
|
status.self_mute = False
|
||||||
srv = self._config.server
|
srv = self._config.server
|
||||||
status.server_info = f"{srv.host}:{srv.port}"
|
status.server_info = f"{srv.host}:{srv.port}"
|
||||||
|
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
chatlog.write(
|
chatlog.write(f"[#9ece6a]\u2713 connected as {self._config.server.username}[/]")
|
||||||
f"[#9ece6a]\u2713 connected as {self._config.server.username}[/]"
|
|
||||||
)
|
|
||||||
self._refresh_channel_tree()
|
self._refresh_channel_tree()
|
||||||
self._start_audio()
|
self._start_audio()
|
||||||
|
|
||||||
def on_server_disconnected(self, _msg: ServerDisconnected) -> None:
|
def on_server_disconnected(self, _msg: ServerDisconnected) -> None:
|
||||||
|
self._device_monitor.stop()
|
||||||
self._audio.stop()
|
self._audio.stop()
|
||||||
|
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
@@ -605,15 +617,13 @@ class TuimbleApp(App):
|
|||||||
tree = self.query_one("#sidebar", ChannelTree)
|
tree = self.query_one("#sidebar", ChannelTree)
|
||||||
tree.clear_state()
|
tree.clear_state()
|
||||||
|
|
||||||
if self._intentional_disconnect or self._reconnecting:
|
if self._intentional_disconnect or self._reconnect.active:
|
||||||
return
|
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[/]")
|
||||||
|
|
||||||
self._reconnecting = True
|
self._start_reconnect()
|
||||||
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)
|
||||||
@@ -625,7 +635,8 @@ class TuimbleApp(App):
|
|||||||
if self._tree_refresh_timer is not None:
|
if self._tree_refresh_timer is not None:
|
||||||
self._tree_refresh_timer.stop()
|
self._tree_refresh_timer.stop()
|
||||||
self._tree_refresh_timer = self.set_timer(
|
self._tree_refresh_timer = self.set_timer(
|
||||||
TREE_DEBOUNCE, self._refresh_channel_tree,
|
TREE_DEBOUNCE,
|
||||||
|
self._refresh_channel_tree,
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_channel_selected(self, msg: ChannelSelected) -> None:
|
def on_channel_selected(self, msg: ChannelSelected) -> None:
|
||||||
@@ -647,8 +658,11 @@ class TuimbleApp(App):
|
|||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
event.input.clear()
|
event.input.clear()
|
||||||
self._history.append(text)
|
self._history.push(text)
|
||||||
self._history_idx = -1
|
|
||||||
|
if text.startswith("/"):
|
||||||
|
self._dispatch_command(text)
|
||||||
|
return
|
||||||
|
|
||||||
if not self._client.connected:
|
if not self._client.connected:
|
||||||
self._show_error("not connected")
|
self._show_error("not connected")
|
||||||
@@ -656,17 +670,101 @@ class TuimbleApp(App):
|
|||||||
|
|
||||||
self._client.send_text(text)
|
self._client.send_text(text)
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
chatlog.write(
|
chatlog.write(f"[#e0af68]{self._config.server.username}[/] {text}")
|
||||||
f"[#e0af68]{self._config.server.username}[/] {text}"
|
|
||||||
)
|
def _dispatch_command(self, text: str) -> None:
|
||||||
|
"""Handle slash commands."""
|
||||||
|
cmd = text.split()[0].lower()
|
||||||
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
|
|
||||||
|
if cmd == "/help":
|
||||||
|
chatlog.write("[dim]/deafen toggle self-deafen[/dim]")
|
||||||
|
chatlog.write("[dim]/mute toggle self-mute[/dim]")
|
||||||
|
chatlog.write("[dim]/unmute unmute yourself[/dim]")
|
||||||
|
chatlog.write("[dim]/register register on server[/dim]")
|
||||||
|
elif cmd == "/deafen":
|
||||||
|
self.action_toggle_deaf()
|
||||||
|
elif cmd == "/mute":
|
||||||
|
if self._muted:
|
||||||
|
chatlog.write("[dim]already muted[/dim]")
|
||||||
|
return
|
||||||
|
self._muted = True
|
||||||
|
self._audio.capturing = False
|
||||||
|
self._client.set_self_mute(True)
|
||||||
|
status = self.query_one("#status", StatusBar)
|
||||||
|
status.self_mute = True
|
||||||
|
status.ptt_active = False
|
||||||
|
chatlog.write("[#e0af68]\u2715 muted[/]")
|
||||||
|
elif cmd == "/unmute":
|
||||||
|
if not self._muted:
|
||||||
|
chatlog.write("[dim]already unmuted[/dim]")
|
||||||
|
return
|
||||||
|
self._muted = False
|
||||||
|
self._client.set_self_mute(False)
|
||||||
|
status = self.query_one("#status", StatusBar)
|
||||||
|
status.self_mute = False
|
||||||
|
chatlog.write("[#9ece6a]\u2713 unmuted[/]")
|
||||||
|
elif cmd == "/register":
|
||||||
|
if not self._client.connected:
|
||||||
|
self._show_error("not connected")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._client.register_self()
|
||||||
|
chatlog.write("[#9ece6a]\u2713 registration requested[/]")
|
||||||
|
except Exception as exc:
|
||||||
|
self._show_error(f"register failed: {exc}")
|
||||||
|
else:
|
||||||
|
self._show_error(f"unknown command: {cmd}")
|
||||||
|
|
||||||
# -- audio ---------------------------------------------------------------
|
# -- audio ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_device_change(self) -> None:
|
||||||
|
"""Called from DeviceMonitor thread on device list change."""
|
||||||
|
self.call_from_thread(self.post_message, AudioDeviceChanged())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_device(device_id: int | None, kind: str) -> int | None:
|
||||||
|
"""Return *device_id* if still valid, else ``None`` (system default)."""
|
||||||
|
if device_id is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
sd.query_devices(device_id, kind)
|
||||||
|
return device_id
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_audio_device_changed(self, _msg: AudioDeviceChanged) -> None:
|
||||||
|
"""Rebuild the audio pipeline when devices change."""
|
||||||
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
|
chatlog.write("[dim]audio device change detected[/dim]")
|
||||||
|
|
||||||
|
self._audio.stop()
|
||||||
|
|
||||||
|
acfg = self._config.audio
|
||||||
|
inp = self._validate_device(acfg.input_device, "input")
|
||||||
|
out = self._validate_device(acfg.output_device, "output")
|
||||||
|
|
||||||
|
self._audio = AudioPipeline(
|
||||||
|
sample_rate=acfg.sample_rate,
|
||||||
|
frame_size=acfg.frame_size,
|
||||||
|
input_device=inp,
|
||||||
|
output_device=out,
|
||||||
|
)
|
||||||
|
self._audio.input_gain = acfg.input_gain
|
||||||
|
self._audio.output_gain = acfg.output_gain
|
||||||
|
self._audio.pitch = acfg.pitch
|
||||||
|
|
||||||
|
if self._client.connected:
|
||||||
|
self._start_audio()
|
||||||
|
|
||||||
def _start_audio(self) -> None:
|
def _start_audio(self) -> None:
|
||||||
"""Start audio pipeline; log error if hardware unavailable."""
|
"""Start audio pipeline; log error if hardware unavailable."""
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
try:
|
try:
|
||||||
self._audio.start()
|
self._audio.start()
|
||||||
|
self._device_monitor.start()
|
||||||
chatlog.write("[dim]audio pipeline started[/dim]")
|
chatlog.write("[dim]audio pipeline started[/dim]")
|
||||||
self._audio_send_loop()
|
self._audio_send_loop()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -675,13 +773,11 @@ class TuimbleApp(App):
|
|||||||
|
|
||||||
@work(thread=True)
|
@work(thread=True)
|
||||||
def _audio_send_loop(self) -> None:
|
def _audio_send_loop(self) -> None:
|
||||||
"""Poll capture queue and send encoded frames to server."""
|
"""Send captured audio frames to the server."""
|
||||||
while self._client.connected:
|
while self._client.connected:
|
||||||
frame = self._audio.get_capture_frame()
|
frame = self._audio.get_capture_frame(timeout=0.02)
|
||||||
if frame is not None:
|
if frame is not None:
|
||||||
self._client.send_audio(frame)
|
self._client.send_audio(frame)
|
||||||
else:
|
|
||||||
time.sleep(0.005)
|
|
||||||
|
|
||||||
# -- channel tree --------------------------------------------------------
|
# -- channel tree --------------------------------------------------------
|
||||||
|
|
||||||
@@ -735,80 +831,89 @@ class TuimbleApp(App):
|
|||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
chatlog.write(f"[dim]input volume {pct}%[/dim]")
|
chatlog.write(f"[dim]input volume {pct}%[/dim]")
|
||||||
|
|
||||||
|
# -- pitch ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def action_pitch_down(self) -> None:
|
||||||
|
"""Decrease voice pitch by 1 semitone."""
|
||||||
|
self._audio.pitch = max(-12.0, self._audio.pitch - 1.0)
|
||||||
|
st = int(self._audio.pitch)
|
||||||
|
status = self.query_one("#status", StatusBar)
|
||||||
|
status.pitch = st
|
||||||
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
|
chatlog.write(f"[dim]pitch {st:+d} semitones[/dim]")
|
||||||
|
|
||||||
|
def action_pitch_up(self) -> None:
|
||||||
|
"""Increase voice pitch by 1 semitone."""
|
||||||
|
self._audio.pitch = min(12.0, self._audio.pitch + 1.0)
|
||||||
|
st = int(self._audio.pitch)
|
||||||
|
status = self.query_one("#status", StatusBar)
|
||||||
|
status.pitch = st
|
||||||
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
|
chatlog.write(f"[dim]pitch {st:+d} semitones[/dim]")
|
||||||
|
|
||||||
# -- config reload --------------------------------------------------------
|
# -- config reload --------------------------------------------------------
|
||||||
|
|
||||||
def _detect_config_changes(
|
def _detect_config_changes(
|
||||||
self, old: Config, new: Config,
|
self,
|
||||||
|
old: Config,
|
||||||
|
new: Config,
|
||||||
) -> tuple[list[str], list[str]]:
|
) -> tuple[list[str], list[str]]:
|
||||||
"""Compare configs, return (safe_changes, restart_changes)."""
|
"""Compare configs, return (safe_changes, restart_changes)."""
|
||||||
safe: list[str] = []
|
safe: list[str] = []
|
||||||
restart: list[str] = []
|
restart: list[str] = []
|
||||||
|
|
||||||
if old.ptt.key != new.ptt.key:
|
# PTT: all fields are safe to hot-reload
|
||||||
safe.append(f"ptt key: {old.ptt.key} -> {new.ptt.key}")
|
old_ptt = dataclasses.asdict(old.ptt)
|
||||||
if old.ptt.mode != new.ptt.mode:
|
new_ptt = dataclasses.asdict(new.ptt)
|
||||||
safe.append(f"ptt mode: {old.ptt.mode} -> {new.ptt.mode}")
|
for key in old_ptt:
|
||||||
if old.ptt.backend != new.ptt.backend:
|
if old_ptt[key] != new_ptt[key]:
|
||||||
safe.append(
|
safe.append(f"ptt {key}: {old_ptt[key]} -> {new_ptt[key]}")
|
||||||
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
|
# Audio: gains are safe; hardware settings require restart
|
||||||
for attr in ("host", "port", "username", "password",
|
safe_audio = {"input_gain", "output_gain", "pitch"}
|
||||||
"certfile", "keyfile"):
|
old_aud = dataclasses.asdict(old.audio)
|
||||||
ov, nv = getattr(o_srv, attr), getattr(n_srv, attr)
|
new_aud = dataclasses.asdict(new.audio)
|
||||||
if ov != nv:
|
for key in old_aud:
|
||||||
label = "password" if attr == "password" else attr
|
if old_aud[key] != new_aud[key]:
|
||||||
|
if key in safe_audio:
|
||||||
|
safe.append(f"{key}: {old_aud[key]} -> {new_aud[key]}")
|
||||||
|
else:
|
||||||
|
restart.append(f"audio.{key} changed")
|
||||||
|
|
||||||
|
# Server: all changes require restart
|
||||||
|
old_srv = dataclasses.asdict(old.server)
|
||||||
|
new_srv = dataclasses.asdict(new.server)
|
||||||
|
for key in old_srv:
|
||||||
|
if old_srv[key] != new_srv[key]:
|
||||||
|
label = "password" if key == "password" else key
|
||||||
restart.append(f"server.{label} changed")
|
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
|
return safe, restart
|
||||||
|
|
||||||
def _apply_safe_changes(self, new: Config) -> None:
|
def _apply_safe_changes(self, new: Config) -> None:
|
||||||
"""Apply hot-reload-safe config changes immediately."""
|
"""Apply hot-reload-safe config changes immediately."""
|
||||||
self._config.ptt = new.ptt
|
self._config.ptt = new.ptt
|
||||||
self._ptt = detect_backend(
|
self._ptt = detect_backend(self._on_ptt_change, new.ptt.backend)
|
||||||
self._on_ptt_change, new.ptt.backend
|
|
||||||
)
|
|
||||||
|
|
||||||
self._audio.input_gain = new.audio.input_gain
|
self._audio.input_gain = new.audio.input_gain
|
||||||
self._audio.output_gain = new.audio.output_gain
|
self._audio.output_gain = new.audio.output_gain
|
||||||
|
self._audio.pitch = new.audio.pitch
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
status.input_vol = int(new.audio.input_gain * 100)
|
status.input_vol = int(new.audio.input_gain * 100)
|
||||||
status.output_vol = int(new.audio.output_gain * 100)
|
status.output_vol = int(new.audio.output_gain * 100)
|
||||||
|
status.pitch = int(new.audio.pitch)
|
||||||
|
|
||||||
def _apply_restart_changes(self, new: Config) -> None:
|
def _apply_restart_changes(self, new: Config) -> None:
|
||||||
"""Apply changes that require reconnect/audio restart."""
|
"""Apply changes that require reconnect/audio restart."""
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
old = self._config
|
old = self._config
|
||||||
|
|
||||||
server_changed = (
|
server_changed = dataclasses.asdict(old.server) != dataclasses.asdict(
|
||||||
old.server.host != new.server.host
|
new.server
|
||||||
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 = (
|
audio_hw_changed = any(
|
||||||
old.audio.input_device != new.audio.input_device
|
getattr(old.audio, a) != getattr(new.audio, a)
|
||||||
or old.audio.output_device != new.audio.output_device
|
for a in ("input_device", "output_device", "sample_rate")
|
||||||
or old.audio.sample_rate != new.audio.sample_rate
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if server_changed:
|
if server_changed:
|
||||||
@@ -820,18 +925,10 @@ class TuimbleApp(App):
|
|||||||
tree = self.query_one("#sidebar", ChannelTree)
|
tree = self.query_one("#sidebar", ChannelTree)
|
||||||
tree.clear_state()
|
tree.clear_state()
|
||||||
|
|
||||||
srv = new.server
|
self._client = self._make_client(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
|
self._config = new
|
||||||
chatlog.write(
|
chatlog.write(
|
||||||
f"[dim]reconnecting to {srv.host}:{srv.port}...[/]"
|
f"[dim]reconnecting to {new.server.host}:{new.server.port}...[/]"
|
||||||
)
|
)
|
||||||
self._connect_to_server()
|
self._connect_to_server()
|
||||||
elif audio_hw_changed:
|
elif audio_hw_changed:
|
||||||
@@ -845,6 +942,7 @@ class TuimbleApp(App):
|
|||||||
)
|
)
|
||||||
self._audio.input_gain = acfg.input_gain
|
self._audio.input_gain = acfg.input_gain
|
||||||
self._audio.output_gain = acfg.output_gain
|
self._audio.output_gain = acfg.output_gain
|
||||||
|
self._audio.pitch = acfg.pitch
|
||||||
self._config = new
|
self._config = new
|
||||||
if self._client.connected:
|
if self._client.connected:
|
||||||
self._start_audio()
|
self._start_audio()
|
||||||
@@ -855,23 +953,14 @@ class TuimbleApp(App):
|
|||||||
|
|
||||||
Also serves as manual reconnect when disconnected.
|
Also serves as manual reconnect when disconnected.
|
||||||
"""
|
"""
|
||||||
if self._reconnecting:
|
if self._reconnect.active:
|
||||||
self._cancel_reconnect()
|
self._cancel_reconnect()
|
||||||
|
|
||||||
if not self._client.connected:
|
if not self._client.connected:
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
|
self._client = self._make_client()
|
||||||
srv = self._config.server
|
srv = self._config.server
|
||||||
self._client = MumbleClient(
|
chatlog.write(f"[dim]connecting to {srv.host}:{srv.port}...[/dim]")
|
||||||
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._intentional_disconnect = False
|
||||||
self._connect_to_server()
|
self._connect_to_server()
|
||||||
return
|
return
|
||||||
@@ -899,6 +988,7 @@ class TuimbleApp(App):
|
|||||||
self._apply_safe_changes(new)
|
self._apply_safe_changes(new)
|
||||||
self._config.audio.input_gain = new.audio.input_gain
|
self._config.audio.input_gain = new.audio.input_gain
|
||||||
self._config.audio.output_gain = new.audio.output_gain
|
self._config.audio.output_gain = new.audio.output_gain
|
||||||
|
self._config.audio.pitch = new.audio.pitch
|
||||||
|
|
||||||
if safe:
|
if safe:
|
||||||
for change in safe:
|
for change in safe:
|
||||||
@@ -907,10 +997,7 @@ class TuimbleApp(App):
|
|||||||
if restart:
|
if restart:
|
||||||
for change in restart:
|
for change in restart:
|
||||||
chatlog.write(f"[#e0af68]\u26a0 {change}[/]")
|
chatlog.write(f"[#e0af68]\u26a0 {change}[/]")
|
||||||
chatlog.write(
|
chatlog.write("[dim]press F5 again to apply, or any key to cancel[/dim]")
|
||||||
"[dim]press F5 again to apply, "
|
|
||||||
"or any key to cancel[/dim]"
|
|
||||||
)
|
|
||||||
self._pending_reload = new
|
self._pending_reload = new
|
||||||
else:
|
else:
|
||||||
chatlog.write("[dim]\u2713 config reloaded[/dim]")
|
chatlog.write("[dim]\u2713 config reloaded[/dim]")
|
||||||
@@ -928,27 +1015,12 @@ class TuimbleApp(App):
|
|||||||
if isinstance(focused, Input) and event.key in ("up", "down"):
|
if isinstance(focused, Input) and event.key in ("up", "down"):
|
||||||
inp = focused
|
inp = focused
|
||||||
if event.key == "up":
|
if event.key == "up":
|
||||||
if not self._history:
|
val = self._history.up(inp.value)
|
||||||
event.prevent_default()
|
|
||||||
return
|
|
||||||
if self._history_idx == -1:
|
|
||||||
self._history_draft = inp.value
|
|
||||||
self._history_idx = len(self._history) - 1
|
|
||||||
elif self._history_idx > 0:
|
|
||||||
self._history_idx -= 1
|
|
||||||
inp.value = self._history[self._history_idx]
|
|
||||||
inp.cursor_position = len(inp.value)
|
|
||||||
else: # down
|
|
||||||
if self._history_idx == -1:
|
|
||||||
event.prevent_default()
|
|
||||||
return
|
|
||||||
if self._history_idx < len(self._history) - 1:
|
|
||||||
self._history_idx += 1
|
|
||||||
inp.value = self._history[self._history_idx]
|
|
||||||
else:
|
else:
|
||||||
self._history_idx = -1
|
val = self._history.down()
|
||||||
inp.value = self._history_draft
|
if val is not None:
|
||||||
inp.cursor_position = len(inp.value)
|
inp.value = val
|
||||||
|
inp.cursor_position = len(val)
|
||||||
event.prevent_default()
|
event.prevent_default()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -963,9 +1035,13 @@ class TuimbleApp(App):
|
|||||||
self._ptt.key_down()
|
self._ptt.key_down()
|
||||||
|
|
||||||
def _on_ptt_change(self, transmitting: bool) -> None:
|
def _on_ptt_change(self, transmitting: bool) -> None:
|
||||||
|
if self._muted:
|
||||||
|
transmitting = False
|
||||||
self._audio.capturing = transmitting
|
self._audio.capturing = transmitting
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
status.ptt_active = transmitting
|
status.ptt_active = transmitting
|
||||||
|
if self._config.ptt.mode == "hold":
|
||||||
|
return
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
if transmitting:
|
if transmitting:
|
||||||
chatlog.write("[#e0af68]● transmitting[/]")
|
chatlog.write("[#e0af68]● transmitting[/]")
|
||||||
@@ -975,9 +1051,20 @@ class TuimbleApp(App):
|
|||||||
# -- resize --------------------------------------------------------------
|
# -- resize --------------------------------------------------------------
|
||||||
|
|
||||||
def on_resize(self, event: events.Resize) -> None:
|
def on_resize(self, event: events.Resize) -> None:
|
||||||
"""Adapt layout to new terminal dimensions."""
|
"""Adapt layout to new terminal dimensions (debounced)."""
|
||||||
|
self._pending_width = event.size.width
|
||||||
|
if self._resize_timer is not None:
|
||||||
|
self._resize_timer.stop()
|
||||||
|
self._resize_timer = self.set_timer(
|
||||||
|
TREE_DEBOUNCE, self._apply_resize
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_resize(self) -> None:
|
||||||
|
"""Apply deferred resize — keeps event loop free during drag."""
|
||||||
|
self._resize_timer = None
|
||||||
|
w = self._pending_width
|
||||||
sidebar = self.query_one("#sidebar", ChannelTree)
|
sidebar = self.query_one("#sidebar", ChannelTree)
|
||||||
sidebar.styles.width = max(16, min(32, event.size.width // 4))
|
sidebar.styles.width = max(16, min(32, w // 4))
|
||||||
self.query_one("#status", StatusBar).refresh()
|
self.query_one("#status", StatusBar).refresh()
|
||||||
|
|
||||||
# -- lifecycle -----------------------------------------------------------
|
# -- lifecycle -----------------------------------------------------------
|
||||||
@@ -985,15 +1072,29 @@ class TuimbleApp(App):
|
|||||||
def action_quit(self) -> None:
|
def action_quit(self) -> None:
|
||||||
self._intentional_disconnect = True
|
self._intentional_disconnect = True
|
||||||
self._cancel_reconnect()
|
self._cancel_reconnect()
|
||||||
|
self._device_monitor.stop()
|
||||||
self._audio.stop()
|
self._audio.stop()
|
||||||
self._client.set_dispatcher(None)
|
self._client.set_dispatcher(None)
|
||||||
self._client.disconnect()
|
self._client.disconnect()
|
||||||
self.exit()
|
self.exit()
|
||||||
|
|
||||||
|
|
||||||
|
class _HTMLStripper(HTMLParser):
|
||||||
|
"""Extract text content from HTML, discarding all tags."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._parts: list[str] = []
|
||||||
|
|
||||||
|
def handle_data(self, data: str):
|
||||||
|
self._parts.append(data)
|
||||||
|
|
||||||
|
def get_text(self) -> str:
|
||||||
|
return "".join(self._parts)
|
||||||
|
|
||||||
|
|
||||||
def _strip_html(text: str) -> str:
|
def _strip_html(text: str) -> str:
|
||||||
"""Remove HTML tags and unescape entities from Mumble messages."""
|
"""Remove HTML tags and unescape entities from Mumble messages."""
|
||||||
import re
|
stripper = _HTMLStripper()
|
||||||
|
stripper.feed(text)
|
||||||
clean = re.sub(r"<[^>]+>", "", text)
|
return html.unescape(stripper.get_text())
|
||||||
return html.unescape(clean)
|
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ Playback path: pymumble (decodes) -> raw PCM -> queue -> speakers.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import array
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import struct
|
import threading
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from tuimble.modulator import PitchShifter
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,13 +25,67 @@ DTYPE = "int16"
|
|||||||
|
|
||||||
def _apply_gain(pcm: bytes, gain: float) -> bytes:
|
def _apply_gain(pcm: bytes, gain: float) -> bytes:
|
||||||
"""Scale int16 PCM samples by gain factor with clipping."""
|
"""Scale int16 PCM samples by gain factor with clipping."""
|
||||||
n = len(pcm) // 2
|
if len(pcm) < 2:
|
||||||
if n == 0:
|
|
||||||
return pcm
|
return pcm
|
||||||
fmt = f"<{n}h"
|
samples = array.array("h")
|
||||||
samples = struct.unpack(fmt, pcm[: n * 2])
|
samples.frombytes(pcm[: len(pcm) & ~1])
|
||||||
scaled = [max(-32768, min(32767, int(s * gain))) for s in samples]
|
for i in range(len(samples)):
|
||||||
return struct.pack(fmt, *scaled)
|
samples[i] = max(-32768, min(32767, int(samples[i] * gain)))
|
||||||
|
return samples.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceMonitor:
|
||||||
|
"""Poll sounddevice for device list changes.
|
||||||
|
|
||||||
|
Runs a daemon thread that compares a string fingerprint of
|
||||||
|
``sounddevice.query_devices()`` against a cached snapshot every
|
||||||
|
*interval* seconds. Fires *callback* when the list changes.
|
||||||
|
|
||||||
|
Uses ``threading.Event`` for cancellation (same pattern as
|
||||||
|
``ReconnectManager``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, callback: Callable[[], None], interval: float = 2.0):
|
||||||
|
self._callback = callback
|
||||||
|
self._interval = interval
|
||||||
|
self._cancel = threading.Event()
|
||||||
|
self._snapshot = self._device_fingerprint()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _device_fingerprint() -> str:
|
||||||
|
"""Return a string snapshot of the current device list."""
|
||||||
|
try:
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
return str(sd.query_devices())
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start polling in a daemon thread."""
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._cancel.clear()
|
||||||
|
self._snapshot = self._device_fingerprint()
|
||||||
|
self._thread = threading.Thread(target=self._poll, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Signal the poll loop to exit."""
|
||||||
|
self._cancel.set()
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
def _poll(self) -> None:
|
||||||
|
while not self._cancel.wait(timeout=self._interval):
|
||||||
|
current = self._device_fingerprint()
|
||||||
|
if current != self._snapshot:
|
||||||
|
self._snapshot = current
|
||||||
|
log.info("audio device list changed")
|
||||||
|
try:
|
||||||
|
self._callback()
|
||||||
|
except Exception:
|
||||||
|
log.exception("device change callback failed")
|
||||||
|
|
||||||
|
|
||||||
class AudioPipeline:
|
class AudioPipeline:
|
||||||
@@ -54,8 +112,9 @@ class AudioPipeline:
|
|||||||
self._deafened = False
|
self._deafened = False
|
||||||
self._input_gain = 1.0
|
self._input_gain = 1.0
|
||||||
self._output_gain = 1.0
|
self._output_gain = 1.0
|
||||||
|
self._pitch_shifter = PitchShifter(sample_rate)
|
||||||
|
|
||||||
def start(self):
|
def start(self) -> None:
|
||||||
"""Open audio streams."""
|
"""Open audio streams."""
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
|
|
||||||
@@ -81,14 +140,20 @@ class AudioPipeline:
|
|||||||
|
|
||||||
log.info("audio pipeline started (rate=%d)", self._sample_rate)
|
log.info("audio pipeline started (rate=%d)", self._sample_rate)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
"""Close audio streams."""
|
"""Close audio streams and drain stale frames."""
|
||||||
for stream in (self._input_stream, self._output_stream):
|
for stream in (self._input_stream, self._output_stream):
|
||||||
if stream is not None:
|
if stream is not None:
|
||||||
stream.stop()
|
stream.stop()
|
||||||
stream.close()
|
stream.close()
|
||||||
self._input_stream = None
|
self._input_stream = None
|
||||||
self._output_stream = None
|
self._output_stream = None
|
||||||
|
for q in (self._capture_queue, self._playback_queue):
|
||||||
|
while not q.empty():
|
||||||
|
try:
|
||||||
|
q.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
log.info("audio pipeline stopped")
|
log.info("audio pipeline stopped")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -123,7 +188,15 @@ class AudioPipeline:
|
|||||||
def output_gain(self, value: float):
|
def output_gain(self, value: float):
|
||||||
self._output_gain = max(0.0, min(2.0, value))
|
self._output_gain = max(0.0, min(2.0, value))
|
||||||
|
|
||||||
def _capture_callback(self, indata, frames, time_info, status):
|
@property
|
||||||
|
def pitch(self) -> float:
|
||||||
|
return self._pitch_shifter.semitones
|
||||||
|
|
||||||
|
@pitch.setter
|
||||||
|
def pitch(self, value: float):
|
||||||
|
self._pitch_shifter.semitones = value
|
||||||
|
|
||||||
|
def _capture_callback(self, indata, frames, time_info, status) -> None:
|
||||||
"""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)
|
||||||
@@ -136,7 +209,7 @@ class AudioPipeline:
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _playback_callback(self, outdata, frames, time_info, status):
|
def _playback_callback(self, outdata, frames, time_info, status) -> None:
|
||||||
"""Called by sounddevice when output buffer needs data."""
|
"""Called by sounddevice when output buffer needs data."""
|
||||||
if status:
|
if status:
|
||||||
log.warning("playback status: %s", status)
|
log.warning("playback status: %s", status)
|
||||||
@@ -154,14 +227,25 @@ class AudioPipeline:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
outdata[:] = b"\x00" * len(outdata)
|
outdata[:] = b"\x00" * len(outdata)
|
||||||
|
|
||||||
def get_capture_frame(self) -> bytes | None:
|
def get_capture_frame(self, timeout: float = 0.0) -> bytes | None:
|
||||||
"""Retrieve next captured PCM frame for transmission."""
|
"""Retrieve next captured PCM frame for transmission.
|
||||||
|
|
||||||
|
Pitch shifting runs here (worker thread) rather than in the
|
||||||
|
PortAudio callback, which must return within the frame period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Seconds to wait for a frame. 0 returns immediately.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return self._capture_queue.get_nowait()
|
if timeout > 0:
|
||||||
|
pcm = self._capture_queue.get(timeout=timeout)
|
||||||
|
else:
|
||||||
|
pcm = self._capture_queue.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
return None
|
return None
|
||||||
|
return self._pitch_shifter.process(pcm)
|
||||||
|
|
||||||
def queue_playback(self, pcm_data: bytes):
|
def queue_playback(self, pcm_data: bytes) -> None:
|
||||||
"""Queue raw PCM data for playback (16-bit, mono, 48kHz)."""
|
"""Queue raw PCM data for playback (16-bit, mono, 48kHz)."""
|
||||||
if self._deafened:
|
if self._deafened:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ class MumbleClient:
|
|||||||
self._mumble = None
|
self._mumble = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._dispatcher: Callable | None = None
|
self._dispatcher: Callable | None = None
|
||||||
|
self._users_cache: dict[int, User] = {}
|
||||||
|
self._channels_cache: dict[int, Channel] = {}
|
||||||
|
self._users_dirty: bool = True
|
||||||
|
self._channels_dirty: bool = True
|
||||||
|
|
||||||
# Application callbacks (fired via dispatcher)
|
# Application callbacks (fired via dispatcher)
|
||||||
self.on_connected = None
|
self.on_connected = None
|
||||||
@@ -83,14 +87,14 @@ class MumbleClient:
|
|||||||
self.on_channel_update = None # ()
|
self.on_channel_update = None # ()
|
||||||
self.on_sound_received = None # (user, pcm_data)
|
self.on_sound_received = None # (user, pcm_data)
|
||||||
|
|
||||||
def set_dispatcher(self, fn: Callable):
|
def set_dispatcher(self, fn: Callable) -> None:
|
||||||
"""Set a function to marshal callbacks into the host event loop.
|
"""Set a function to marshal callbacks into the host event loop.
|
||||||
|
|
||||||
Typically Textual's ``call_from_thread``.
|
Typically Textual's ``call_from_thread``.
|
||||||
"""
|
"""
|
||||||
self._dispatcher = fn
|
self._dispatcher = fn
|
||||||
|
|
||||||
def _dispatch(self, callback, *args):
|
def _dispatch(self, callback, *args) -> None:
|
||||||
"""Call *callback* via the dispatcher, or directly if none is set."""
|
"""Call *callback* via the dispatcher, or directly if none is set."""
|
||||||
if callback is None:
|
if callback is None:
|
||||||
return
|
return
|
||||||
@@ -113,6 +117,8 @@ class MumbleClient:
|
|||||||
def users(self) -> dict[int, User]:
|
def users(self) -> dict[int, User]:
|
||||||
if not self._mumble:
|
if not self._mumble:
|
||||||
return {}
|
return {}
|
||||||
|
if not self._users_dirty:
|
||||||
|
return self._users_cache
|
||||||
result = {}
|
result = {}
|
||||||
for sid, u in self._mumble.users.items():
|
for sid, u in self._mumble.users.items():
|
||||||
result[sid] = User(
|
result[sid] = User(
|
||||||
@@ -124,12 +130,16 @@ class MumbleClient:
|
|||||||
self_mute=u.get("self_mute", False),
|
self_mute=u.get("self_mute", False),
|
||||||
self_deaf=u.get("self_deaf", False),
|
self_deaf=u.get("self_deaf", False),
|
||||||
)
|
)
|
||||||
|
self._users_cache = result
|
||||||
|
self._users_dirty = False
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def channels(self) -> dict[int, Channel]:
|
def channels(self) -> dict[int, Channel]:
|
||||||
if not self._mumble:
|
if not self._mumble:
|
||||||
return {}
|
return {}
|
||||||
|
if not self._channels_dirty:
|
||||||
|
return self._channels_cache
|
||||||
result = {}
|
result = {}
|
||||||
for cid, ch in self._mumble.channels.items():
|
for cid, ch in self._mumble.channels.items():
|
||||||
result[cid] = Channel(
|
result[cid] = Channel(
|
||||||
@@ -138,6 +148,8 @@ class MumbleClient:
|
|||||||
parent_id=ch.get("parent", 0),
|
parent_id=ch.get("parent", 0),
|
||||||
description=ch.get("description", ""),
|
description=ch.get("description", ""),
|
||||||
)
|
)
|
||||||
|
self._channels_cache = result
|
||||||
|
self._channels_dirty = False
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -151,7 +163,7 @@ class MumbleClient:
|
|||||||
|
|
||||||
# -- connection ----------------------------------------------------------
|
# -- connection ----------------------------------------------------------
|
||||||
|
|
||||||
def connect(self):
|
def connect(self) -> None:
|
||||||
"""Connect to the Mumble server (blocking).
|
"""Connect to the Mumble server (blocking).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -186,7 +198,8 @@ class MumbleClient:
|
|||||||
except (socket.error, OSError) as exc:
|
except (socket.error, OSError) as exc:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
raise ConnectionFailed(
|
raise ConnectionFailed(
|
||||||
f"network error: {exc}", retryable=True,
|
f"network error: {exc}",
|
||||||
|
retryable=True,
|
||||||
) from exc
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
@@ -195,16 +208,19 @@ class MumbleClient:
|
|||||||
if self._mumble.connected != const.PYMUMBLE_CONN_STATE_CONNECTED:
|
if self._mumble.connected != const.PYMUMBLE_CONN_STATE_CONNECTED:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
raise ConnectionFailed(
|
raise ConnectionFailed(
|
||||||
"server rejected connection", retryable=False,
|
"server rejected connection",
|
||||||
|
retryable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
log.info(
|
log.info(
|
||||||
"connected to %s:%d as %s",
|
"connected to %s:%d as %s",
|
||||||
self._host, self._port, self._username,
|
self._host,
|
||||||
|
self._port,
|
||||||
|
self._username,
|
||||||
)
|
)
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self) -> None:
|
||||||
"""Disconnect from the server."""
|
"""Disconnect from the server."""
|
||||||
if self._mumble:
|
if self._mumble:
|
||||||
try:
|
try:
|
||||||
@@ -212,9 +228,11 @@ class MumbleClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
self._users_dirty = True
|
||||||
|
self._channels_dirty = True
|
||||||
log.info("disconnected")
|
log.info("disconnected")
|
||||||
|
|
||||||
def reconnect(self):
|
def reconnect(self) -> None:
|
||||||
"""Disconnect and reconnect to the same server.
|
"""Disconnect and reconnect to the same server.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -226,23 +244,35 @@ class MumbleClient:
|
|||||||
|
|
||||||
# -- actions -------------------------------------------------------------
|
# -- actions -------------------------------------------------------------
|
||||||
|
|
||||||
def send_text(self, message: str):
|
def send_text(self, message: str) -> None:
|
||||||
"""Send a text message to the current channel."""
|
"""Send a text message to the current channel."""
|
||||||
if self._mumble and self._connected:
|
if self._mumble and self._connected:
|
||||||
ch = self._mumble.channels[self._mumble.users.myself["channel_id"]]
|
try:
|
||||||
|
cid = self._mumble.users.myself["channel_id"]
|
||||||
|
ch = self._mumble.channels[cid]
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
log.warning("send_text: channel unavailable")
|
||||||
|
return
|
||||||
ch.send_text_message(message)
|
ch.send_text_message(message)
|
||||||
|
|
||||||
def send_audio(self, pcm_data: bytes):
|
def send_audio(self, pcm_data: bytes) -> None:
|
||||||
"""Send PCM audio to the server (pymumble encodes to Opus)."""
|
"""Send PCM audio to the server (pymumble encodes to Opus)."""
|
||||||
if self._mumble and self._connected:
|
if self._mumble and self._connected:
|
||||||
self._mumble.sound_output.add_sound(pcm_data)
|
self._mumble.sound_output.add_sound(pcm_data)
|
||||||
|
|
||||||
def join_channel(self, channel_id: int):
|
def join_channel(self, channel_id: int) -> None:
|
||||||
"""Move to a different channel."""
|
"""Move to a different channel.
|
||||||
if self._mumble and self._connected:
|
|
||||||
self._mumble.channels[channel_id].move_in()
|
|
||||||
|
|
||||||
def set_self_deaf(self, deaf: bool):
|
Raises:
|
||||||
|
ValueError: If the channel no longer exists on the server.
|
||||||
|
"""
|
||||||
|
if self._mumble and self._connected:
|
||||||
|
ch = self._mumble.channels.get(channel_id)
|
||||||
|
if ch is None:
|
||||||
|
raise ValueError(f"channel {channel_id} not found")
|
||||||
|
ch.move_in()
|
||||||
|
|
||||||
|
def set_self_deaf(self, deaf: bool) -> None:
|
||||||
"""Toggle self-deafen on the server."""
|
"""Toggle self-deafen on the server."""
|
||||||
if self._mumble and self._connected:
|
if self._mumble and self._connected:
|
||||||
if deaf:
|
if deaf:
|
||||||
@@ -250,9 +280,22 @@ class MumbleClient:
|
|||||||
else:
|
else:
|
||||||
self._mumble.users.myself.undeafen()
|
self._mumble.users.myself.undeafen()
|
||||||
|
|
||||||
|
def set_self_mute(self, mute: bool) -> None:
|
||||||
|
"""Toggle self-mute on the server."""
|
||||||
|
if self._mumble and self._connected:
|
||||||
|
if mute:
|
||||||
|
self._mumble.users.myself.mute()
|
||||||
|
else:
|
||||||
|
self._mumble.users.myself.unmute()
|
||||||
|
|
||||||
|
def register_self(self) -> None:
|
||||||
|
"""Register the current user on the server."""
|
||||||
|
if self._mumble and self._connected:
|
||||||
|
self._mumble.users.myself.register()
|
||||||
|
|
||||||
# -- pymumble callbacks (run on pymumble thread) -------------------------
|
# -- pymumble callbacks (run on pymumble thread) -------------------------
|
||||||
|
|
||||||
def _register_callbacks(self):
|
def _register_callbacks(self) -> None:
|
||||||
import pymumble_py3.constants as const
|
import pymumble_py3.constants as const
|
||||||
|
|
||||||
cb = self._mumble.callbacks
|
cb = self._mumble.callbacks
|
||||||
@@ -267,25 +310,31 @@ class MumbleClient:
|
|||||||
cb.set_callback(const.PYMUMBLE_CLBK_CHANNELUPDATED, self._on_channel_event)
|
cb.set_callback(const.PYMUMBLE_CLBK_CHANNELUPDATED, self._on_channel_event)
|
||||||
cb.set_callback(const.PYMUMBLE_CLBK_CHANNELREMOVED, self._on_channel_event)
|
cb.set_callback(const.PYMUMBLE_CLBK_CHANNELREMOVED, self._on_channel_event)
|
||||||
|
|
||||||
def _on_connected(self):
|
def _on_connected(self) -> None:
|
||||||
self._connected = True
|
self._connected = True
|
||||||
|
self._users_dirty = True
|
||||||
|
self._channels_dirty = True
|
||||||
self._dispatch(self.on_connected)
|
self._dispatch(self.on_connected)
|
||||||
|
|
||||||
def _on_disconnected(self):
|
def _on_disconnected(self) -> None:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
self._users_dirty = True
|
||||||
|
self._channels_dirty = True
|
||||||
self._dispatch(self.on_disconnected)
|
self._dispatch(self.on_disconnected)
|
||||||
|
|
||||||
def _on_text_message(self, message):
|
def _on_text_message(self, message) -> None:
|
||||||
users = self._mumble.users
|
users = self._mumble.users
|
||||||
actor = message.actor
|
actor = message.actor
|
||||||
name = users[actor]["name"] if actor in users else "?"
|
name = users[actor]["name"] if actor in users else "?"
|
||||||
self._dispatch(self.on_text_message, name, message.message)
|
self._dispatch(self.on_text_message, name, message.message)
|
||||||
|
|
||||||
def _on_sound_received(self, user, sound_chunk):
|
def _on_sound_received(self, user, sound_chunk) -> None:
|
||||||
self._dispatch(self.on_sound_received, user, sound_chunk.pcm)
|
self._dispatch(self.on_sound_received, user, sound_chunk.pcm)
|
||||||
|
|
||||||
def _on_user_event(self, *_args):
|
def _on_user_event(self, *_args) -> None:
|
||||||
|
self._users_dirty = True
|
||||||
self._dispatch(self.on_user_update)
|
self._dispatch(self.on_user_update)
|
||||||
|
|
||||||
def _on_channel_event(self, *_args):
|
def _on_channel_event(self, *_args) -> None:
|
||||||
|
self._channels_dirty = True
|
||||||
self._dispatch(self.on_channel_update)
|
self._dispatch(self.on_channel_update)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Configuration management."""
|
"""Configuration management."""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONFIG_DIR = Path.home() / ".config" / "tuimble"
|
CONFIG_DIR = Path.home() / ".config" / "tuimble"
|
||||||
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
||||||
|
|
||||||
@@ -28,6 +32,7 @@ class AudioConfig:
|
|||||||
frame_size: int = 960 # 20ms at 48kHz
|
frame_size: int = 960 # 20ms at 48kHz
|
||||||
input_gain: float = 1.0
|
input_gain: float = 1.0
|
||||||
output_gain: float = 1.0
|
output_gain: float = 1.0
|
||||||
|
pitch: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -60,9 +65,18 @@ def load_config(path: Path | None = None) -> Config:
|
|||||||
|
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
if "server" in data:
|
if "server" in data:
|
||||||
cfg.server = ServerConfig(**data["server"])
|
cfg.server = _load_section(ServerConfig, data["server"])
|
||||||
if "audio" in data:
|
if "audio" in data:
|
||||||
cfg.audio = AudioConfig(**data["audio"])
|
cfg.audio = _load_section(AudioConfig, data["audio"])
|
||||||
if "ptt" in data:
|
if "ptt" in data:
|
||||||
cfg.ptt = PttConfig(**data["ptt"])
|
cfg.ptt = _load_section(PttConfig, data["ptt"])
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _load_section(cls, raw: dict):
|
||||||
|
"""Instantiate a dataclass, silently dropping unknown keys."""
|
||||||
|
valid = {f.name for f in dataclasses.fields(cls)}
|
||||||
|
unknown = set(raw) - valid
|
||||||
|
if unknown:
|
||||||
|
log.warning("ignoring unknown config keys: %s", ", ".join(sorted(unknown)))
|
||||||
|
return cls(**{k: v for k, v in raw.items() if k in valid})
|
||||||
|
|||||||
134
src/tuimble/modulator.py
Normal file
134
src/tuimble/modulator.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Real-time pitch shifting for the capture path.
|
||||||
|
|
||||||
|
Uses a numpy-only phase vocoder + linear resample. All heavy
|
||||||
|
operations (rfft, irfft, array arithmetic) release the GIL so
|
||||||
|
PortAudio callbacks are never starved.
|
||||||
|
|
||||||
|
Stateful across frames: carries phase and input context across both
|
||||||
|
frame boundaries and parameter changes for glitch-free transitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
_N_FFT = 512
|
||||||
|
_HOP = 128
|
||||||
|
_N_FREQ = _N_FFT // 2 + 1
|
||||||
|
_WINDOW = np.hanning(_N_FFT).astype(np.float32)
|
||||||
|
_PHASE_ADV = 2.0 * np.pi * _HOP * np.arange(_N_FREQ) / _N_FFT
|
||||||
|
|
||||||
|
|
||||||
|
class PitchShifter:
|
||||||
|
"""Shift pitch of int16 PCM frames via phase vocoder + resample.
|
||||||
|
|
||||||
|
Maintains inter-frame state (input overlap and synthesis phase)
|
||||||
|
so consecutive 20 ms frames produce a continuous output signal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sample_rate: int = 48000):
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._semitones = 0.0
|
||||||
|
self._prev_in = np.zeros(_N_FFT, dtype=np.float32)
|
||||||
|
self._phase: np.ndarray | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def semitones(self) -> float:
|
||||||
|
return self._semitones
|
||||||
|
|
||||||
|
@semitones.setter
|
||||||
|
def semitones(self, value: float) -> None:
|
||||||
|
self._semitones = max(-12.0, min(12.0, float(value)))
|
||||||
|
|
||||||
|
def process(self, pcm: bytes) -> bytes:
|
||||||
|
"""Pitch-shift a single int16 PCM frame.
|
||||||
|
|
||||||
|
Returns *pcm* unchanged when semitones == 0 or the frame is
|
||||||
|
too short to process.
|
||||||
|
"""
|
||||||
|
if self._semitones == 0.0 or len(pcm) < 2:
|
||||||
|
return pcm
|
||||||
|
|
||||||
|
samples = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
n = len(samples)
|
||||||
|
if n < _N_FFT:
|
||||||
|
return pcm
|
||||||
|
|
||||||
|
ratio = 2.0 ** (self._semitones / 12.0)
|
||||||
|
|
||||||
|
# Build continuous signal: previous context + current frame
|
||||||
|
# Previous context provides real samples instead of reflect
|
||||||
|
# padding, eliminating edge discontinuities.
|
||||||
|
y = np.concatenate([self._prev_in, samples])
|
||||||
|
y_pad = np.pad(y, (0, _N_FFT // 2), mode="reflect")
|
||||||
|
self._prev_in = samples[-_N_FFT:].copy()
|
||||||
|
|
||||||
|
# -- Vectorised STFT (one rfft call, GIL released) --
|
||||||
|
n_frames = 1 + (len(y_pad) - _N_FFT) // _HOP
|
||||||
|
if n_frames < 2:
|
||||||
|
return pcm
|
||||||
|
|
||||||
|
offsets = _HOP * np.arange(n_frames)
|
||||||
|
stft = np.fft.rfft(
|
||||||
|
y_pad[offsets[:, None] + np.arange(_N_FFT)] * _WINDOW, axis=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Time-stretch interpolation --
|
||||||
|
n_out = max(1, int(np.ceil(n_frames * ratio)))
|
||||||
|
src = np.minimum(np.arange(n_out) / ratio, n_frames - 1)
|
||||||
|
i0 = src.astype(int)
|
||||||
|
i1 = np.minimum(i0 + 1, n_frames - 1)
|
||||||
|
frac = (src - i0)[:, None]
|
||||||
|
|
||||||
|
mag = (1 - frac) * np.abs(stft[i0]) + frac * np.abs(stft[i1])
|
||||||
|
|
||||||
|
# -- Phase propagation with inter-frame continuity --
|
||||||
|
dphi = np.angle(stft[i1]) - np.angle(stft[i0]) - _PHASE_ADV
|
||||||
|
dphi -= 2.0 * np.pi * np.round(dphi / (2.0 * np.pi))
|
||||||
|
increments = _PHASE_ADV + dphi
|
||||||
|
|
||||||
|
phase = np.empty((n_out, _N_FREQ))
|
||||||
|
if self._phase is not None:
|
||||||
|
# Continue from previous frame's final phase
|
||||||
|
phase[0] = self._phase + increments[0]
|
||||||
|
else:
|
||||||
|
phase[0] = np.angle(stft[0])
|
||||||
|
if n_out > 1:
|
||||||
|
phase[1:] = phase[0] + np.cumsum(increments[1:], axis=0)
|
||||||
|
|
||||||
|
# Carry phase (wrap to [-pi, pi] to avoid precision drift)
|
||||||
|
self._phase = (phase[-1] + np.pi) % (2.0 * np.pi) - np.pi
|
||||||
|
|
||||||
|
# -- Vectorised ISTFT + overlap-add --
|
||||||
|
frames = (
|
||||||
|
np.fft.irfft(mag * np.exp(1j * phase), n=_N_FFT, axis=1).astype(
|
||||||
|
np.float32
|
||||||
|
)
|
||||||
|
* _WINDOW
|
||||||
|
)
|
||||||
|
out_len = (n_out - 1) * _HOP + _N_FFT
|
||||||
|
output = np.zeros(out_len, dtype=np.float32)
|
||||||
|
for i in range(n_out):
|
||||||
|
output[i * _HOP : i * _HOP + _N_FFT] += frames[i]
|
||||||
|
|
||||||
|
# Extract portion for current frame only.
|
||||||
|
# Left context (_N_FFT samples) maps to ~_N_FFT*ratio in
|
||||||
|
# the time-stretched output; skip that overlap region.
|
||||||
|
skip = int(round(_N_FFT * ratio))
|
||||||
|
target = int(round(n * ratio))
|
||||||
|
end = min(skip + target, len(output))
|
||||||
|
stretched = output[skip:end]
|
||||||
|
|
||||||
|
# Resample to original frame size (numpy, GIL-free)
|
||||||
|
if len(stretched) < 2:
|
||||||
|
return pcm
|
||||||
|
if len(stretched) != n:
|
||||||
|
x_old = np.linspace(0, 1, len(stretched), endpoint=False)
|
||||||
|
x_new = np.linspace(0, 1, n, endpoint=False)
|
||||||
|
stretched = np.interp(x_new, x_old, stretched)
|
||||||
|
|
||||||
|
return (
|
||||||
|
np.clip(stretched * 32768.0, -32768, 32767)
|
||||||
|
.astype(np.int16)
|
||||||
|
.tobytes()
|
||||||
|
)
|
||||||
93
src/tuimble/reconnect.py
Normal file
93
src/tuimble/reconnect.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Reconnection manager with exponential backoff."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INITIAL_DELAY = 2
|
||||||
|
MAX_DELAY = 30
|
||||||
|
MAX_RETRIES = 10
|
||||||
|
|
||||||
|
|
||||||
|
class ReconnectManager:
|
||||||
|
"""Thread-safe reconnection with exponential backoff.
|
||||||
|
|
||||||
|
The manager runs a blocking loop in a worker thread. Cancellation
|
||||||
|
is signalled via ``threading.Event``, making it both thread-safe and
|
||||||
|
instantly responsive (no polling sleep).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connect_fn: Called to attempt a reconnection. Should raise on
|
||||||
|
failure; exceptions with a ``retryable`` attribute set to
|
||||||
|
``False`` cause immediate abort.
|
||||||
|
on_attempt: ``(attempt, delay)`` -- called before each wait.
|
||||||
|
on_success: Called after a successful reconnection.
|
||||||
|
on_failure: ``(attempt, error_msg)`` -- called after each failed
|
||||||
|
attempt.
|
||||||
|
on_exhausted: Called when all retries are spent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
connect_fn: Callable[[], None],
|
||||||
|
on_attempt: Callable[[int, float], None],
|
||||||
|
on_success: Callable[[], None],
|
||||||
|
on_failure: Callable[[int, str], None],
|
||||||
|
on_exhausted: Callable[[], None],
|
||||||
|
):
|
||||||
|
self._connect = connect_fn
|
||||||
|
self._on_attempt = on_attempt
|
||||||
|
self._on_success = on_success
|
||||||
|
self._on_failure = on_failure
|
||||||
|
self._on_exhausted = on_exhausted
|
||||||
|
self._cancel = threading.Event()
|
||||||
|
self._attempt = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return not self._cancel.is_set() and self._attempt > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attempt(self) -> int:
|
||||||
|
return self._attempt
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
"""Signal the loop to stop. Safe to call from any thread."""
|
||||||
|
self._cancel.set()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Blocking reconnect loop -- run in a worker thread."""
|
||||||
|
self._cancel.clear()
|
||||||
|
self._attempt = 0
|
||||||
|
|
||||||
|
while not self._cancel.is_set():
|
||||||
|
self._attempt += 1
|
||||||
|
delay = min(INITIAL_DELAY * (2 ** (self._attempt - 1)), MAX_DELAY)
|
||||||
|
self._on_attempt(self._attempt, delay)
|
||||||
|
|
||||||
|
if self._cancel.wait(timeout=delay):
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._connect()
|
||||||
|
self._attempt = 0
|
||||||
|
self._on_success()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
retryable = getattr(exc, "retryable", True)
|
||||||
|
self._on_failure(self._attempt, str(exc))
|
||||||
|
if not retryable:
|
||||||
|
self._attempt = 0
|
||||||
|
self._on_exhausted()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._attempt >= MAX_RETRIES:
|
||||||
|
self._attempt = 0
|
||||||
|
self._on_exhausted()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._attempt = 0
|
||||||
@@ -2,26 +2,25 @@
|
|||||||
|
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
from tuimble.audio import FRAME_SIZE, SAMPLE_RATE, AudioPipeline, _apply_gain
|
import numpy as np
|
||||||
|
|
||||||
|
from tuimble.audio import FRAME_SIZE, AudioPipeline, _apply_gain
|
||||||
|
|
||||||
|
|
||||||
def test_default_construction():
|
def test_default_construction():
|
||||||
ap = AudioPipeline()
|
ap = AudioPipeline()
|
||||||
assert ap._sample_rate == SAMPLE_RATE
|
|
||||||
assert ap._frame_size == FRAME_SIZE
|
|
||||||
assert ap._input_device is None
|
|
||||||
assert ap._output_device is None
|
|
||||||
assert ap.capturing is False
|
assert ap.capturing is False
|
||||||
assert ap.deafened is False
|
assert ap.deafened is False
|
||||||
|
assert ap.input_gain == 1.0
|
||||||
|
assert ap.output_gain == 1.0
|
||||||
|
|
||||||
|
|
||||||
def test_custom_construction():
|
def test_custom_construction():
|
||||||
ap = AudioPipeline(sample_rate=24000, frame_size=480,
|
ap = AudioPipeline(
|
||||||
input_device=1, output_device=2)
|
sample_rate=24000, frame_size=480, input_device=1, output_device=2
|
||||||
assert ap._sample_rate == 24000
|
)
|
||||||
assert ap._frame_size == 480
|
# Verify via public behavior: get_capture_frame returns None
|
||||||
assert ap._input_device == 1
|
assert ap.get_capture_frame() is None
|
||||||
assert ap._output_device == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_capturing_toggle():
|
def test_capturing_toggle():
|
||||||
@@ -38,10 +37,13 @@ def test_get_capture_frame_empty():
|
|||||||
assert ap.get_capture_frame() is None
|
assert ap.get_capture_frame() is None
|
||||||
|
|
||||||
|
|
||||||
def test_get_capture_frame_returns_queued():
|
def test_capture_and_retrieve():
|
||||||
|
"""Capture callback queues frames; get_capture_frame retrieves them."""
|
||||||
ap = AudioPipeline()
|
ap = AudioPipeline()
|
||||||
ap._capture_queue.put(b"\x01\x02\x03")
|
ap.capturing = True
|
||||||
assert ap.get_capture_frame() == b"\x01\x02\x03"
|
pcm = b"\x01\x02\x03\x04"
|
||||||
|
ap._capture_callback(pcm, 2, None, None)
|
||||||
|
assert ap.get_capture_frame() == pcm
|
||||||
assert ap.get_capture_frame() is None
|
assert ap.get_capture_frame() is None
|
||||||
|
|
||||||
|
|
||||||
@@ -84,12 +86,20 @@ def test_playback_callback_short_pcm_pads_silence():
|
|||||||
def test_queue_playback_overflow_drops():
|
def test_queue_playback_overflow_drops():
|
||||||
"""Full queue drops new data silently."""
|
"""Full queue drops new data silently."""
|
||||||
ap = AudioPipeline(frame_size=FRAME_SIZE)
|
ap = AudioPipeline(frame_size=FRAME_SIZE)
|
||||||
# Fill the queue
|
# Use non-zero PCM so we can distinguish from silence
|
||||||
for i in range(ap._playback_queue.maxsize):
|
frame = b"\x42\x42"
|
||||||
ap.queue_playback(b"\x00")
|
for _ in range(50): # maxsize=50
|
||||||
# This should not raise
|
ap.queue_playback(frame)
|
||||||
ap.queue_playback(b"\xff")
|
# This should not raise (dropped because queue is full)
|
||||||
assert ap._playback_queue.qsize() == ap._playback_queue.maxsize
|
ap.queue_playback(b"\xff\xff")
|
||||||
|
# Drain and count -- frames with our marker byte
|
||||||
|
count = 0
|
||||||
|
for _ in range(60): # more than queue size
|
||||||
|
outdata = bytearray(2)
|
||||||
|
ap._playback_callback(outdata, 1, None, None)
|
||||||
|
if outdata != bytearray(2):
|
||||||
|
count += 1
|
||||||
|
assert count == 50
|
||||||
|
|
||||||
|
|
||||||
def test_deafened_toggle():
|
def test_deafened_toggle():
|
||||||
@@ -106,14 +116,16 @@ def test_queue_playback_discards_when_deafened():
|
|||||||
ap = AudioPipeline()
|
ap = AudioPipeline()
|
||||||
ap.deafened = True
|
ap.deafened = True
|
||||||
ap.queue_playback(b"\x42" * 100)
|
ap.queue_playback(b"\x42" * 100)
|
||||||
assert ap._playback_queue.qsize() == 0
|
# Nothing to play back
|
||||||
|
outdata = bytearray(200)
|
||||||
|
ap._playback_callback(outdata, 100, None, None)
|
||||||
|
assert outdata == bytearray(200) # silence
|
||||||
|
|
||||||
|
|
||||||
def test_playback_callback_silence_when_deafened():
|
def test_playback_callback_silence_when_deafened():
|
||||||
"""Playback callback writes silence when deafened, even with queued data."""
|
"""Playback callback writes silence when deafened, even with queued data."""
|
||||||
ap = AudioPipeline()
|
ap = AudioPipeline()
|
||||||
frame_bytes = FRAME_SIZE * 2
|
frame_bytes = FRAME_SIZE * 2
|
||||||
# Queue data before deafening
|
|
||||||
pcm = b"\x42" * frame_bytes
|
pcm = b"\x42" * frame_bytes
|
||||||
ap.queue_playback(pcm)
|
ap.queue_playback(pcm)
|
||||||
ap.deafened = True
|
ap.deafened = True
|
||||||
@@ -129,6 +141,20 @@ def test_stop_without_start():
|
|||||||
ap.stop()
|
ap.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_drains_queues():
|
||||||
|
"""Queues are empty after stop()."""
|
||||||
|
ap = AudioPipeline()
|
||||||
|
ap.capturing = True
|
||||||
|
ap._capture_callback(b"\x00\x00", 1, None, None)
|
||||||
|
ap.queue_playback(b"\x00\x00")
|
||||||
|
ap.stop()
|
||||||
|
assert ap.get_capture_frame() is None
|
||||||
|
# Playback queue also drained -- callback produces silence
|
||||||
|
outdata = bytearray(2)
|
||||||
|
ap._playback_callback(outdata, 1, None, None)
|
||||||
|
assert outdata == bytearray(2)
|
||||||
|
|
||||||
|
|
||||||
# -- _apply_gain tests -------------------------------------------------------
|
# -- _apply_gain tests -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -203,3 +229,43 @@ def test_playback_callback_applies_output_gain():
|
|||||||
ap._playback_callback(outdata, 2, None, None)
|
ap._playback_callback(outdata, 2, None, None)
|
||||||
result = struct.unpack("<2h", bytes(outdata))
|
result = struct.unpack("<2h", bytes(outdata))
|
||||||
assert result == (500, -500)
|
assert result == (500, -500)
|
||||||
|
|
||||||
|
|
||||||
|
# -- pitch property tests ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitch_default():
|
||||||
|
ap = AudioPipeline()
|
||||||
|
assert ap.pitch == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitch_set_and_get():
|
||||||
|
ap = AudioPipeline()
|
||||||
|
ap.pitch = 5.0
|
||||||
|
assert ap.pitch == 5.0
|
||||||
|
ap.pitch = -3.0
|
||||||
|
assert ap.pitch == -3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitch_clamping():
|
||||||
|
ap = AudioPipeline()
|
||||||
|
ap.pitch = 20.0
|
||||||
|
assert ap.pitch == 12.0
|
||||||
|
ap.pitch = -20.0
|
||||||
|
assert ap.pitch == -12.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitch_applied_on_dequeue():
|
||||||
|
"""Pitch shifting runs in get_capture_frame, not the callback."""
|
||||||
|
ap = AudioPipeline()
|
||||||
|
ap.capturing = True
|
||||||
|
ap.pitch = 4.0
|
||||||
|
|
||||||
|
t = np.arange(FRAME_SIZE) / 48000.0
|
||||||
|
pcm = (np.sin(2 * np.pi * 440.0 * t) * 16000).astype(np.int16).tobytes()
|
||||||
|
|
||||||
|
ap._capture_callback(pcm, FRAME_SIZE, None, None)
|
||||||
|
frame = ap.get_capture_frame()
|
||||||
|
assert frame is not None
|
||||||
|
assert len(frame) == len(pcm)
|
||||||
|
assert frame != pcm
|
||||||
|
|||||||
@@ -120,3 +120,47 @@ def test_disconnect_clears_connected():
|
|||||||
client._mumble = MagicMock()
|
client._mumble = MagicMock()
|
||||||
client.disconnect()
|
client.disconnect()
|
||||||
assert client.connected is False
|
assert client.connected is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_self_mute_calls_pymumble():
|
||||||
|
client = MumbleClient(host="localhost")
|
||||||
|
client._connected = True
|
||||||
|
myself = MagicMock()
|
||||||
|
users = MagicMock()
|
||||||
|
users.myself = myself
|
||||||
|
mumble = MagicMock()
|
||||||
|
mumble.users = users
|
||||||
|
client._mumble = mumble
|
||||||
|
|
||||||
|
client.set_self_mute(True)
|
||||||
|
myself.mute.assert_called_once()
|
||||||
|
myself.unmute.assert_not_called()
|
||||||
|
|
||||||
|
myself.reset_mock()
|
||||||
|
client.set_self_mute(False)
|
||||||
|
myself.unmute.assert_called_once()
|
||||||
|
myself.mute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_self_mute_noop_when_disconnected():
|
||||||
|
client = MumbleClient(host="localhost")
|
||||||
|
client.set_self_mute(True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_self_calls_pymumble():
|
||||||
|
client = MumbleClient(host="localhost")
|
||||||
|
client._connected = True
|
||||||
|
myself = MagicMock()
|
||||||
|
users = MagicMock()
|
||||||
|
users.myself = myself
|
||||||
|
mumble = MagicMock()
|
||||||
|
mumble.users = users
|
||||||
|
client._mumble = mumble
|
||||||
|
|
||||||
|
client.register_self()
|
||||||
|
myself.register.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_self_noop_when_disconnected():
|
||||||
|
client = MumbleClient(host="localhost")
|
||||||
|
client.register_self()
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"""Tests for configuration module."""
|
"""Tests for configuration module."""
|
||||||
|
|
||||||
from tuimble.config import AudioConfig, Config, PttConfig, ServerConfig
|
from tuimble.config import (
|
||||||
|
AudioConfig,
|
||||||
|
Config,
|
||||||
|
PttConfig,
|
||||||
|
ServerConfig,
|
||||||
|
_load_section,
|
||||||
|
load_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_default_config():
|
def test_default_config():
|
||||||
@@ -45,3 +52,49 @@ def test_server_cert_custom():
|
|||||||
srv = ServerConfig(certfile="/path/cert.pem", keyfile="/path/key.pem")
|
srv = ServerConfig(certfile="/path/cert.pem", keyfile="/path/key.pem")
|
||||||
assert srv.certfile == "/path/cert.pem"
|
assert srv.certfile == "/path/cert.pem"
|
||||||
assert srv.keyfile == "/path/key.pem"
|
assert srv.keyfile == "/path/key.pem"
|
||||||
|
|
||||||
|
|
||||||
|
# -- _load_section tests -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_section_filters_unknown_keys():
|
||||||
|
"""Unknown keys are silently dropped, valid keys are kept."""
|
||||||
|
result = _load_section(
|
||||||
|
ServerConfig,
|
||||||
|
{
|
||||||
|
"host": "example.com",
|
||||||
|
"typo_field": "oops",
|
||||||
|
"another_bad": 42,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result.host == "example.com"
|
||||||
|
assert result.port == 64738 # default preserved
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_section_empty_dict():
|
||||||
|
result = _load_section(PttConfig, {})
|
||||||
|
assert result.key == "f4"
|
||||||
|
assert result.mode == "toggle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_section_all_valid():
|
||||||
|
result = _load_section(PttConfig, {"key": "space", "mode": "hold"})
|
||||||
|
assert result.key == "space"
|
||||||
|
assert result.mode == "hold"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_missing_file(tmp_path):
|
||||||
|
"""Missing config file returns defaults."""
|
||||||
|
cfg = load_config(tmp_path / "nonexistent.toml")
|
||||||
|
assert cfg.server.host == "localhost"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_with_unknown_keys(tmp_path):
|
||||||
|
"""Config file with unknown keys loads without error."""
|
||||||
|
toml = tmp_path / "config.toml"
|
||||||
|
toml.write_text(
|
||||||
|
'[server]\nhost = "example.com"\nbogus = true\n[ptt]\nfuture_option = "x"\n'
|
||||||
|
)
|
||||||
|
cfg = load_config(toml)
|
||||||
|
assert cfg.server.host == "example.com"
|
||||||
|
assert cfg.ptt.key == "f4" # default, not overwritten
|
||||||
|
|||||||
94
tests/test_history.py
Normal file
94
tests/test_history.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Tests for InputHistory."""
|
||||||
|
|
||||||
|
from tuimble.app import InputHistory
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_history_up_returns_none():
|
||||||
|
h = InputHistory()
|
||||||
|
assert h.up("current") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_history_down_returns_none():
|
||||||
|
h = InputHistory()
|
||||||
|
assert h.down() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_then_up_returns_last():
|
||||||
|
h = InputHistory()
|
||||||
|
h.push("hello")
|
||||||
|
assert h.up("draft") == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_up_twice_with_single_entry_stays():
|
||||||
|
h = InputHistory()
|
||||||
|
h.push("one")
|
||||||
|
assert h.up("draft") == "one"
|
||||||
|
assert h.up("draft") == "one" # no older entry, stays put
|
||||||
|
|
||||||
|
|
||||||
|
def test_up_navigates_backwards():
|
||||||
|
h = InputHistory()
|
||||||
|
h.push("first")
|
||||||
|
h.push("second")
|
||||||
|
h.push("third")
|
||||||
|
assert h.up("draft") == "third"
|
||||||
|
assert h.up("draft") == "second"
|
||||||
|
assert h.up("draft") == "first"
|
||||||
|
assert h.up("draft") == "first" # clamped at oldest
|
||||||
|
|
||||||
|
|
||||||
|
def test_down_navigates_forward():
|
||||||
|
h = InputHistory()
|
||||||
|
h.push("first")
|
||||||
|
h.push("second")
|
||||||
|
h.push("third")
|
||||||
|
h.up("draft") # -> third
|
||||||
|
h.up("draft") # -> second
|
||||||
|
assert h.down() == "third"
|
||||||
|
|
||||||
|
|
||||||
|
def test_down_past_end_restores_draft():
|
||||||
|
h = InputHistory()
|
||||||
|
h.push("one")
|
||||||
|
h.up("my draft") # -> one
|
||||||
|
result = h.down() # -> back to draft
|
||||||
|
assert result == "my draft"
|
||||||
|
|
||||||
|
|
||||||
|
def test_down_without_prior_up_returns_none():
|
||||||
|
h = InputHistory()
|
||||||
|
h.push("one")
|
||||||
|
assert h.down() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_resets_navigation():
|
||||||
|
h = InputHistory()
|
||||||
|
h.push("first")
|
||||||
|
h.up("draft") # -> first
|
||||||
|
h.push("second")
|
||||||
|
# After push, navigation resets -- up should go to newest
|
||||||
|
assert h.up("new draft") == "second"
|
||||||
|
|
||||||
|
|
||||||
|
def test_up_preserves_current_as_draft():
|
||||||
|
"""First up() saves current input; down past end restores it."""
|
||||||
|
h = InputHistory()
|
||||||
|
h.push("old")
|
||||||
|
h.up("typing in progress")
|
||||||
|
assert h.down() == "typing in progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_cycle():
|
||||||
|
"""Push several, navigate to oldest, then back to draft."""
|
||||||
|
h = InputHistory()
|
||||||
|
h.push("a")
|
||||||
|
h.push("b")
|
||||||
|
h.push("c")
|
||||||
|
|
||||||
|
assert h.up("draft") == "c"
|
||||||
|
assert h.up("draft") == "b"
|
||||||
|
assert h.up("draft") == "a"
|
||||||
|
assert h.down() == "b"
|
||||||
|
assert h.down() == "c"
|
||||||
|
assert h.down() == "draft"
|
||||||
|
assert h.down() is None # already at draft
|
||||||
78
tests/test_modulator.py
Normal file
78
tests/test_modulator.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for PitchShifter."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from tuimble.modulator import PitchShifter
|
||||||
|
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
FRAME_SIZE = 960 # 20ms at 48kHz
|
||||||
|
|
||||||
|
|
||||||
|
def _sine_pcm(freq: float, n_samples: int = FRAME_SIZE) -> bytes:
|
||||||
|
"""Generate a single-frequency int16 PCM frame."""
|
||||||
|
t = np.arange(n_samples) / SAMPLE_RATE
|
||||||
|
samples = (np.sin(2 * np.pi * freq * t) * 16000).astype(np.int16)
|
||||||
|
return samples.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant_freq(pcm: bytes) -> float:
|
||||||
|
"""Return the dominant frequency in an int16 PCM buffer."""
|
||||||
|
samples = np.frombuffer(pcm, dtype=np.int16).astype(np.float32)
|
||||||
|
fft = np.abs(np.fft.rfft(samples))
|
||||||
|
freqs = np.fft.rfftfreq(len(samples), 1.0 / SAMPLE_RATE)
|
||||||
|
return freqs[np.argmax(fft)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_semitones_passthrough():
|
||||||
|
ps = PitchShifter(SAMPLE_RATE)
|
||||||
|
pcm = _sine_pcm(440.0)
|
||||||
|
assert ps.process(pcm) == pcm
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitch_shift_changes_output():
|
||||||
|
ps = PitchShifter(SAMPLE_RATE)
|
||||||
|
pcm = _sine_pcm(440.0)
|
||||||
|
ps.semitones = 3.0
|
||||||
|
result = ps.process(pcm)
|
||||||
|
assert result != pcm
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_length_preserved():
|
||||||
|
ps = PitchShifter(SAMPLE_RATE)
|
||||||
|
pcm = _sine_pcm(440.0)
|
||||||
|
ps.semitones = 5.0
|
||||||
|
result = ps.process(pcm)
|
||||||
|
assert len(result) == len(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
def test_semitones_clamping():
|
||||||
|
ps = PitchShifter()
|
||||||
|
ps.semitones = 20.0
|
||||||
|
assert ps.semitones == 12.0
|
||||||
|
ps.semitones = -20.0
|
||||||
|
assert ps.semitones == -12.0
|
||||||
|
ps.semitones = 5.0
|
||||||
|
assert ps.semitones == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_input():
|
||||||
|
ps = PitchShifter()
|
||||||
|
ps.semitones = 3.0
|
||||||
|
assert ps.process(b"") == b""
|
||||||
|
assert ps.process(b"\x00") == b"\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitch_up_frequency_increases():
|
||||||
|
ps = PitchShifter(SAMPLE_RATE)
|
||||||
|
pcm = _sine_pcm(440.0)
|
||||||
|
ps.semitones = 4.0
|
||||||
|
result = ps.process(pcm)
|
||||||
|
assert _dominant_freq(result) > _dominant_freq(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitch_down_frequency_decreases():
|
||||||
|
ps = PitchShifter(SAMPLE_RATE)
|
||||||
|
pcm = _sine_pcm(440.0)
|
||||||
|
ps.semitones = -4.0
|
||||||
|
result = ps.process(pcm)
|
||||||
|
assert _dominant_freq(result) < _dominant_freq(pcm)
|
||||||
192
tests/test_reconnect.py
Normal file
192
tests/test_reconnect.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""Tests for ReconnectManager."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from tuimble.reconnect import INITIAL_DELAY, ReconnectManager
|
||||||
|
|
||||||
|
|
||||||
|
def _make_manager(connect_fn=None, **overrides):
|
||||||
|
"""Build a ReconnectManager with recording callbacks."""
|
||||||
|
log = {"attempts": [], "failures": [], "success": 0, "exhausted": 0}
|
||||||
|
|
||||||
|
def on_attempt(n, delay):
|
||||||
|
log["attempts"].append((n, delay))
|
||||||
|
|
||||||
|
def on_success():
|
||||||
|
log["success"] += 1
|
||||||
|
|
||||||
|
def on_failure(n, msg):
|
||||||
|
log["failures"].append((n, msg))
|
||||||
|
|
||||||
|
def on_exhausted():
|
||||||
|
log["exhausted"] += 1
|
||||||
|
|
||||||
|
if connect_fn is None:
|
||||||
|
|
||||||
|
def connect_fn():
|
||||||
|
return None
|
||||||
|
|
||||||
|
mgr = ReconnectManager(
|
||||||
|
connect_fn=connect_fn,
|
||||||
|
on_attempt=overrides.get("on_attempt", on_attempt),
|
||||||
|
on_success=overrides.get("on_success", on_success),
|
||||||
|
on_failure=overrides.get("on_failure", on_failure),
|
||||||
|
on_exhausted=overrides.get("on_exhausted", on_exhausted),
|
||||||
|
)
|
||||||
|
return mgr, log
|
||||||
|
|
||||||
|
|
||||||
|
# -- basic lifecycle ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state():
|
||||||
|
mgr, _ = _make_manager()
|
||||||
|
assert mgr.active is False
|
||||||
|
assert mgr.attempt == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_on_first_attempt():
|
||||||
|
"""Connect succeeds immediately -- one attempt, no failures."""
|
||||||
|
mgr, log = _make_manager(connect_fn=lambda: None)
|
||||||
|
mgr.run()
|
||||||
|
assert log["success"] == 1
|
||||||
|
assert log["exhausted"] == 0
|
||||||
|
assert len(log["attempts"]) == 1
|
||||||
|
assert len(log["failures"]) == 0
|
||||||
|
assert mgr.active is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_after_failures():
|
||||||
|
"""Connect fails twice, then succeeds on third attempt."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def flaky_connect():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count < 3:
|
||||||
|
raise ConnectionError("down")
|
||||||
|
|
||||||
|
mgr, log = _make_manager(connect_fn=flaky_connect)
|
||||||
|
# Patch delay to zero for test speed
|
||||||
|
import tuimble.reconnect as mod
|
||||||
|
|
||||||
|
orig = mod.INITIAL_DELAY
|
||||||
|
mod.INITIAL_DELAY = 0
|
||||||
|
try:
|
||||||
|
mgr.run()
|
||||||
|
finally:
|
||||||
|
mod.INITIAL_DELAY = orig
|
||||||
|
|
||||||
|
assert log["success"] == 1
|
||||||
|
assert len(log["failures"]) == 2
|
||||||
|
assert len(log["attempts"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# -- non-retryable -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_retryable_aborts_immediately():
|
||||||
|
"""Exception with retryable=False stops the loop."""
|
||||||
|
|
||||||
|
class Rejected(Exception):
|
||||||
|
retryable = False
|
||||||
|
|
||||||
|
def _raise():
|
||||||
|
raise Rejected("banned")
|
||||||
|
|
||||||
|
mgr, log = _make_manager(connect_fn=_raise)
|
||||||
|
mgr.run()
|
||||||
|
assert log["exhausted"] == 1
|
||||||
|
assert log["success"] == 0
|
||||||
|
assert len(log["attempts"]) == 1
|
||||||
|
assert len(log["failures"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -- max retries --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_exhaustion_after_max_retries():
|
||||||
|
"""Loop stops after MAX_RETRIES failed attempts."""
|
||||||
|
import tuimble.reconnect as mod
|
||||||
|
|
||||||
|
orig_delay = mod.INITIAL_DELAY
|
||||||
|
orig_retries = mod.MAX_RETRIES
|
||||||
|
mod.INITIAL_DELAY = 0
|
||||||
|
mod.MAX_RETRIES = 3
|
||||||
|
try:
|
||||||
|
mgr, log = _make_manager(
|
||||||
|
connect_fn=lambda: (_ for _ in ()).throw(ConnectionError("nope")),
|
||||||
|
)
|
||||||
|
mgr.run()
|
||||||
|
finally:
|
||||||
|
mod.INITIAL_DELAY = orig_delay
|
||||||
|
mod.MAX_RETRIES = orig_retries
|
||||||
|
|
||||||
|
assert log["exhausted"] == 1
|
||||||
|
assert log["success"] == 0
|
||||||
|
assert len(log["attempts"]) == 3
|
||||||
|
assert len(log["failures"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# -- cancellation -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_stops_loop():
|
||||||
|
"""cancel() interrupts the wait and exits the loop."""
|
||||||
|
barrier = threading.Event()
|
||||||
|
|
||||||
|
def slow_attempt(n, delay):
|
||||||
|
barrier.set()
|
||||||
|
|
||||||
|
mgr, log = _make_manager(
|
||||||
|
connect_fn=lambda: (_ for _ in ()).throw(ConnectionError("fail")),
|
||||||
|
on_attempt=slow_attempt,
|
||||||
|
)
|
||||||
|
|
||||||
|
t = threading.Thread(target=mgr.run)
|
||||||
|
t.start()
|
||||||
|
barrier.wait(timeout=2)
|
||||||
|
mgr.cancel()
|
||||||
|
t.join(timeout=2)
|
||||||
|
|
||||||
|
assert not t.is_alive()
|
||||||
|
assert mgr.active is False
|
||||||
|
|
||||||
|
|
||||||
|
# -- backoff ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_backoff_delays():
|
||||||
|
"""Verify exponential backoff sequence up to MAX_DELAY."""
|
||||||
|
mgr, log = _make_manager()
|
||||||
|
# We only need the attempt callback to record delays; cancel after
|
||||||
|
# a few attempts to avoid waiting.
|
||||||
|
import tuimble.reconnect as mod
|
||||||
|
|
||||||
|
orig_delay = mod.INITIAL_DELAY
|
||||||
|
orig_retries = mod.MAX_RETRIES
|
||||||
|
mod.INITIAL_DELAY = 0 # zero delay for speed
|
||||||
|
mod.MAX_RETRIES = 5
|
||||||
|
try:
|
||||||
|
mgr, log = _make_manager(
|
||||||
|
connect_fn=lambda: (_ for _ in ()).throw(ConnectionError("x")),
|
||||||
|
)
|
||||||
|
mgr.run()
|
||||||
|
finally:
|
||||||
|
mod.INITIAL_DELAY = orig_delay
|
||||||
|
mod.MAX_RETRIES = orig_retries
|
||||||
|
|
||||||
|
delays = [d for _, d in log["attempts"]]
|
||||||
|
# With INITIAL_DELAY=0, all delays are 0 (min(0 * 2^n, MAX_DELAY))
|
||||||
|
# Test the formula with real values instead
|
||||||
|
assert len(delays) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_backoff_formula():
|
||||||
|
"""Delay = min(INITIAL_DELAY * 2^(attempt-1), MAX_DELAY)."""
|
||||||
|
from tuimble.reconnect import MAX_DELAY
|
||||||
|
|
||||||
|
expected = []
|
||||||
|
for i in range(1, 7):
|
||||||
|
expected.append(min(INITIAL_DELAY * (2 ** (i - 1)), MAX_DELAY))
|
||||||
|
assert expected == [2, 4, 8, 16, 30, 30]
|
||||||
59
tests/test_strip_html.py
Normal file
59
tests/test_strip_html.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Tests for _strip_html edge cases."""
|
||||||
|
|
||||||
|
from tuimble.app import _strip_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_text_unchanged():
|
||||||
|
assert _strip_html("hello world") == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_tags_stripped():
|
||||||
|
assert _strip_html("<b>bold</b>") == "bold"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_tags():
|
||||||
|
assert _strip_html("<div><p>text</p></div>") == "text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_closing_tags():
|
||||||
|
assert _strip_html("line<br/>break") == "linebreak"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entities_unescaped():
|
||||||
|
assert _strip_html("& < >") == "& < >"
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_entities_in_tags():
|
||||||
|
assert _strip_html("<b>&</b>") == "&"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mumble_style_message():
|
||||||
|
"""Typical Mumble chat message with anchor tag."""
|
||||||
|
msg = '<a href="https://example.com">link text</a> and more'
|
||||||
|
assert _strip_html(msg) == "link text and more"
|
||||||
|
|
||||||
|
|
||||||
|
def test_img_tag_with_attributes():
|
||||||
|
assert _strip_html('before<img src="x.png" alt="pic"/>after') == "beforeafter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_stripped():
|
||||||
|
assert _strip_html("before<!-- comment -->after") == "beforeafter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_string():
|
||||||
|
assert _strip_html("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_tags():
|
||||||
|
assert _strip_html("<br><hr><img/>") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_unclosed_tag():
|
||||||
|
"""Malformed HTML should not crash."""
|
||||||
|
result = _strip_html("<b>unclosed")
|
||||||
|
assert "unclosed" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_entities():
|
||||||
|
assert _strip_html(""quoted"") == '"quoted"'
|
||||||
406
tests/test_widgets.py
Normal file
406
tests/test_widgets.py
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
"""Tests for StatusBar, ChannelTree, and related widget helpers."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tuimble.app import (
|
||||||
|
VOLUME_STEPS,
|
||||||
|
ChannelSelected,
|
||||||
|
ChannelTree,
|
||||||
|
StatusBar,
|
||||||
|
_next_volume,
|
||||||
|
)
|
||||||
|
from tuimble.client import Channel, User
|
||||||
|
|
||||||
|
# -- test helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_channels():
|
||||||
|
return {
|
||||||
|
0: Channel(channel_id=0, name="Root", parent_id=-1),
|
||||||
|
1: Channel(channel_id=1, name="Alpha", parent_id=0),
|
||||||
|
2: Channel(channel_id=2, name="Beta", parent_id=0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_users():
|
||||||
|
return {
|
||||||
|
1: [User(session_id=10, name="Alice", channel_id=1)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- A. Pure unit tests (sync) ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolBar:
|
||||||
|
def test_zero(self):
|
||||||
|
assert StatusBar._vol_bar(0) == "\u2591\u2591\u2591\u2591"
|
||||||
|
|
||||||
|
def test_25(self):
|
||||||
|
assert StatusBar._vol_bar(25) == "\u2588\u2591\u2591\u2591"
|
||||||
|
|
||||||
|
def test_50(self):
|
||||||
|
assert StatusBar._vol_bar(50) == "\u2588\u2588\u2591\u2591"
|
||||||
|
|
||||||
|
def test_100(self):
|
||||||
|
assert StatusBar._vol_bar(100) == "\u2588\u2588\u2588\u2588"
|
||||||
|
|
||||||
|
def test_200_overgain(self):
|
||||||
|
result = StatusBar._vol_bar(200)
|
||||||
|
# 200/25 = 8, clamped to 4 filled + max(0, 4-8)=0 light
|
||||||
|
assert "\u2588" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncate:
|
||||||
|
def test_short(self):
|
||||||
|
assert ChannelTree._truncate("hi", 10) == "hi"
|
||||||
|
|
||||||
|
def test_exact(self):
|
||||||
|
assert ChannelTree._truncate("abcde", 5) == "abcde"
|
||||||
|
|
||||||
|
def test_long(self):
|
||||||
|
assert ChannelTree._truncate("abcdefghij", 7) == "abcd..."
|
||||||
|
|
||||||
|
def test_tiny_max(self):
|
||||||
|
assert ChannelTree._truncate("abcdef", 3) == "abc"
|
||||||
|
|
||||||
|
def test_max_two(self):
|
||||||
|
assert ChannelTree._truncate("abcdef", 2) == "ab"
|
||||||
|
|
||||||
|
def test_zero_max(self):
|
||||||
|
assert ChannelTree._truncate("abc", 0) == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserStatus:
|
||||||
|
def test_normal(self):
|
||||||
|
u = User(session_id=1, name="X", channel_id=0)
|
||||||
|
assert ChannelTree._user_status(u) == ""
|
||||||
|
|
||||||
|
def test_self_deaf(self):
|
||||||
|
u = User(session_id=1, name="X", channel_id=0, self_deaf=True)
|
||||||
|
assert "\u2298" in ChannelTree._user_status(u)
|
||||||
|
|
||||||
|
def test_self_mute(self):
|
||||||
|
u = User(session_id=1, name="X", channel_id=0, self_mute=True)
|
||||||
|
assert "\u2715" in ChannelTree._user_status(u)
|
||||||
|
|
||||||
|
def test_server_deaf(self):
|
||||||
|
u = User(session_id=1, name="X", channel_id=0, deaf=True)
|
||||||
|
assert "\u2298" in ChannelTree._user_status(u)
|
||||||
|
|
||||||
|
def test_server_mute(self):
|
||||||
|
u = User(session_id=1, name="X", channel_id=0, mute=True)
|
||||||
|
assert "\u2715" in ChannelTree._user_status(u)
|
||||||
|
|
||||||
|
def test_deaf_takes_priority(self):
|
||||||
|
u = User(session_id=1, name="X", channel_id=0, self_deaf=True, self_mute=True)
|
||||||
|
assert "\u2298" in ChannelTree._user_status(u)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNextVolume:
|
||||||
|
def test_full_cycle(self):
|
||||||
|
vol = 0.0
|
||||||
|
seen = [vol]
|
||||||
|
for _ in range(len(VOLUME_STEPS)):
|
||||||
|
vol = _next_volume(vol)
|
||||||
|
seen.append(vol)
|
||||||
|
# Must cycle through all steps and wrap
|
||||||
|
assert seen[-1] == VOLUME_STEPS[0]
|
||||||
|
|
||||||
|
def test_wraparound_from_max(self):
|
||||||
|
assert _next_volume(VOLUME_STEPS[-1]) == VOLUME_STEPS[0]
|
||||||
|
|
||||||
|
def test_mid_value(self):
|
||||||
|
assert _next_volume(0.5) == 0.75
|
||||||
|
|
||||||
|
|
||||||
|
# -- B. ChannelTree state tests (sync, direct instantiation) ----------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelTreeState:
|
||||||
|
def test_build_order_matches_dfs(self):
|
||||||
|
tree = ChannelTree()
|
||||||
|
tree.set_state(_sample_channels(), {})
|
||||||
|
# Root, then sorted children: Alpha, Beta
|
||||||
|
assert tree._channel_ids == [0, 1, 2]
|
||||||
|
|
||||||
|
def test_empty_channels(self):
|
||||||
|
tree = ChannelTree()
|
||||||
|
tree.set_state({}, {})
|
||||||
|
assert tree._channel_ids == []
|
||||||
|
|
||||||
|
def test_clear_state(self):
|
||||||
|
tree = ChannelTree()
|
||||||
|
tree.set_state(_sample_channels(), _sample_users(), my_channel_id=1)
|
||||||
|
tree.clear_state()
|
||||||
|
assert tree._channels == {}
|
||||||
|
assert tree._users_by_channel == {}
|
||||||
|
assert tree._channel_ids == []
|
||||||
|
assert tree._focused_idx == 0
|
||||||
|
assert tree._my_channel_id is None
|
||||||
|
|
||||||
|
def test_focus_clamped_when_shrinks(self):
|
||||||
|
tree = ChannelTree()
|
||||||
|
tree.set_state(_sample_channels(), {})
|
||||||
|
tree._focused_idx = 2 # last channel
|
||||||
|
# Shrink to only root
|
||||||
|
tree.set_state({0: Channel(channel_id=0, name="Root", parent_id=-1)}, {})
|
||||||
|
assert tree._focused_idx == 0
|
||||||
|
|
||||||
|
def test_focus_preserved_when_in_range(self):
|
||||||
|
tree = ChannelTree()
|
||||||
|
tree.set_state(_sample_channels(), {})
|
||||||
|
tree._focused_idx = 1
|
||||||
|
# Re-set same state
|
||||||
|
tree.set_state(_sample_channels(), {})
|
||||||
|
assert tree._focused_idx == 1
|
||||||
|
|
||||||
|
def test_nested_channels_order(self):
|
||||||
|
channels = {
|
||||||
|
0: Channel(channel_id=0, name="Root", parent_id=-1),
|
||||||
|
1: Channel(channel_id=1, name="Zulu", parent_id=0),
|
||||||
|
2: Channel(channel_id=2, name="Alpha", parent_id=0),
|
||||||
|
3: Channel(channel_id=3, name="Sub", parent_id=2),
|
||||||
|
}
|
||||||
|
tree = ChannelTree()
|
||||||
|
tree.set_state(channels, {})
|
||||||
|
# Root -> Alpha (sorted) -> Sub -> Zulu
|
||||||
|
assert tree._channel_ids == [0, 2, 3, 1]
|
||||||
|
|
||||||
|
|
||||||
|
# -- C. StatusBar integration tests (async) ---------------------------------
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class StatusBarApp(App):
|
||||||
|
CSS = """
|
||||||
|
#status { dock: bottom; height: 1; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield StatusBar(id="status")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_default_disconnected():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(80, 5)) as _pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
assert bar.connected is False
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "\u25cb" in rendered # disconnected circle
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_connected():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(80, 5)) as _pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
bar.connected = True
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "\u25cf" in rendered # filled circle
|
||||||
|
assert "connected" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_compact_width():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(30, 5)) as pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
await pilot.resize_terminal(30, 5)
|
||||||
|
await pilot.pause()
|
||||||
|
rendered = bar.render()
|
||||||
|
# Compact mode: symbols only, no labels
|
||||||
|
assert "connected" not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_medium_width():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(50, 5)) as _pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "disconnected" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_full_width():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(80, 5)) as _pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
bar.connected = True
|
||||||
|
bar.output_vol = 100
|
||||||
|
bar.input_vol = 50
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "out" in rendered
|
||||||
|
assert "in" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_ptt_active():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(80, 5)) as _pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
bar.ptt_active = True
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "TX" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_deaf():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(80, 5)) as _pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
bar.self_deaf = True
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "\u2298" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_muted():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(80, 5)) as _pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
bar.self_mute = True
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "\u2715" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_muted_compact():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(30, 5)) as pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
bar.self_mute = True
|
||||||
|
await pilot.resize_terminal(30, 5)
|
||||||
|
await pilot.pause()
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "\u2715" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_muted_medium():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(50, 5)) as _pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
bar.self_mute = True
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "\u2715" in rendered
|
||||||
|
assert "mute" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_statusbar_reconnecting():
|
||||||
|
app = StatusBarApp()
|
||||||
|
async with app.run_test(size=(80, 5)) as _pilot:
|
||||||
|
bar = app.query_one("#status", StatusBar)
|
||||||
|
bar.reconnecting = True
|
||||||
|
rendered = bar.render()
|
||||||
|
assert "reconnecting" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
# -- D. ChannelTree integration tests (async) --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelTreeApp(App):
|
||||||
|
CSS = """
|
||||||
|
#sidebar { width: 24; height: 1fr; }
|
||||||
|
"""
|
||||||
|
BINDINGS = [("q", "quit", "Quit")]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.selected_messages: list[int] = []
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield ChannelTree(id="sidebar")
|
||||||
|
|
||||||
|
def on_channel_selected(self, msg: ChannelSelected) -> None:
|
||||||
|
self.selected_messages.append(msg.channel_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tree_empty_render():
|
||||||
|
app = ChannelTreeApp()
|
||||||
|
async with app.run_test(size=(40, 10)) as _pilot:
|
||||||
|
tree = app.query_one("#sidebar", ChannelTree)
|
||||||
|
rendered = tree.render()
|
||||||
|
assert "(not connected)" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tree_populated():
|
||||||
|
app = ChannelTreeApp()
|
||||||
|
async with app.run_test(size=(40, 10)) as _pilot:
|
||||||
|
tree = app.query_one("#sidebar", ChannelTree)
|
||||||
|
tree.set_state(_sample_channels(), _sample_users())
|
||||||
|
rendered = tree.render()
|
||||||
|
assert "Root" in rendered
|
||||||
|
assert "Alpha" in rendered
|
||||||
|
assert "Beta" in rendered
|
||||||
|
assert "Alice" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tree_down_arrow():
|
||||||
|
app = ChannelTreeApp()
|
||||||
|
async with app.run_test(size=(40, 10)) as pilot:
|
||||||
|
tree = app.query_one("#sidebar", ChannelTree)
|
||||||
|
tree.set_state(_sample_channels(), {})
|
||||||
|
tree.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
assert tree._focused_idx == 0
|
||||||
|
await pilot.press("down")
|
||||||
|
assert tree._focused_idx == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tree_up_arrow():
|
||||||
|
app = ChannelTreeApp()
|
||||||
|
async with app.run_test(size=(40, 10)) as pilot:
|
||||||
|
tree = app.query_one("#sidebar", ChannelTree)
|
||||||
|
tree.set_state(_sample_channels(), {})
|
||||||
|
tree._focused_idx = 2
|
||||||
|
tree.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("up")
|
||||||
|
assert tree._focused_idx == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tree_bounds_top():
|
||||||
|
app = ChannelTreeApp()
|
||||||
|
async with app.run_test(size=(40, 10)) as pilot:
|
||||||
|
tree = app.query_one("#sidebar", ChannelTree)
|
||||||
|
tree.set_state(_sample_channels(), {})
|
||||||
|
tree.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("up")
|
||||||
|
assert tree._focused_idx == 0 # stays at 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tree_bounds_bottom():
|
||||||
|
app = ChannelTreeApp()
|
||||||
|
async with app.run_test(size=(40, 10)) as pilot:
|
||||||
|
tree = app.query_one("#sidebar", ChannelTree)
|
||||||
|
tree.set_state(_sample_channels(), {})
|
||||||
|
tree._focused_idx = 2
|
||||||
|
tree.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("down")
|
||||||
|
assert tree._focused_idx == 2 # stays at max
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tree_enter_posts_message():
|
||||||
|
app = ChannelTreeApp()
|
||||||
|
async with app.run_test(size=(40, 10)) as pilot:
|
||||||
|
tree = app.query_one("#sidebar", ChannelTree)
|
||||||
|
tree.set_state(_sample_channels(), {})
|
||||||
|
tree._focused_idx = 1 # Alpha (channel_id=1)
|
||||||
|
tree.focus()
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
assert 1 in app.selected_messages
|
||||||
Reference in New Issue
Block a user