Nvidia Sagemaker Nemotron ASR STT service
This commit is contained in:
305
src/pipecat/services/nvidia/sagemaker/stt.py
Normal file
305
src/pipecat/services/nvidia/sagemaker/stt.py
Normal file
@@ -0,0 +1,305 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""NVIDIA Nemotron ASR STT service backed by an AWS SageMaker bidirectional-stream endpoint.
|
||||
|
||||
Uses SageMaker's HTTP/2 bidi-stream API to maintain a persistent connection to
|
||||
the wrapper's /invocations-bidirectional-stream endpoint, which proxies to NIM's
|
||||
realtime WebSocket.
|
||||
|
||||
Audio is streamed as base64-encoded PCM16 chunks via input_audio_buffer.append
|
||||
events. Transcription deltas arrive as InterimTranscriptionFrames and final
|
||||
results as TranscriptionFrames.
|
||||
|
||||
When the VAD detects the user has stopped speaking, input_audio_buffer.commit
|
||||
is sent to trigger NIM to finalise the current utterance.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
ErrorFrame,
|
||||
Frame,
|
||||
InterimTranscriptionFrame,
|
||||
StartFrame,
|
||||
TranscriptionFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient
|
||||
from pipecat.services.settings import STTSettings
|
||||
from pipecat.services.stt_service import STTService
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
from pipecat.utils.tracing.service_decorators import traced_stt
|
||||
|
||||
|
||||
@dataclass
|
||||
class NvidiaSageMakerWSSTTSettings(STTSettings):
|
||||
"""Settings for NvidiaSageMakerWebsocketSTTService.
|
||||
|
||||
Parameters:
|
||||
language: ISO-639-1 language code passed to NIM (e.g. ``en``).
|
||||
"""
|
||||
|
||||
language: str = "en-US"
|
||||
|
||||
|
||||
class NvidiaSageMakerWebsocketSTTService(STTService):
|
||||
"""NVIDIA Nemotron ASR STT service using SageMaker bidirectional streaming.
|
||||
|
||||
Maintains a persistent HTTP/2 bidi-stream session to the SageMaker endpoint
|
||||
for the lifetime of the pipeline. Audio chunks are forwarded as base64-encoded
|
||||
PCM16 via NIM realtime events; transcription results arrive asynchronously and
|
||||
are pushed as :class:`InterimTranscriptionFrame` and :class:`TranscriptionFrame`
|
||||
frames.
|
||||
|
||||
Example::
|
||||
|
||||
stt = NvidiaSageMakerWebsocketSTTService(
|
||||
endpoint_name=os.getenv("SAGEMAKER_ASR_ENDPOINT_NAME"),
|
||||
region=os.getenv("AWS_REGION", "us-west-2"),
|
||||
settings=NvidiaSageMakerWebsocketSTTService.Settings(
|
||||
language="en-US",
|
||||
),
|
||||
)
|
||||
"""
|
||||
|
||||
Settings = NvidiaSageMakerWSSTTSettings
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
endpoint_name: str,
|
||||
region: str = "us-west-2",
|
||||
sample_rate: int | None = None,
|
||||
settings: NvidiaSageMakerWSSTTSettings | None = None,
|
||||
ttfs_p99_latency: float | None = 1.5,
|
||||
**kwargs,
|
||||
):
|
||||
default_settings = self.Settings(
|
||||
model="cache-aware-parakeet-rnnt-en-US-asr-streaming-sortformer"
|
||||
)
|
||||
|
||||
if settings is not None:
|
||||
default_settings.apply_update(settings)
|
||||
|
||||
super().__init__(
|
||||
sample_rate=sample_rate,
|
||||
settings=default_settings,
|
||||
ttfs_p99_latency=ttfs_p99_latency,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._endpoint_name = endpoint_name
|
||||
self._region = region
|
||||
self._client: SageMakerBidiClient | None = None
|
||||
self._response_task: asyncio.Task | None = None
|
||||
|
||||
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()
|
||||
|
||||
# ── Audio input ───────────────────────────────────────────────────────────
|
||||
|
||||
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
|
||||
"""Send an audio chunk to NIM; transcription results arrive asynchronously.
|
||||
|
||||
Each chunk is appended and immediately committed, matching the NVIDIA
|
||||
reference client pattern for continuous streaming transcription.
|
||||
"""
|
||||
if self._client and self._client.is_active:
|
||||
try:
|
||||
await self._client.send_json(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.b64encode(audio).decode(),
|
||||
}
|
||||
)
|
||||
await self._client.send_json({"type": "input_audio_buffer.commit"})
|
||||
except Exception as e:
|
||||
yield ErrorFrame(error=f"Unknown error occurred: {e}")
|
||||
yield None
|
||||
|
||||
# ── VAD integration ───────────────────────────────────────────────────────
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
if isinstance(frame, VADUserStartedSpeakingFrame):
|
||||
logger.debug(f"{self}: VAD user started speaking")
|
||||
await self.start_processing_metrics()
|
||||
if isinstance(frame, VADUserStoppedSpeakingFrame):
|
||||
logger.debug(f"{self}: VAD user stopped speaking")
|
||||
|
||||
# ── Connection management ─────────────────────────────────────────────────
|
||||
|
||||
async def _connect(self):
|
||||
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()
|
||||
self._response_task = self.create_task(self._process_responses())
|
||||
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(self):
|
||||
if self._response_task and not self._response_task.done():
|
||||
await self.cancel_task(self._response_task)
|
||||
self._response_task = None
|
||||
|
||||
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")
|
||||
|
||||
self._client = None
|
||||
await self._call_event_handler("on_disconnected")
|
||||
|
||||
async def _send_session_config(self):
|
||||
"""Send transcription_session.update to configure audio format and params.
|
||||
|
||||
Specifies ``"model": "nemotron-asr-streaming"`` in ``input_audio_transcription`` so
|
||||
NIM selects the correct Nemotron ASR Streaming model.
|
||||
"""
|
||||
logger.debug(
|
||||
f"{self}: sending session config,"
|
||||
f" sample_rate={self.sample_rate} language={self._settings.language}"
|
||||
)
|
||||
await self._client.send_json(
|
||||
{
|
||||
"type": "transcription_session.update",
|
||||
"session": {
|
||||
"input_audio_format": "pcm16",
|
||||
"input_audio_params": {
|
||||
"sample_rate_hz": self.sample_rate,
|
||||
"num_channels": 1,
|
||||
},
|
||||
"input_audio_transcription": {
|
||||
"language": self._settings.language,
|
||||
"model": self._settings.model,
|
||||
},
|
||||
"recognition_config": {
|
||||
"enable_automatic_punctuation": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# ── Response processing ───────────────────────────────────────────────────
|
||||
|
||||
async def _process_responses(self):
|
||||
"""Receive NIM JSON events and push transcription frames."""
|
||||
try:
|
||||
while self._client and self._client.is_active:
|
||||
result = await self._client.receive_response()
|
||||
|
||||
if result is None or not (
|
||||
hasattr(result, "value") and hasattr(result.value, "bytes_")
|
||||
):
|
||||
continue
|
||||
|
||||
payload = result.value.bytes_
|
||||
if not payload:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
event_type = msg.get("type", "")
|
||||
|
||||
if event_type not in (
|
||||
"conversation.item.input_audio_transcription.delta",
|
||||
"input_audio_buffer.committed",
|
||||
):
|
||||
logger.debug(f"{self}: received event: {event_type}")
|
||||
|
||||
if event_type == "conversation.item.input_audio_transcription.delta":
|
||||
delta = msg.get("delta", "")
|
||||
if delta:
|
||||
logger.debug(f"{self}: received transcription delta: {delta}")
|
||||
await self.push_frame(
|
||||
InterimTranscriptionFrame(
|
||||
delta,
|
||||
self._user_id,
|
||||
time_now_iso8601(),
|
||||
)
|
||||
)
|
||||
|
||||
elif event_type == "conversation.item.input_audio_transcription.completed":
|
||||
transcript = msg.get("transcript", "")
|
||||
if transcript.strip():
|
||||
logger.debug(f"{self}: received final transcription: {transcript}")
|
||||
await self.push_frame(
|
||||
TranscriptionFrame(
|
||||
transcript,
|
||||
self._user_id,
|
||||
time_now_iso8601(),
|
||||
result=msg,
|
||||
)
|
||||
)
|
||||
await self._handle_transcription(transcript, True)
|
||||
await self.stop_processing_metrics()
|
||||
|
||||
elif event_type in (
|
||||
"conversation.item.input_audio_transcription.failed",
|
||||
"error",
|
||||
):
|
||||
await self.push_error(error_msg=f"NIM ASR error: {msg}")
|
||||
# In case of error we need to reconnect, otherwise we are not going to receive from the STT service anymore
|
||||
await self._disconnect()
|
||||
await self._connect()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"{self}: response processor cancelled")
|
||||
except Exception as e:
|
||||
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
|
||||
finally:
|
||||
logger.debug(f"{self}: response processor stopped")
|
||||
|
||||
@traced_stt
|
||||
async def _handle_transcription(self, transcript: str, is_final: bool, language=None):
|
||||
pass
|
||||
Reference in New Issue
Block a user