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()