Added ultravox service
This commit is contained in:
393
src/pipecat/services/ultravox.py
Normal file
393
src/pipecat/services/ultravox.py
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
"""This module implements Ultravox speech-to-text with a locally-loaded model."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import numpy as np
|
||||||
|
from enum import Enum
|
||||||
|
from typing import AsyncGenerator, Optional, List
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from huggingface_hub import login
|
||||||
|
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
Frame,
|
||||||
|
AudioRawFrame,
|
||||||
|
TranscriptionFrame,
|
||||||
|
TextFrame,
|
||||||
|
StartFrame,
|
||||||
|
EndFrame,
|
||||||
|
CancelFrame,
|
||||||
|
UserStartedSpeakingFrame,
|
||||||
|
UserStoppedSpeakingFrame,
|
||||||
|
ErrorFrame
|
||||||
|
)
|
||||||
|
from pipecat.services.ai_services import AIService
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.utils.time import time_now_iso8601
|
||||||
|
|
||||||
|
try:
|
||||||
|
from vllm import SamplingParams, AsyncLLMEngine
|
||||||
|
from vllm.engine.arg_utils import AsyncEngineArgs
|
||||||
|
from transformers import AutoTokenizer
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
logger.error(f"Exception: {e}")
|
||||||
|
logger.error("In order to use Ultravox, you need to `pip install pipecat-ai[ultravox]`.")
|
||||||
|
raise Exception(f"Missing module: {e}")
|
||||||
|
|
||||||
|
class AudioBuffer:
|
||||||
|
"""Buffer to collect audio frames before processing.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
frames: List of AudioRawFrames to process
|
||||||
|
started_at: Timestamp when speech started
|
||||||
|
is_processing: Flag to prevent concurrent processing
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.frames: List[AudioRawFrame] = []
|
||||||
|
self.started_at: Optional[float] = None
|
||||||
|
self.is_processing: bool = False
|
||||||
|
|
||||||
|
class UltravoxModel:
|
||||||
|
"""Model wrapper for the Ultravox multimodal model.
|
||||||
|
|
||||||
|
This class handles loading and running the Ultravox model for speech-to-text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_name: The name or path of the Ultravox model to load
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
model_name: The name of the loaded model
|
||||||
|
engine: The vLLM engine for model inference
|
||||||
|
tokenizer: The tokenizer for the model
|
||||||
|
stop_token_ids: Optional token IDs to stop generation
|
||||||
|
"""
|
||||||
|
def __init__(self, model_name: str = "fixie-ai/ultravox-v0_4_1-llama-3_1-8b"):
|
||||||
|
self.model_name = model_name
|
||||||
|
self._initialize_engine()
|
||||||
|
self._initialize_tokenizer()
|
||||||
|
self.stop_token_ids = None
|
||||||
|
|
||||||
|
def _initialize_engine(self):
|
||||||
|
"""Initialize the vLLM engine for inference."""
|
||||||
|
engine_args = AsyncEngineArgs(
|
||||||
|
model=self.model_name,
|
||||||
|
gpu_memory_utilization=0.9,
|
||||||
|
max_model_len=8192,
|
||||||
|
trust_remote_code=True
|
||||||
|
)
|
||||||
|
self.engine = AsyncLLMEngine.from_engine_args(engine_args)
|
||||||
|
|
||||||
|
def _initialize_tokenizer(self):
|
||||||
|
"""Initialize the tokenizer for the model."""
|
||||||
|
self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
||||||
|
|
||||||
|
def format_prompt(self, messages: list):
|
||||||
|
"""Format chat messages into a prompt for the model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of message dictionaries with 'role' and 'content'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted prompt string
|
||||||
|
"""
|
||||||
|
return self.tokenizer.apply_chat_template(
|
||||||
|
messages,
|
||||||
|
tokenize=False,
|
||||||
|
add_generation_prompt=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate(self, messages: list, temperature: float = 0.7, max_tokens: int = 100, audio: np.ndarray = None):
|
||||||
|
"""Generate text from audio input using the model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of message dictionaries
|
||||||
|
temperature: Sampling temperature
|
||||||
|
max_tokens: Maximum tokens to generate
|
||||||
|
audio: Audio data as numpy array
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
str: JSON chunks of the generated response
|
||||||
|
"""
|
||||||
|
sampling_params = SamplingParams(
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
stop_token_ids=self.stop_token_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
mm_data = {
|
||||||
|
"audio": audio
|
||||||
|
}
|
||||||
|
inputs = {"prompt": self.format_prompt(messages), "multi_modal_data": mm_data}
|
||||||
|
results_generator = self.engine.generate(inputs, sampling_params, str(time.time()))
|
||||||
|
|
||||||
|
previous_text = ""
|
||||||
|
first_chunk = True
|
||||||
|
|
||||||
|
async for output in results_generator:
|
||||||
|
prompt_output = output.outputs
|
||||||
|
new_text = prompt_output[0].text[len(previous_text):]
|
||||||
|
previous_text = prompt_output[0].text
|
||||||
|
|
||||||
|
# Construct OpenAI-compatible chunk
|
||||||
|
chunk = {
|
||||||
|
"id": str(int(time.time() * 1000)),
|
||||||
|
"object": "chat.completion.chunk",
|
||||||
|
"created": int(time.time()),
|
||||||
|
"model": self.model_name,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {},
|
||||||
|
"finish_reason": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include the role in the first chunk
|
||||||
|
if first_chunk:
|
||||||
|
chunk["choices"][0]["delta"]["role"] = "assistant"
|
||||||
|
first_chunk = False
|
||||||
|
|
||||||
|
# Add new text to the delta if any
|
||||||
|
if new_text:
|
||||||
|
chunk["choices"][0]["delta"]["content"] = new_text
|
||||||
|
|
||||||
|
# Capture a finish reason if it's provided
|
||||||
|
finish_reason = prompt_output[0].finish_reason or None
|
||||||
|
if finish_reason and finish_reason != "none":
|
||||||
|
chunk["choices"][0]["finish_reason"] = finish_reason
|
||||||
|
|
||||||
|
yield json.dumps(chunk)
|
||||||
|
|
||||||
|
class UltravoxSTTService(AIService):
|
||||||
|
"""Service to transcribe audio using the Ultravox multimodal model.
|
||||||
|
|
||||||
|
This service collects audio frames and processes them with Ultravox
|
||||||
|
to generate text transcriptions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_size: The Ultravox model to use (ModelSize enum or string)
|
||||||
|
hf_token: Hugging Face token for model access
|
||||||
|
temperature: Sampling temperature for generation
|
||||||
|
max_tokens: Maximum tokens to generate
|
||||||
|
**kwargs: Additional arguments passed to AIService
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
model: The UltravoxModel instance
|
||||||
|
buffer: Buffer to collect audio frames
|
||||||
|
temperature: Temperature for text generation
|
||||||
|
max_tokens: Maximum tokens to generate
|
||||||
|
_connection_active: Flag indicating if service is active
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model_size: str = "fixie-ai/ultravox-v0_4_1-llama-3_1-8b",
|
||||||
|
hf_token: Optional[str] = None,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
max_tokens: int = 100,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
# Authenticate with Hugging Face if token provided
|
||||||
|
if hf_token:
|
||||||
|
login(token=hf_token)
|
||||||
|
elif os.environ.get("HF_TOKEN"):
|
||||||
|
login(token=os.environ.get("HF_TOKEN"))
|
||||||
|
else:
|
||||||
|
logger.warning("No Hugging Face token provided. Model may not load correctly.")
|
||||||
|
|
||||||
|
# Initialize model
|
||||||
|
model_name = model_size if isinstance(model_size, str) else model_size.value
|
||||||
|
self.model = UltravoxModel(model_name=model_name)
|
||||||
|
|
||||||
|
# Initialize service state
|
||||||
|
self.buffer = AudioBuffer()
|
||||||
|
self.temperature = temperature
|
||||||
|
self.max_tokens = max_tokens
|
||||||
|
self._connection_active = False
|
||||||
|
|
||||||
|
logger.info(f"Initialized UltravoxSTTService with model: {model_name}")
|
||||||
|
|
||||||
|
def can_generate_metrics(self) -> bool:
|
||||||
|
"""Indicates whether this service can generate metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True, as this service supports metric generation.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def start(self, frame: StartFrame):
|
||||||
|
"""Handle service start.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: StartFrame that triggered this method
|
||||||
|
"""
|
||||||
|
await super().start(frame)
|
||||||
|
self._connection_active = True
|
||||||
|
logger.info("UltravoxSTTService started")
|
||||||
|
|
||||||
|
async def stop(self, frame: EndFrame):
|
||||||
|
"""Handle service stop.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: EndFrame that triggered this method
|
||||||
|
"""
|
||||||
|
await super().stop(frame)
|
||||||
|
self._connection_active = False
|
||||||
|
logger.info("UltravoxSTTService stopped")
|
||||||
|
|
||||||
|
async def cancel(self, frame: CancelFrame):
|
||||||
|
"""Handle service cancellation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: CancelFrame that triggered this method
|
||||||
|
"""
|
||||||
|
await super().cancel(frame)
|
||||||
|
self._connection_active = False
|
||||||
|
self.buffer = AudioBuffer()
|
||||||
|
logger.info("UltravoxSTTService cancelled")
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
"""Process incoming frames.
|
||||||
|
|
||||||
|
This method collects audio frames and processes them when speech ends.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The frame to process
|
||||||
|
direction: Direction of the frame (input/output)
|
||||||
|
"""
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
if isinstance(frame, UserStartedSpeakingFrame):
|
||||||
|
logger.info("Speech started")
|
||||||
|
self.buffer = AudioBuffer()
|
||||||
|
self.buffer.started_at = time.time()
|
||||||
|
|
||||||
|
elif isinstance(frame, AudioRawFrame) and self.buffer.started_at is not None:
|
||||||
|
self.buffer.frames.append(frame)
|
||||||
|
|
||||||
|
elif isinstance(frame, UserStoppedSpeakingFrame):
|
||||||
|
if self.buffer.frames and not self.buffer.is_processing:
|
||||||
|
logger.info("Speech ended, processing buffer...")
|
||||||
|
await self.process_generator(self._process_audio_buffer())
|
||||||
|
return # Return early to avoid pushing None frame
|
||||||
|
|
||||||
|
# Only push the original frame if we haven't processed audio
|
||||||
|
if frame is not None:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
async def _process_audio_buffer(self) -> AsyncGenerator[Frame, None]:
|
||||||
|
"""Process collected audio frames with Ultravox.
|
||||||
|
|
||||||
|
This method concatenates audio frames, processes them with the model,
|
||||||
|
and yields the resulting text frames.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Frame: TextFrame containing the transcribed text
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.buffer.is_processing = True
|
||||||
|
|
||||||
|
# Check if we have valid frames before processing
|
||||||
|
if not self.buffer.frames:
|
||||||
|
logger.warning("No audio frames to process")
|
||||||
|
yield ErrorFrame("No audio frames to process")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process audio frames
|
||||||
|
audio_arrays = []
|
||||||
|
for f in self.buffer.frames:
|
||||||
|
if hasattr(f, 'audio') and f.audio:
|
||||||
|
# Handle bytes data - these are int16 PCM samples
|
||||||
|
if isinstance(f.audio, bytes):
|
||||||
|
try:
|
||||||
|
# Convert bytes to int16 array
|
||||||
|
arr = np.frombuffer(f.audio, dtype=np.int16)
|
||||||
|
if arr.size > 0: # Check if array is not empty
|
||||||
|
audio_arrays.append(arr)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing bytes audio frame: {e}")
|
||||||
|
# Handle numpy array data
|
||||||
|
elif isinstance(f.audio, np.ndarray):
|
||||||
|
if f.audio.size > 0: # Check if array is not empty
|
||||||
|
# Ensure it's int16 data
|
||||||
|
if f.audio.dtype != np.int16:
|
||||||
|
logger.info(f"Converting array from {f.audio.dtype} to int16")
|
||||||
|
audio_arrays.append(f.audio.astype(np.int16))
|
||||||
|
else:
|
||||||
|
audio_arrays.append(f.audio)
|
||||||
|
|
||||||
|
# Only proceed if we have valid audio arrays
|
||||||
|
if not audio_arrays:
|
||||||
|
logger.warning("No valid audio data found in frames")
|
||||||
|
yield ErrorFrame("No valid audio data found in frames")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Concatenate audio frames - all should be int16 now
|
||||||
|
audio_data = np.concatenate(audio_arrays)
|
||||||
|
|
||||||
|
# Generate text using the model
|
||||||
|
if self.model:
|
||||||
|
try:
|
||||||
|
logger.info("Generating text from audio using model...")
|
||||||
|
full_response = ""
|
||||||
|
|
||||||
|
# Start metrics tracking
|
||||||
|
await self.start_ttfb_metrics()
|
||||||
|
await self.start_processing_metrics()
|
||||||
|
|
||||||
|
async for response in self.model.generate(
|
||||||
|
messages=[{
|
||||||
|
'role': 'user',
|
||||||
|
'content': "<|audio|>\n"
|
||||||
|
}],
|
||||||
|
temperature=self.temperature,
|
||||||
|
max_tokens=self.max_tokens,
|
||||||
|
audio=audio_data
|
||||||
|
):
|
||||||
|
# Stop TTFB metrics after first response
|
||||||
|
await self.stop_ttfb_metrics()
|
||||||
|
|
||||||
|
chunk = json.loads(response)
|
||||||
|
if "choices" in chunk and len(chunk["choices"]) > 0:
|
||||||
|
delta = chunk["choices"][0]["delta"]
|
||||||
|
if "content" in delta:
|
||||||
|
new_text = delta["content"]
|
||||||
|
full_response += new_text
|
||||||
|
|
||||||
|
# Stop processing metrics after completion
|
||||||
|
await self.stop_processing_metrics()
|
||||||
|
|
||||||
|
logger.info(f"Generated text: {full_response}")
|
||||||
|
|
||||||
|
# Create a transcription frame with the generated text
|
||||||
|
transcription = full_response.strip()
|
||||||
|
if transcription:
|
||||||
|
yield TranscriptionFrame(
|
||||||
|
text=transcription,
|
||||||
|
interim_text="",
|
||||||
|
timestamp=time_now_iso8601()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Empty transcription result")
|
||||||
|
yield ErrorFrame("Empty transcription result")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating text from model: {e}")
|
||||||
|
yield ErrorFrame(f"Error generating text: {str(e)}")
|
||||||
|
else:
|
||||||
|
logger.warning("No model available for text generation")
|
||||||
|
yield ErrorFrame("No model available for text generation")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing audio buffer: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
yield ErrorFrame(f"Error processing audio: {str(e)}")
|
||||||
|
finally:
|
||||||
|
self.buffer.is_processing = False
|
||||||
|
self.buffer.frames = []
|
||||||
|
self.buffer.started_at = None
|
||||||
Reference in New Issue
Block a user