245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
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()
|