From 043403fe2354c3c76c33adee7fce90e55c1d1c46 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sat, 17 Jan 2026 08:17:06 -0500 Subject: [PATCH] fix: AzureTTSService punctuation spacing --- changelog/3489.fixed.md | 1 + src/pipecat/services/azure/tts.py | 49 ++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 changelog/3489.fixed.md diff --git a/changelog/3489.fixed.md b/changelog/3489.fixed.md new file mode 100644 index 000000000..0957a9084 --- /dev/null +++ b/changelog/3489.fixed.md @@ -0,0 +1 @@ +- Fixed `AzureTTSService` transcript formatting where punctuation appeared with extra spaces (e.g., "Hello !" instead of "Hello!"). Azure sends punctuation as separate word boundaries, which are now merged with preceding words to produce properly formatted transcripts across all languages. diff --git a/src/pipecat/services/azure/tts.py b/src/pipecat/services/azure/tts.py index 751320c19..bd355a214 100644 --- a/src/pipecat/services/azure/tts.py +++ b/src/pipecat/services/azure/tts.py @@ -277,6 +277,8 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): self._started = False self._first_chunk = True self._cumulative_audio_offset: float = 0.0 # Cumulative audio duration in seconds + self._last_word: Optional[str] = None # Track last word for punctuation merging + self._last_timestamp: Optional[float] = None # Track last timestamp def can_generate_metrics(self) -> bool: """Check if this service can generate processing metrics. @@ -346,9 +348,24 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): await self.cancel_task(self._word_processor_task) self._word_processor_task = None + def _is_punctuation_only(self, text: str) -> bool: + """Check if text consists only of punctuation and whitespace. + + Args: + text: Text to check. + + Returns: + True if text is only punctuation/whitespace, False otherwise. + """ + return text and all(not c.isalnum() for c in text) + def _handle_word_boundary(self, evt): """Handle word boundary events from Azure SDK. + Azure sends punctuation as separate word boundaries, which causes + spacing issues in the final transcript. This method merges punctuation + with the previous word to maintain proper formatting. + Args: evt: SpeechSynthesisWordBoundaryEventArgs from Azure Speech SDK containing word text and audio offset timing. @@ -362,13 +379,23 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): # Add cumulative offset to get absolute timestamp across sentences absolute_seconds = self._cumulative_audio_offset + sentence_relative_seconds - # Queue word timestamp for async processing - # Use thread-safe queue since this is called from Azure SDK thread - if word: - logger.trace(f"{self}: Word boundary - '{word}' at {absolute_seconds:.2f}s") - # Put in temporary queue - will be processed by async task - # Store as (word, timestamp_in_seconds) tuple - self._word_boundary_queue.put_nowait((word, absolute_seconds)) + if not word: + return + + # Check if this is punctuation-only + is_punctuation = self._is_punctuation_only(word) + + if is_punctuation and self._last_word is not None: + # Merge punctuation with the previous word (don't queue yet, more punctuation might follow) + self._last_word += word + else: + # This is a real word. First, queue any pending word from before. + if self._last_word is not None: + self._word_boundary_queue.put_nowait((self._last_word, self._last_timestamp)) + + # Now store this new word for next time + self._last_word = word + self._last_timestamp = absolute_seconds async def _word_processor_task_handler(self): """Process word timestamps from the queue and call add_word_timestamps.""" @@ -397,6 +424,12 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): Args: evt: Completion event from Azure Speech SDK. """ + # Flush any pending word before completing + if self._last_word is not None: + self._word_boundary_queue.put_nowait((self._last_word, self._last_timestamp)) + self._last_word = None + self._last_timestamp = None + # Update cumulative audio offset for next sentence if evt.result and evt.result.audio_duration: self._cumulative_audio_offset += evt.result.audio_duration.total_seconds() @@ -435,6 +468,8 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService): self._started = False self._first_chunk = True self._cumulative_audio_offset = 0.0 + self._last_word = None + self._last_timestamp = None async def flush_audio(self): """Flush any pending audio data."""