app: wire audio device hot-swap on change
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user