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:
@@ -50,113 +50,24 @@ FILENAME_FORMAT = '%Y%m%d_%H%M%S'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Audio Device & Backend System
|
||||
# Audio Device Discovery (ALSA)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class AudioDevice:
|
||||
"""Represents an audio input device."""
|
||||
id: str # Backend-specific identifier
|
||||
name: str # Human-readable name
|
||||
channels: int # Max input channels
|
||||
sample_rate: int # Default sample rate
|
||||
backend: str # Backend name ('alsa')
|
||||
is_default: bool = False # Is system default
|
||||
is_monitor: bool = False # Is a monitor/loopback source
|
||||
"""An ALSA capture device.
|
||||
|
||||
Only the identity matters: capture format comes from config (sample_rate,
|
||||
channels), not from the device, so this deliberately carries no
|
||||
capability fields — reporting a card's channel count here would just be a
|
||||
number that has no bearing on what actually gets recorded.
|
||||
"""
|
||||
id: str # ALSA device spec: 'hw:1,0', or a PCM name
|
||||
name: str # Human-readable name from `arecord -l`
|
||||
is_default: bool = False
|
||||
|
||||
def __str__(self):
|
||||
flags = []
|
||||
if self.is_default:
|
||||
flags.append("DEFAULT")
|
||||
if self.is_monitor:
|
||||
flags.append("MONITOR")
|
||||
flag_str = f" [{', '.join(flags)}]" if flags else ""
|
||||
return f"{self.name}{flag_str} ({self.backend})"
|
||||
|
||||
|
||||
class AudioBackend(ABC):
|
||||
"""Abstract base for audio capture backends."""
|
||||
|
||||
name: str = "base"
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def is_available(cls) -> bool:
|
||||
"""Check if this backend can be used on the current system."""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def list_devices(cls) -> List[AudioDevice]:
|
||||
"""List all available input devices."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
|
||||
callback: Callable[[bytes], None]) -> Any:
|
||||
"""Open an audio capture stream. Returns a context manager."""
|
||||
pass
|
||||
|
||||
|
||||
class ALSABackend(AudioBackend):
|
||||
"""ALSA backend using arecord (raw PCM output, no sound server required)."""
|
||||
|
||||
name = "alsa"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return shutil.which('arecord') is not None
|
||||
|
||||
@classmethod
|
||||
def list_devices(cls) -> List[AudioDevice]:
|
||||
devices = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['arecord', '-l'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return devices
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
if not line.startswith('card '):
|
||||
continue
|
||||
try:
|
||||
card_part, rest = line.split(':', 1)
|
||||
card_num = card_part.replace('card', '').strip()
|
||||
|
||||
if ', device ' in rest:
|
||||
dev_part = rest.split(', device ')[1]
|
||||
dev_num = dev_part.split(':')[0].strip()
|
||||
else:
|
||||
dev_num = '0'
|
||||
|
||||
long_name = rest.split('[')[1].split(']')[0] if '[' in rest else rest.strip()
|
||||
hw_id = f"hw:{card_num},{dev_num}"
|
||||
|
||||
devices.append(AudioDevice(
|
||||
id=hw_id,
|
||||
name=long_name,
|
||||
channels=2,
|
||||
sample_rate=44100,
|
||||
backend=cls.name,
|
||||
is_default=(card_num == '0' and dev_num == '0'),
|
||||
is_monitor=False,
|
||||
))
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return devices
|
||||
|
||||
def __init__(self, logger: logging.Logger):
|
||||
self.logger = logger
|
||||
|
||||
def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
|
||||
callback: Callable[[bytes], None]):
|
||||
return ALSAStream(device, sample_rate, channels, callback, self.logger)
|
||||
return f"{self.name} ({self.id})" + (" [DEFAULT]" if self.is_default else "")
|
||||
|
||||
|
||||
class ALSAStream:
|
||||
@@ -258,95 +169,94 @@ class ALSAStream:
|
||||
|
||||
|
||||
class AudioSystem:
|
||||
"""Manages audio backends and device discovery."""
|
||||
"""Discovers ALSA capture devices and opens capture streams.
|
||||
|
||||
# All available backend classes
|
||||
_backend_classes: List[type] = [ALSABackend]
|
||||
ALSA (via `arecord`) is the only backend. This was once a plugin system
|
||||
with a registry and an abstract base class; there has only ever been one
|
||||
implementation, so it is a plain class now.
|
||||
"""
|
||||
|
||||
def __init__(self, logger: logging.Logger):
|
||||
self.logger = logger
|
||||
self._backends: Dict[str, AudioBackend] = {}
|
||||
self._discover_backends()
|
||||
self.available = shutil.which('arecord') is not None
|
||||
|
||||
def _discover_backends(self):
|
||||
"""Find and initialize available backends."""
|
||||
for cls in self._backend_classes:
|
||||
if cls.is_available():
|
||||
self._backends[cls.name] = cls(self.logger)
|
||||
self.logger.debug(f"Audio backend available: {cls.name}")
|
||||
def list_devices(self) -> List[AudioDevice]:
|
||||
"""Hardware capture devices, parsed out of `arecord -l`.
|
||||
|
||||
@property
|
||||
def available_backends(self) -> List[str]:
|
||||
return list(self._backends.keys())
|
||||
Virtual PCMs defined in asound.conf (dsnoop, plug, ...) are NOT listed
|
||||
by `arecord -l` and so never appear here — see find_device().
|
||||
"""
|
||||
devices = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['arecord', '-l'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return devices
|
||||
|
||||
def get_backend(self, name: str) -> Optional[AudioBackend]:
|
||||
return self._backends.get(name)
|
||||
for line in result.stdout.split('\n'):
|
||||
if not line.startswith('card '):
|
||||
continue
|
||||
try:
|
||||
card_part, rest = line.split(':', 1)
|
||||
card_num = card_part.replace('card', '').strip()
|
||||
|
||||
def list_all_devices(self) -> List[AudioDevice]:
|
||||
"""List devices from all available backends."""
|
||||
all_devices = []
|
||||
seen_names = set()
|
||||
if ', device ' in rest:
|
||||
dev_part = rest.split(', device ')[1]
|
||||
dev_num = dev_part.split(':')[0].strip()
|
||||
else:
|
||||
dev_num = '0'
|
||||
|
||||
for cls in self._backend_classes:
|
||||
if cls.name in self._backends:
|
||||
for dev in cls.list_devices():
|
||||
# Deduplicate by name (same device may appear in multiple backends)
|
||||
key = dev.name.lower()
|
||||
if key not in seen_names:
|
||||
all_devices.append(dev)
|
||||
seen_names.add(key)
|
||||
long_name = rest.split('[')[1].split(']')[0] if '[' in rest else rest.strip()
|
||||
|
||||
return all_devices
|
||||
devices.append(AudioDevice(
|
||||
id=f"hw:{card_num},{dev_num}",
|
||||
name=long_name,
|
||||
is_default=(card_num == '0' and dev_num == '0'),
|
||||
))
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return devices
|
||||
|
||||
def find_device(self, spec: str, preferred_backend: str = None) -> Optional[AudioDevice]:
|
||||
"""Find a device by ID, name, or pattern."""
|
||||
devices = self.list_all_devices()
|
||||
|
||||
if not devices:
|
||||
return None
|
||||
|
||||
# "default" - get the default device from preferred backend
|
||||
if spec.lower() == 'default':
|
||||
for dev in devices:
|
||||
if dev.is_default:
|
||||
if preferred_backend is None or dev.backend == preferred_backend:
|
||||
return dev
|
||||
return devices[0] if devices else None
|
||||
|
||||
# "monitor" - get first monitor source
|
||||
if spec.lower() == 'monitor':
|
||||
for dev in devices:
|
||||
if dev.is_monitor:
|
||||
if preferred_backend is None or dev.backend == preferred_backend:
|
||||
return dev
|
||||
return None
|
||||
def find_device(self, spec: str) -> AudioDevice:
|
||||
"""Resolve a config `device =` value to an ALSA device.
|
||||
|
||||
Never returns None. An unrecognised spec is passed to ALSA verbatim,
|
||||
because a valid PCM name from asound.conf (`shared_mic`, `plughw:0`)
|
||||
does not appear in `arecord -l` — and that must keep working even on a
|
||||
host where no hardware card is enumerable at all, which is exactly the
|
||||
case an earlier "no devices, give up" guard broke.
|
||||
"""
|
||||
devices = self.list_devices()
|
||||
spec_lower = spec.lower()
|
||||
|
||||
# Try exact ID match first
|
||||
for dev in devices:
|
||||
if dev.id == spec:
|
||||
return dev
|
||||
if spec_lower == 'default':
|
||||
for dev in devices:
|
||||
if dev.is_default:
|
||||
return dev
|
||||
# 'default' is itself a valid ALSA PCM when no card is enumerable.
|
||||
return devices[0] if devices else AudioDevice(id='default', name='default')
|
||||
|
||||
# Try exact name match
|
||||
for dev in devices:
|
||||
if dev.name.lower() == spec_lower:
|
||||
return dev
|
||||
exact_id = [d for d in devices if d.id == spec]
|
||||
exact_name = [d for d in devices if d.name.lower() == spec_lower]
|
||||
partial = [d for d in devices if spec_lower in d.name.lower()]
|
||||
for candidates in (exact_id, exact_name, partial):
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
|
||||
# Try partial name match
|
||||
for dev in devices:
|
||||
if spec_lower in dev.name.lower():
|
||||
return dev
|
||||
self.logger.info(
|
||||
f"Device '{spec}' is not in `arecord -l` — using it as an ALSA PCM name")
|
||||
return AudioDevice(id=spec, name=spec)
|
||||
|
||||
# No hardware device matched. Treat spec as a direct ALSA PCM name so
|
||||
# virtual devices defined in asound.conf (dsnoop, plug, etc.) work without
|
||||
# appearing in `arecord -l`.
|
||||
if 'alsa' in self._backends:
|
||||
return AudioDevice(
|
||||
id=spec, name=spec, channels=2, sample_rate=44100, backend='alsa'
|
||||
)
|
||||
|
||||
return None
|
||||
def open_stream(self, device: AudioDevice, sample_rate: int, channels: int,
|
||||
callback: Callable[[bytes], None]) -> 'ALSAStream':
|
||||
"""Open a capture stream. Returns a context manager."""
|
||||
return ALSAStream(device, sample_rate, channels, callback, self.logger)
|
||||
|
||||
|
||||
class BaseRecorder(ABC):
|
||||
@@ -675,7 +585,7 @@ class _AudioFileWriter:
|
||||
|
||||
|
||||
class SoundcardRecorder(BaseRecorder):
|
||||
"""Records from soundcard/audio devices using multiple backends."""
|
||||
"""Records from a soundcard/audio device via ALSA."""
|
||||
|
||||
def __init__(self, name: str, config: Dict[str, Any], logger: logging.Logger,
|
||||
audio_system: 'AudioSystem' = None,
|
||||
@@ -685,9 +595,9 @@ class SoundcardRecorder(BaseRecorder):
|
||||
# Initialize audio system if not provided
|
||||
self.audio_system = audio_system or AudioSystem(logger)
|
||||
|
||||
if not self.audio_system.available_backends:
|
||||
if not self.audio_system.available:
|
||||
raise RuntimeError(
|
||||
"No audio backends available.\n"
|
||||
"arecord not found — soundcard capture is unavailable.\n"
|
||||
"Install ALSA utilities: sudo apt install alsa-utils\n"
|
||||
"(In Docker, also ensure /dev/snd is mapped into the container)"
|
||||
)
|
||||
@@ -696,7 +606,6 @@ class SoundcardRecorder(BaseRecorder):
|
||||
self.device_spec = config.get('device', 'default')
|
||||
self.sample_rate = config.get('sample_rate', 44100)
|
||||
self.channels = config.get('channels', 2)
|
||||
self.preferred_backend = config.get('backend', None)
|
||||
|
||||
# Validate format
|
||||
if self.file_format not in ('wav', 'flac') and self.file_format != 'auto':
|
||||
@@ -713,23 +622,19 @@ class SoundcardRecorder(BaseRecorder):
|
||||
self.buffer_lock = threading.Lock()
|
||||
|
||||
def _resolve_device(self) -> AudioDevice:
|
||||
"""Resolve device specification to AudioDevice."""
|
||||
device = self.audio_system.find_device(self.device_spec, self.preferred_backend)
|
||||
|
||||
if device is None:
|
||||
# List available devices in error
|
||||
available = self.audio_system.list_all_devices()
|
||||
device_list = "\n ".join(str(d) for d in available) if available else "None found"
|
||||
raise ValueError(
|
||||
f"[{self.name}] Device '{self.device_spec}' not found.\n"
|
||||
f"Available devices:\n {device_list}"
|
||||
)
|
||||
"""Resolve the configured device spec to an AudioDevice.
|
||||
|
||||
find_device() always succeeds — an unknown spec becomes a literal ALSA
|
||||
PCM name — so a typo surfaces as an arecord failure at capture time
|
||||
rather than here. That is the price of supporting asound.conf PCMs,
|
||||
which are invisible to `arecord -l`.
|
||||
"""
|
||||
device = self.audio_system.find_device(self.device_spec)
|
||||
self.logger.info(f"[{self.name}] Using device: {device}")
|
||||
return device
|
||||
|
||||
def _audio_callback(self, data: bytes):
|
||||
"""Callback for audio data from backend."""
|
||||
"""Callback for audio data from the capture thread."""
|
||||
with self.buffer_lock:
|
||||
self.audio_buffer.append(data)
|
||||
|
||||
@@ -767,13 +672,8 @@ class SoundcardRecorder(BaseRecorder):
|
||||
retry_count = 0
|
||||
next_split = self.get_next_split_time()
|
||||
|
||||
backend = self.audio_system.get_backend(self.device.backend)
|
||||
if not backend:
|
||||
self.logger.error(f"[{self.name}] Backend '{self.device.backend}' not available")
|
||||
return
|
||||
|
||||
self.logger.info(f"[{self.name}] Starting recorder - Split every {self.split_duration} min")
|
||||
self.logger.info(f"[{self.name}] Backend: {self.device.backend}, Device: {self.device.name}")
|
||||
self.logger.info(f"[{self.name}] Device: {self.device}")
|
||||
self.logger.info(f"[{self.name}] Sample rate: {self.sample_rate}, Channels: {self.channels}")
|
||||
self.logger.info(f"[{self.name}] Next split: {next_split.strftime('%H:%M:%S')}")
|
||||
|
||||
@@ -782,7 +682,7 @@ class SoundcardRecorder(BaseRecorder):
|
||||
if self.current_file is None:
|
||||
self._open_output_file()
|
||||
|
||||
with backend.open_stream(
|
||||
with self.audio_system.open_stream(
|
||||
self.device, self.sample_rate, self.channels, self._audio_callback
|
||||
) as stream:
|
||||
self.logger.info(f"[{self.name}] Recording started")
|
||||
@@ -862,8 +762,8 @@ class RecorderManager:
|
||||
|
||||
# Initialize shared audio system for soundcard recorders
|
||||
self.audio_system = AudioSystem(self.logger)
|
||||
if self.audio_system.available_backends:
|
||||
self.logger.info(f"Audio backends: {', '.join(self.audio_system.available_backends)}")
|
||||
if self.audio_system.available:
|
||||
self.logger.info("Audio capture available: ALSA (arecord)")
|
||||
|
||||
# Parse source sections
|
||||
for section in config.sections():
|
||||
@@ -1005,7 +905,7 @@ class RecorderManager:
|
||||
|
||||
|
||||
def list_audio_devices():
|
||||
"""List available audio input devices from all backends."""
|
||||
"""Print the ALSA capture devices usable as `device =` in config.ini."""
|
||||
# Create a minimal logger for device listing
|
||||
logger = logging.getLogger('isr-devices')
|
||||
logger.setLevel(logging.WARNING) # Suppress debug output
|
||||
@@ -1014,40 +914,36 @@ def list_audio_devices():
|
||||
print(" ISR Audio Device Discovery")
|
||||
print("=" * 70)
|
||||
|
||||
if not ALSABackend.is_available():
|
||||
print("\n No audio backends available!")
|
||||
audio_system = AudioSystem(logger)
|
||||
if not audio_system.available:
|
||||
print("\n arecord not found!")
|
||||
print("\n Install ALSA utilities:")
|
||||
print(" sudo apt install alsa-utils")
|
||||
print()
|
||||
return
|
||||
|
||||
print("\n Available Backends:")
|
||||
print(" - ALSA (arecord)")
|
||||
|
||||
# Initialize audio system and list devices
|
||||
audio_system = AudioSystem(logger)
|
||||
devices = audio_system.list_all_devices()
|
||||
|
||||
if not devices:
|
||||
print("\n No input devices found!")
|
||||
print()
|
||||
return
|
||||
|
||||
print("\n Input Devices:")
|
||||
print(" " + "-" * 68)
|
||||
for dev in devices:
|
||||
flag_str = " [DEFAULT]" if dev.is_default else ""
|
||||
print(f"\n {dev.name}{flag_str}")
|
||||
print(f" ID: {dev.id} | Backend: {dev.backend}")
|
||||
print(f" Channels: {dev.channels} | Sample Rate: {dev.sample_rate} Hz")
|
||||
devices = audio_system.list_devices()
|
||||
if devices:
|
||||
print("\n Hardware Input Devices:")
|
||||
print(" " + "-" * 68)
|
||||
for dev in devices:
|
||||
flag_str = " [DEFAULT]" if dev.is_default else ""
|
||||
print(f"\n {dev.name}{flag_str}")
|
||||
print(f" ID: {dev.id}")
|
||||
else:
|
||||
print("\n No hardware capture devices found.")
|
||||
|
||||
# This listing only ever shows hardware cards, so say so — a working
|
||||
# asound.conf PCM will never appear here and users have gone looking.
|
||||
print("\n" + "=" * 70)
|
||||
print(" Configuration Examples:")
|
||||
print("-" * 70)
|
||||
print(" device = default # Use system default input")
|
||||
print(" device = <name> # Match by partial name")
|
||||
print(" device = <id> # Use exact ALSA ID, e.g. hw:0,0")
|
||||
print(" device = <pcm> # Any ALSA PCM name from asound.conf, e.g. shared_mic")
|
||||
print(" device = <pcm> # Any ALSA PCM name from asound.conf, e.g.")
|
||||
print(" # shared_mic — these are never listed above,")
|
||||
print(" # but work regardless")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user