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.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, 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
@@ -91,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."""
@@ -381,12 +387,12 @@ class TuimbleApp(App):
} }
#chatlog { #chatlog {
height: 1fr; height: 1fr;
border-bottom: solid #292e42;
scrollbar-size: 1 1; scrollbar-size: 1 1;
} }
#input { #input {
height: 3; height: 3;
border-top: solid #292e42; border: none;
padding: 0 1 0 0;
} }
#status { #status {
dock: bottom; dock: bottom;
@@ -425,6 +431,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._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._reconnect = ReconnectManager( self._reconnect = ReconnectManager(
@@ -581,6 +588,7 @@ class TuimbleApp(App):
self._start_audio() self._start_audio()
def on_server_disconnected(self, _msg: ServerDisconnected) -> None: def on_server_disconnected(self, _msg: ServerDisconnected) -> None:
self._device_monitor.stop()
self._audio.stop() self._audio.stop()
status = self.query_one("#status", StatusBar) status = self.query_one("#status", StatusBar)
@@ -643,11 +651,52 @@ class TuimbleApp(App):
# -- 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
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:
@@ -918,6 +967,7 @@ 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()