stereo-support and dep-integration (#19)

Co-authored-by: pstruebi <struebin.patrick@gmail.com>
Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/19
This commit was merged in pull request #19.
This commit is contained in:
pober
2026-01-20 12:57:17 +01:00
parent dd02e0ddc3
commit 59ca5dafd2
82 changed files with 10236 additions and 395 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import json
from datetime import datetime
import asyncio
import random
import subprocess
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
@@ -21,6 +22,7 @@ from auracast.utils.sounddevice_utils import (
get_alsa_usb_inputs,
resolve_input_device_index,
refresh_pw_cache,
devices_by_backend,
)
load_dotenv()
@@ -326,30 +328,122 @@ 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()}
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:
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)
# Configure each BIG independently so Dante multi-stream can select different channels.
for big in conf.bigs:
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
# Dante stereo devices: dante_stereo_X_Y (e.g., dante_stereo_1_2)
if sel and sel.startswith('dante_stereo_'):
is_stereo = getattr(big, 'num_bis', 1) == 2
if is_stereo:
# Stereo mode: use the stereo ALSA device with 2 channels
big.audio_source = f'alsa:{sel}'
big.input_format = f"int16le,{hardware_capture_rate},2"
log.info("Configured Dante stereo input: using ALSA %s with 2 channels", sel)
else:
# Fallback to mono if num_bis != 2 (shouldn't happen)
big.audio_source = f'alsa:{sel}'
big.input_format = f"int16le,{hardware_capture_rate},2"
log.warning("Dante stereo device %s used but num_bis=%d, capturing as stereo anyway", sel, getattr(big, 'num_bis', 1))
continue
if sel in ('ch1', 'ch2'):
# Analog channels: check if this should be stereo based on num_bis
is_stereo = getattr(big, 'num_bis', 1) == 2
if is_stereo and sel == 'ch1':
# Stereo mode: use ALSA directly to capture both channels from hardware
# ch1=left (channel 0), ch2=right (channel 1)
big.audio_source = 'alsa:hw:CARD=i2s,DEV=0'
big.input_format = f"int16le,{hardware_capture_rate},2"
log.info("Configured analog stereo input: using ALSA hw:CARD=i2s,DEV=0 with ch1=left, ch2=right")
elif is_stereo and sel == 'ch2':
# Skip ch2 in stereo mode as it's already captured as part of stereo pair
continue
else:
# Mono mode: individual channel capture
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}'
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))
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
target_sampling_rate = getattr(conf, 'auracast_sampling_rate_hz', None)
if target_sampling_rate is None and conf.bigs:
target_sampling_rate = getattr(conf.bigs[0], 'sampling_frequency', 48000)
if target_sampling_rate is None:
target_sampling_rate = 48000
# Keep the config group sampling rate as set by frontend
conf.auracast_sampling_rate_hz = target_sampling_rate
# Ensure octets_per_frame matches the target sampling rate
if target_sampling_rate == 48000:
conf.octets_per_frame = 120
elif target_sampling_rate == 32000:
conf.octets_per_frame = 80
elif target_sampling_rate == 24000:
conf.octets_per_frame = 60
elif target_sampling_rate == 16000:
conf.octets_per_frame = 40
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.")
for big in conf.bigs:
if isinstance(big.audio_source, str) and big.audio_source.startswith('device:'):
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))
for big in conf.bigs:
big.input_format = f"int16le,{48000},{channels}"
conf.octets_per_frame = 120 # default to 48000 setting
conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3
@@ -365,7 +459,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
@@ -390,6 +484,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
'qos_preset': _resolve_qos_preset_name(conf.qos_config),
'immediate_rendering': getattr(conf, 'immediate_rendering', False),
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False),
'analog_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False,
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
'big_ids': [getattr(big, 'id', DEFAULT_BIG_ID) for big in conf.bigs],
'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs],
@@ -577,6 +672,9 @@ async def _autostart_from_settings():
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
bigs=bigs,
)
# Set num_bis for stereo mode if needed
if conf.bigs and settings.get('analog_stereo_mode', False):
conf.bigs[0].num_bis = 2
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
log.info("[AUTOSTART][PRIMARY] Scheduling demo init_radio in 2s")
await asyncio.sleep(2)
@@ -641,6 +739,9 @@ async def _autostart_from_settings():
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
bigs=bigs,
)
# Set num_bis for stereo mode if needed
if conf.bigs and settings.get('analog_stereo_mode', False):
conf.bigs[0].num_bis = 2
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
log.info("[AUTOSTART][PRIMARY] Scheduling device init_radio in 2s")
await asyncio.sleep(2)
@@ -810,6 +911,18 @@ async def _autostart_from_settings():
async def _startup_autostart_event():
# Spawn the autostart task without blocking startup
log.info("[STARTUP] Auracast multicast server startup: initializing settings cache, I2C, and PipeWire cache")
# Run install_asoundconf.sh script
script_path = os.path.join(os.path.dirname(__file__), '..', 'misc', 'install_asoundconf.sh')
try:
log.info("[STARTUP] Running install_asoundconf.sh script")
result = subprocess.run(['bash', script_path], capture_output=True, text=True, check=True)
log.info(f"[STARTUP] install_asoundconf.sh completed: {result.stdout.strip()}")
except subprocess.CalledProcessError as e:
log.error(f"[STARTUP] Failed to run install_asoundconf.sh: {e.stderr.strip()}")
except Exception as e:
log.error(f"[STARTUP] Error running install_asoundconf.sh: {str(e)}")
# Hydrate settings cache once to avoid disk I/O during /status
_init_settings_cache_from_disk()
await _init_i2c_on_startup()
@@ -845,6 +958,28 @@ async def audio_inputs_pw_network():
log.error("Exception in /audio_inputs_pw_network: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.get("/audio_inputs_dante")
async def audio_inputs_dante():
"""List Dante ALSA input devices from asound.conf."""
try:
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))
@app.post("/refresh_audio_devices")
async def refresh_audio_devices():
"""Triggers a re-scan of audio devices, but only if no stream is active."""
@@ -899,6 +1034,56 @@ async def system_reboot():
raise HTTPException(status_code=500, detail=str(e))
@app.post("/restart_dep")
async def restart_dep():
"""Restart DEP by running dep.sh stop then dep.sh start in the dep directory.
Requires the service user to have passwordless sudo permissions to run dep.sh.
"""
try:
# Get the dep directory path (dep.sh is in dante_package subdirectory)
dep_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'dep', 'dante_package')
# Run dep.sh stop first
log.info("Stopping DEP...")
stop_process = await asyncio.create_subprocess_exec(
"sudo", "bash", "dep.sh", "stop",
cwd=dep_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stop_stdout, stop_stderr = await stop_process.communicate()
if stop_process.returncode != 0:
error_msg = stop_stderr.decode() if stop_stderr else "Unknown error"
log.error(f"Failed to stop DEP: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to stop DEP: {error_msg}")
# Run dep.sh start after stop succeeds
log.info("Starting DEP...")
start_process = await asyncio.create_subprocess_exec(
"sudo", "bash", "dep.sh", "start",
cwd=dep_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
start_stdout, start_stderr = await start_process.communicate()
if start_process.returncode == 0:
log.info("DEP restarted successfully")
return {"status": "success", "message": "DEP restarted successfully"}
else:
error_msg = start_stderr.decode() if start_stderr else "Unknown error"
log.error(f"Failed to start DEP: {error_msg}")
raise HTTPException(status_code=500, detail=f"Failed to start DEP: {error_msg}")
except HTTPException:
raise
except Exception as e:
log.error("Exception in /restart_dep: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.get("/version")
async def get_version():
"""Get the current software version (git tag or commit)."""
@@ -1069,6 +1254,169 @@ async def system_update():
raise HTTPException(status_code=500, detail=str(e))
# Recording functionality
RECORDINGS_DIR = os.path.join(os.path.dirname(__file__), 'recordings')
os.makedirs(RECORDINGS_DIR, exist_ok=True)
def cleanup_old_recordings(keep_latest: str = None):
"""Delete all recordings except the latest one (or specified file)."""
try:
recordings = []
for filename in os.listdir(RECORDINGS_DIR):
if filename.endswith('.wav'):
filepath = os.path.join(RECORDINGS_DIR, filename)
if os.path.isfile(filepath):
recordings.append((filename, os.path.getmtime(filepath)))
# Sort by modification time (newest first)
recordings.sort(key=lambda x: x[1], reverse=True)
# Keep only the latest recording (or the specified one)
if keep_latest and os.path.exists(os.path.join(RECORDINGS_DIR, keep_latest)):
files_to_keep = {keep_latest}
else:
files_to_keep = {recordings[0][0]} if recordings else set()
# Delete old recordings
for filename, _ in recordings:
if filename not in files_to_keep:
filepath = os.path.join(RECORDINGS_DIR, filename)
try:
os.remove(filepath)
log.info("Deleted old recording: %s", filename)
except Exception as e:
log.warning("Failed to delete recording %s: %s", filename, e)
except Exception as e:
log.warning("Error during recording cleanup: %s", e)
@app.get("/alsa_devices")
async def get_alsa_devices():
"""Get list of available ALSA input devices."""
try:
devices = []
dev_list = sd.query_devices()
for idx, dev in enumerate(dev_list):
if dev.get('max_input_channels', 0) > 0:
devices.append({
'id': idx,
'name': dev['name'],
'max_input_channels': dev['max_input_channels']
})
# Add individual Dante ASRC channels if shared device is found
dante_shared_device = None
for device in devices:
if device['name'] == 'dante_asrc_shared6':
dante_shared_device = device
break
if dante_shared_device:
# Add individual Dante ASRC channels as virtual devices
for i in range(1, 7): # ch1 to ch6
devices.append({
'id': f"dante_asrc_ch{i}",
'name': f'dante_asrc_ch{i}',
'max_input_channels': 1,
'parent_device': dante_shared_device['name'],
'parent_id': dante_shared_device['id']
})
return {"devices": devices}
except Exception as e:
log.error("Exception in /alsa_devices: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/start_recording")
async def start_recording(request: dict):
"""Start a 5-second recording from the specified ALSA device."""
try:
device_name = request.get('device')
if not device_name:
raise HTTPException(status_code=400, detail="Device name is required")
# Generate filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"recording_{timestamp}.wav"
filepath = os.path.join(RECORDINGS_DIR, filename)
# Determine channel count based on device type
# For other devices, try to find actual channel count
channels = 1 # Default to mono
try:
devices = sd.query_devices()
for dev in devices:
if dev['name'] == device_name and dev.get('max_input_channels', 0) > 0:
channels = dev.get('max_input_channels', 1)
break
except Exception:
pass
# Build arecord command
cmd = [
"arecord",
"-D", device_name, # Use the device name directly
"-f", "cd", # CD quality (16-bit little-endian, 44100 Hz)
"-c", str(channels), # Channel count
"-d", "5", # Duration in seconds
"-t", "wav", # WAV format
filepath
]
log.info("Starting recording with command: %s", " ".join(cmd))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode(errors="ignore").strip() if stderr else "Unknown error"
log.error("Recording failed: %s", error_msg)
raise HTTPException(status_code=500, detail=f"Recording failed: {error_msg}")
# Verify file was created and has content
if not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
raise HTTPException(status_code=500, detail="Recording file was not created or is empty")
# Clean up old recordings, keeping only this new one
cleanup_old_recordings(keep_latest=filename)
log.info("Recording completed successfully: %s", filename)
return {"success": True, "filename": filename}
except HTTPException:
raise
except Exception as e:
log.error("Exception in /start_recording: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.get("/download_recording/{filename}")
async def download_recording(filename: str):
"""Download a recorded WAV file."""
try:
# Validate filename to prevent directory traversal
if not filename.endswith('.wav') or '/' in filename or '\\' in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
filepath = os.path.join(RECORDINGS_DIR, filename)
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="Recording file not found")
return FileResponse(filepath, filename=filename, media_type="audio/wav")
except HTTPException:
raise
except Exception as e:
log.error("Exception in /download_recording: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if __name__ == '__main__':
import os
os.chdir(os.path.dirname(__file__))