From 1687a2b7902c4493f6af6a66e4526f46736fe82d Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Wed, 18 Mar 2026 17:37:34 +0100 Subject: [PATCH] Latency lowered. --- src/auracast/multicast.py | 60 ++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 8a77d3c..f680a27 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -140,7 +140,7 @@ class AlsaArecordAudioInput(audio_io.AudioInput): class PyAlsaAudioInput(audio_io.ThreadedAudioInput): - """PyALSA audio input with callback thread and ring buffer.""" + """PyALSA audio input with callback thread and ring buffer - supports mono/stereo.""" def __init__(self, device, pcm_format: audio_io.PcmFormat): super().__init__() @@ -153,10 +153,22 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): self._ring_lock = threading.Lock() self._running = False self._callback_thread = None - self._max_buffer_bytes = int(self._pcm_format.sample_rate * 0.1 * 2) + self._actual_channels = None + self._periodsize = None def _open(self) -> audio_io.PcmFormat: + # ========== LATENCY CONFIGURATION ========== + # Adjust these parameters to tune latency vs stability + ALSA_PERIODSIZE = 120 # Samples per ALSA read (240@48kHz = 5ms, 120 = 2.5ms, 96 = 2ms) + ALSA_PERIODS = 2 # Number of periods in ALSA buffer (lower = less latency, more risk of underrun) + # Ring buffer: keep only 3 periods max to minimize latency (safety margin only) + # =========================================== + requested_rate = int(self._pcm_format.sample_rate) + requested_channels = int(self._pcm_format.channels) + self._periodsize = ALSA_PERIODSIZE + # Max ring buffer = 3 periods worth of data (tight coupling, minimal latency) + self._max_buffer_bytes = ALSA_PERIODSIZE * 3 * 2 * requested_channels self._pcm = alsaaudio.PCM( type=alsaaudio.PCM_CAPTURE, @@ -164,17 +176,25 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): device=self._device, ) - self._pcm.setchannels(1) + self._pcm.setchannels(requested_channels) self._pcm.setformat(alsaaudio.PCM_FORMAT_S16_LE) actual_rate = self._pcm.setrate(requested_rate) - self._pcm.setperiodsize(240) + self._pcm.setperiodsize(ALSA_PERIODSIZE) + try: + self._pcm.setperiods(ALSA_PERIODS) + except AttributeError: + pass # Some pyalsaaudio versions don't have setperiods() - logging.info("PyALSA: device=%s requested=%d actual=%d periodsize=240 (5ms)", - self._device, requested_rate, actual_rate) + ring_buf_samples = self._max_buffer_bytes // (2 * requested_channels) + ring_buf_ms = (ring_buf_samples / actual_rate) * 1000 + logging.info("PyALSA: device=%s rate=%d ch=%d periodsize=%d (%.1fms) periods=%d ring_buf=%d samples (%.1fms)", + self._device, actual_rate, requested_channels, ALSA_PERIODSIZE, + (ALSA_PERIODSIZE / actual_rate) * 1000, ALSA_PERIODS, ring_buf_samples, ring_buf_ms) 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._running = True self._callback_thread = threading.Thread(target=self._capture_loop, daemon=True) self._callback_thread.start() @@ -183,25 +203,28 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): audio_io.PcmFormat.Endianness.LITTLE, audio_io.PcmFormat.SampleType.INT16, actual_rate, - 1, + requested_channels, ) def _capture_loop(self): first_read = True + hw_channels = None while self._running: try: length, data = self._pcm.read() if length > 0: if first_read: - expected_bytes = 240 * 2 # 240 frames * 2 bytes/sample for mono - logging.info("PyALSA first capture: length=%d bytes=%d expected=%d", length, len(data), expected_bytes) + expected_mono = self._periodsize * 2 + expected_stereo = self._periodsize * 2 * 2 + hw_channels = 2 if len(data) == expected_stereo else 1 + logging.info("PyALSA first capture: bytes=%d detected_hw_channels=%d requested_channels=%d", + len(data), hw_channels, self._actual_channels) first_read = False - # If we got stereo data (480 bytes instead of 240), downsample to mono - if len(data) == 960: # 240 frames * 2 channels * 2 bytes = stereo - logging.warning("PyALSA: Got stereo data, converting to mono") + # Convert stereo hardware to mono if needed + if hw_channels == 2 and self._actual_channels == 1: pcm_stereo = np.frombuffer(data, dtype=np.int16) - pcm_mono = pcm_stereo[::2] # Take only left channel + pcm_mono = pcm_stereo[::2] data = pcm_mono.tobytes() with self._ring_lock: @@ -229,10 +252,13 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): result += chunk[:needed] self._ring_buffer.appendleft(chunk[needed:]) else: - break - - if len(result) < bytes_needed: - result += b'\x00' * (bytes_needed - len(result)) + # Ring buffer empty - release lock and wait a bit + pass + + if len(result) < bytes_needed: + # Don't busy-wait - sleep briefly to let capture thread fill buffer + import time + time.sleep(0.0001) # 0.1ms return result