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:
@@ -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`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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__))
|
||||
|
||||
@@ -243,7 +243,8 @@ def get_alsa_usb_inputs():
|
||||
'usb' in name or
|
||||
re.search(r'hw:\d+(?:,\d+)?', name) or
|
||||
name.startswith('dsnoop') or
|
||||
name in ('ch1', 'ch2')
|
||||
name in ('ch1', 'ch2') or
|
||||
name.startswith('dante_asrc_ch')
|
||||
):
|
||||
usb_inputs.append((idx, dev))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user