Merge branch 'wip_alsaaudio' TODO poetry lock
This commit is contained in:
18
poetry.lock
generated
18
poetry.lock
generated
@@ -1804,6 +1804,22 @@ files = [
|
|||||||
{file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"},
|
{file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyalsaaudio"
|
||||||
|
version = "0.11.0"
|
||||||
|
description = "ALSA bindings"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
groups = ["main"]
|
||||||
|
files = []
|
||||||
|
develop = false
|
||||||
|
|
||||||
|
[package.source]
|
||||||
|
type = "git"
|
||||||
|
url = "ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git"
|
||||||
|
reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
|
||||||
|
resolved_reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyarrow"
|
name = "pyarrow"
|
||||||
version = "20.0.0"
|
version = "20.0.0"
|
||||||
@@ -2947,4 +2963,4 @@ test = ["pytest", "pytest-asyncio"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.11"
|
python-versions = ">=3.11"
|
||||||
content-hash = "e39f622c983015c1a1c86236114c339044130db172cd420eecdd17f546af20de"
|
content-hash = "7c3c5cf6a836a9d7705e3b120610d98912cfd228b9abe162e15e6bed5bcb44a1"
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ dependencies = [
|
|||||||
"python-dotenv (>=1.1.1,<2.0.0)",
|
"python-dotenv (>=1.1.1,<2.0.0)",
|
||||||
"smbus2 (>=0.5.0,<0.6.0)",
|
"smbus2 (>=0.5.0,<0.6.0)",
|
||||||
"samplerate (>=0.2.2,<0.3.0)",
|
"samplerate (>=0.2.2,<0.3.0)",
|
||||||
"rpi-gpio (>=0.7.1,<0.8.0)"
|
"rpi-gpio (>=0.7.1,<0.8.0)",
|
||||||
|
"pyalsaaudio @ git+ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git@b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import time
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
import numpy as np # for audio down-mix
|
import numpy as np # for audio down-mix
|
||||||
|
import samplerate
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import lc3 # type: ignore # pylint: disable=E0401
|
import lc3 # type: ignore # pylint: disable=E0401
|
||||||
@@ -56,7 +57,7 @@ from auracast.utils.webrtc_audio_input import WebRTCAudioInput
|
|||||||
|
|
||||||
|
|
||||||
# Patch sounddevice.InputStream globally to use low-latency settings
|
# Patch sounddevice.InputStream globally to use low-latency settings
|
||||||
import sounddevice as sd
|
import alsaaudio
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
|
|
||||||
@@ -139,96 +140,146 @@ class AlsaArecordAudioInput(audio_io.AudioInput):
|
|||||||
self._proc = None
|
self._proc = None
|
||||||
|
|
||||||
|
|
||||||
class ModSoundDeviceAudioInput(audio_io.SoundDeviceAudioInput):
|
class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
|
||||||
"""Patched SoundDeviceAudioInput with low-latency capture and adaptive resampling."""
|
"""PyALSA audio input with non-blocking reads - supports mono/stereo."""
|
||||||
|
|
||||||
def _open(self):
|
def __init__(self, device, pcm_format: audio_io.PcmFormat):
|
||||||
"""Create RawInputStream with low-latency parameters and initialize ring buffer."""
|
super().__init__()
|
||||||
dev_info = sd.query_devices(self._device)
|
logging.info("PyALSA: device = %s", device)
|
||||||
hostapis = sd.query_hostapis()
|
self._device = str(device) if not isinstance(device, str) else device
|
||||||
api_index = dev_info.get('hostapi')
|
if self._device.isdigit():
|
||||||
api_name = hostapis[api_index]['name'] if isinstance(api_index, int) and 0 <= api_index < len(hostapis) else 'unknown'
|
self._device = 'default' if self._device == '0' else f'hw:{self._device}'
|
||||||
pa_ver = sd.get_portaudio_version()
|
self._pcm_format = pcm_format
|
||||||
|
self._pcm = None
|
||||||
|
self._actual_channels = None
|
||||||
|
self._periodsize = None
|
||||||
|
self._hw_channels = None
|
||||||
|
self._first_read = True
|
||||||
|
self._resampler = None
|
||||||
|
self._resampler_buffer = np.empty(0, dtype=np.float32)
|
||||||
|
|
||||||
logging.info(
|
def _open(self) -> audio_io.PcmFormat:
|
||||||
"SoundDevice backend=%s device='%s' (id=%s) ch=%s default_low_input_latency=%.4f default_high_input_latency=%.4f portaudio=%s",
|
ALSA_PERIODSIZE = 240
|
||||||
api_name,
|
ALSA_PERIODS = 4
|
||||||
dev_info.get('name'),
|
ALSA_MODE = alsaaudio.PCM_NONBLOCK
|
||||||
self._device,
|
|
||||||
dev_info.get('max_input_channels'),
|
|
||||||
float(dev_info.get('default_low_input_latency') or 0.0),
|
|
||||||
float(dev_info.get('default_high_input_latency') or 0.0),
|
|
||||||
pa_ver[1] if isinstance(pa_ver, tuple) and len(pa_ver) >= 2 else pa_ver,
|
|
||||||
)
|
|
||||||
# Create RawInputStream with injected low-latency parameters
|
|
||||||
# Target ~2 ms blocksize (48 kHz -> 96 frames). For other rates, keep ~2 ms.
|
|
||||||
_sr = int(self._pcm_format.sample_rate)
|
|
||||||
|
|
||||||
self.counter=0
|
requested_rate = int(self._pcm_format.sample_rate)
|
||||||
self.max_avail=0
|
requested_channels = int(self._pcm_format.channels)
|
||||||
self.logfile_name="available_samples.txt"
|
self._periodsize = ALSA_PERIODSIZE
|
||||||
self.blocksize = 120
|
|
||||||
|
|
||||||
if os.path.exists(self.logfile_name):
|
self._pcm = alsaaudio.PCM(
|
||||||
os.remove(self.logfile_name)
|
type=alsaaudio.PCM_CAPTURE,
|
||||||
|
mode=ALSA_MODE,
|
||||||
self._stream = sd.RawInputStream(
|
|
||||||
samplerate=self._pcm_format.sample_rate,
|
|
||||||
device=self._device,
|
device=self._device,
|
||||||
channels=self._pcm_format.channels,
|
periods=ALSA_PERIODS,
|
||||||
dtype='int16',
|
|
||||||
blocksize=self.blocksize,
|
|
||||||
latency=0.004,
|
|
||||||
)
|
)
|
||||||
self._stream.start()
|
|
||||||
|
self._pcm.setchannels(requested_channels)
|
||||||
|
self._pcm.setformat(alsaaudio.PCM_FORMAT_S16_LE)
|
||||||
|
actual_rate = self._pcm.setrate(requested_rate)
|
||||||
|
self._pcm.setperiodsize(ALSA_PERIODSIZE)
|
||||||
|
|
||||||
|
logging.info("PyALSA: device=%s rate=%d ch=%d periodsize=%d (%.1fms) periods=%d mode=%s",
|
||||||
|
self._device, actual_rate, requested_channels, ALSA_PERIODSIZE,
|
||||||
|
(ALSA_PERIODSIZE / actual_rate) * 1000, ALSA_PERIODS, ALSA_MODE)
|
||||||
|
|
||||||
|
if actual_rate != requested_rate:
|
||||||
|
logging.warning("PyALSA: Sample rate mismatch! requested=%d actual=%d", requested_rate, actual_rate)
|
||||||
|
|
||||||
|
self._actual_channels = requested_channels
|
||||||
|
self._resampler = samplerate.Resampler('sinc_fastest', channels=requested_channels)
|
||||||
|
self._resampler_buffer = np.empty(0, dtype=np.float32)
|
||||||
|
|
||||||
|
self._bang_bang = 0
|
||||||
|
|
||||||
return audio_io.PcmFormat(
|
return audio_io.PcmFormat(
|
||||||
audio_io.PcmFormat.Endianness.LITTLE,
|
audio_io.PcmFormat.Endianness.LITTLE,
|
||||||
audio_io.PcmFormat.SampleType.INT16,
|
audio_io.PcmFormat.SampleType.INT16,
|
||||||
self._pcm_format.sample_rate,
|
actual_rate,
|
||||||
1,
|
requested_channels,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _read(self, frame_size: int) -> bytes:
|
def _read(self, frame_size: int) -> bytes:
|
||||||
"""Read PCM samples from the stream."""
|
try:
|
||||||
|
avail = self._pcm.avail()
|
||||||
|
logging.debug("PyALSA: avail before read: %d", avail)
|
||||||
|
length, data = self._pcm.read_sw(frame_size + self._bang_bang)
|
||||||
|
avail = self._pcm.avail()
|
||||||
|
SETPOINT = 120
|
||||||
|
TOLERANCE = 40
|
||||||
|
if avail < SETPOINT - TOLERANCE:
|
||||||
|
self._bang_bang = -1
|
||||||
|
elif avail > SETPOINT + TOLERANCE:
|
||||||
|
self._bang_bang = 1
|
||||||
|
else:
|
||||||
|
self._bang_bang = 0
|
||||||
|
|
||||||
#if self.counter % 50 == 0:
|
|
||||||
frame_size = frame_size + 1 # consume samples a little faster to avoid latency akkumulation
|
|
||||||
|
|
||||||
pcm_buffer, overflowed = self._stream.read(frame_size)
|
logging.debug("PyALSA: read length=%d, data length=%d, avail=%d, bang_bang=%d", length, len(data), avail, self._bang_bang)
|
||||||
if overflowed:
|
|
||||||
logging.warning("SoundDeviceAudioInput: overflowed")
|
|
||||||
|
|
||||||
n_available = self._stream.read_available
|
if length > 0:
|
||||||
|
if self._first_read:
|
||||||
|
expected_mono = self._periodsize * 2
|
||||||
|
expected_stereo = self._periodsize * 2 * 2
|
||||||
|
# self._hw_channels = 2 if len(data) == expected_stereo else 1
|
||||||
|
self._hw_channels = self._actual_channels
|
||||||
|
logging.info("PyALSA first read: bytes=%d detected_hw_channels=%d requested_channels=%d",
|
||||||
|
len(data), self._hw_channels, self._actual_channels)
|
||||||
|
self._first_read = False
|
||||||
|
|
||||||
# adapt = n_available > 20
|
if self._hw_channels == 2 and self._actual_channels == 1:
|
||||||
# if adapt:
|
pcm_stereo = np.frombuffer(data, dtype=np.int16)
|
||||||
# pcm_extra, overflowed = self._stream.read(3)
|
pcm_mono = pcm_stereo[::2]
|
||||||
# logging.info('consuming extra samples, available was %d', n_available)
|
data = pcm_mono.tobytes()
|
||||||
# if overflowed:
|
|
||||||
# logging.warning("SoundDeviceAudioInput: overflowed")
|
|
||||||
|
|
||||||
# out = bytes(pcm_buffer) + bytes(pcm_extra)
|
actual_samples = len(data) // (2 * self._actual_channels)
|
||||||
# else:
|
ratio = frame_size / actual_samples
|
||||||
out = bytes(pcm_buffer)
|
pcm_f32 = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
if self._actual_channels > 1:
|
||||||
|
pcm_f32 = pcm_f32.reshape(-1, self._actual_channels)
|
||||||
|
resampled = self._resampler.process(pcm_f32, ratio, end_of_input=False)
|
||||||
|
if self._actual_channels > 1:
|
||||||
|
resampled = resampled.reshape(-1)
|
||||||
|
self._resampler_buffer = np.concatenate([self._resampler_buffer, resampled])
|
||||||
|
else:
|
||||||
|
logging.warning("PyALSA: No data read from ALSA")
|
||||||
|
self._resampler_buffer = np.concatenate([
|
||||||
|
self._resampler_buffer,
|
||||||
|
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
|
||||||
|
])
|
||||||
|
except alsaaudio.ALSAAudioError as e:
|
||||||
|
logging.error("PyALSA: ALSA read error: %s", e)
|
||||||
|
self._resampler_buffer = np.concatenate([
|
||||||
|
self._resampler_buffer,
|
||||||
|
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
|
||||||
|
])
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("PyALSA: Unexpected error in _read: %s", e, exc_info=True)
|
||||||
|
self._resampler_buffer = np.concatenate([
|
||||||
|
self._resampler_buffer,
|
||||||
|
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
|
||||||
|
])
|
||||||
|
|
||||||
self.max_avail = max(self.max_avail, n_available)
|
needed = frame_size * self._actual_channels
|
||||||
|
if len(self._resampler_buffer) < needed:
|
||||||
|
pad = np.zeros(needed - len(self._resampler_buffer), dtype=np.float32)
|
||||||
|
self._resampler_buffer = np.concatenate([self._resampler_buffer, pad])
|
||||||
|
logging.debug("PyALSA: padded buffer with %d samples", needed - len(self._resampler_buffer))
|
||||||
|
|
||||||
#Diagnostics
|
output = self._resampler_buffer[:needed]
|
||||||
#with open(self.logfile_name, "a", encoding="utf-8") as f:
|
self._resampler_buffer = self._resampler_buffer[needed:]
|
||||||
# f.write(f"{n_available}, {adapt}, {round(self._runavg, 2)}, {overflowed}\n")
|
|
||||||
|
|
||||||
if self.counter % 500 == 0:
|
logging.debug("PyALSA: resampler_buffer remaining=%d", len(self._resampler_buffer))
|
||||||
logging.info(
|
return np.clip(output * 32767.0, -32768, 32767).astype(np.int16).tobytes()
|
||||||
"read available=%d, max=%d, latency:%d",
|
|
||||||
n_available, self.max_avail, self._stream.latency
|
|
||||||
)
|
|
||||||
self.max_avail = 0
|
|
||||||
|
|
||||||
self.counter += 1
|
|
||||||
return out
|
|
||||||
|
|
||||||
audio_io.SoundDeviceAudioInput = ModSoundDeviceAudioInput
|
|
||||||
|
def _close(self) -> None:
|
||||||
|
if self._pcm:
|
||||||
|
self._pcm.close()
|
||||||
|
self._pcm = None
|
||||||
|
|
||||||
|
audio_io.SoundDeviceAudioInput = PyAlsaAudioInput
|
||||||
|
|
||||||
# modified from bumble
|
# modified from bumble
|
||||||
class ModWaveAudioInput(audio_io.ThreadedAudioInput):
|
class ModWaveAudioInput(audio_io.ThreadedAudioInput):
|
||||||
@@ -538,7 +589,7 @@ async def init_broadcast(
|
|||||||
|
|
||||||
def on_flow():
|
def on_flow():
|
||||||
data_packet_queue = iso_queue.data_packet_queue
|
data_packet_queue = iso_queue.data_packet_queue
|
||||||
print(
|
logging.info(
|
||||||
f'\rPACKETS: pending={data_packet_queue.pending}, '
|
f'\rPACKETS: pending={data_packet_queue.pending}, '
|
||||||
f'queued={data_packet_queue.queued}, '
|
f'queued={data_packet_queue.queued}, '
|
||||||
f'completed={data_packet_queue.completed}',
|
f'completed={data_packet_queue.completed}',
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ async def main():
|
|||||||
level=os.environ.get('LOG_LEVEL', logging.DEBUG),
|
level=os.environ.get('LOG_LEVEL', logging.DEBUG),
|
||||||
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Enable debug logging for bumble
|
||||||
|
# logging.getLogger('bumble').setLevel(logging.DEBUG)
|
||||||
|
|
||||||
os.chdir(os.path.dirname(__file__))
|
os.chdir(os.path.dirname(__file__))
|
||||||
|
|
||||||
global_conf = auracast_config.AuracastGlobalConfig(
|
global_conf = auracast_config.AuracastGlobalConfig(
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ def _led_off():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Configure bumble debug logging
|
||||||
|
# log.getLogger('bumble').setLevel(log.DEBUG)
|
||||||
|
|
||||||
# make sure pipewire sets latency
|
# make sure pipewire sets latency
|
||||||
# Primary and secondary persisted settings files
|
# Primary and secondary persisted settings files
|
||||||
STREAM_SETTINGS_FILE1 = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
|
STREAM_SETTINGS_FILE1 = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
|
||||||
@@ -439,18 +443,15 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
|||||||
if is_stereo and sel == 'ch1':
|
if is_stereo and sel == 'ch1':
|
||||||
# Stereo mode: use ALSA directly to capture both channels from hardware
|
# Stereo mode: use ALSA directly to capture both channels from hardware
|
||||||
# ch1=left (channel 0), ch2=right (channel 1)
|
# ch1=left (channel 0), ch2=right (channel 1)
|
||||||
big.audio_source = 'alsa:hw:CARD=i2s,DEV=0'
|
big.audio_source = 'device:hw:2'
|
||||||
big.input_format = f"int16le,{hardware_capture_rate},2"
|
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")
|
log.info("Configured analog stereo input: using ALSA hw:CARD=i2s,DEV=0 with ch1=left, ch2=right")
|
||||||
elif is_stereo and sel == 'ch2':
|
elif is_stereo and sel == 'ch2':
|
||||||
# Skip ch2 in stereo mode as it's already captured as part of stereo pair
|
# Skip ch2 in stereo mode as it's already captured as part of stereo pair
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
# Mono mode: individual channel capture
|
# Mono mode: use dsnoop virtual device directly (ch1=left, ch2=right)
|
||||||
device_index = resolve_input_device_index(sel)
|
big.audio_source = f'device:{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"
|
big.input_format = f"int16le,{hardware_capture_rate},1"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1007,7 +1008,7 @@ async def _startup_autostart_event():
|
|||||||
_led_off()
|
_led_off()
|
||||||
|
|
||||||
# Run install_asoundconf.sh script
|
# Run install_asoundconf.sh script
|
||||||
script_path = os.path.join(os.path.dirname(__file__), '..', 'misc', 'install_asoundconf.sh')
|
script_path = os.path.join(os.path.dirname(__file__), '..', '..', 'misc', 'install_asoundconf.sh')
|
||||||
try:
|
try:
|
||||||
log.info("[STARTUP] Running install_asoundconf.sh script")
|
log.info("[STARTUP] Running install_asoundconf.sh script")
|
||||||
result = subprocess.run(['bash', script_path], capture_output=True, text=True, check=True)
|
result = subprocess.run(['bash', script_path], capture_output=True, text=True, check=True)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ pcm.ch1 {
|
|||||||
channels 2
|
channels 2
|
||||||
rate 48000
|
rate 48000
|
||||||
format S16_LE
|
format S16_LE
|
||||||
period_size 120
|
period_size 240
|
||||||
buffer_size 240
|
buffer_size 960
|
||||||
}
|
}
|
||||||
bindings.0 0
|
bindings.0 0
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,8 @@ pcm.ch2 {
|
|||||||
channels 2
|
channels 2
|
||||||
rate 48000
|
rate 48000
|
||||||
format S16_LE
|
format S16_LE
|
||||||
period_size 120
|
period_size 240
|
||||||
buffer_size 240
|
buffer_size 960
|
||||||
}
|
}
|
||||||
bindings.0 1
|
bindings.0 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
sudo cp src/misc/asound.conf /etc/asound.conf
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
sudo cp "$SCRIPT_DIR/asound.conf" /etc/asound.conf
|
||||||
0
src/service/update_and_run_server_and_frontend.sh
Normal file → Executable file
0
src/service/update_and_run_server_and_frontend.sh
Normal file → Executable file
Reference in New Issue
Block a user