app: wire audio device hot-swap on change

This commit is contained in:
Username
2026-02-25 00:26:27 +01:00
parent 9e6c11e588
commit 3dbd126239

View File

@@ -14,7 +14,7 @@ from textual.message import Message
from textual.reactive import reactive
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, MumbleClient, User
from tuimble.config import Config, load_config
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
@@ -91,6 +91,12 @@ class ServerStateChanged(Message):
pass
class AudioDeviceChanged(Message):
"""Audio device list changed (hot-swap detected)."""
pass
class ChannelSelected(Message):
"""User selected a channel to join."""
@@ -381,12 +387,12 @@ class TuimbleApp(App):
}
#chatlog {
height: 1fr;
border-bottom: solid #292e42;
scrollbar-size: 1 1;
}
#input {
height: 3;
border-top: solid #292e42;
padding: 0 1 0 0;
border: none;
}
#status {
dock: bottom;
@@ -425,6 +431,7 @@ class TuimbleApp(App):
)
self._audio.input_gain = acfg.input_gain
self._audio.output_gain = acfg.output_gain
self._device_monitor = DeviceMonitor(self._on_device_change)
self._pending_reload: Config | None = None
self._tree_refresh_timer = None
self._reconnect = ReconnectManager(
@@ -581,6 +588,7 @@ class TuimbleApp(App):
self._start_audio()
def on_server_disconnected(self, _msg: ServerDisconnected) -> None:
self._device_monitor.stop()
self._audio.stop()
status = self.query_one("#status", StatusBar)
@@ -643,11 +651,52 @@ class TuimbleApp(App):
# -- 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
if self._client.connected:
self._start_audio()
def _start_audio(self) -> None:
"""Start audio pipeline; log error if hardware unavailable."""
chatlog = self.query_one("#chatlog", ChatLog)
try:
self._audio.start()
self._device_monitor.start()
chatlog.write("[dim]audio pipeline started[/dim]")
self._audio_send_loop()
except Exception as exc:
@@ -918,6 +967,7 @@ class TuimbleApp(App):
def action_quit(self) -> None:
self._intentional_disconnect = True
self._cancel_reconnect()
self._device_monitor.stop()
self._audio.stop()
self._client.set_dispatcher(None)
self._client.disconnect()