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