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