The alsa ch1..ch6 for dante now work. Implements direct capture from alsa device. To review, if this approach has downsides.

This commit is contained in:
pober
2026-01-19 14:06:15 +01:00
committed by pstruebi
parent 6aeac5759a
commit fd69b30c98
2 changed files with 151 additions and 91 deletions
+86 -1
View File
@@ -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:<device>` captures) has no `.rewind`.
+65 -90
View File
@@ -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))