From 3d59a6dabfc8479dbd67ed6adbf9edf27b8924ae Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Wed, 1 Apr 2026 14:00:26 +0200 Subject: [PATCH] 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. --- src/auracast/multicast.py | 98 ++++++++++--------- .../update_and_run_server_and_frontend.sh | 0 2 files changed, 50 insertions(+), 48 deletions(-) mode change 100644 => 100755 src/service/update_and_run_server_and_frontend.sh diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index a8b9ade..614846f 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -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), + ]) - - logging.info("PyALSA: result length=%d, frame_size=%d", len(result), frame_size) + 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)) - if len(result) < bytes_needed: - result += b'\x00' * (bytes_needed - len(result)) - - return result[:bytes_needed] + output = self._resampler_buffer[:needed] + self._resampler_buffer = self._resampler_buffer[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() diff --git a/src/service/update_and_run_server_and_frontend.sh b/src/service/update_and_run_server_and_frontend.sh old mode 100644 new mode 100755