refactor: collapse the single-implementation backend abstraction, fix device resolution
isr.py carried a plugin architecture — AudioBackend ABC, a _backend_classes registry, _discover_backends, available_backends/get_backend, a preferred_backend config knob — for exactly one implementation. It was also the source of three real bugs: * find_device() returned None before reaching the ALSA PCM-name fallback whenever `arecord -l` enumerated nothing, so the documented `device = shared_mic` (dsnoop from asound.conf) case failed outright on any host with no hardware card. The fallback is the whole point: those PCMs never appear in `arecord -l`. * `device = monitor` could never resolve. ALSA has no generic monitor source and list_devices hardcoded is_monitor=False, so the keyword — advertised in config.example.ini as the "EASY" way to capture system audio — only ever produced "device not found". Removed; it is a PulseAudio idea that outlived the PulseAudio backend. * list_all_devices() deduplicated by lowercased name "in case a device appears in multiple backends". With one backend what it actually did was delete the second of two identical USB cards. Dedup dropped. find_device() now always returns a device, so _resolve_device's unreachable "Available devices:" error is gone; an unknown spec is passed to ALSA verbatim and logged. AudioDevice loses backend/ is_monitor plus channels/sample_rate — the latter were hardcoded 2/44100 and only ever printed by --list-devices, i.e. numbers with no bearing on what gets recorded. --list-devices now says outright that it lists hardware cards only, which is where the shared_mic confusion started. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+34
-46
@@ -51,15 +51,12 @@ class TestAudioSystemFindDevice:
|
||||
def _make_system(self, devices: List[isr.AudioDevice]) -> isr.AudioSystem:
|
||||
system = isr.AudioSystem.__new__(isr.AudioSystem)
|
||||
system.logger = make_logger()
|
||||
system._backends = {}
|
||||
system.list_all_devices = lambda: devices
|
||||
system.available = True
|
||||
system.list_devices = lambda: devices
|
||||
return system
|
||||
|
||||
def _dev(self, id_, name, *, is_default=False, is_monitor=False, backend="portaudio"):
|
||||
return isr.AudioDevice(
|
||||
id=id_, name=name, channels=2, sample_rate=44100,
|
||||
backend=backend, is_default=is_default, is_monitor=is_monitor,
|
||||
)
|
||||
def _dev(self, id_, name, *, is_default=False):
|
||||
return isr.AudioDevice(id=id_, name=name, is_default=is_default)
|
||||
|
||||
def test_default_returns_default_device(self):
|
||||
devices = [
|
||||
@@ -76,19 +73,9 @@ class TestAudioSystemFindDevice:
|
||||
result = system.find_device("default")
|
||||
assert result.id == "0"
|
||||
|
||||
def test_monitor_returns_monitor_device(self):
|
||||
devices = [
|
||||
self._dev("0", "Microphone"),
|
||||
self._dev("1", "Monitor of Built-in Audio", is_monitor=True),
|
||||
]
|
||||
system = self._make_system(devices)
|
||||
result = system.find_device("monitor")
|
||||
assert result.id == "1"
|
||||
|
||||
def test_monitor_returns_none_when_no_monitor(self):
|
||||
devices = [self._dev("0", "Mic")]
|
||||
system = self._make_system(devices)
|
||||
assert system.find_device("monitor") is None
|
||||
def test_default_with_no_cards_uses_the_alsa_default_pcm(self):
|
||||
system = self._make_system([])
|
||||
assert system.find_device("default").id == "default"
|
||||
|
||||
def test_exact_id_match(self):
|
||||
devices = [self._dev("42", "Device 42"), self._dev("7", "Device 7")]
|
||||
@@ -105,19 +92,26 @@ class TestAudioSystemFindDevice:
|
||||
system = self._make_system(devices)
|
||||
assert system.find_device("stereo").id == "0"
|
||||
|
||||
def test_returns_none_for_unknown_spec(self):
|
||||
devices = [self._dev("0", "Mic")]
|
||||
def test_exact_id_wins_over_partial_name(self):
|
||||
# Two identical USB cards: both must stay reachable. An earlier
|
||||
# dedup-by-name dropped the second one entirely.
|
||||
devices = [self._dev("hw:1,0", "USB Audio"), self._dev("hw:2,0", "USB Audio")]
|
||||
system = self._make_system(devices)
|
||||
assert system.find_device("nonexistent_device_xyz") is None
|
||||
assert system.find_device("hw:2,0").id == "hw:2,0"
|
||||
|
||||
def test_preferred_backend_filter_on_default(self):
|
||||
devices = [
|
||||
self._dev("0", "Mic", is_default=True, backend="pulseaudio"),
|
||||
self._dev("1", "Mic", is_default=True, backend="portaudio"),
|
||||
]
|
||||
def test_unknown_spec_becomes_a_literal_alsa_pcm_name(self):
|
||||
# This is how `device = shared_mic` (a dsnoop PCM from asound.conf)
|
||||
# resolves — it is never listed by `arecord -l`.
|
||||
devices = [self._dev("hw:0,0", "Mic")]
|
||||
system = self._make_system(devices)
|
||||
result = system.find_device("default", preferred_backend="portaudio")
|
||||
assert result.backend == "portaudio"
|
||||
assert system.find_device("shared_mic").id == "shared_mic"
|
||||
|
||||
def test_pcm_fallback_works_when_no_cards_are_enumerable(self):
|
||||
# Regression: a `if not devices: return None` guard used to fire before
|
||||
# the fallback, so shared_mic failed outright on a host with no
|
||||
# enumerable hardware card.
|
||||
system = self._make_system([])
|
||||
assert system.find_device("shared_mic").id == "shared_mic"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
@@ -433,7 +427,7 @@ class TestALSAStreamLiveness:
|
||||
|
||||
def _stream(self, poll_result=None) -> isr.ALSAStream:
|
||||
device = isr.AudioDevice(
|
||||
id="hw:0,0", name="Test", channels=2, sample_rate=44100, backend="alsa",
|
||||
id="hw:0,0", name="Test",
|
||||
)
|
||||
s = isr.ALSAStream(device, 44100, 2, lambda data: None, make_logger())
|
||||
s._process = MagicMock()
|
||||
@@ -525,7 +519,7 @@ format = ogg
|
||||
|
||||
# Patch AudioSystem so it doesn't probe real hardware
|
||||
with patch.object(isr, "AudioSystem") as mock_as:
|
||||
mock_as.return_value.available_backends = []
|
||||
mock_as.return_value.available = False
|
||||
mgr = isr.RecorderManager(str(cfg_path))
|
||||
|
||||
assert len(mgr.recorders) == 1
|
||||
@@ -539,7 +533,7 @@ format = ogg
|
||||
cfg_path.write_text(self._minimal_config(tmp_path))
|
||||
|
||||
with patch.object(isr, "AudioSystem") as mock_as:
|
||||
mock_as.return_value.available_backends = []
|
||||
mock_as.return_value.available = False
|
||||
mgr = isr.RecorderManager(str(cfg_path))
|
||||
|
||||
rec = mgr.recorders[0]
|
||||
@@ -566,7 +560,7 @@ split_minutes = 15
|
||||
cfg_path.write_text(config_text)
|
||||
|
||||
with patch.object(isr, "AudioSystem") as mock_as:
|
||||
mock_as.return_value.available_backends = []
|
||||
mock_as.return_value.available = False
|
||||
mgr = isr.RecorderManager(str(cfg_path))
|
||||
|
||||
rec = mgr.recorders[0]
|
||||
@@ -591,7 +585,7 @@ url = http://x.com/s2
|
||||
cfg_path.write_text(config_text)
|
||||
|
||||
with patch.object(isr, "AudioSystem") as mock_as:
|
||||
mock_as.return_value.available_backends = []
|
||||
mock_as.return_value.available = False
|
||||
mgr = isr.RecorderManager(str(cfg_path))
|
||||
|
||||
assert len(mgr.recorders) == 1
|
||||
@@ -643,22 +637,18 @@ class TestStreamRecorderRecord:
|
||||
# ===========================================================================
|
||||
|
||||
class TestSoundcardRecorder:
|
||||
def _make_device(self, backend="portaudio") -> isr.AudioDevice:
|
||||
return isr.AudioDevice(
|
||||
id="0", name="Test Device", channels=2,
|
||||
sample_rate=44100, backend=backend,
|
||||
)
|
||||
def _make_device(self) -> isr.AudioDevice:
|
||||
return isr.AudioDevice(id="0", name="Test Device")
|
||||
|
||||
def _make_audio_system(self, device: isr.AudioDevice) -> isr.AudioSystem:
|
||||
system = MagicMock(spec=isr.AudioSystem)
|
||||
system.available_backends = [device.backend]
|
||||
system.available = True
|
||||
system.find_device.return_value = device
|
||||
return system
|
||||
|
||||
def test_wav_file_written_correctly(self, tmp_path):
|
||||
device = self._make_device()
|
||||
audio_system = self._make_audio_system(device)
|
||||
audio_system.get_backend.return_value = MagicMock()
|
||||
|
||||
cfg = {
|
||||
"device": "default",
|
||||
@@ -710,9 +700,7 @@ class TestSoundcardRecorder:
|
||||
# split_minutes and writing zero bytes into each one.
|
||||
device = self._make_device()
|
||||
audio_system = self._make_audio_system(device)
|
||||
backend = MagicMock()
|
||||
backend.open_stream.return_value = _DeadStream()
|
||||
audio_system.get_backend.return_value = backend
|
||||
audio_system.open_stream.return_value = _DeadStream()
|
||||
|
||||
cfg = {
|
||||
"device": "default",
|
||||
@@ -728,7 +716,7 @@ class TestSoundcardRecorder:
|
||||
with patch.object(isr.time, "sleep"): # don't pay the 0.5 s poll interval
|
||||
rec.record()
|
||||
|
||||
assert backend.open_stream.call_count == cfg["max_retries"]
|
||||
assert audio_system.open_stream.call_count == cfg["max_retries"]
|
||||
|
||||
@pytest.mark.skipif(not isr.SOUNDFILE_AVAILABLE, reason="soundfile not installed")
|
||||
def test_flac_file_written_correctly(self, tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user