diff --git a/src/tuimble/app.py b/src/tuimble/app.py index eef5e78..92b591e 100644 --- a/src/tuimble/app.py +++ b/src/tuimble/app.py @@ -4,6 +4,7 @@ from __future__ import annotations import html import logging +import time from textual import events, on, work from textual.app import App, ComposeResult @@ -12,6 +13,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.client import Channel, MumbleClient from tuimble.config import Config, load_config from tuimble.ptt import KittyPtt, TogglePtt, detect_backend @@ -210,6 +212,13 @@ class TuimbleApp(App): username=srv.username, password=srv.password, ) + acfg = self._config.audio + self._audio = AudioPipeline( + sample_rate=acfg.sample_rate, + frame_size=acfg.frame_size, + input_device=acfg.input_device, + output_device=acfg.output_device, + ) def compose(self) -> ComposeResult: yield Header() @@ -238,6 +247,7 @@ class TuimbleApp(App): self._client.on_text_message = self._cb_text_message self._client.on_user_update = self._cb_state_changed self._client.on_channel_update = self._cb_state_changed + self._client.on_sound_received = self._cb_sound_received try: self._client.connect() @@ -256,6 +266,9 @@ class TuimbleApp(App): def _cb_state_changed(self) -> None: self.post_message(ServerStateChanged()) + def _cb_sound_received(self, _user, pcm_data: bytes) -> None: + self._audio.queue_playback(pcm_data) + def _show_error(self, text: str) -> None: chatlog = self.query_one("#chatlog", ChatLog) chatlog.write(f"[#f7768e]\u2717 {text}[/]") @@ -273,6 +286,7 @@ class TuimbleApp(App): f"[#9ece6a]\u2713 connected as {self._config.server.username}[/]" ) self._refresh_channel_tree() + self._start_audio() def on_server_disconnected(self, _msg: ServerDisconnected) -> None: status = self.query_one("#status", StatusBar) @@ -311,6 +325,29 @@ class TuimbleApp(App): f"[#e0af68]{self._config.server.username}[/] {text}" ) + # -- audio --------------------------------------------------------------- + + def _start_audio(self) -> None: + """Start audio pipeline; log error if hardware unavailable.""" + chatlog = self.query_one("#chatlog", ChatLog) + try: + self._audio.start() + chatlog.write("[dim]audio pipeline started[/dim]") + self._audio_send_loop() + except Exception as exc: + chatlog.write(f"[#f7768e]\u2717 audio: {exc}[/]") + log.warning("audio start failed: %s", exc) + + @work(thread=True) + def _audio_send_loop(self) -> None: + """Poll capture queue and send encoded frames to server.""" + while self._client.connected: + frame = self._audio.get_encoded_frame() + if frame is not None: + self._client.send_audio(frame) + else: + time.sleep(0.005) + # -- channel tree -------------------------------------------------------- def _refresh_channel_tree(self) -> None: @@ -344,12 +381,14 @@ class TuimbleApp(App): self._ptt.toggle() def _on_ptt_change(self, transmitting: bool) -> None: + self._audio.capturing = transmitting status = self.query_one("#status", StatusBar) status.ptt_active = transmitting # -- lifecycle ----------------------------------------------------------- def action_quit(self) -> None: + self._audio.stop() self._client.set_dispatcher(None) self._client.disconnect() self.exit()