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:
@@ -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`.
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user