ASRC: Adds NONBLOCK read from ALSA buffer; controls the amount of frames in the ALSA buffer; Adds resampling to get rid of audio glitches; no latency buildup anymore.

This commit is contained in:
Pbopbo
2026-04-01 14:00:26 +02:00
parent cf69ad2957
commit 3d59a6dabf
2 changed files with 50 additions and 48 deletions

View File

@@ -30,6 +30,7 @@ import time
import threading
import numpy as np # for audio down-mix
import samplerate
import os
import lc3 # type: ignore # pylint: disable=E0401
@@ -153,10 +154,13 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
self._periodsize = None
self._hw_channels = None
self._first_read = True
self._resampler = None
self._resampler_buffer = np.empty(0, dtype=np.float32)
def _open(self) -> audio_io.PcmFormat:
ALSA_PERIODSIZE = 240
ALSA_PERIODS = 4
ALSA_MODE = alsaaudio.PCM_NONBLOCK
requested_rate = int(self._pcm_format.sample_rate)
requested_channels = int(self._pcm_format.channels)
@@ -164,7 +168,7 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
self._pcm = alsaaudio.PCM(
type=alsaaudio.PCM_CAPTURE,
mode=alsaaudio.PCM_NORMAL,
mode=ALSA_MODE,
device=self._device,
periods=ALSA_PERIODS,
)
@@ -174,14 +178,16 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
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=NONBLOCK",
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_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
@@ -193,14 +199,22 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
)
def _read(self, frame_size: int) -> bytes:
bytes_needed = frame_size * 2 * self._actual_channels
result = b''
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()
self._bang_bang = 1 if avail > 50 else 0
logging.info("PyALSA: read length=%d, data length=%d, avail=%d, bang_bang=%d", length, len(data), avail, self._bang_bang)
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
logging.debug("PyALSA: read length=%d, data length=%d, avail=%d, bang_bang=%d", length, len(data), avail, self._bang_bang)
if length > 0:
if self._first_read:
@@ -217,51 +231,39 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
pcm_mono = pcm_stereo[::2]
data = pcm_mono.tobytes()
result += data
actual_samples = len(data) // (2 * self._actual_channels)
ratio = frame_size / actual_samples
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.info("PyALSA: No data read from ALSA")
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.info("PyALSA: ALSA read error: %s", 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),
])
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))
logging.info("PyALSA: result length=%d, frame_size=%d", len(result), frame_size)
output = self._resampler_buffer[:needed]
self._resampler_buffer = self._resampler_buffer[needed:]
if len(result) < bytes_needed:
result += b'\x00' * (bytes_needed - len(result))
return result[:bytes_needed]
def _read2(self, frame_size: int) -> bytes:
bytes_needed = frame_size * 2 * self._actual_channels
result = b''
while len(result) < bytes_needed:
try:
length, data = self._pcm.read()
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
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
if self._hw_channels == 2 and self._actual_channels == 1:
pcm_stereo = np.frombuffer(data, dtype=np.int16)
pcm_mono = pcm_stereo[::2]
data = pcm_mono.tobytes()
result += data
else:
break
except alsaaudio.ALSAAudioError:
break
if len(result) < bytes_needed:
result += b'\x00' * (bytes_needed - len(result))
return result[:bytes_needed]
logging.debug("PyALSA: resampler_buffer remaining=%d", len(self._resampler_buffer))
return np.clip(output * 32767.0, -32768, 32767).astype(np.int16).tobytes()

0
src/service/update_and_run_server_and_frontend.sh Normal file → Executable file
View File