Merge pull request #226 from pipecat-ai/aleix/chunk-audio-output

transport: chunk longer audio frames
This commit is contained in:
Aleix Conchillo Flaqué
2024-06-12 13:28:28 +08:00
committed by GitHub
2 changed files with 28 additions and 19 deletions

View File

@@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
except system frames like `StartFrame`, `CancelFrame` or except system frames like `StartFrame`, `CancelFrame` or
`StartInterruptionFrame`. `StartInterruptionFrame`.
### Fixed
- Fixed an issue where `BaseOutputTransport` would send longer audio frames
preventing interruptions.
## [0.0.29] - 2024-06-07 ## [0.0.29] - 2024-06-07
### Added ### Added

View File

@@ -59,6 +59,12 @@ class BaseOutputTransport(FrameProcessor):
self._stopped_event = asyncio.Event() self._stopped_event = asyncio.Event()
self._is_interrupted = threading.Event() self._is_interrupted = threading.Event()
# We will send 10ms audio which is the smallest possible amount of audio
# we can send. If we receive long audio frames we will chunk them into
# 10ms. This will help with interruption handling.
self._audio_chunk_size = int(self._params.audio_out_sample_rate / 100) * \
self._params.audio_out_channels * 2
# Create push frame task. This is the task that will push frames in # Create push frame task. This is the task that will push frames in
# order. We also guarantee that all frames are pushed in the same task. # order. We also guarantee that all frames are pushed in the same task.
self._create_push_task() self._create_push_task()
@@ -121,7 +127,7 @@ class BaseOutputTransport(FrameProcessor):
# #
if isinstance(frame, StartFrame): if isinstance(frame, StartFrame):
await self.start(frame) await self.start(frame)
self._sink_queue.put_nowait(frame) await self.push_frame(frame, direction)
# EndFrame is managed in the queue handler. # EndFrame is managed in the queue handler.
elif isinstance(frame, CancelFrame): elif isinstance(frame, CancelFrame):
await self.stop() await self.stop()
@@ -131,6 +137,8 @@ class BaseOutputTransport(FrameProcessor):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
elif isinstance(frame, SystemFrame): elif isinstance(frame, SystemFrame):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
elif isinstance(frame, AudioRawFrame):
await self._handle_audio(frame)
else: else:
self._sink_queue.put_nowait(frame) self._sink_queue.put_nowait(frame)
@@ -151,24 +159,23 @@ class BaseOutputTransport(FrameProcessor):
elif isinstance(frame, StopInterruptionFrame): elif isinstance(frame, StopInterruptionFrame):
self._is_interrupted.clear() self._is_interrupted.clear()
async def _handle_audio(self, frame: AudioRawFrame):
audio = frame.audio
for i in range(0, len(audio), self._audio_chunk_size):
chunk = AudioRawFrame(audio[i: i + self._audio_chunk_size],
sample_rate=frame.sample_rate, num_channels=frame.num_channels)
self._sink_queue.put_nowait(chunk)
def _sink_thread_handler(self): def _sink_thread_handler(self):
# 10ms bytes
bytes_size_10ms = int(self._params.audio_out_sample_rate / 100) * \
self._params.audio_out_channels * 2
# We will send at least 100ms bytes.
smallest_write_size = bytes_size_10ms * 10
# Audio accumlation buffer # Audio accumlation buffer
buffer = bytearray() buffer = bytearray()
while self._running: while self._running:
try: try:
frame = self._sink_queue.get(timeout=1) frame = self._sink_queue.get(timeout=1)
if not self._is_interrupted.is_set(): if not self._is_interrupted.is_set():
if isinstance(frame, AudioRawFrame): if isinstance(frame, AudioRawFrame) and self._params.audio_out_enabled:
if self._params.audio_out_enabled: buffer.extend(frame.audio)
buffer.extend(frame.audio) buffer = self._maybe_send_audio(buffer)
buffer = self._send_audio_truncated(buffer, smallest_write_size)
elif isinstance(frame, ImageRawFrame) and self._params.camera_out_enabled: elif isinstance(frame, ImageRawFrame) and self._params.camera_out_enabled:
self._set_camera_image(frame) self._set_camera_image(frame)
elif isinstance(frame, SpriteFrame) and self._params.camera_out_enabled: elif isinstance(frame, SpriteFrame) and self._params.camera_out_enabled:
@@ -186,8 +193,6 @@ class BaseOutputTransport(FrameProcessor):
buffer = bytearray() buffer = bytearray()
if isinstance(frame, EndFrame): if isinstance(frame, EndFrame):
# Send all remaining audio before stopping (multiple of 10ms of audio).
self._send_audio_truncated(buffer, bytes_size_10ms)
future = asyncio.run_coroutine_threadsafe(self.stop(), self.get_event_loop()) future = asyncio.run_coroutine_threadsafe(self.stop(), self.get_event_loop())
future.result() future.result()
@@ -273,12 +278,11 @@ class BaseOutputTransport(FrameProcessor):
async def send_audio(self, frame: AudioRawFrame): async def send_audio(self, frame: AudioRawFrame):
await self.process_frame(frame, FrameDirection.DOWNSTREAM) await self.process_frame(frame, FrameDirection.DOWNSTREAM)
def _send_audio_truncated(self, buffer: bytearray, smallest_write_size: int) -> bytearray: def _maybe_send_audio(self, buffer: bytearray) -> bytearray:
try: try:
truncated_length: int = len(buffer) - (len(buffer) % smallest_write_size) if len(buffer) >= self._audio_chunk_size:
if truncated_length: self.write_raw_audio_frames(bytes(buffer[:self._audio_chunk_size]))
self.write_raw_audio_frames(bytes(buffer[:truncated_length])) buffer = buffer[self._audio_chunk_size:]
buffer = buffer[truncated_length:]
return buffer return buffer
except BaseException as e: except BaseException as e:
logger.error(f"Error writing audio frames: {e}") logger.error(f"Error writing audio frames: {e}")