134ms constant delay, no build up, seems to be no glitches, bang bang control.
This commit is contained in:
@@ -140,7 +140,7 @@ class AlsaArecordAudioInput(audio_io.AudioInput):
|
|||||||
|
|
||||||
|
|
||||||
class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
|
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):
|
def __init__(self, device, pcm_format: audio_io.PcmFormat):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -149,28 +149,18 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
|
|||||||
self._device = 'default' if self._device == '0' else f'hw:{self._device}'
|
self._device = 'default' if self._device == '0' else f'hw:{self._device}'
|
||||||
self._pcm_format = pcm_format
|
self._pcm_format = pcm_format
|
||||||
self._pcm = None
|
self._pcm = None
|
||||||
self._ring_buffer = deque()
|
|
||||||
self._ring_lock = threading.Lock()
|
|
||||||
self._running = False
|
|
||||||
self._callback_thread = None
|
|
||||||
self._actual_channels = None
|
self._actual_channels = None
|
||||||
self._periodsize = None
|
self._periodsize = None
|
||||||
self._buffer_log_last_ts = time.monotonic()
|
self._hw_channels = None
|
||||||
self._buffer_log_max_last_sec = 0
|
self._first_read = True
|
||||||
|
|
||||||
def _open(self) -> audio_io.PcmFormat:
|
def _open(self) -> audio_io.PcmFormat:
|
||||||
# ========== LATENCY CONFIGURATION ==========
|
ALSA_PERIODSIZE = 240
|
||||||
# Adjust these parameters to tune latency vs stability
|
ALSA_PERIODS = 4
|
||||||
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)
|
|
||||||
# ===========================================
|
|
||||||
|
|
||||||
requested_rate = int(self._pcm_format.sample_rate)
|
requested_rate = int(self._pcm_format.sample_rate)
|
||||||
requested_channels = int(self._pcm_format.channels)
|
requested_channels = int(self._pcm_format.channels)
|
||||||
self._periodsize = ALSA_PERIODSIZE
|
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(
|
self._pcm = alsaaudio.PCM(
|
||||||
type=alsaaudio.PCM_CAPTURE,
|
type=alsaaudio.PCM_CAPTURE,
|
||||||
@@ -184,19 +174,16 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
|
|||||||
actual_rate = self._pcm.setrate(requested_rate)
|
actual_rate = self._pcm.setrate(requested_rate)
|
||||||
self._pcm.setperiodsize(ALSA_PERIODSIZE)
|
self._pcm.setperiodsize(ALSA_PERIODSIZE)
|
||||||
|
|
||||||
ring_buf_samples = self._max_buffer_bytes // (2 * requested_channels)
|
logging.info("PyALSA: device=%s rate=%d ch=%d periodsize=%d (%.1fms) periods=%d mode=NONBLOCK",
|
||||||
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,
|
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:
|
if actual_rate != requested_rate:
|
||||||
logging.warning("PyALSA: Sample rate mismatch! requested=%d actual=%d", requested_rate, actual_rate)
|
logging.warning("PyALSA: Sample rate mismatch! requested=%d actual=%d", requested_rate, actual_rate)
|
||||||
|
|
||||||
self._actual_channels = requested_channels
|
self._actual_channels = requested_channels
|
||||||
self._running = True
|
|
||||||
self._callback_thread = threading.Thread(target=self._capture_loop, daemon=True)
|
self._bang_bang = 0
|
||||||
self._callback_thread.start()
|
|
||||||
|
|
||||||
return audio_io.PcmFormat(
|
return audio_io.PcmFormat(
|
||||||
audio_io.PcmFormat.Endianness.LITTLE,
|
audio_io.PcmFormat.Endianness.LITTLE,
|
||||||
@@ -205,128 +192,80 @@ class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
|
|||||||
requested_channels,
|
requested_channels,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _capture_loop(self):
|
def _read(self, frame_size: int) -> bytes:
|
||||||
first_read = True
|
bytes_needed = frame_size * 2 * self._actual_channels
|
||||||
hw_channels = None
|
result = b''
|
||||||
while self._running:
|
|
||||||
|
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:
|
try:
|
||||||
length, data = self._pcm.read()
|
length, data = self._pcm.read()
|
||||||
if length > 0:
|
if length > 0:
|
||||||
if first_read:
|
if self._first_read:
|
||||||
expected_mono = self._periodsize * 2
|
expected_mono = self._periodsize * 2
|
||||||
expected_stereo = self._periodsize * 2 * 2
|
expected_stereo = self._periodsize * 2 * 2
|
||||||
hw_channels = 2 if len(data) == expected_stereo else 1
|
self._hw_channels = 2 if len(data) == expected_stereo else 1
|
||||||
logging.info("PyALSA first capture: bytes=%d detected_hw_channels=%d requested_channels=%d",
|
logging.info("PyALSA first read: bytes=%d detected_hw_channels=%d requested_channels=%d",
|
||||||
len(data), hw_channels, self._actual_channels)
|
len(data), self._hw_channels, self._actual_channels)
|
||||||
first_read = False
|
self._first_read = False
|
||||||
|
|
||||||
# Convert stereo hardware to mono if needed
|
if self._hw_channels == 2 and self._actual_channels == 1:
|
||||||
if hw_channels == 2 and self._actual_channels == 1:
|
|
||||||
pcm_stereo = np.frombuffer(data, dtype=np.int16)
|
pcm_stereo = np.frombuffer(data, dtype=np.int16)
|
||||||
pcm_mono = pcm_stereo[::2]
|
pcm_mono = pcm_stereo[::2]
|
||||||
data = pcm_mono.tobytes()
|
data = pcm_mono.tobytes()
|
||||||
|
|
||||||
with self._ring_lock:
|
result += data
|
||||||
self._ring_buffer.append(data)
|
else:
|
||||||
# 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
|
break
|
||||||
|
except alsaaudio.ALSAAudioError:
|
||||||
# def _read(self, frame_size: int) -> bytes:
|
break
|
||||||
# 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''
|
|
||||||
|
|
||||||
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:
|
if len(result) < bytes_needed:
|
||||||
result += b'\x00' * (bytes_needed - len(result))
|
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:
|
def _close(self) -> None:
|
||||||
self._running = False
|
|
||||||
if self._callback_thread:
|
|
||||||
self._callback_thread.join(timeout=1.0)
|
|
||||||
if self._pcm:
|
if self._pcm:
|
||||||
self._pcm.close()
|
self._pcm.close()
|
||||||
self._pcm = None
|
self._pcm = None
|
||||||
|
|||||||
Reference in New Issue
Block a user