Preserve macOS app permissions via dynamic launcher, .env workspace path, and session/audio tools
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
|
||||
from audio_device_monitor import AudioDevice, AudioDeviceMonitor, DeviceChangeReason
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
def __init__(self):
|
||||
self.devices = {}
|
||||
self.input = None
|
||||
self.output = None
|
||||
self.listeners = []
|
||||
self.removed = []
|
||||
|
||||
def enumerate_devices(self):
|
||||
return list(self.devices.values())
|
||||
|
||||
def default_input_uid(self):
|
||||
return self.input
|
||||
|
||||
def default_output_uid(self):
|
||||
return self.output
|
||||
|
||||
def add_listener(self, callback):
|
||||
self.listeners.append(callback)
|
||||
return callback
|
||||
|
||||
def remove_listener(self, token):
|
||||
self.removed.append(token)
|
||||
self.listeners.remove(token)
|
||||
|
||||
def notify(self):
|
||||
# Emulate Core Audio's arbitrary callback thread.
|
||||
threads = [threading.Thread(target=callback) for callback in list(self.listeners)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
|
||||
class AudioDeviceMonitorTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.backend = FakeBackend()
|
||||
self.mic = AudioDevice("mic", "Microphone", True, False, "built-in")
|
||||
self.speaker = AudioDevice("speaker", "Speaker", False, True, "built-in")
|
||||
self.airpods = AudioDevice("airpods", "AirPods", True, True, "bluetooth")
|
||||
self.backend.devices = {x.uid: x for x in (self.mic, self.speaker)}
|
||||
self.backend.input, self.backend.output = "mic", "speaker"
|
||||
|
||||
async def test_initial_and_independent_default_change(self):
|
||||
events = []
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.01)
|
||||
await monitor.start(events.append)
|
||||
self.assertEqual(events[-1].default_input_uid, "mic")
|
||||
|
||||
self.backend.devices["airpods"] = self.airpods
|
||||
self.backend.output = "airpods"
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.04)
|
||||
self.assertEqual(len(events), 2)
|
||||
self.assertEqual(events[-1].default_output_uid, "airpods")
|
||||
self.assertEqual(events[-1].default_input_uid, "mic")
|
||||
self.assertEqual(events[-1].reason, DeviceChangeReason.DEFAULT_CHANGED)
|
||||
await monitor.stop()
|
||||
|
||||
async def test_duplicate_burst_is_coalesced(self):
|
||||
events = []
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.03)
|
||||
await monitor.start(events.append)
|
||||
for _ in range(10):
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.08)
|
||||
self.assertEqual(len(events), 1) # no actual state change
|
||||
await monitor.stop()
|
||||
|
||||
async def test_unavailable_default_is_none(self):
|
||||
events = []
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.01)
|
||||
await monitor.start(events.append)
|
||||
self.backend.input = "gone"
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.04)
|
||||
self.assertIsNone(events[-1].default_input_uid)
|
||||
self.assertEqual(events[-1].default_output_uid, "speaker")
|
||||
await monitor.stop()
|
||||
|
||||
async def test_stop_removes_listener_and_blocks_late_callbacks(self):
|
||||
events = []
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.01)
|
||||
await monitor.start(events.append)
|
||||
await monitor.stop()
|
||||
self.assertEqual(len(self.backend.removed), 1)
|
||||
self.backend.input = "gone"
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.04)
|
||||
self.assertEqual(len(events), 1)
|
||||
await monitor.stop() # idempotent
|
||||
|
||||
@unittest.skipUnless(sys.platform == "darwin", "Core Audio is macOS-only")
|
||||
def test_native_adapter_uses_coreaudio_uids_not_portaudio_indices(self):
|
||||
"""Stable Core Audio identity must survive PortAudio index renumbering."""
|
||||
from audio_device_monitor import NativeMacOSCoreAudioAdapter
|
||||
|
||||
adapter = NativeMacOSCoreAudioAdapter()
|
||||
devices = adapter.enumerate_devices()
|
||||
self.assertTrue(devices)
|
||||
self.assertTrue(all(not device.uid.isdecimal() for device in devices))
|
||||
|
||||
|
||||
async def test_connect_disconnect_replacement_and_profile_events_are_classified(self):
|
||||
events = []
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
|
||||
await monitor.start(events.append)
|
||||
|
||||
self.backend.devices["airpods"] = self.airpods
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.01)
|
||||
self.assertEqual(events[-1].reason, DeviceChangeReason.DEVICE_ADDED)
|
||||
|
||||
del self.backend.devices["speaker"]
|
||||
self.backend.output = "airpods"
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.01)
|
||||
self.assertEqual(events[-1].reason, DeviceChangeReason.DEFAULT_CHANGED)
|
||||
|
||||
self.backend.devices["airpods"] = AudioDevice(
|
||||
"airpods", "AirPods Hands-Free", True, True, "bluetooth"
|
||||
)
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.01)
|
||||
self.assertEqual(events[-1].reason, DeviceChangeReason.PROFILE_CHANGED)
|
||||
|
||||
del self.backend.devices["airpods"]
|
||||
self.backend.output = None
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.01)
|
||||
self.assertEqual(events[-1].reason, DeviceChangeReason.DEFAULT_CHANGED)
|
||||
self.backend.devices["usb"] = AudioDevice("usb", "USB headset", False, True)
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.01)
|
||||
del self.backend.devices["usb"]
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.01)
|
||||
self.assertEqual(events[-1].reason, DeviceChangeReason.DEVICE_REMOVED)
|
||||
self.assertIsNone(events[-1].default_output_uid)
|
||||
await monitor.stop()
|
||||
|
||||
async def test_backend_callback_is_dispatched_on_monitor_event_loop(self):
|
||||
callback_threads = []
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
|
||||
|
||||
def record(snapshot):
|
||||
callback_threads.append((snapshot, threading.get_ident()))
|
||||
|
||||
await monitor.start(record)
|
||||
loop_thread = threading.get_ident()
|
||||
self.backend.output = "airpods"
|
||||
self.backend.devices["airpods"] = self.airpods
|
||||
self.backend.notify()
|
||||
await asyncio.sleep(.01)
|
||||
|
||||
self.assertEqual(callback_threads[-1][1], loop_thread)
|
||||
await monitor.stop()
|
||||
|
||||
async def test_listener_registration_failure_cleans_up_registered_tokens(self):
|
||||
class FailingBackend(FakeBackend):
|
||||
def add_listener(self, callback):
|
||||
raise RuntimeError("listener registration failed")
|
||||
|
||||
backend = FailingBackend()
|
||||
backend.devices = self.backend.devices
|
||||
backend.input, backend.output = self.backend.input, self.backend.output
|
||||
monitor = AudioDeviceMonitor(backend)
|
||||
with self.assertRaises(RuntimeError):
|
||||
await monitor.start(lambda _snapshot: None)
|
||||
self.assertFalse(monitor._running)
|
||||
self.assertEqual(len(backend.listeners), 0)
|
||||
|
||||
async def test_repeated_start_is_idempotent_and_does_not_duplicate_listener(self):
|
||||
first_events = []
|
||||
second_events = []
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
|
||||
|
||||
await monitor.start(first_events.append)
|
||||
await monitor.start(second_events.append)
|
||||
|
||||
self.assertEqual(len(self.backend.listeners), 1)
|
||||
self.assertEqual(len(first_events), 1)
|
||||
self.assertEqual(second_events, [])
|
||||
await monitor.stop()
|
||||
|
||||
async def test_callback_failure_stops_monitor_and_removes_listener(self):
|
||||
callback_started = asyncio.Event()
|
||||
|
||||
async def failing_callback(_snapshot):
|
||||
callback_started.set()
|
||||
raise RuntimeError("consumer failed")
|
||||
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
|
||||
with self.assertRaises(RuntimeError):
|
||||
await monitor.start(failing_callback)
|
||||
self.assertFalse(monitor._running)
|
||||
self.assertEqual(len(self.backend.listeners), 0)
|
||||
self.assertEqual(len(self.backend.removed), 1)
|
||||
self.assertTrue(callback_started.is_set())
|
||||
|
||||
async def test_callback_failure_after_start_cleans_up_listener(self):
|
||||
callback_started = asyncio.Event()
|
||||
calls = 0
|
||||
|
||||
def failing_after_initial(snapshot):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
callback_started.set()
|
||||
raise RuntimeError("consumer failed after notification")
|
||||
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
|
||||
await monitor.start(failing_after_initial)
|
||||
self.backend.output = None
|
||||
self.backend.notify()
|
||||
await asyncio.wait_for(callback_started.wait(), timeout=.2)
|
||||
await asyncio.sleep(.01)
|
||||
|
||||
self.assertFalse(monitor._running)
|
||||
self.assertEqual(len(self.backend.listeners), 0)
|
||||
self.assertEqual(len(self.backend.removed), 1)
|
||||
|
||||
async def test_notifications_after_stop_are_ignored_even_if_callback_was_queued(self):
|
||||
events = []
|
||||
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.05)
|
||||
await monitor.start(events.append)
|
||||
self.backend.notify()
|
||||
await monitor.stop()
|
||||
await asyncio.sleep(.06)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(len(self.backend.listeners), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user