diff --git a/soundcard/coreaudio.py b/soundcard/coreaudio.py index e13ab9f..ce12e6d 100644 --- a/soundcard/coreaudio.py +++ b/soundcard/coreaudio.py @@ -3,11 +3,12 @@ import numpy import collections import time -import re import math import threading import warnings +from soundcard.utils import match_device + _ffi = cffi.FFI() _package_dir, _ = os.path.split(__file__) with open(os.path.join(_package_dir, 'coreaudio.py.h'), 'rt') as f: @@ -60,7 +61,7 @@ def get_speaker(id): fuzzy-matched pattern for the speaker name. """ - return _match_device(id, all_speakers()) + return match_device(id, all_speakers()) def default_microphone(): @@ -79,30 +80,7 @@ def get_microphone(id, include_loopback=False): fuzzy-matched pattern for the microphone name. """ - return _match_device(id, all_microphones(include_loopback)) - - -def _match_device(id, devices): - """Find id in a list of devices. - - id can be a CoreAudio id, a substring of the device name, or a - fuzzy-matched pattern for the microphone name. - - """ - devices_by_id = {device.id: device for device in devices} - devices_by_name = {device.name: device for device in devices} - if id in devices_by_id: - return devices_by_id[id] - # try substring match: - for name, device in devices_by_name.items(): - if id in name: - return device - # try fuzzy match: - pattern = '.*'.join(id) - for name, device in devices_by_name.items(): - if re.match(pattern, name): - return device - raise IndexError('no device with id {}'.format(id)) + return match_device(id, all_microphones(include_loopback)) def get_name(): diff --git a/soundcard/mediafoundation.py b/soundcard/mediafoundation.py index bffba73..b1a0533 100644 --- a/soundcard/mediafoundation.py +++ b/soundcard/mediafoundation.py @@ -2,7 +2,6 @@ import os import cffi -import re import time import struct import collections @@ -10,6 +9,8 @@ import numpy +from soundcard.utils import match_device + _ffi = cffi.FFI() _package_dir, _ = os.path.split(__file__) with open(os.path.join(_package_dir, 'mediafoundation.py.h'), 'rt') as f: @@ -123,7 +124,7 @@ def get_speaker(id): fuzzy-matched pattern for the speaker name. """ - return _match_device(id, all_speakers()) + return match_device(id, all_speakers()) def all_microphones(include_loopback=False): """A list of all connected microphones. @@ -151,29 +152,7 @@ def get_microphone(id, include_loopback=False): fuzzy-matched pattern for the microphone name. """ - return _match_device(id, all_microphones(include_loopback)) - -def _match_device(id, devices): - """Find id in a list of devices. - - id can be a WASAPI id, a substring of the device name, or a - fuzzy-matched pattern for the microphone name. - - """ - devices_by_id = {device.id: device for device in devices} - devices_by_name = {device.name: device for device in devices} - if id in devices_by_id: - return devices_by_id[id] - # try substring match: - for name, device in devices_by_name.items(): - if id in name: - return device - # try fuzzy match: - pattern = '.*'.join(id) - for name, device in devices_by_name.items(): - if re.match(pattern, name): - return device - raise IndexError('no device with id {}'.format(id)) + return match_device(id, all_microphones(include_loopback)) def _str2wstr(string): """Converts a Python str to a Windows WSTR_T.""" diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index b675238..35600f2 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -2,12 +2,13 @@ import atexit import collections import time -import re import threading import warnings import numpy import cffi +from soundcard.utils import match_device + _ffi = cffi.FFI() _package_dir, _ = os.path.split(__file__) with open(os.path.join(_package_dir, 'pulseaudio.py.h'), 'rt') as f: @@ -302,8 +303,7 @@ def get_speaker(id): speaker : _Speaker """ - speakers = _pulse.sink_list - return _Speaker(id=_match_soundcard(id, speakers)['id']) + return match_device(id, all_speakers()) def all_microphones(include_loopback=False, exclude_monitors=True): @@ -367,41 +367,7 @@ def get_microphone(id, include_loopback=False, exclude_monitors=True): ------- microphone : _Microphone """ - - if not exclude_monitors: - warnings.warn("The exclude_monitors flag is being replaced by the include_loopback flag", DeprecationWarning) - include_loopback = not exclude_monitors - - microphones = _pulse.source_list - return _Microphone(id=_match_soundcard(id, microphones, include_loopback)['id']) - - -def _match_soundcard(id, soundcards, include_loopback=False): - """Find id in a list of soundcards. - - id can be a pulseaudio id, a substring of the microphone name, or - a fuzzy-matched pattern for the microphone name. - """ - if not include_loopback: - soundcards_by_id = {soundcard['id']: soundcard for soundcard in soundcards - if not 'monitor' in soundcard['id']} - soundcards_by_name = {soundcard['name']: soundcard for soundcard in soundcards - if not 'monitor' in soundcard['id']} - else: - soundcards_by_id = {soundcard['id']: soundcard for soundcard in soundcards} - soundcards_by_name = {soundcard['name']: soundcard for soundcard in soundcards} - if id in soundcards_by_id: - return soundcards_by_id[id] - # try substring match: - for name, soundcard in soundcards_by_name.items(): - if id in name: - return soundcard - # try fuzzy match: - pattern = '.*'.join(id) - for name, soundcard in soundcards_by_name.items(): - if re.match(pattern, name): - return soundcard - raise IndexError('no soundcard with id {}'.format(id)) + return match_device(id, all_microphones(include_loopback)) def get_name(): diff --git a/soundcard/utils.py b/soundcard/utils.py new file mode 100644 index 0000000..3c3df37 --- /dev/null +++ b/soundcard/utils.py @@ -0,0 +1,37 @@ +import re + +def match_device(id, devices): + """Find id in a list of devices. + + id can be a platfom specific id, a substring of the device name, or a + fuzzy-matched pattern for the microphone name. + """ + devices_by_id = {device.id: device for device in devices} + real_devices_by_name = { + device.name: device for device in devices + if not getattr(device, 'isloopback', True)} + loopback_devices_by_name = { + device.name: device for device in devices + if getattr(device, 'isloopback', True)} + if id in devices_by_id: + return devices_by_id[id] + for device_map in real_devices_by_name, loopback_devices_by_name: + if id in device_map: + return device_map[id] + # MacOS/coreaudio uses integer IDs where string operations of course + # make no sense. + if isinstance(id, int): + raise IndexError('no device with id {}'.format(id)) + # try substring match: + for device_map in real_devices_by_name, loopback_devices_by_name: + for name, device in device_map.items(): + if id in name: + return device + # try fuzzy match: + id_parts = [re.escape(c) for c in id] + pattern = '.*'.join(id_parts) + for device_map in real_devices_by_name, loopback_devices_by_name: + for name, device in device_map.items(): + if re.search(pattern, name): + return device + raise IndexError('no device with id {}'.format(id)) diff --git a/test_soundcard.py b/test_soundcard.py index 68d7949..e3a675d 100644 --- a/test_soundcard.py +++ b/test_soundcard.py @@ -3,6 +3,14 @@ import numpy import pytest + +if sys.platform == 'linux': + import soundcard.pulseaudio as platform_lib +elif sys.platform == 'darwin': + import soundcard.coreaudio as platform_lib +elif sys.platform == 'win32': + import soundcard.mediafoundation as platform_lib + skip_if_not_linux = pytest.mark.skipif(sys.platform != 'linux', reason='Only implemented for PulseAudio so far') ones = numpy.ones(1024) @@ -157,3 +165,231 @@ def test_loopback_multichannel_channelmap(loopback_speaker, loopback_microphone) assert right.mean() < 0 assert (left > 0.5).sum() == len(signal) assert (right < -0.5).sum() == len(signal) + + +class FakeMicrophone: + def __init__(self, id, name, isloopback): + self.id = id + self.name = name + self.isloopback = isloopback + + +fake_microphones = [ + # Linux/Alsa name and ID pattern. + # [0] + FakeMicrophone( + 'alsa_output.usb-PCM2702-00.analog-stereo.monitor', + 'Monitor of PCM2702 16-bit stereo audio DAC Analog Stereo', + True), + # [1] + FakeMicrophone( + 'alsa_output.pci-0000_00_1b.0.analog-stereo.monitor', + 'Monitor of Build-in Sound Device Analog Stereo', + True), + # [2] + FakeMicrophone( + 'alsa_input.pci-0000_00_1b.0.analog-stereo', + 'Build-in Sound Device Analog Stereo', + False), + # [3] + FakeMicrophone( + 'alsa_output.pci-0000_00_03.0.hdmi-stereo-extra1.monitor', + 'Monitor of Build-in Sound Device Digital Stereo (HDMI 2)', + True), + # Two completely made-up examples where a monitor name is the substring + # of the anme of a real device. + # [4] + FakeMicrophone( + 'alsa_input.bluetooth-stuff.monitor', + 'Name with regex pitfalls [).', + True), + # [5] + FakeMicrophone( + 'alsa_input.bluetooth-stuff', + 'Name with regex pitfalls [). Longer than than the lookback name.', + False), + + # MacOS/coreaudio ID and name patterns. + # [6] + FakeMicrophone(42, 'Built-in Microphone', False), + # [7] + FakeMicrophone(59, 'Samson GoMic', False), + + # Windows/mediafoundazion ID and name patterns. + # [8] + FakeMicrophone( + '{0.0.1.00000000}.{c0c95239-3a6c-427b-a788-9caeb13a7f43}', + 'Mikrofonarray (Realtek(R) Audio) False', + False), + # [9] + FakeMicrophone( + '{0.0.0.00000000}.{f08702cd-ee32-4c95-ac85-ff21a9d4d8ec}', + 'Lautsprecher (Realtek(R) Audio) True', + True), + ] + +@pytest.fixture +def mock_all_microphones(monkeypatch): + + def mocked_all_microphones(include_loopback=False, exclude_monitors=True): + return fake_microphones + + monkeypatch.setattr( + platform_lib, "all_microphones", mocked_all_microphones) + + +def test_get_microphone(mock_all_microphones): + # Internal IDs can be specified. + # Linux ID. + mic = soundcard.get_microphone('alsa_input.pci-0000_00_1b.0.analog-stereo') + assert mic == fake_microphones[2] + # Windows ID. + mic = soundcard.get_microphone( + '{0.0.1.00000000}.{c0c95239-3a6c-427b-a788-9caeb13a7f43}') + assert mic == fake_microphones[8] + # Mac ID. + mic = soundcard.get_microphone(42) + assert mic == fake_microphones[6] + + # No fuzzy matching for IDs. + # Non-existing Linux ID. + with pytest.raises(IndexError) as exc_info: + soundcard.get_microphone('alsa_input.pci-0000_00_1b.0') + assert ( + exc_info.exconly() == + 'IndexError: no device with id alsa_input.pci-0000_00_1b.0') + # Non-existing Windows ID. + with pytest.raises(IndexError) as exc_info: + soundcard.get_microphone('0.0.1.00000000}.{c0c95239-3a6c-427b-') + assert ( + exc_info.exconly() == + 'IndexError: no device with id 0.0.1.00000000}.{c0c95239-3a6c-427b-') + # Non-existing Mac ID. + with pytest.raises(IndexError) as exc_info: + mic = soundcard.get_microphone(13) + assert exc_info.exconly() == 'IndexError: no device with id 13' + + # The name of a microphone can be specified. + mic = soundcard.get_microphone('Build-in Sound Device Analog Stereo') + assert mic == fake_microphones[2] + + # Complete name matches have precedence over substring matches. + mic = soundcard.get_microphone('Name with regex pitfalls [).') + assert mic == fake_microphones[4] + + mic = soundcard.get_microphone('Name with regex pitfalls') + assert mic == fake_microphones[5] + + # A substring of a device name can be specified. If the parameter passed + # to get_microphone() is a substring of more than one microphone name, + # real microphones are preferably returned. + mic = soundcard.get_microphone('Sound Device Analog') + assert mic == fake_microphones[2] + + # If none of the lookup methods above matches a device, a "fuzzy match" + # is tried. + mic = soundcard.get_microphone('Snd Dev Analog') + assert mic == fake_microphones[2] + + # "Fuzzy matching" uses a regular expression; symbols with a special + # meaning in regexes are escaped. + mic = soundcard.get_microphone('regex pitfall [') + assert mic == fake_microphones[5] + + +class FakeSpeaker: + def __init__(self, id, name): + self.id = id + self.name = name + + +fake_speakers = [ + # Linux/Alsa name and ID pattern. + # [0] + FakeSpeaker( + 'alsa_output.usb-PCM2702-00.analog-stereo', + 'PCM2702 16-bit stereo audio DAC Analog Stereo'), + # [1] + FakeSpeaker( + 'alsa_output.pci-0000_00_1b.0.analog-stereo', + 'Build-in Sound Device Analog Stereo'), + # [2] + FakeSpeaker( + 'alsa_output.pci-0000_00_03.0.hdmi-stereo-extra1', + 'Build-in Sound Device Digital Stereo (HDMI 2)'), + # [3] + FakeSpeaker( + 'alsa_output.wire_fire_thingy', + r'A nonsensical name \[a-z]{3}'), + # Windows/mediafoundazion ID and name patterns. + # [4] + FakeSpeaker( + '{0.0.0.00000000}.{075a16c1-576e-419e-84c1-70d03e0d6276}', + 'BenQ PD2700U (Intel(R) Display-Audio)'), + # [5] + FakeSpeaker( + '{0.0.0.00000000}.{27473622-d168-4a9c-87c5-230c148c09c9}', + 'Lautsprecher (4- Samson GoMic)'), + # MacOS/coreaudio ID and name patterns. + # [6] + FakeSpeaker(49, 'Built-in Output'), + # [7] + FakeSpeaker(59, 'Samson GoMic'), + ] + +@pytest.fixture +def mock_all_speakers(monkeypatch): + + def mocked_all_speakers(include_loopback=False, exclude_monitors=True): + return fake_speakers + + monkeypatch.setattr( + platform_lib, "all_speakers", mocked_all_speakers) + +def test_get_speaker(mock_all_speakers): + # Internal IDs can be specified. + # Linux + spk = soundcard.get_speaker('alsa_output.pci-0000_00_1b.0.analog-stereo') + assert spk == fake_speakers[1] + # Windows. + spk = soundcard.get_speaker( + '{0.0.0.00000000}.{075a16c1-576e-419e-84c1-70d03e0d6276}') + assert spk == fake_speakers[4] + # MacOS. + spk = soundcard.get_speaker(49) + assert spk == fake_speakers[6] + + # No fuzzy matching for IDs. + with pytest.raises(IndexError) as exc_info: + soundcard.get_speaker('alsa_output.pci-0000_00_1b.0') + assert ( + exc_info.exconly() == + 'IndexError: no device with id alsa_output.pci-0000_00_1b.0') + with pytest.raises(IndexError) as exc_info: + soundcard.get_speaker('{0.0.0.00000000') + assert ( + exc_info.exconly() == + 'IndexError: no device with id {0.0.0.00000000') + with pytest.raises(IndexError) as exc_info: + soundcard.get_speaker(-15) + assert ( + exc_info.exconly() == + 'IndexError: no device with id -15') + + # The name of a speaker can be specified. + spk = soundcard.get_speaker('Build-in Sound Device Analog Stereo') + assert spk == fake_speakers[1] + + # Substrings of a device name can be specified. + spk = soundcard.get_speaker('Sound Device Analog') + assert spk == fake_speakers[1] + + # If none of the lookup methods above matches a device, a "fuzzy match" + # is tried. + spk = soundcard.get_speaker('Snd Dev Analog') + assert spk == fake_speakers[1] + + # "Fuzzy matching" uses a regular expression; symbols with a specail + # meaning in regexes are escaped. + spk = soundcard.get_speaker('nonsense {3') + assert spk == fake_speakers[3]