From 3dbd126239e2f2481debee52463ccf7c793ba9cc Mon Sep 17 00:00:00 2001 From: Username Date: Wed, 25 Feb 2026 00:26:27 +0100 Subject: [PATCH] app: wire audio device hot-swap on change --- src/tuimble/app.py | 56 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/tuimble/app.py b/src/tuimble/app.py index 60a26c6..2804a1b 100644 --- a/src/tuimble/app.py +++ b/src/tuimble/app.py @@ -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()