From fd69b30c98f86f84980277debdd05d3a5c698b62 Mon Sep 17 00:00:00 2001 From: pober Date: Mon, 19 Jan 2026 14:06:15 +0100 Subject: [PATCH] The alsa ch1..ch6 for dante now work. Implements direct capture from alsa device. To review, if this approach has downsides. --- src/auracast/multicast.py | 87 ++++++++++++- src/auracast/server/multicast_server.py | 155 ++++++++++-------------- 2 files changed, 151 insertions(+), 91 deletions(-) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 431763d..98c35e4 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -60,6 +60,85 @@ import sounddevice as sd from collections import deque +class AlsaArecordAudioInput(audio_io.AudioInput): + def __init__(self, device_name: str, pcm_format: audio_io.PcmFormat): + self._device_name = device_name + self._pcm_format = pcm_format + self._proc: asyncio.subprocess.Process | None = None + + async def open(self) -> audio_io.PcmFormat: + if self._proc is not None: + return self._pcm_format + + args = [ + 'arecord', + '-D', self._device_name, + '-q', + '-t', 'raw', + '-f', 'S16_LE', + '-r', str(int(self._pcm_format.sample_rate)), + '-c', str(int(self._pcm_format.channels)), + ] + + logging.info( + "Opening ALSA capture via arecord: device='%s' rate=%s ch=%s", + self._device_name, + self._pcm_format.sample_rate, + self._pcm_format.channels, + ) + + self._proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + + if self._proc.stdout is None: + raise RuntimeError('arecord stdout pipe was not created') + + return self._pcm_format + + def frames(self, frame_size: int) -> AsyncGenerator[bytes]: + async def _gen() -> AsyncGenerator[bytes]: + if self._proc is None: + await self.open() + + if self._proc is None or self._proc.stdout is None: + return + + bytes_per_frame = frame_size * self._pcm_format.channels * self._pcm_format.bytes_per_sample + + while True: + try: + data = await self._proc.stdout.readexactly(bytes_per_frame) + except asyncio.IncompleteReadError: + return + except Exception: + return + yield data + + return _gen() + + async def aclose(self) -> None: + if self._proc is None: + return + try: + if self._proc.returncode is None: + self._proc.terminate() + except ProcessLookupError: + pass + except Exception: + pass + with contextlib.suppress(Exception): + await asyncio.wait_for(self._proc.wait(), timeout=1.0) + if self._proc.returncode is None: + with contextlib.suppress(Exception): + self._proc.kill() + with contextlib.suppress(Exception): + await asyncio.wait_for(self._proc.wait(), timeout=1.0) + self._proc = None + + class ModSoundDeviceAudioInput(audio_io.SoundDeviceAudioInput): """Patched SoundDeviceAudioInput with low-latency capture and adaptive resampling.""" @@ -671,7 +750,13 @@ class Streamer(): # anything else, e.g. realtime stream from device (bumble) else: - audio_input = await audio_io.create_audio_input(audio_source, input_format) + if isinstance(audio_source, str) and audio_source.startswith('alsa:'): + if input_format == 'auto': + raise ValueError('input format details required for alsa input') + pcm = audio_io.PcmFormat.from_str(input_format) + audio_input = AlsaArecordAudioInput(audio_source[5:], pcm) + else: + audio_input = await audio_io.create_audio_input(audio_source, input_format) # Store early so stop_streaming can close even if open() fails big['audio_input'] = audio_input # SoundDeviceAudioInput (used for `mic:` captures) has no `.rewind`. diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 2531a4c..e0f7d93 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -328,74 +328,71 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, first_source = conf.bigs[0].audio_source if conf.bigs else '' input_device_name = None audio_mode_persist = 'Demo' - if isinstance(first_source, str) and first_source.startswith('device:'): - input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None + if any(isinstance(b.audio_source, str) and b.audio_source.startswith('device:') for b in conf.bigs): + if isinstance(first_source, str) and first_source.startswith('device:'): + input_device_name = first_source.split(':', 1)[1] if ':' in first_source else None + alsa_usb_names = {d.get('name') for _, d in get_alsa_usb_inputs()} net_names = {d.get('name') for _, d in get_network_pw_inputs()} - # Define Dante channel names dante_channels = {"dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3", "dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"} - + if input_device_name in ('ch1', 'ch2'): - # Explicitly treat ch1/ch2 as Analog input mode audio_mode_persist = 'Analog' elif input_device_name in dante_channels: - # Treat Dante channels as Network - Dante mode audio_mode_persist = 'Network - Dante' else: audio_mode_persist = 'Network' if (input_device_name in net_names) else 'USB' - if input_device_name and input_device_name.isdigit(): - device_index = int(input_device_name) - else: - # Special handling for Dante channels - map to shared device - dante_channels = {"dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3", "dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"} - if input_device_name in dante_channels: - # Find the dante_asrc_shared6 device index - device_index = None - try: - devices_by_backend_list = devices_by_backend('ALSA') - for idx, dev in devices_by_backend_list: - if dev.get('name') == 'dante_asrc_shared6' and dev.get('max_input_channels', 0) > 0: - device_index = idx - break - except Exception: - pass - else: - device_index = resolve_input_device_index(input_device_name or '') - if device_index is None: - raise HTTPException(status_code=400, detail=f"Audio device '{input_device_name}' not found.") + # Configure each BIG independently so Dante multi-stream can select different channels. for big in conf.bigs: - if isinstance(big.audio_source, str) and big.audio_source.startswith('device:'): + if not (isinstance(big.audio_source, str) and big.audio_source.startswith('device:')): + continue + sel = big.audio_source.split(':', 1)[1] if ':' in big.audio_source else None + + # IMPORTANT: All hardware capture is at 48kHz; LC3 encoder may downsample. + hardware_capture_rate = 48000 + + if sel in dante_channels: + # Use ALSA directly (PortAudio doesn't enumerate route PCMs on some systems). + big.audio_source = f'alsa:{sel}' + big.input_format = f"int16le,{hardware_capture_rate},1" + continue + + if sel in ('ch1', 'ch2'): + # Analog channels: mono at 48kHz + device_index = resolve_input_device_index(sel) + if device_index is None: + raise HTTPException(status_code=400, detail=f"Audio device '{sel}' not found.") big.audio_source = f'device:{device_index}' - - # Configure input format based on device type - # IMPORTANT: All hardware devices (Analog ch1/ch2, Dante, USB, Network) only support 48kHz - # We always capture at 48kHz and let the LC3 encoder handle downsampling to target rates - if input_device_name in dante_channels: - # For Dante channels, use mono (1 channel) from shared device - max_in = 1 - channels = 1 - # Always use 48kHz for hardware capture, regardless of target quality - hardware_capture_rate = 48000 - for big in conf.bigs: - big.input_format = f"int16le,{hardware_capture_rate},{channels}" - elif input_device_name in ('ch1', 'ch2'): - # For Analog channels, use mono (1 channel) - max_in = 1 - channels = 1 - # Always use 48kHz for hardware capture, regardless of target quality - hardware_capture_rate = 48000 - for big in conf.bigs: - big.input_format = f"int16le,{hardware_capture_rate},{channels}" - else: - # For USB/Network devices, check device capabilities + big.input_format = f"int16le,{hardware_capture_rate},1" + continue + + if sel and sel.isdigit(): + device_index = int(sel) + else: + device_index = resolve_input_device_index(sel or '') + if device_index is None: + raise HTTPException(status_code=400, detail=f"Audio device '{sel}' not found.") + + try: + resolved_devinfo = sd.query_devices(device_index) + log.info( + "Resolved input device '%s' -> idx=%s name='%s' hostapi=%s max_in=%s", + sel, + device_index, + resolved_devinfo.get('name'), + resolved_devinfo.get('hostapi'), + resolved_devinfo.get('max_input_channels'), + ) + except Exception: + log.info("Resolved input device '%s' -> idx=%s (devinfo unavailable)", sel, device_index) + + big.audio_source = f'device:{device_index}' + devinfo = sd.query_devices(device_index) max_in = int(devinfo.get('max_input_channels') or 1) channels = max(1, min(2, max_in)) - # Always use 48kHz for hardware capture, regardless of target quality - hardware_capture_rate = 48000 - for big in conf.bigs: - big.input_format = f"int16le,{hardware_capture_rate},{channels}" + big.input_format = f"int16le,{hardware_capture_rate},{channels}" # The config group keeps the target sampling rate for LC3 encoder # The audio input will capture at 48kHz and LC3 encoder will downsample @@ -434,7 +431,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, await mc.init_broadcast() auto_started = False - if any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("file:")) for big in conf.bigs): + if any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("alsa:") or big.audio_source.startswith("file:")) for big in conf.bigs): await mc.start_streaming() auto_started = True @@ -930,42 +927,20 @@ async def audio_inputs_pw_network(): async def audio_inputs_dante(): """List Dante ALSA input devices from asound.conf.""" try: - dante_devices = [] - # Define the 6 Dante channel names - dante_channels = ["dante_asrc_ch1", "dante_asrc_ch2", "dante_asrc_ch3", "dante_asrc_ch4", "dante_asrc_ch5", "dante_asrc_ch6"] - - # Find the shared device index - shared_device_idx = None - try: - devices_by_backend_list = devices_by_backend('ALSA') - for idx, dev in devices_by_backend_list: - if dev.get('name') == 'dante_asrc_shared6' and dev.get('max_input_channels', 0) > 0: - shared_device_idx = idx - log.info(f"[DANTE] Found shared device at index {idx}") - break - except Exception as e: - log.error(f"[DANTE] Error finding shared device: {e}") - - if shared_device_idx is not None: - # Return all Dante channels mapped to the shared device index - for channel_name in dante_channels: - dante_devices.append({ - "id": shared_device_idx, - "name": channel_name, - "max_input_channels": 1 # Use 1 channel (mono) from shared device - }) - log.info(f"[DANTE] Returning {len(dante_devices)} Dante channels mapped to device index {shared_device_idx}") - else: - # Fallback to placeholder devices - log.warning("[DANTE] Shared device not found, returning placeholder devices") - for i, channel_name in enumerate(dante_channels): - dante_devices.append({ - "id": i + 1000, - "name": channel_name, - "max_input_channels": 1 - }) - - return {"inputs": dante_devices} + dante_channels = [ + "dante_asrc_ch1", + "dante_asrc_ch2", + "dante_asrc_ch3", + "dante_asrc_ch4", + "dante_asrc_ch5", + "dante_asrc_ch6", + ] + return { + "inputs": [ + {"id": name, "name": name, "max_input_channels": 1} + for name in dante_channels + ] + } except Exception as e: log.error("Exception in /audio_inputs_dante: %s", traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e))