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.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()
|
||||||
|
|||||||
Reference in New Issue
Block a user