From 11daa43b1b898493d7118233c577ad7ee25321e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Wed, 28 Jan 2026 23:54:17 -0800 Subject: [PATCH] TTSService: resample _stream_audio_frames_from_iterator() input audio if needed --- src/pipecat/services/tts_service.py | 42 ++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/pipecat/services/tts_service.py b/src/pipecat/services/tts_service.py index e04c4b649..37aad0372 100644 --- a/src/pipecat/services/tts_service.py +++ b/src/pipecat/services/tts_service.py @@ -24,6 +24,7 @@ from typing import ( from loguru import logger +from pipecat.audio.utils import create_stream_resampler from pipecat.frames.frames import ( AggregatedTextFrame, AggregationType, @@ -202,6 +203,8 @@ class TTSService(AIService): ) self._text_filters = [text_filter] + self._resampler = create_stream_resampler() + self._stop_frame_task: Optional[asyncio.Task] = None self._stop_frame_queue: asyncio.Queue = asyncio.Queue() @@ -505,12 +508,40 @@ class TTSService(AIService): await self._stop_frame_queue.put(frame) async def _stream_audio_frames_from_iterator( - self, iterator: AsyncIterator[bytes], *, strip_wav_header: bool + self, + iterator: AsyncIterator[bytes], + *, + strip_wav_header: bool = False, + in_sample_rate: Optional[int] = None, ) -> AsyncGenerator[Frame, None]: + """Stream audio frames from an async byte iterator with optional resampling. + + For WAV data, use `strip_wav_header=True` to strip the header and + auto-detect the source sample rate. For raw PCM data, pass + `in_sample_rate` directly. Audio is resampled to `self.sample_rate` when + the source rate differs. + + Args: + iterator: Async iterator yielding audio bytes. + strip_wav_header: Strip WAV header and parse source sample rate from it. + in_sample_rate: Source sample rate for raw PCM data. Overrides + WAV-detected rate if both are provided. + + """ buffer = bytearray() + source_sample_rate = in_sample_rate need_to_strip_wav_header = strip_wav_header + + async def maybe_resample(audio: bytes) -> bytes: + if source_sample_rate and source_sample_rate != self.sample_rate: + return await self._resampler.resample(audio, source_sample_rate, self.sample_rate) + return audio + async for chunk in iterator: if need_to_strip_wav_header and chunk.startswith(b"RIFF"): + # Parse sample rate from WAV header (bytes 24-28, little-endian uint32). + if len(chunk) >= 44 and source_sample_rate is None: + source_sample_rate = int.from_bytes(chunk[24:28], "little") chunk = chunk[44:] need_to_strip_wav_header = False @@ -520,19 +551,18 @@ class TTSService(AIService): # Round to nearest even number. aligned_length = len(buffer) & ~1 # 111111111...11110 if aligned_length > 0: - aligned_chunk = buffer[:aligned_length] + aligned_chunk = await maybe_resample(bytes(buffer[:aligned_length])) buffer = buffer[aligned_length:] # keep any leftover byte if len(aligned_chunk) > 0: - frame = TTSAudioRawFrame(bytes(aligned_chunk), self.sample_rate, 1) - yield frame + yield TTSAudioRawFrame(aligned_chunk, self.sample_rate, 1) if len(buffer) > 0: # Make sure we don't need an extra padding byte. if len(buffer) % 2 == 1: buffer.extend(b"\x00") - frame = TTSAudioRawFrame(bytes(buffer), self.sample_rate, 1) - yield frame + audio = await maybe_resample(bytes(buffer)) + yield TTSAudioRawFrame(audio, self.sample_rate, 1) async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection): self._processing_text = False