| from __future__ import annotations |
|
|
| import asyncio |
| import threading |
| from types import SimpleNamespace |
| from unittest.mock import MagicMock |
|
|
| import numpy as np |
| import pytest |
|
|
| from jarvis.runtime_audio_output import ( |
| clear_tts_queue, |
| flush_output, |
| play_audio_chunk, |
| tts_loop, |
| ) |
|
|
|
|
| def test_flush_output_uses_robot_audio_path() -> None: |
| robot = SimpleNamespace(flush_audio_output=MagicMock()) |
| runtime = SimpleNamespace( |
| _use_robot_audio=True, |
| robot=robot, |
| _output_stream=None, |
| ) |
|
|
| flush_output(runtime) |
| robot.flush_audio_output.assert_called_once() |
|
|
|
|
| def test_flush_output_recovers_local_stream_abort_failure() -> None: |
| stream = MagicMock() |
| stream.abort.side_effect = RuntimeError("abort failed") |
| runtime = SimpleNamespace( |
| _use_robot_audio=False, |
| robot=SimpleNamespace(flush_audio_output=MagicMock()), |
| _output_stream=stream, |
| ) |
|
|
| flush_output(runtime) |
| stream.stop.assert_called_once() |
| stream.start.assert_called_once() |
|
|
|
|
| def test_play_audio_chunk_resamples_before_robot_push() -> None: |
| input_audio = np.ones(8, dtype=np.float32) |
| output_audio = np.ones(4, dtype=np.float32) |
| robot = SimpleNamespace(push_audio_sample=MagicMock()) |
| runtime = SimpleNamespace( |
| _use_robot_audio=True, |
| _robot_output_sr=24000, |
| config=SimpleNamespace(sample_rate=16000), |
| robot=robot, |
| _output_stream=None, |
| ) |
| resample_audio = MagicMock(return_value=output_audio) |
| logger = SimpleNamespace(warning=MagicMock()) |
|
|
| play_audio_chunk( |
| runtime, |
| input_audio, |
| resample_audio_fn=resample_audio, |
| logger=logger, |
| ) |
|
|
| resample_audio.assert_called_once_with(input_audio, 16000, 24000) |
| robot.push_audio_sample.assert_called_once_with(output_audio) |
|
|
|
|
| def test_play_audio_chunk_swallows_output_stream_errors() -> None: |
| stream = MagicMock() |
| stream.write.side_effect = RuntimeError("write failed") |
| logger = SimpleNamespace(warning=MagicMock()) |
| runtime = SimpleNamespace( |
| _use_robot_audio=False, |
| _output_stream=stream, |
| config=SimpleNamespace(sample_rate=16000), |
| _robot_output_sr=16000, |
| robot=SimpleNamespace(push_audio_sample=MagicMock()), |
| ) |
|
|
| play_audio_chunk( |
| runtime, |
| np.ones(10, dtype=np.float32), |
| resample_audio_fn=lambda audio, _in, _out: audio, |
| logger=logger, |
| ) |
|
|
| logger.warning.assert_called_once() |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_clear_tts_queue_drains_items() -> None: |
| runtime = SimpleNamespace(_tts_queue=asyncio.Queue()) |
| await runtime._tts_queue.put((1, "a", False, 0.0)) |
| await runtime._tts_queue.put((1, "b", False, 0.0)) |
|
|
| clear_tts_queue(runtime) |
| assert runtime._tts_queue.empty() |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_tts_loop_fallback_records_telemetry_and_observability() -> None: |
| class _FailingTTS: |
| async def stream_chunks_async(self, _sentence: str): |
| raise RuntimeError("boom") |
| yield np.ones(2, dtype=np.float32) |
|
|
| observability = SimpleNamespace(record_event=MagicMock()) |
| logger = SimpleNamespace(info=MagicMock(), warning=MagicMock()) |
| runtime = SimpleNamespace( |
| tts=_FailingTTS(), |
| _tts_queue=asyncio.Queue(), |
| _barge_in=threading.Event(), |
| _active_response_id=1, |
| _first_audio_at=None, |
| _response_start_at=None, |
| _flush_output=MagicMock(), |
| _normalize_tts_chunk=lambda x: x, |
| _play_audio_chunk=MagicMock(), |
| presence=SimpleNamespace(signals=SimpleNamespace(speech_energy=0.0)), |
| config=SimpleNamespace(tts_fallback_text_only=True), |
| _telemetry={"fallback_responses": 0.0}, |
| _observability=observability, |
| _tts_output_enabled=True, |
| ) |
|
|
| await runtime._tts_queue.put((1, "hello", False, 0.0)) |
| task = asyncio.create_task(tts_loop(runtime, logger=logger)) |
| await asyncio.sleep(0.05) |
| task.cancel() |
| with pytest.raises(asyncio.CancelledError): |
| await task |
|
|
| assert runtime._telemetry["fallback_responses"] == 1.0 |
| assert runtime.presence.signals.speech_energy == 0.0 |
| observability.record_event.assert_called_once() |
|
|