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':