From a6051956461b7f10f62ca1d6984a1f53433f9f72 Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Wed, 18 Mar 2026 16:55:55 +0100 Subject: [PATCH 1/9] First good audio with alsaaudio. --- pyproject.toml | 3 +- src/auracast/multicast.py | 172 +++++++++++++++++++++----------------- 2 files changed, 96 insertions(+), 79 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e93c66d..8c9d8be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ dependencies = [ "sounddevice (>=0.5.2,<0.6.0)", "python-dotenv (>=1.1.1,<2.0.0)", "smbus2 (>=0.5.0,<0.6.0)", - "samplerate (>=0.2.2,<0.3.0)" + "samplerate (>=0.2.2,<0.3.0)", + "pyalsaaudio (>=0.9.0,<1.0.0)" ] [project.optional-dependencies] diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index d9045a9..8a77d3c 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -56,7 +56,7 @@ from auracast.utils.webrtc_audio_input import WebRTCAudioInput # Patch sounddevice.InputStream globally to use low-latency settings -import sounddevice as sd +import alsaaudio from collections import deque @@ -139,96 +139,112 @@ class AlsaArecordAudioInput(audio_io.AudioInput): self._proc = None -class ModSoundDeviceAudioInput(audio_io.SoundDeviceAudioInput): - """Patched SoundDeviceAudioInput with low-latency capture and adaptive resampling.""" +class PyAlsaAudioInput(audio_io.ThreadedAudioInput): + """PyALSA audio input with callback thread and ring buffer.""" - def _open(self): - """Create RawInputStream with low-latency parameters and initialize ring buffer.""" - dev_info = sd.query_devices(self._device) - hostapis = sd.query_hostapis() - api_index = dev_info.get('hostapi') - api_name = hostapis[api_index]['name'] if isinstance(api_index, int) and 0 <= api_index < len(hostapis) else 'unknown' - pa_ver = sd.get_portaudio_version() + def __init__(self, device, pcm_format: audio_io.PcmFormat): + super().__init__() + self._device = str(device) if not isinstance(device, str) else device + if self._device.isdigit(): + self._device = 'default' if self._device == '0' else f'hw:{self._device}' + self._pcm_format = pcm_format + self._pcm = None + self._ring_buffer = deque() + 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) - logging.info( - "SoundDevice backend=%s device='%s' (id=%s) ch=%s default_low_input_latency=%.4f default_high_input_latency=%.4f portaudio=%s", - api_name, - dev_info.get('name'), - 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 - self.max_avail=0 - self.logfile_name="available_samples.txt" - self.blocksize = 120 - - if os.path.exists(self.logfile_name): - os.remove(self.logfile_name) - - self._stream = sd.RawInputStream( - samplerate=self._pcm_format.sample_rate, + def _open(self) -> audio_io.PcmFormat: + requested_rate = int(self._pcm_format.sample_rate) + + self._pcm = alsaaudio.PCM( + type=alsaaudio.PCM_CAPTURE, + mode=alsaaudio.PCM_NORMAL, device=self._device, - channels=self._pcm_format.channels, - dtype='int16', - blocksize=self.blocksize, - latency=0.004, ) - self._stream.start() - + + self._pcm.setchannels(1) + self._pcm.setformat(alsaaudio.PCM_FORMAT_S16_LE) + actual_rate = self._pcm.setrate(requested_rate) + self._pcm.setperiodsize(240) + + logging.info("PyALSA: device=%s requested=%d actual=%d periodsize=240 (5ms)", + self._device, requested_rate, actual_rate) + + if actual_rate != requested_rate: + logging.warning("PyALSA: Sample rate mismatch! requested=%d actual=%d", requested_rate, actual_rate) + + self._running = True + self._callback_thread = threading.Thread(target=self._capture_loop, daemon=True) + self._callback_thread.start() + return audio_io.PcmFormat( audio_io.PcmFormat.Endianness.LITTLE, audio_io.PcmFormat.SampleType.INT16, - self._pcm_format.sample_rate, + actual_rate, 1, ) + def _capture_loop(self): + first_read = True + 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) + 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") + pcm_stereo = np.frombuffer(data, dtype=np.int16) + pcm_mono = pcm_stereo[::2] # Take only left channel + data = pcm_mono.tobytes() + + with self._ring_lock: + self._ring_buffer.append(data) + total_bytes = sum(len(chunk) for chunk in self._ring_buffer) + while total_bytes > self._max_buffer_bytes: + self._ring_buffer.popleft() + total_bytes = sum(len(chunk) for chunk in self._ring_buffer) + except: + if self._running: + break + def _read(self, frame_size: int) -> bytes: - """Read PCM samples from the stream.""" + bytes_needed = frame_size * 2 + result = b'' + + while len(result) < bytes_needed: + with self._ring_lock: + if self._ring_buffer: + chunk = self._ring_buffer.popleft() + needed = bytes_needed - len(result) + if len(chunk) <= needed: + result += chunk + else: + result += chunk[:needed] + self._ring_buffer.appendleft(chunk[needed:]) + else: + break + + if len(result) < bytes_needed: + result += b'\x00' * (bytes_needed - len(result)) + + return result - #if self.counter % 50 == 0: - frame_size = frame_size + 1 # consume samples a little faster to avoid latency akkumulation + def _close(self) -> None: + self._running = False + if self._callback_thread: + self._callback_thread.join(timeout=1.0) + if self._pcm: + self._pcm.close() + self._pcm = None - pcm_buffer, overflowed = self._stream.read(frame_size) - if overflowed: - logging.warning("SoundDeviceAudioInput: overflowed") - - n_available = self._stream.read_available - - # adapt = n_available > 20 - # if adapt: - # pcm_extra, overflowed = self._stream.read(3) - # logging.info('consuming extra samples, available was %d', n_available) - # if overflowed: - # logging.warning("SoundDeviceAudioInput: overflowed") - - # out = bytes(pcm_buffer) + bytes(pcm_extra) - # else: - out = bytes(pcm_buffer) - - self.max_avail = max(self.max_avail, n_available) - - #Diagnostics - #with open(self.logfile_name, "a", encoding="utf-8") as f: - # f.write(f"{n_available}, {adapt}, {round(self._runavg, 2)}, {overflowed}\n") - - if self.counter % 500 == 0: - logging.info( - "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 +audio_io.SoundDeviceAudioInput = PyAlsaAudioInput # modified from bumble class ModWaveAudioInput(audio_io.ThreadedAudioInput): From 1687a2b7902c4493f6af6a66e4526f46736fe82d Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Wed, 18 Mar 2026 17:37:34 +0100 Subject: [PATCH 2/9] 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 From cdfecaf5ebd1a6c29d72d91a998ee420d3902320 Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Tue, 24 Mar 2026 13:14:56 +0100 Subject: [PATCH 3/9] delay method wip save to test no thread method. --- src/auracast/multicast.py | 119 ++++++++++++++++++------ src/auracast/multicast_control.py | 4 + src/auracast/server/multicast_server.py | 4 + 3 files changed, 98 insertions(+), 29 deletions(-) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index f680a27..60c959f 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -155,11 +155,13 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): self._callback_thread = None self._actual_channels = None self._periodsize = None + self._buffer_log_last_ts = time.monotonic() + self._buffer_log_max_last_sec = 0 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_PERIODSIZE = 96 # 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) # =========================================== @@ -168,22 +170,19 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): 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._max_buffer_bytes = ALSA_PERIODSIZE * 60 * 2 * requested_channels self._pcm = alsaaudio.PCM( type=alsaaudio.PCM_CAPTURE, mode=alsaaudio.PCM_NORMAL, device=self._device, + periods=ALSA_PERIODS, ) 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) - try: - self._pcm.setperiods(ALSA_PERIODS) - except AttributeError: - pass # Some pyalsaaudio versions don't have setperiods() ring_buf_samples = self._max_buffer_bytes // (2 * requested_channels) ring_buf_ms = (ring_buf_samples / actual_rate) * 1000 @@ -229,39 +228,101 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): with self._ring_lock: self._ring_buffer.append(data) - total_bytes = sum(len(chunk) for chunk in self._ring_buffer) - while total_bytes > self._max_buffer_bytes: - self._ring_buffer.popleft() - total_bytes = sum(len(chunk) for chunk in self._ring_buffer) + # total_bytes = sum(len(chunk) for chunk in self._ring_buffer) + # # logging.info("Ringbuffer: bytes=%d", total_bytes) + # while total_bytes > self._max_buffer_bytes: + # self._ring_buffer.popleft() + # logging.error("Ringbuffer: OVERFLOW") + # total_bytes = sum(len(chunk) for chunk in self._ring_buffer) except: if self._running: break + # def _read(self, frame_size: int) -> bytes: + # bytes_needed = frame_size * 2 + # result = b'' + + # buffer_not_empty = True + # while (len(result) < bytes_needed) and buffer_not_empty: + # with self._ring_lock: + # buffer_size = sum(len(chunk) for chunk in self._ring_buffer) + # self._buffer_log_max_last_sec = max(self._buffer_log_max_last_sec, buffer_size) + # now = time.monotonic() + # if now - self._buffer_log_last_ts >= 1.0: + # logging.info( + # "Buffer size (bytes): current=%d max_last_sec=%d", + # buffer_size, + # self._buffer_log_max_last_sec, + # ) + # self._buffer_log_last_ts = now + # self._buffer_log_max_last_sec = 0 + # if self._ring_buffer and buffer_size > bytes_needed : + # chunk = self._ring_buffer.popleft() + # needed = bytes_needed - len(result) + # if len(chunk) <= needed: + # result += chunk + # else: + # result += chunk[:needed] + # self._ring_buffer.appendleft(chunk[needed:]) + # else: + # # 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 + # time.sleep(0.001) # 0.1ms + + # return result + def _read(self, frame_size: int) -> bytes: bytes_needed = frame_size * 2 result = b'' - - while len(result) < bytes_needed: - with self._ring_lock: - if self._ring_buffer: - chunk = self._ring_buffer.popleft() - needed = bytes_needed - len(result) - if len(chunk) <= needed: - result += chunk - else: - result += chunk[:needed] - self._ring_buffer.appendleft(chunk[needed:]) + + if self._ring_buffer: + buffer_size = sum(len(chunk) for chunk in self._ring_buffer) + else: + buffer_size = 0 + buffer_not_empty = (buffer_size != 0) + with self._ring_lock: + while (len(result) < bytes_needed) and buffer_not_empty: + chunk = self._ring_buffer.popleft() + needed = bytes_needed - len(result) + if len(chunk) <= needed: + result += chunk else: - # Ring buffer empty - release lock and wait a bit - pass + result += chunk[:needed] + self._ring_buffer.appendleft(chunk[needed:]) + if self._ring_buffer: + buffer_size = sum(len(chunk) for chunk in self._ring_buffer) + self._buffer_log_max_last_sec = max(self._buffer_log_max_last_sec, buffer_size) + now = time.monotonic() + if now - self._buffer_log_last_ts >= 1.0: + logging.info( + "Buffer size (bytes): current=%d max_last_sec=%d", + buffer_size, + self._buffer_log_max_last_sec, + ) + self._buffer_log_last_ts = now + self._buffer_log_max_last_sec = 0 + else: + buffer_size = 0 + buffer_not_empty = (buffer_size != 0) + #append to bytesneeded + if len(result) < bytes_needed: + result += b'\x00' * (bytes_needed - len(result)) - 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 + # def _read(self, frame_size: int) -> bytes: + # bytes_needed = frame_size * 2 + # result = b'' + # # Generate 500Hz sine wave + # samples = [] + # for i in range(frame_size): + # sample = int(np.sin(2 * np.pi * 500 * i / 48000) * 32767) + # samples.append(sample) + # return struct.pack('h' * len(samples), *samples) + def _close(self) -> None: self._running = False if self._callback_thread: @@ -580,7 +641,7 @@ async def init_broadcast( def on_flow(): data_packet_queue = iso_queue.data_packet_queue - print( + logging.info( f'\rPACKETS: pending={data_packet_queue.pending}, ' f'queued={data_packet_queue.queued}, ' f'completed={data_packet_queue.completed}', diff --git a/src/auracast/multicast_control.py b/src/auracast/multicast_control.py index c34f3a5..38a0a2c 100644 --- a/src/auracast/multicast_control.py +++ b/src/auracast/multicast_control.py @@ -143,6 +143,10 @@ async def main(): level=os.environ.get('LOG_LEVEL', logging.DEBUG), 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__)) global_conf = auracast_config.AuracastGlobalConfig( diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 7a05a1b..4852627 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -26,6 +26,10 @@ from auracast.utils.sounddevice_utils import ( ) load_dotenv() + +# Configure bumble debug logging +# log.getLogger('bumble').setLevel(log.DEBUG) + # make sure pipewire sets latency # Primary and secondary persisted settings files STREAM_SETTINGS_FILE1 = os.path.join(os.path.dirname(__file__), 'stream_settings.json') From cf69ad2957727db707680ef86a65e14187678e10 Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Mon, 30 Mar 2026 14:45:25 +0200 Subject: [PATCH 4/9] 134ms constant delay, no build up, seems to be no glitches, bang bang control. --- src/auracast/multicast.py | 191 +++++++++++++------------------------- 1 file changed, 65 insertions(+), 126 deletions(-) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 60c959f..a8b9ade 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 - supports mono/stereo.""" + """PyALSA audio input with non-blocking reads - supports mono/stereo.""" def __init__(self, device, pcm_format: audio_io.PcmFormat): super().__init__() @@ -149,28 +149,18 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): self._device = 'default' if self._device == '0' else f'hw:{self._device}' self._pcm_format = pcm_format self._pcm = None - self._ring_buffer = deque() - self._ring_lock = threading.Lock() - self._running = False - self._callback_thread = None self._actual_channels = None self._periodsize = None - self._buffer_log_last_ts = time.monotonic() - self._buffer_log_max_last_sec = 0 + self._hw_channels = None + self._first_read = True def _open(self) -> audio_io.PcmFormat: - # ========== LATENCY CONFIGURATION ========== - # Adjust these parameters to tune latency vs stability - ALSA_PERIODSIZE = 96 # 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) - # =========================================== + ALSA_PERIODSIZE = 240 + ALSA_PERIODS = 4 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 * 60 * 2 * requested_channels self._pcm = alsaaudio.PCM( type=alsaaudio.PCM_CAPTURE, @@ -184,19 +174,16 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): actual_rate = self._pcm.setrate(requested_rate) self._pcm.setperiodsize(ALSA_PERIODSIZE) - 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)", + logging.info("PyALSA: device=%s rate=%d ch=%d periodsize=%d (%.1fms) periods=%d mode=NONBLOCK", self._device, actual_rate, requested_channels, ALSA_PERIODSIZE, - (ALSA_PERIODSIZE / actual_rate) * 1000, ALSA_PERIODS, ring_buf_samples, ring_buf_ms) + (ALSA_PERIODSIZE / actual_rate) * 1000, ALSA_PERIODS) 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() + + self._bang_bang = 0 return audio_io.PcmFormat( audio_io.PcmFormat.Endianness.LITTLE, @@ -205,128 +192,80 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): requested_channels, ) - def _capture_loop(self): - first_read = True - hw_channels = None - while self._running: + def _read(self, frame_size: int) -> bytes: + bytes_needed = frame_size * 2 * self._actual_channels + result = b'' + + try: + 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) + + 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 = 2 # TODO fix stereo detection, on first read might detect 0 data + 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: + logging.info("PyALSA: No data read from ALSA") + except alsaaudio.ALSAAudioError as e: + logging.info("PyALSA: ALSA read error: %s", e) + + + logging.info("PyALSA: result length=%d, frame_size=%d", len(result), frame_size) + + 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 first_read: + if self._first_read: 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 + 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 - # Convert stereo hardware to mono if needed - if hw_channels == 2 and self._actual_channels == 1: + 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() - with self._ring_lock: - self._ring_buffer.append(data) - # total_bytes = sum(len(chunk) for chunk in self._ring_buffer) - # # logging.info("Ringbuffer: bytes=%d", total_bytes) - # while total_bytes > self._max_buffer_bytes: - # self._ring_buffer.popleft() - # logging.error("Ringbuffer: OVERFLOW") - # total_bytes = sum(len(chunk) for chunk in self._ring_buffer) - except: - if self._running: + result += data + else: break - - # def _read(self, frame_size: int) -> bytes: - # bytes_needed = frame_size * 2 - # result = b'' - - # buffer_not_empty = True - # while (len(result) < bytes_needed) and buffer_not_empty: - # with self._ring_lock: - # buffer_size = sum(len(chunk) for chunk in self._ring_buffer) - # self._buffer_log_max_last_sec = max(self._buffer_log_max_last_sec, buffer_size) - # now = time.monotonic() - # if now - self._buffer_log_last_ts >= 1.0: - # logging.info( - # "Buffer size (bytes): current=%d max_last_sec=%d", - # buffer_size, - # self._buffer_log_max_last_sec, - # ) - # self._buffer_log_last_ts = now - # self._buffer_log_max_last_sec = 0 - # if self._ring_buffer and buffer_size > bytes_needed : - # chunk = self._ring_buffer.popleft() - # needed = bytes_needed - len(result) - # if len(chunk) <= needed: - # result += chunk - # else: - # result += chunk[:needed] - # self._ring_buffer.appendleft(chunk[needed:]) - # else: - # # 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 - # time.sleep(0.001) # 0.1ms + except alsaaudio.ALSAAudioError: + break - # return result - - def _read(self, frame_size: int) -> bytes: - bytes_needed = frame_size * 2 - result = b'' - - if self._ring_buffer: - buffer_size = sum(len(chunk) for chunk in self._ring_buffer) - else: - buffer_size = 0 - buffer_not_empty = (buffer_size != 0) - with self._ring_lock: - while (len(result) < bytes_needed) and buffer_not_empty: - chunk = self._ring_buffer.popleft() - needed = bytes_needed - len(result) - if len(chunk) <= needed: - result += chunk - else: - result += chunk[:needed] - self._ring_buffer.appendleft(chunk[needed:]) - if self._ring_buffer: - buffer_size = sum(len(chunk) for chunk in self._ring_buffer) - self._buffer_log_max_last_sec = max(self._buffer_log_max_last_sec, buffer_size) - now = time.monotonic() - if now - self._buffer_log_last_ts >= 1.0: - logging.info( - "Buffer size (bytes): current=%d max_last_sec=%d", - buffer_size, - self._buffer_log_max_last_sec, - ) - self._buffer_log_last_ts = now - self._buffer_log_max_last_sec = 0 - else: - buffer_size = 0 - buffer_not_empty = (buffer_size != 0) - #append to bytesneeded if len(result) < bytes_needed: result += b'\x00' * (bytes_needed - len(result)) - return result + return result[:bytes_needed] - # def _read(self, frame_size: int) -> bytes: - # bytes_needed = frame_size * 2 - # result = b'' - # # Generate 500Hz sine wave - # samples = [] - # for i in range(frame_size): - # sample = int(np.sin(2 * np.pi * 500 * i / 48000) * 32767) - # samples.append(sample) - # return struct.pack('h' * len(samples), *samples) + def _close(self) -> None: - self._running = False - if self._callback_thread: - self._callback_thread.join(timeout=1.0) if self._pcm: self._pcm.close() self._pcm = None From 3d59a6dabfc8479dbd67ed6adbf9edf27b8924ae Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Wed, 1 Apr 2026 14:00:26 +0200 Subject: [PATCH 5/9] 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 From e818765b4fde021eed4960d89711ccf224365414 Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Thu, 2 Apr 2026 17:37:38 +0200 Subject: [PATCH 6/9] Adds sw_pyalsaaudio repo so our custom function works. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8c9d8be..e081b68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "python-dotenv (>=1.1.1,<2.0.0)", "smbus2 (>=0.5.0,<0.6.0)", "samplerate (>=0.2.2,<0.3.0)", - "pyalsaaudio (>=0.9.0,<1.0.0)" + "pyalsaaudio @ git+ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git@b3d11582e03df6929b2e7acbaa1306afc7b8a6bc" ] [project.optional-dependencies] From 036b5f80ddc4eb4445544c6ab93e93a50642b128 Mon Sep 17 00:00:00 2001 From: pober Date: Thu, 2 Apr 2026 18:10:23 +0200 Subject: [PATCH 7/9] Updates poetry lock. --- poetry.lock | 63 +++++++++++++++-------------------------------------- 1 file changed, 17 insertions(+), 46 deletions(-) diff --git a/poetry.lock b/poetry.lock index c609b04..a77d5ce 100644 --- a/poetry.lock +++ b/poetry.lock @@ -270,51 +270,6 @@ optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "av-14.4.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:10219620699a65b9829cfa08784da2ed38371f1a223ab8f3523f440a24c8381c"}, - {file = "av-14.4.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:8bac981fde1c05e231df9f73a06ed9febce1f03fb0f1320707ac2861bba2567f"}, - {file = "av-14.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc634ed5bdeb362f0523b73693b079b540418d35d7f3003654f788ae6c317eef"}, - {file = "av-14.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23973ed5c5bec9565094d2b3643f10a6996707ddffa5252e112d578ad34aa9ae"}, - {file = "av-14.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0655f7207db6a211d7cedb8ac6a2f7ccc9c4b62290130e393a3fd99425247311"}, - {file = "av-14.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1edaab73319bfefe53ee09c4b1cf7b141ea7e6678a0a1c62f7bac1e2c68ec4e7"}, - {file = "av-14.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b54838fa17c031ffd780df07b9962fac1be05220f3c28468f7fe49474f1bf8d2"}, - {file = "av-14.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f4b59ac6c563b9b6197299944145958a8ec34710799fd851f1a889b0cbcd1059"}, - {file = "av-14.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a0192a584fae9f6cedfac03c06d5bf246517cdf00c8779bc33414404796a526e"}, - {file = "av-14.4.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b21d5586a88b9fce0ab78e26bd1c38f8642f8e2aad5b35e619f4d202217c701"}, - {file = "av-14.4.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:cf8762d90b0f94a20c9f6e25a94f1757db5a256707964dfd0b1d4403e7a16835"}, - {file = "av-14.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0ac9f08920c7bbe0795319689d901e27cb3d7870b9a0acae3f26fc9daa801a6"}, - {file = "av-14.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a56d9ad2afdb638ec0404e962dc570960aae7e08ae331ad7ff70fbe99a6cf40e"}, - {file = "av-14.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bed513cbcb3437d0ae47743edc1f5b4a113c0b66cdd4e1aafc533abf5b2fbf2"}, - {file = "av-14.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d030c2d3647931e53d51f2f6e0fcf465263e7acf9ec6e4faa8dbfc77975318c3"}, - {file = "av-14.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1cc21582a4f606271d8c2036ec7a6247df0831050306c55cf8a905701d0f0474"}, - {file = "av-14.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce7c9cd452153d36f1b1478f904ed5f9ab191d76db873bdd3a597193290805d4"}, - {file = "av-14.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd261e31cc6b43ca722f80656c39934199d8f2eb391e0147e704b6226acebc29"}, - {file = "av-14.4.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a53e682b239dd23b4e3bc9568cfb1168fc629ab01925fdb2e7556eb426339e94"}, - {file = "av-14.4.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:5aa0b901751a32703fa938d2155d56ce3faf3630e4a48d238b35d2f7e49e5395"}, - {file = "av-14.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b316fed3597675fe2aacfed34e25fc9d5bb0196dc8c0b014ae5ed4adda48de"}, - {file = "av-14.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a587b5c5014c3c0e16143a0f8d99874e46b5d0c50db6111aa0b54206b5687c81"}, - {file = "av-14.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d53f75e8ac1ec8877a551c0db32a83c0aaeae719d05285281eaaba211bbc30"}, - {file = "av-14.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c8558cfde79dd8fc92d97c70e0f0fa8c94c7a66f68ae73afdf58598f0fe5e10d"}, - {file = "av-14.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:455b6410dea0ab2d30234ffb28df7d62ca3cdf10708528e247bec3a4cdcced09"}, - {file = "av-14.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1661efbe9d975f927b8512d654704223d936f39016fad2ddab00aee7c40f412c"}, - {file = "av-14.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbbeef1f421a3461086853d6464ad5526b56ffe8ccb0ab3fd0a1f121dfbf26ad"}, - {file = "av-14.4.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3d2aea7c602b105363903e4017103bc4b60336e7aff80e1c22e8b4ec09fd125f"}, - {file = "av-14.4.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:38c18f036aeb6dc9abf5e867d998c867f9ec93a5f722b60721fdffc123bbb2ae"}, - {file = "av-14.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58c1e18c8be73b6eada2d9ec397852ec74ebe51938451bdf83644a807189d6c8"}, - {file = "av-14.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4c32ff03a357feb030634f093089a73cb474b04efe7fbfba31f229cb2fab115"}, - {file = "av-14.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af31d16ae25964a6a02e09cc132b9decd5ee493c5dcb21bcdf0d71b2d6adbd59"}, - {file = "av-14.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9fb297009e528f4851d25f3bb2781b2db18b59b10aed10240e947b77c582fb7"}, - {file = "av-14.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:573314cb9eafec2827dc98c416c965330dc7508193adbccd281700d8673b9f0a"}, - {file = "av-14.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f82ab27ee57c3b80eb50a5293222307dfdc02f810ea41119078cfc85ea3cf9a8"}, - {file = "av-14.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f682003bbcaac620b52f68ff0e85830fff165dea53949e217483a615993ca20"}, - {file = "av-14.4.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8ff683777e0bb3601f7cfb4545dca25db92817585330b773e897e1f6f9d612f7"}, - {file = "av-14.4.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:fe372acf7b1814bc2b16d89161609db63f81dad88684da76d26dd32cd1c16f92"}, - {file = "av-14.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de869030eb8acfdfe39f39965de3a899dcde9b08df2db41f183c6166ca6f6d09"}, - {file = "av-14.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9117ed91fba6299b7d5233dd3e471770bab829f97e5a157f182761e9fb59254c"}, - {file = "av-14.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54e8f9209184098b7755e6250be8ffa48a8aa5b554a02555406120583da17373"}, - {file = "av-14.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:38ea51e62a014663caec7f621d6601cf269ef450f3c8705f5e3225e5623fd15d"}, - {file = "av-14.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d1d89842efe913448482573a253bd6955ce30a77f8a4cd04a1a3537cc919896"}, - {file = "av-14.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c3048e333da1367a2bca47e69593e10bc70f027d876adee9d1582c8cb818f36a"}, - {file = "av-14.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d6f25570d0782dd05640c7e1f71cb29857d94d915b5521a1e757ecae78a5a50"}, {file = "av-14.4.0.tar.gz", hash = "sha256:3ecbf803a7fdf67229c0edada0830d6bfaea4d10bfb24f0c3f4e607cd1064b42"}, ] @@ -1849,6 +1804,22 @@ files = [ {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]] name = "pyarrow" version = "20.0.0" @@ -2976,4 +2947,4 @@ test = ["pytest", "pytest-asyncio"] [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "3c9f92c7a5af40f98da9c7824d9c2a6f7eb809e91e43cfef4995761b2e887256" +content-hash = "7c3c5cf6a836a9d7705e3b120610d98912cfd228b9abe162e15e6bed5bcb44a1" From a1266137394e977ef1be73f7ede08889b23e93a9 Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Thu, 2 Apr 2026 18:55:10 +0200 Subject: [PATCH 8/9] First working version of two monos at the same time. --- src/auracast/multicast.py | 3 ++- src/auracast/server/multicast_server.py | 9 +++------ src/misc/asound.conf | 8 ++++---- src/misc/install_asoundconf.sh | 3 ++- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 614846f..e561a6f 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -145,6 +145,7 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): def __init__(self, device, pcm_format: audio_io.PcmFormat): super().__init__() + logging.info("PyALSA: device = %s", device) self._device = str(device) if not isinstance(device, str) else device if self._device.isdigit(): self._device = 'default' if self._device == '0' else f'hw:{self._device}' @@ -221,7 +222,7 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): 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 = 2 # TODO fix stereo detection, on first read might detect 0 data + 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 diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index 4852627..fad7a42 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -398,11 +398,8 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, # Skip ch2 in stereo mode as it's already captured as part of stereo pair continue else: - # Mono mode: individual channel capture - device_index = resolve_input_device_index(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}' + # Mono mode: use dsnoop virtual device directly (ch1=left, ch2=right) + big.audio_source = f'device:{sel}' big.input_format = f"int16le,{hardware_capture_rate},1" continue @@ -944,7 +941,7 @@ async def _startup_autostart_event(): log.info("[STARTUP] Auracast multicast server startup: initializing settings cache, I2C, and PipeWire cache") # 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: log.info("[STARTUP] Running install_asoundconf.sh script") result = subprocess.run(['bash', script_path], capture_output=True, text=True, check=True) diff --git a/src/misc/asound.conf b/src/misc/asound.conf index b9351ad..b8e7188 100644 --- a/src/misc/asound.conf +++ b/src/misc/asound.conf @@ -6,8 +6,8 @@ pcm.ch1 { channels 2 rate 48000 format S16_LE - period_size 120 - buffer_size 240 + period_size 240 + buffer_size 960 } bindings.0 0 } @@ -21,8 +21,8 @@ pcm.ch2 { channels 2 rate 48000 format S16_LE - period_size 120 - buffer_size 240 + period_size 240 + buffer_size 960 } bindings.0 1 } diff --git a/src/misc/install_asoundconf.sh b/src/misc/install_asoundconf.sh index fce13fc..e51b60d 100755 --- a/src/misc/install_asoundconf.sh +++ b/src/misc/install_asoundconf.sh @@ -1 +1,2 @@ -sudo cp src/misc/asound.conf /etc/asound.conf \ No newline at end of file +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +sudo cp "$SCRIPT_DIR/asound.conf" /etc/asound.conf \ No newline at end of file From 291d75b13792993e592e7e72c7e463095fe9fb3f Mon Sep 17 00:00:00 2001 From: Pbopbo Date: Tue, 7 Apr 2026 14:36:15 +0200 Subject: [PATCH 9/9] stereo seems to work, NEEDS RADIO FIRMWARE WITH 2 TX BUFFERS. --- src/auracast/multicast.py | 6 ++++++ src/auracast/server/multicast_server.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index e561a6f..75483d3 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -253,6 +253,12 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput): 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), + ]) needed = frame_size * self._actual_channels if len(self._resampler_buffer) < needed: diff --git a/src/auracast/server/multicast_server.py b/src/auracast/server/multicast_server.py index fad7a42..cc3cd89 100644 --- a/src/auracast/server/multicast_server.py +++ b/src/auracast/server/multicast_server.py @@ -391,7 +391,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup, if is_stereo and sel == 'ch1': # Stereo mode: use ALSA directly to capture both channels from hardware # 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" log.info("Configured analog stereo input: using ALSA hw:CARD=i2s,DEV=0 with ch1=left, ch2=right") elif is_stereo and sel == 'ch2':