Preserve macOS app permissions via dynamic launcher, .env workspace path, and session/audio tools
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user