351 lines
15 KiB
Python
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()
|