feat: refactor audio input to use dedicated reader thread instead of per-frame executor

- Replaced per-frame `run_in_executor` calls with single background reader thread in `ThreadedAudioInput`
- Reader thread continuously calls `_read()` and enqueues data via `call_soon_threadsafe` to asyncio.Queue
- Reduces per-frame scheduling overhead and context-switch jitter while preserving async API
- Added thread lifecycle management: lazy start on first `frames()` call, graceful stop in `aclose()`
- Update
This commit is contained in:
pstruebi
2025-11-19 18:52:37 +01:00
parent 1bda74cf79
commit c681e4ce39
5 changed files with 182 additions and 21 deletions

View File

@@ -27,6 +27,7 @@ import sys
import wave
from concurrent.futures import ThreadPoolExecutor
from typing import TYPE_CHECKING, AsyncGenerator, BinaryIO
import threading
if TYPE_CHECKING:
@@ -406,6 +407,9 @@ class ThreadedAudioInput(AudioInput):
def __init__(self) -> None:
self._thread_pool = ThreadPoolExecutor(1)
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
self._reader_thread: threading.Thread | None = None
self._running: bool = False
self._loop: asyncio.AbstractEventLoop | None = None
@abc.abstractmethod
def _read(self, frame_size: int) -> bytes:
@@ -424,12 +428,46 @@ class ThreadedAudioInput(AudioInput):
)
async def frames(self, frame_size: int) -> AsyncGenerator[bytes]:
while pcm_sample := await asyncio.get_running_loop().run_in_executor(
self._thread_pool, self._read, frame_size
):
# Start a dedicated reader thread on first use to avoid per-frame
# run_in_executor overhead while preserving the same async API.
if not self._running:
self._running = True
self._loop = asyncio.get_running_loop()
def _reader() -> None:
try:
while self._running:
pcm_sample = self._read(frame_size)
if not pcm_sample:
# Propagate termination to the async generator.
if self._loop is not None:
self._loop.call_soon_threadsafe(
self._pcm_samples.put_nowait, b""
)
break
if self._loop is not None:
self._loop.call_soon_threadsafe(
self._pcm_samples.put_nowait, pcm_sample
)
except Exception:
logger.exception("ThreadedAudioInput reader thread failed")
self._reader_thread = threading.Thread(target=_reader, daemon=True)
self._reader_thread.start()
while True:
pcm_sample = await self._pcm_samples.get()
if not pcm_sample:
break
yield pcm_sample
async def aclose(self) -> None:
# Stop reader thread first so no more _read() calls are issued.
self._running = False
if self._reader_thread is not None:
self._reader_thread.join(timeout=1.0)
self._reader_thread = None
await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close)
self._thread_pool.shutdown()