Files
VoiceAgent/test_sounddevice_transport.py
T

351 lines
15 KiB
Python

import unittest
from unittest.mock import AsyncMock, patch
from audio_device_monitor import AudioDeviceSnapshot
from pipecat.frames.frames import StartFrame
from sounddevice_transport import (
SoundDeviceInputTransport,
SoundDeviceOutputTransport,
SoundDeviceTransport,
SoundDeviceTransportParams,
)
class FakeMonitor:
def __init__(self):
self.start = AsyncMock()
self.stop = AsyncMock()
class SoundDeviceTransportSwitchTests(unittest.IsolatedAsyncioTestCase):
def _transport(self, *, input_device=None, output_device=None):
transport = SoundDeviceTransport(
SoundDeviceTransportParams(input_device=input_device, output_device=output_device)
)
transport._input = type("Input", (), {"reopen": AsyncMock()})()
transport._output = type("Output", (), {"reopen": AsyncMock()})()
return transport
async def test_default_changes_reopen_only_unset_sides(self):
transport = self._transport(input_device=None, output_device="My Speakers")
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speakers"))
await transport._on_device_change(AudioDeviceSnapshot(2, "airpods", "airpods"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_not_awaited()
async def test_default_changes_preserve_each_override_for_all_override_combinations(self):
devices = {
"old-mic": type("Device", (), {"name": "Built-in Mic", "can_input": True, "can_output": False})(),
"old-speaker": type("Device", (), {"name": "Built-in Speaker", "can_input": False, "can_output": True})(),
"new-mic": type("Device", (), {"name": "USB Mic", "can_input": True, "can_output": False})(),
"new-speaker": type("Device", (), {"name": "USB Speaker", "can_input": False, "can_output": True})(),
}
portaudio_devices = [
{"name": "Built-in Mic", "max_input_channels": 1, "max_output_channels": 0},
{"name": "Built-in Speaker", "max_input_channels": 0, "max_output_channels": 2},
{"name": "USB Mic", "max_input_channels": 1, "max_output_channels": 0},
{"name": "USB Speaker", "max_input_channels": 0, "max_output_channels": 2},
]
initial = AudioDeviceSnapshot(1, "old-mic", "old-speaker", devices=devices)
changed = AudioDeviceSnapshot(2, "new-mic", "new-speaker", devices=devices)
with patch("sounddevice_transport.sd.query_devices", return_value=portaudio_devices):
for input_override, output_override in (
(None, None),
("Pinned Mic", None),
(None, "Pinned Speaker"),
("Pinned Mic", "Pinned Speaker"),
):
with self.subTest(input_override=input_override, output_override=output_override):
transport = self._transport(
input_device=input_override,
output_device=output_override,
)
await transport._on_device_change(initial)
await transport._on_device_change(changed)
if input_override is None:
transport._input.reopen.assert_awaited_once_with(device=2)
else:
transport._input.reopen.assert_not_awaited()
if output_override is None:
transport._output.reopen.assert_awaited_once_with(device=3)
else:
transport._output.reopen.assert_not_awaited()
async def test_unavailable_default_does_not_reopen_that_side_but_reopens_other_side(self):
transport = self._transport()
devices = {
"mic": type("Device", (), {"name": "Mic", "can_input": True, "can_output": False})(),
"speaker": type("Device", (), {"name": "Speaker", "can_input": False, "can_output": True})(),
"headphones": type("Device", (), {"name": "Headphones", "can_input": False, "can_output": True})(),
}
initial = AudioDeviceSnapshot(1, "mic", "speaker", devices=devices)
unavailable_input = AudioDeviceSnapshot(2, None, "headphones", devices=devices)
with patch("sounddevice_transport.sd.query_devices", return_value=[
{"name": "Headphones", "max_input_channels": 0, "max_output_channels": 2},
]):
await transport._on_device_change(initial)
await transport._on_device_change(unavailable_input)
transport._input.reopen.assert_not_awaited()
transport._output.reopen.assert_awaited_once_with(device=0)
async def test_input_only_output_only_and_simultaneous_changes_route_independently(self):
transport = self._transport()
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speaker"))
await transport._on_device_change(AudioDeviceSnapshot(2, "airpods-mic", "speaker"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_not_awaited()
await transport._on_device_change(AudioDeviceSnapshot(3, "airpods-mic", "airpods-speaker"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_awaited_once_with()
await transport._on_device_change(AudioDeviceSnapshot(4, "mac-mic", "mac-speaker"))
self.assertEqual(transport._input.reopen.await_count, 2)
self.assertEqual(transport._output.reopen.await_count, 2)
async def test_default_change_uses_current_portaudio_device_not_process_startup_default(self):
transport = self._transport()
initial = AudioDeviceSnapshot(1, "built-in-mic", "built-in-speaker")
switched = AudioDeviceSnapshot(2, "airpods-input", "airpods-output", devices={
"airpods-input": type("Device", (), {"name": "AirPods", "can_input": True, "can_output": False})(),
"airpods-output": type("Device", (), {"name": "AirPods", "can_input": False, "can_output": True})(),
})
with patch("sounddevice_transport.sd.query_devices", return_value=[
{"name": "MacBook Air Speakers", "max_input_channels": 0, "max_output_channels": 2},
{"name": "AirPods", "max_input_channels": 1, "max_output_channels": 0},
{"name": "AirPods", "max_input_channels": 0, "max_output_channels": 2},
]):
await transport._on_device_change(initial)
await transport._on_device_change(switched)
transport._input.reopen.assert_awaited_once_with(device=1)
transport._output.reopen.assert_awaited_once_with(device=2)
async def test_input_override_does_not_follow_default_but_output_does(self):
params = SoundDeviceTransportParams(input_device="USB Mic", output_device=None)
transport = SoundDeviceTransport(params)
transport._input = type("Input", (), {"reopen": AsyncMock()})()
transport._output = type("Output", (), {"reopen": AsyncMock()})()
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speakers"))
await transport._on_device_change(AudioDeviceSnapshot(2, "airpods", "headphones"))
transport._input.reopen.assert_not_awaited()
transport._output.reopen.assert_awaited_once_with()
async def test_runtime_event_sink_receives_native_device_snapshot(self):
sink = AsyncMock()
transport = SoundDeviceTransport(SoundDeviceTransportParams(), device_event_sink=sink)
snapshot = AudioDeviceSnapshot(1, "mic", "speaker")
await transport._on_device_change(snapshot)
sink.assert_awaited_once_with(snapshot)
async def test_stale_snapshot_cannot_reopen_a_replaced_stream(self):
transport = self._transport()
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speaker"))
await transport._on_device_change(AudioDeviceSnapshot(3, "airpods", "airpods"))
await transport._on_device_change(AudioDeviceSnapshot(2, "mic", "speaker"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_awaited_once_with()
async def test_failed_input_reopen_keeps_output_route_change_alive(self):
transport = self._transport()
transport._input.reopen.side_effect = OSError("device unavailable")
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speaker"))
await transport._on_device_change(AudioDeviceSnapshot(2, "airpods", "airpods"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_awaited_once_with()
async def test_transport_starts_and_stops_injected_monitor_once(self):
monitor = FakeMonitor()
transport = SoundDeviceTransport(SoundDeviceTransportParams(), device_monitor=monitor)
await transport.start_device_monitor()
await transport.start_device_monitor()
await transport.stop_device_monitor()
await transport.stop_device_monitor()
monitor.start.assert_awaited_once_with(transport._on_device_change)
monitor.stop.assert_awaited_once_with()
async def test_production_transport_installs_macos_monitor_when_defaults_are_unset(self):
monitor = FakeMonitor()
with (
patch("sounddevice_transport.sys.platform", "darwin"),
patch("sounddevice_transport.create_macos_audio_monitor", return_value=monitor) as factory,
):
transport = SoundDeviceTransport(SoundDeviceTransportParams())
await transport.start_device_monitor()
factory.assert_called_once_with()
monitor.start.assert_awaited_once_with(transport._on_device_change)
async def test_production_input_start_installs_and_cleanup_releases_device_monitor(self):
monitor = FakeMonitor()
transport = SoundDeviceTransport(
SoundDeviceTransportParams(), device_monitor=monitor
)
input_transport = transport.input()
class FakeInputStream:
device = 0
def __init__(self, **_kwargs):
pass
def start(self):
pass
def stop(self):
pass
def close(self):
pass
with (
patch("sounddevice_transport.sd.RawInputStream", FakeInputStream),
patch("sounddevice_transport.sd.query_devices", return_value={"name": "Fake Mic"}),
):
await input_transport.start(StartFrame(audio_in_sample_rate=16000))
await input_transport.cleanup()
monitor.start.assert_awaited_once_with(transport._on_device_change)
monitor.stop.assert_awaited_once_with()
async def test_production_input_start_skips_unavailable_monitor_off_macos(self):
transport = SoundDeviceTransport(SoundDeviceTransportParams())
input_transport = transport.input()
class FakeInputStream:
device = 0
def __init__(self, **_kwargs):
pass
def start(self):
pass
def stop(self):
pass
def close(self):
pass
with (
patch("sounddevice_transport.sys.platform", "linux"),
patch("sounddevice_transport.create_macos_audio_monitor") as factory,
patch("sounddevice_transport.sd.RawInputStream", FakeInputStream),
patch("sounddevice_transport.sd.query_devices", return_value={"name": "Fake Mic"}),
):
await input_transport.start(StartFrame(audio_in_sample_rate=16000))
await input_transport.cleanup()
factory.assert_not_called()
async def test_cleaning_one_side_keeps_monitor_until_last_side_stops(self):
monitor = FakeMonitor()
transport = SoundDeviceTransport(SoundDeviceTransportParams(), device_monitor=monitor)
input_transport = transport.input()
output_transport = transport.output()
await transport.start_device_monitor(input_transport)
await transport.start_device_monitor(output_transport)
await input_transport.cleanup()
monitor.stop.assert_not_awaited()
await output_transport.cleanup()
monitor.start.assert_awaited_once_with(transport._on_device_change)
monitor.stop.assert_awaited_once_with()
async def test_failed_runtime_selection_keeps_previous_pin(self):
transport = self._transport(output_device=5)
transport._output.reopen.side_effect = OSError("unavailable")
with patch("sounddevice_transport.sd.query_devices", return_value=[
{"name": "MacBook Air Speakers", "max_input_channels": 0, "max_output_channels": 2},
{"name": "AirPods", "max_input_channels": 0, "max_output_channels": 2},
]):
with self.assertRaises(OSError):
await transport.set_runtime_device("output", "airpods")
self.assertEqual(transport._params.output_device, 5)
async def test_runtime_selection_pins_only_requested_direction(self):
transport = self._transport()
with patch("sounddevice_transport.sd.query_devices", return_value=[
{"name": "MacBook Air Speakers", "max_input_channels": 0, "max_output_channels": 2},
{"name": "AirPods", "max_input_channels": 1, "max_output_channels": 0},
{"name": "AirPods", "max_input_channels": 0, "max_output_channels": 2},
]):
result = await transport.set_runtime_device("output", "airpods")
self.assertEqual(result["device"], 2)
self.assertEqual(transport._params.output_device, 2)
self.assertIsNone(transport._params.input_device)
transport._output.reopen.assert_awaited_once_with(device=2)
transport._input.reopen.assert_not_awaited()
async def test_switching_a_pinned_side_to_default_starts_monitor_for_snapshot(self):
monitor = FakeMonitor()
async def publish_initial_snapshot(callback):
await callback(AudioDeviceSnapshot(1, "mic", "speaker"))
monitor.start.side_effect = publish_initial_snapshot
transport = SoundDeviceTransport(
SoundDeviceTransportParams(input_device="USB Mic", output_device="USB Speakers"),
device_monitor=monitor,
)
transport._input = type("Input", (), {"reopen": AsyncMock()})()
await transport.set_runtime_device("input", None)
monitor.start.assert_awaited_once_with(transport._on_device_change)
transport._input.reopen.assert_awaited_once_with()
async def test_old_input_callback_cannot_deliver_after_replacement(self):
input_transport = SoundDeviceInputTransport(SoundDeviceTransportParams())
input_transport.push_audio_frame = AsyncMock()
input_transport._stream_generation = 2
await input_transport._push_audio_frame_if_current(1, object())
input_transport.push_audio_frame.assert_not_awaited()
async def test_failed_output_stream_start_closes_partial_stream(self):
class FailingStream:
closed = False
def __init__(self, **_kwargs):
pass
def start(self):
raise OSError("unavailable")
def close(self):
self.closed = True
output = SoundDeviceOutputTransport(SoundDeviceTransportParams())
output._sample_rate = 24000
with patch("sounddevice_transport.sd.RawOutputStream", FailingStream):
with self.assertRaises(OSError):
await output._open_stream()
self.assertIsNone(output._out_stream)
if __name__ == "__main__":
unittest.main()