fix: AzureTTSService punctuation spacing

This commit is contained in:
Mark Backman
2026-01-17 08:17:06 -05:00
parent f7d3e63063
commit 043403fe23
2 changed files with 43 additions and 7 deletions

1
changelog/3489.fixed.md Normal file
View File

@@ -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.

View File

@@ -277,6 +277,8 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
self._started = False self._started = False
self._first_chunk = True self._first_chunk = True
self._cumulative_audio_offset: float = 0.0 # Cumulative audio duration in seconds 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: def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics. """Check if this service can generate processing metrics.
@@ -346,9 +348,24 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
await self.cancel_task(self._word_processor_task) await self.cancel_task(self._word_processor_task)
self._word_processor_task = None 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): def _handle_word_boundary(self, evt):
"""Handle word boundary events from Azure SDK. """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: Args:
evt: SpeechSynthesisWordBoundaryEventArgs from Azure Speech SDK evt: SpeechSynthesisWordBoundaryEventArgs from Azure Speech SDK
containing word text and audio offset timing. containing word text and audio offset timing.
@@ -362,13 +379,23 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
# Add cumulative offset to get absolute timestamp across sentences # Add cumulative offset to get absolute timestamp across sentences
absolute_seconds = self._cumulative_audio_offset + sentence_relative_seconds absolute_seconds = self._cumulative_audio_offset + sentence_relative_seconds
# Queue word timestamp for async processing if not word:
# Use thread-safe queue since this is called from Azure SDK thread return
if word:
logger.trace(f"{self}: Word boundary - '{word}' at {absolute_seconds:.2f}s") # Check if this is punctuation-only
# Put in temporary queue - will be processed by async task is_punctuation = self._is_punctuation_only(word)
# Store as (word, timestamp_in_seconds) tuple
self._word_boundary_queue.put_nowait((word, absolute_seconds)) 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): async def _word_processor_task_handler(self):
"""Process word timestamps from the queue and call add_word_timestamps.""" """Process word timestamps from the queue and call add_word_timestamps."""
@@ -397,6 +424,12 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
Args: Args:
evt: Completion event from Azure Speech SDK. 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 # Update cumulative audio offset for next sentence
if evt.result and evt.result.audio_duration: if evt.result and evt.result.audio_duration:
self._cumulative_audio_offset += evt.result.audio_duration.total_seconds() self._cumulative_audio_offset += evt.result.audio_duration.total_seconds()
@@ -435,6 +468,8 @@ class AzureTTSService(WordTTSService, AzureBaseTTSService):
self._started = False self._started = False
self._first_chunk = True self._first_chunk = True
self._cumulative_audio_offset = 0.0 self._cumulative_audio_offset = 0.0
self._last_word = None
self._last_timestamp = None
async def flush_audio(self): async def flush_audio(self):
"""Flush any pending audio data.""" """Flush any pending audio data."""