Nvidia Sagemaker Magpie TTS service
This commit is contained in:
0
src/pipecat/services/nvidia/sagemaker/__init__.py
Normal file
0
src/pipecat/services/nvidia/sagemaker/__init__.py
Normal file
452
src/pipecat/services/nvidia/sagemaker/tts.py
Normal file
452
src/pipecat/services/nvidia/sagemaker/tts.py
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024-2026, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""NVIDIA Magpie TTS service backed by an AWS SageMaker endpoint."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aioboto3
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
CancelFrame,
|
||||||
|
EndFrame,
|
||||||
|
ErrorFrame,
|
||||||
|
Frame,
|
||||||
|
InterruptionFrame,
|
||||||
|
StartFrame,
|
||||||
|
TTSAudioRawFrame,
|
||||||
|
)
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient
|
||||||
|
from pipecat.services.settings import TTSSettings
|
||||||
|
from pipecat.services.tts_service import InterruptibleTTSService, TTSService
|
||||||
|
from pipecat.utils.text.tts_text_sanitizer import sanitize_text_for_tts
|
||||||
|
from pipecat.utils.tracing.service_decorators import traced_tts
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NvidiaSageMakerTTSSettings(TTSSettings):
|
||||||
|
"""Settings for NvidiaSageMakerHTTPTTSService.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
voice: NIM voice name (e.g. ``Magpie-Multilingual.EN-US.Aria``).
|
||||||
|
language: BCP-47 language code passed to NIM (e.g. ``en-US``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
voice: str = "Magpie-Multilingual.EN-US.Aria"
|
||||||
|
language: str = "en-US"
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaSageMakerHTTPTTSService(TTSService):
|
||||||
|
"""NVIDIA Magpie TTS service that calls a SageMaker HTTP endpoint.
|
||||||
|
|
||||||
|
Sends each text segment to the wrapper's ``POST /invocations`` endpoint
|
||||||
|
as a JSON body and streams the raw PCM audio response back to bot
|
||||||
|
as :class:`TTSAudioRawFrame` frames.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
tts = NvidiaSageMakerHTTPTTSService(
|
||||||
|
endpoint_name=os.getenv("SAGEMAKER_MAGPIE_ENDPOINT_NAME"),
|
||||||
|
region=os.getenv("AWS_REGION", "us-west-2"),
|
||||||
|
settings=NvidiaSageMakerHTTPTTSService.Settings(
|
||||||
|
voice="Magpie-Multilingual.EN-US.Aria",
|
||||||
|
language="en-US",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
Settings = NvidiaSageMakerTTSSettings
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint_name: str,
|
||||||
|
region: str = "us-west-2",
|
||||||
|
sample_rate: int | None = None,
|
||||||
|
settings: NvidiaSageMakerTTSSettings | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Initialize the SageMaker HTTP TTS service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint_name: Name of the deployed SageMaker endpoint.
|
||||||
|
region: AWS region where the endpoint lives.
|
||||||
|
sample_rate: Output sample rate in Hz. Defaults to bot's pipeline rate.
|
||||||
|
params: Deprecated — use ``settings`` instead.
|
||||||
|
settings: Runtime-updatable settings (voice, language).
|
||||||
|
**kwargs: Forwarded to :class:`TTSService`.
|
||||||
|
"""
|
||||||
|
default_settings = self.Settings(model="magpie")
|
||||||
|
|
||||||
|
if settings is not None:
|
||||||
|
default_settings.apply_update(settings)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
push_start_frame=True,
|
||||||
|
push_stop_frames=True,
|
||||||
|
settings=default_settings,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._endpoint_name = endpoint_name
|
||||||
|
self._region = region
|
||||||
|
self._client = None
|
||||||
|
self._client_ctx = None
|
||||||
|
|
||||||
|
def can_generate_metrics(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def start(self, frame: StartFrame):
|
||||||
|
await super().start(frame)
|
||||||
|
session = aioboto3.Session(
|
||||||
|
aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
|
||||||
|
aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
|
||||||
|
region_name=self._region,
|
||||||
|
)
|
||||||
|
self._client_ctx = session.client("sagemaker-runtime")
|
||||||
|
self._client = await self._client_ctx.__aenter__()
|
||||||
|
logger.debug(f"{self}: connected to SageMaker endpoint '{self._endpoint_name}'")
|
||||||
|
|
||||||
|
async def _close_client(self):
|
||||||
|
if self._client_ctx is not None:
|
||||||
|
try:
|
||||||
|
await self._client_ctx.__aexit__(None, None, None)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"{self}: error closing SageMaker client: {e}")
|
||||||
|
self._client_ctx = None
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame):
|
||||||
|
await super().stop(frame)
|
||||||
|
await self._close_client()
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame):
|
||||||
|
await super().cancel(frame)
|
||||||
|
await self._close_client()
|
||||||
|
|
||||||
|
# ── Synthesis ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@traced_tts
|
||||||
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
|
"""Synthesize text via SageMaker and yield a single PCM audio frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to synthesize.
|
||||||
|
context_id: Pipecat audio context identifier.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
:class:`TTSAudioRawFrame` chunks of signed 16-bit mono PCM.
|
||||||
|
"""
|
||||||
|
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||||
|
|
||||||
|
text = sanitize_text_for_tts(text)
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"text": text,
|
||||||
|
"voice_name": self._settings.voice,
|
||||||
|
"language_code": self._settings.language,
|
||||||
|
"sample_rate_hz": self.sample_rate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self._client.invoke_endpoint(
|
||||||
|
EndpointName=self._endpoint_name,
|
||||||
|
ContentType="application/json",
|
||||||
|
Accept="application/octet-stream",
|
||||||
|
Body=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "Body" not in response:
|
||||||
|
yield ErrorFrame(error="SageMaker TTS returned no audio stream")
|
||||||
|
return
|
||||||
|
|
||||||
|
async for chunk in response["Body"].iter_chunks(chunk_size=self.chunk_size):
|
||||||
|
if chunk:
|
||||||
|
yield TTSAudioRawFrame(
|
||||||
|
audio=chunk,
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{self}: SageMaker TTS error: {e}")
|
||||||
|
yield ErrorFrame(error=f"SageMaker TTS error: {e}")
|
||||||
|
finally:
|
||||||
|
await self.stop_ttfb_metrics()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NvidiaSageMakerWSTTSSettings(TTSSettings):
|
||||||
|
"""Settings for NvidiaSageMakerWebsocketTTSService.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
voice: NIM voice name (e.g. ``Magpie-Multilingual.EN-US.Aria``).
|
||||||
|
language: BCP-47 language code passed to NIM (e.g. ``en-US``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
voice: str = "Magpie-Multilingual.EN-US.Aria"
|
||||||
|
language: str = "en-US"
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaSageMakerWebsocketTTSService(InterruptibleTTSService):
|
||||||
|
"""NVIDIA Magpie TTS service using SageMaker bidirectional streaming.
|
||||||
|
|
||||||
|
Maintains a persistent HTTP/2 bidi-stream session to the SageMaker endpoint
|
||||||
|
for the lifetime of the pipeline. Each text segment is sent as NIM realtime
|
||||||
|
events; audio chunks arrive asynchronously and are pushed as
|
||||||
|
:class:`TTSAudioRawFrame` frames.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
tts = NvidiaSageMakerWebsocketTTSService(
|
||||||
|
endpoint_name=os.getenv("SAGEMAKER_MAGPIE_ENDPOINT_NAME"),
|
||||||
|
region=os.getenv("AWS_REGION", "us-west-2"),
|
||||||
|
settings=NvidiaSageMakerWebsocketTTSService.Settings(
|
||||||
|
voice="Magpie-Multilingual.EN-US.Aria",
|
||||||
|
language="en-US",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
Settings = NvidiaSageMakerWSTTSSettings
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint_name: str,
|
||||||
|
region: str = "us-west-2",
|
||||||
|
sample_rate: int | None = None,
|
||||||
|
settings: NvidiaSageMakerWSTTSSettings | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
default_settings = self.Settings(model="magpie")
|
||||||
|
|
||||||
|
if settings is not None:
|
||||||
|
default_settings.apply_update(settings)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
push_start_frame=True,
|
||||||
|
push_stop_frames=True,
|
||||||
|
pause_frame_processing=True,
|
||||||
|
append_trailing_space=True,
|
||||||
|
settings=default_settings,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._endpoint_name = endpoint_name
|
||||||
|
self._region = region
|
||||||
|
self._client: SageMakerBidiClient | None = None
|
||||||
|
self._receive_task = None
|
||||||
|
self._speech_completed_event = asyncio.Event()
|
||||||
|
|
||||||
|
def can_generate_metrics(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def start(self, frame: StartFrame):
|
||||||
|
await super().start(frame)
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame):
|
||||||
|
await super().stop(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame):
|
||||||
|
await super().cancel(frame)
|
||||||
|
await self._disconnect()
|
||||||
|
|
||||||
|
# ── Connection management (WebsocketService abstract interface) ────────────
|
||||||
|
|
||||||
|
async def _connect(self):
|
||||||
|
await super()._connect()
|
||||||
|
await self._connect_websocket()
|
||||||
|
if self._client and self._client.is_active and not self._receive_task:
|
||||||
|
self._receive_task = self.create_task(self._receive_task_handler(self._report_error))
|
||||||
|
|
||||||
|
async def _disconnect(self):
|
||||||
|
await super()._disconnect()
|
||||||
|
if self._receive_task:
|
||||||
|
await self.cancel_task(self._receive_task)
|
||||||
|
self._receive_task = None
|
||||||
|
await self._disconnect_websocket()
|
||||||
|
|
||||||
|
async def _connect_websocket(self):
|
||||||
|
if self._client and self._client.is_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"{self}: connecting to SageMaker bidi-stream endpoint '{self._endpoint_name}'"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._client = SageMakerBidiClient(
|
||||||
|
endpoint_name=self._endpoint_name,
|
||||||
|
region=self._region,
|
||||||
|
model_query_string=None,
|
||||||
|
model_invocation_path=None,
|
||||||
|
)
|
||||||
|
await self._client.start_session()
|
||||||
|
await self._send_session_config()
|
||||||
|
logger.debug(f"{self}: connected")
|
||||||
|
await self._call_event_handler("on_connected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{self}: connection error: {e}")
|
||||||
|
self._client = None
|
||||||
|
await self._call_event_handler("on_connection_error", f"{e}")
|
||||||
|
|
||||||
|
async def _disconnect_websocket(self):
|
||||||
|
try:
|
||||||
|
if self._client and self._client.is_active:
|
||||||
|
logger.debug(f"{self}: disconnecting")
|
||||||
|
try:
|
||||||
|
await self._client.send_json({"type": "session.end"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"{self}: error sending session.end: {e}")
|
||||||
|
await self._client.close_session()
|
||||||
|
logger.debug(f"{self}: disconnected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"{self}: error during disconnect: {e}")
|
||||||
|
finally:
|
||||||
|
self._client = None
|
||||||
|
await self._call_event_handler("on_disconnected")
|
||||||
|
|
||||||
|
async def _verify_connection(self):
|
||||||
|
active = self._client and self._client.is_active
|
||||||
|
logger.info(f"{self}: verifying if websocket connection is active {active}")
|
||||||
|
return active
|
||||||
|
|
||||||
|
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
|
||||||
|
if self._bot_speaking:
|
||||||
|
logger.debug(
|
||||||
|
f"{self}: interruption detected, sending input_text.done and waiting for speech.completed"
|
||||||
|
)
|
||||||
|
self._disconnecting = True
|
||||||
|
self._speech_completed_event.clear()
|
||||||
|
try:
|
||||||
|
await self._client.send_json({"type": "input_text.done"})
|
||||||
|
await asyncio.wait_for(self._speech_completed_event.wait(), timeout=5.0)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(f"{self}: timed out waiting for conversation.item.speech.completed")
|
||||||
|
await super()._handle_interruption(frame, direction)
|
||||||
|
|
||||||
|
async def _receive_messages(self):
|
||||||
|
"""Receive NIM JSON events and push audio frames."""
|
||||||
|
while self._client and self._client.is_active and not self._disconnecting:
|
||||||
|
result = await self._client.receive_response()
|
||||||
|
|
||||||
|
if self._disconnecting:
|
||||||
|
self._speech_completed_event.set()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not (hasattr(result, "value") and hasattr(result.value, "bytes_")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = result.value.bytes_
|
||||||
|
if not payload:
|
||||||
|
continue
|
||||||
|
|
||||||
|
context_id = self.get_active_audio_context_id()
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = json.loads(payload.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
# Unexpected binary frame — treat as raw PCM
|
||||||
|
await self.push_frame(
|
||||||
|
TTSAudioRawFrame(
|
||||||
|
audio=payload,
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_type = msg.get("type", "")
|
||||||
|
|
||||||
|
if event_type != "conversation.item.speech.data":
|
||||||
|
logger.debug(f"{self}: received event: {event_type}")
|
||||||
|
|
||||||
|
if event_type == "conversation.item.speech.data":
|
||||||
|
chunk_b64 = msg.get("audio", "")
|
||||||
|
if chunk_b64:
|
||||||
|
await self.stop_ttfb_metrics()
|
||||||
|
await self.push_frame(
|
||||||
|
TTSAudioRawFrame(
|
||||||
|
audio=base64.b64decode(chunk_b64),
|
||||||
|
sample_rate=self.sample_rate,
|
||||||
|
num_channels=1,
|
||||||
|
context_id=context_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif event_type == "error":
|
||||||
|
await self.push_error(error_msg=f"NIM error: {msg.get('message', msg)}")
|
||||||
|
# In case of error we need to reconnect, otherwise we are not going to receive audio from the TTS service anymore
|
||||||
|
break
|
||||||
|
elif event_type == "conversation.item.speech.completed":
|
||||||
|
# Need to reconnect to reset the synthesis state and be able to synthesize new text
|
||||||
|
break
|
||||||
|
|
||||||
|
# synthesize_session.updated, input_text.committed, etc. are ignored.
|
||||||
|
|
||||||
|
async def _send_session_config(self):
|
||||||
|
"""Send synthesize_session.update to configure voice and audio params."""
|
||||||
|
logger.debug(f"{self}: sending session config, sample_rate={self.sample_rate}")
|
||||||
|
await self._client.send_json(
|
||||||
|
{
|
||||||
|
"type": "synthesize_session.update",
|
||||||
|
"session": {
|
||||||
|
"input_text_synthesis": {
|
||||||
|
"voice_name": self._settings.voice,
|
||||||
|
"language_code": self._settings.language,
|
||||||
|
},
|
||||||
|
"output_audio_params": {
|
||||||
|
"sample_rate_hz": self.sample_rate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Synthesis ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@traced_tts
|
||||||
|
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
|
||||||
|
"""Send text to NIM; audio arrives asynchronously via _receive_messages."""
|
||||||
|
logger.debug(f"{self}: Generating TTS [{text}]")
|
||||||
|
|
||||||
|
text = sanitize_text_for_tts(text)
|
||||||
|
|
||||||
|
logger.debug(f"{self}: sanitized text: {text}")
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not self._client or not self._client.is_active:
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
await self._client.send_json({"type": "input_text.append", "text": text})
|
||||||
|
await self._client.send_json({"type": "input_text.commit"})
|
||||||
|
yield None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{self}: TTS error: {e}")
|
||||||
|
yield ErrorFrame(error=f"NvidiaSageMakerWebsocketTTSService error: {e}")
|
||||||
132
src/pipecat/utils/text/tts_text_sanitizer.py
Normal file
132
src/pipecat/utils/text/tts_text_sanitizer.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024-2026, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""Utility for stripping non-speakable characters and markdown formatting from
|
||||||
|
text before it is sent to a TTS service.
|
||||||
|
|
||||||
|
Both NvidiaSageMakerHTTPTTSService and NvidiaSageMakerWebsocketTTSService
|
||||||
|
use :func:`sanitize_text_for_tts` so the logic lives in one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Emoji / symbol ranges
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_EMOJI_PATTERN = re.compile(
|
||||||
|
"["
|
||||||
|
"\U0001f600-\U0001f64f" # Emoticons
|
||||||
|
"\U0001f300-\U0001f5ff" # Misc Symbols and Pictographs
|
||||||
|
"\U0001f680-\U0001f6ff" # Transport and Map
|
||||||
|
"\U0001f700-\U0001f77f" # Alchemical Symbols
|
||||||
|
"\U0001f780-\U0001f7ff" # Geometric Shapes Extended
|
||||||
|
"\U0001f800-\U0001f8ff" # Supplemental Arrows-C
|
||||||
|
"\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs
|
||||||
|
"\U0001fa00-\U0001fa6f" # Chess Symbols
|
||||||
|
"\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A
|
||||||
|
"\U00002702-\U000027b0" # Dingbats
|
||||||
|
"\U0001f1e0-\U0001f1ff" # Flags (iOS)
|
||||||
|
"]+",
|
||||||
|
flags=re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_text_for_tts(text: str) -> str:
|
||||||
|
"""Remove emojis and markdown formatting that should not be spoken aloud.
|
||||||
|
|
||||||
|
Transformations applied (in order):
|
||||||
|
1. Fenced code blocks (``` ... ```) → removed entirely
|
||||||
|
2. Markdown headers (# / ## / …) → header text kept, # stripped
|
||||||
|
3. Horizontal rules (--- / *** / ___) → removed
|
||||||
|
4. Table separator rows (|---|---|) → removed
|
||||||
|
5. Table data rows (| a | b |) → cells joined with commas
|
||||||
|
6. Bold / italic markers (**x**, *x*, __x__, _x_) → text kept, markers stripped
|
||||||
|
7. Blockquote markers (> …) → marker stripped, text kept
|
||||||
|
8. Inline code backticks (`x`) → backticks stripped, text kept
|
||||||
|
9. Emojis → removed
|
||||||
|
10. Curly quotes → straight quotes
|
||||||
|
Em/en dashes → comma (natural pause, not a spoken symbol)
|
||||||
|
Separator hyphens ( - ) → comma
|
||||||
|
Unordered list bullets (^- , ^* ) → removed
|
||||||
|
Remaining bare * and _ → removed
|
||||||
|
Other non-speakable symbols → removed
|
||||||
|
11. Collapse extra whitespace
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Raw text, potentially containing markdown and/or emoji.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Plain text suitable for speech synthesis.
|
||||||
|
"""
|
||||||
|
# 1. Fenced code blocks
|
||||||
|
text = re.sub(r"```[\s\S]*?```", "", text)
|
||||||
|
|
||||||
|
# 2. Markdown headers (# Heading → Heading)
|
||||||
|
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
# 3. Horizontal rules (---, ***, ___ on their own line)
|
||||||
|
text = re.sub(r"^\s*[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
# 4. Table separator rows |---|:---:|---|
|
||||||
|
text = re.sub(r"^\s*\|[\s\-|:]+\|\s*$", "", text, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
# 5. Table data rows | cell | cell | → cell, cell
|
||||||
|
def _table_row_to_csv(m: re.Match) -> str:
|
||||||
|
cells = [c.strip() for c in m.group(1).split("|")]
|
||||||
|
return ", ".join(c for c in cells if c)
|
||||||
|
|
||||||
|
text = re.sub(r"^\s*\|(.+)\|\s*$", _table_row_to_csv, text, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
# 6. Bold / italic (**x**, *x*, __x__, _x_)
|
||||||
|
# Handle triple before double before single to avoid partial matches.
|
||||||
|
text = re.sub(r"\*{3}([^*]+)\*{3}", r"\1", text)
|
||||||
|
text = re.sub(r"\*{2}([^*]+)\*{2}", r"\1", text)
|
||||||
|
text = re.sub(r"\*([^*\s][^*]*[^*\s]|\S)\*", r"\1", text)
|
||||||
|
text = re.sub(r"_{3}([^_]+)_{3}", r"\1", text)
|
||||||
|
text = re.sub(r"_{2}([^_]+)_{2}", r"\1", text)
|
||||||
|
text = re.sub(r"_([^_\s][^_]*[^_\s]|\S)_", r"\1", text)
|
||||||
|
|
||||||
|
# 7. Blockquote markers
|
||||||
|
text = re.sub(r"^\s*>\s*", "", text, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
# 8. Inline code backticks
|
||||||
|
text = re.sub(r"`([^`]*)`", r"\1", text)
|
||||||
|
|
||||||
|
# 9. Emojis
|
||||||
|
text = _EMOJI_PATTERN.sub("", text)
|
||||||
|
|
||||||
|
# 10. Typographic and non-speakable characters
|
||||||
|
|
||||||
|
# Curly quotes → straight equivalents (speakable)
|
||||||
|
text = text.replace("\u2018", "'") # LEFT SINGLE QUOTATION MARK
|
||||||
|
text = text.replace("\u2019", "'") # RIGHT SINGLE QUOTATION MARK
|
||||||
|
text = text.replace("\u201c", '"') # LEFT DOUBLE QUOTATION MARK
|
||||||
|
text = text.replace("\u201d", '"') # RIGHT DOUBLE QUOTATION MARK
|
||||||
|
|
||||||
|
# Em/en dashes → comma (they mark a pause, not a spoken symbol)
|
||||||
|
text = text.replace("\u2014", ", ") # EM DASH
|
||||||
|
text = text.replace("\u2013", ", ") # EN DASH
|
||||||
|
|
||||||
|
# Hyphen used as a separator ( - ) → comma; keep word-hyphens (e.g. "well-known")
|
||||||
|
text = re.sub(r"(?<= )- | -(?= )", ", ", text)
|
||||||
|
|
||||||
|
# Unordered list bullets at line start (not caught by step 3)
|
||||||
|
text = re.sub(r"^[\s]*[-*]\s+", "", text, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
# Remaining bare * and _ (e.g. orphaned markers)
|
||||||
|
text = text.replace("*", "")
|
||||||
|
text = text.replace("_", " ")
|
||||||
|
|
||||||
|
# Other symbols that TTS engines typically misread or glitch on
|
||||||
|
text = re.sub(r"[\\|<>{}[\]~^=+#@]", "", text)
|
||||||
|
|
||||||
|
# 11. Collapse whitespace
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text) # no more than one blank line
|
||||||
|
text = re.sub(r"[ \t]+", " ", text) # collapse spaces/tabs
|
||||||
|
# text = text.strip()
|
||||||
|
|
||||||
|
return text
|
||||||
Reference in New Issue
Block a user