chore: import upstream snapshot with attribution
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from loguru import logger
|
||||
from omegaconf import OmegaConf
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import EndTaskFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||
from pipecat.processors.frameworks.rtvi import RTVIAction, RTVIConfig, RTVIObserverParams, RTVIProcessor
|
||||
from pipecat.serializers.protobuf import ProtobufFrameSerializer
|
||||
from websocket_url import build_websocket_url
|
||||
|
||||
from nemo.agents.voice_agent.pipecat.processors.frameworks.rtvi import RTVIObserver
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.audio_logger import AudioLogger, RTVIAudioLoggerObserver
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.diar import NemoDiarService
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.llm import get_llm_service_from_config
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.stt import NemoSTTService
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.tts import get_tts_service_from_config
|
||||
from nemo.agents.voice_agent.pipecat.services.nemo.turn_taking import NeMoTurnTakingService
|
||||
from nemo.agents.voice_agent.pipecat.transports.network.websocket_server import (
|
||||
WebsocketServerParams,
|
||||
WebsocketServerTransport,
|
||||
)
|
||||
from nemo.agents.voice_agent.utils.config_manager import ConfigManager
|
||||
from nemo.agents.voice_agent.utils.tool_calling.basic_tools import tool_get_city_weather
|
||||
from nemo.agents.voice_agent.utils.tool_calling.mixins import register_direct_tools_to_llm
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def setup_logging():
|
||||
# Configure loguru to output to both console and file
|
||||
logger.remove() # Remove default handler
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||
level="DEBUG",
|
||||
)
|
||||
|
||||
logger.add("bot_server.log", rotation="1 day", level="DEBUG")
|
||||
|
||||
|
||||
setup_logging()
|
||||
|
||||
# Global flag for graceful shutdown
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
# Initialize configuration manager
|
||||
config_manager = ConfigManager(
|
||||
server_base_path=os.path.dirname(__file__), server_config_path=os.environ.get("SERVER_CONFIG_PATH", None)
|
||||
)
|
||||
server_config = config_manager.get_server_config()
|
||||
|
||||
logger.info(f"Server config: {OmegaConf.to_container(server_config, resolve=True)}")
|
||||
|
||||
# Access configuration parameters from ConfigManager
|
||||
SAMPLE_RATE = config_manager.SAMPLE_RATE
|
||||
RAW_AUDIO_FRAME_LEN_IN_SECS = config_manager.RAW_AUDIO_FRAME_LEN_IN_SECS
|
||||
SYSTEM_PROMPT = config_manager.SYSTEM_PROMPT
|
||||
SYSTEM_ROLE = config_manager.SYSTEM_ROLE
|
||||
|
||||
# Transport configuration
|
||||
TRANSPORT_AUDIO_OUT_10MS_CHUNKS = config_manager.TRANSPORT_AUDIO_OUT_10MS_CHUNKS
|
||||
RECORD_AUDIO_DATA = server_config.transport.get("record_audio_data", False)
|
||||
AUDIO_LOG_DIR = server_config.transport.get("audio_log_dir", "./audio_logs")
|
||||
SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0")
|
||||
SERVER_PUBLIC_HOST = os.getenv("SERVER_PUBLIC_HOST", "127.0.0.1")
|
||||
WEBSOCKET_SCHEME = os.getenv("WEBSOCKET_SCHEME", "ws")
|
||||
WEBSOCKET_PORT = int(os.getenv("WEBSOCKET_PORT", 8765))
|
||||
FASTAPI_PORT = int(os.getenv("FASTAPI_PORT", 7860))
|
||||
|
||||
# VAD configuration
|
||||
vad_params = config_manager.get_vad_params()
|
||||
|
||||
# STT configuration
|
||||
STT_MODEL = config_manager.STT_MODEL
|
||||
STT_DEVICE = config_manager.STT_DEVICE
|
||||
stt_params = config_manager.get_stt_params()
|
||||
|
||||
# Diarization configuration
|
||||
DIAR_MODEL = config_manager.DIAR_MODEL
|
||||
USE_DIAR = config_manager.USE_DIAR
|
||||
diar_params = config_manager.get_diar_params()
|
||||
|
||||
# Turn taking configuration
|
||||
TURN_TAKING_BACKCHANNEL_PHRASES_PATH = config_manager.TURN_TAKING_BACKCHANNEL_PHRASES_PATH
|
||||
TURN_TAKING_MAX_BUFFER_SIZE = config_manager.TURN_TAKING_MAX_BUFFER_SIZE
|
||||
TURN_TAKING_BOT_STOP_DELAY = config_manager.TURN_TAKING_BOT_STOP_DELAY
|
||||
|
||||
# TTS configuration
|
||||
TTS_TYPE = config_manager.server_config.tts.type
|
||||
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
"""Handle shutdown signals gracefully"""
|
||||
logger.info(f"Received signal {signum}, initiating graceful shutdown...")
|
||||
shutdown_event.set()
|
||||
|
||||
|
||||
async def run_bot_websocket_server(host: str = "0.0.0.0", port: int = None):
|
||||
"""
|
||||
NO-TIMEOUT CONFIGURATION:
|
||||
- session_timeout=None: Disables WebSocket session timeout
|
||||
- idle_timeout=None: Disables pipeline idle timeout
|
||||
- asyncio.wait_for(timeout=None): No timeout on pipeline runner
|
||||
- Server will run indefinitely until manually stopped (Ctrl+C)
|
||||
"""
|
||||
if port is None:
|
||||
port = WEBSOCKET_PORT
|
||||
logger.info(f"Starting websocket server on {host}:{port}")
|
||||
logger.info(f"Server configured to run indefinitely with no timeouts, use Ctrl+C to quit.")
|
||||
|
||||
# Set up signal handlers for graceful shutdown
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
logger.info("Initializing WebSocket server transport...")
|
||||
logger.info("Server configured to run indefinitely with no timeouts")
|
||||
# Initialize AudioLogger if recording is enabled
|
||||
audio_logger = None
|
||||
if RECORD_AUDIO_DATA:
|
||||
session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
audio_logger = AudioLogger(
|
||||
log_dir=AUDIO_LOG_DIR,
|
||||
session_id=session_id,
|
||||
enabled=True,
|
||||
)
|
||||
logger.info(f"AudioLogger initialized for session: {session_id} at {AUDIO_LOG_DIR}")
|
||||
|
||||
vad_analyzer = SileroVADAnalyzer(
|
||||
sample_rate=SAMPLE_RATE,
|
||||
params=vad_params,
|
||||
)
|
||||
logger.info("VAD analyzer initialized")
|
||||
|
||||
ws_transport = WebsocketServerTransport(
|
||||
params=WebsocketServerParams(
|
||||
serializer=ProtobufFrameSerializer(),
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
add_wav_header=False,
|
||||
vad_analyzer=vad_analyzer,
|
||||
session_timeout=None, # Disable session timeout
|
||||
audio_in_sample_rate=SAMPLE_RATE,
|
||||
can_create_user_frames=False,
|
||||
audio_out_10ms_chunks=TRANSPORT_AUDIO_OUT_10MS_CHUNKS,
|
||||
),
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
|
||||
logger.info("Initializing STT service...")
|
||||
|
||||
stt = NemoSTTService(
|
||||
model=STT_MODEL,
|
||||
device=STT_DEVICE,
|
||||
params=stt_params,
|
||||
sample_rate=SAMPLE_RATE,
|
||||
audio_passthrough=True,
|
||||
backend="legacy",
|
||||
decoder_type="rnnt",
|
||||
audio_logger=audio_logger,
|
||||
)
|
||||
logger.info("STT service initialized")
|
||||
|
||||
if USE_DIAR:
|
||||
diar = NemoDiarService(
|
||||
model=DIAR_MODEL,
|
||||
device=STT_DEVICE,
|
||||
params=diar_params,
|
||||
sample_rate=SAMPLE_RATE,
|
||||
backend="legacy",
|
||||
enabled=USE_DIAR,
|
||||
)
|
||||
logger.info("Diarization service initialized")
|
||||
else:
|
||||
diar = None
|
||||
|
||||
turn_taking = NeMoTurnTakingService(
|
||||
use_diar=USE_DIAR,
|
||||
max_buffer_size=TURN_TAKING_MAX_BUFFER_SIZE,
|
||||
bot_stop_delay=TURN_TAKING_BOT_STOP_DELAY,
|
||||
backchannel_phrases=TURN_TAKING_BACKCHANNEL_PHRASES_PATH,
|
||||
audio_logger=audio_logger,
|
||||
)
|
||||
logger.info("Turn taking service initialized")
|
||||
|
||||
if TTS_TYPE == "nemo":
|
||||
tts = get_tts_service_from_config(config_manager.server_config.tts, audio_logger)
|
||||
else:
|
||||
raise ValueError(f"Invalid TTS type: {TTS_TYPE}")
|
||||
|
||||
logger.info("TTS service initialized")
|
||||
|
||||
# Setup logging again to avoid logger from being overwritten during setting up the pipeline components
|
||||
setup_logging()
|
||||
|
||||
# Put LLM in the end of model initialization to reduce the chance of running out of HBM memory
|
||||
logger.info("Initializing LLM service...")
|
||||
llm = get_llm_service_from_config(server_config.llm)
|
||||
logger.info("LLM service initialized")
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": SYSTEM_ROLE,
|
||||
"content": SYSTEM_PROMPT,
|
||||
}
|
||||
]
|
||||
inject_dummy_user_message = server_config.llm.get("inject_dummy_user_message", False)
|
||||
if inject_dummy_user_message:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, who are you?",
|
||||
}
|
||||
)
|
||||
context = OpenAILLMContext(messages=messages)
|
||||
|
||||
if server_config.llm.get("enable_tool_calling", False):
|
||||
logger.info("Tools calling for LLM is enabled by config, registering tools...")
|
||||
register_direct_tools_to_llm(llm=llm, context=context, tool_mixins=[tts], tools=[tool_get_city_weather])
|
||||
else:
|
||||
logger.info("Tools calling for LLM is disabled by config, skipping tool registration.")
|
||||
|
||||
original_messages = copy.deepcopy(context.get_messages())
|
||||
original_context = copy.deepcopy(context)
|
||||
original_context.set_llm_adapter(llm.get_llm_adapter())
|
||||
|
||||
context_aggregator = llm.create_context_aggregator(context)
|
||||
user_context_aggregator = context_aggregator.user()
|
||||
assistant_context_aggregator = context_aggregator.assistant()
|
||||
|
||||
# RTVI events for Pipecat client UI
|
||||
rtvi = RTVIProcessor(config=RTVIConfig(config=[]))
|
||||
|
||||
# Add reset action to RTVI processor
|
||||
async def reset_context_handler(rtvi_processor: RTVIProcessor, service: str, arguments: dict[str, Any]) -> bool:
|
||||
"""Reset both user and assistant context aggregators"""
|
||||
logger.info("Resetting conversation context...")
|
||||
try:
|
||||
user_context_aggregator.reset()
|
||||
assistant_context_aggregator.reset()
|
||||
user_context_aggregator.set_messages(copy.deepcopy(original_messages))
|
||||
assistant_context_aggregator.set_messages(copy.deepcopy(original_messages))
|
||||
tts.reset()
|
||||
if diar is not None:
|
||||
diar.reset()
|
||||
turn_taking.reset()
|
||||
logger.info("Conversation context reset successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting context: {e}")
|
||||
return False
|
||||
|
||||
reset_action = RTVIAction(
|
||||
service="context",
|
||||
action="reset",
|
||||
result="bool",
|
||||
arguments=[],
|
||||
handler=reset_context_handler,
|
||||
)
|
||||
rtvi.register_action(reset_action)
|
||||
|
||||
logger.info("Setting up pipeline...")
|
||||
|
||||
pipeline = [
|
||||
ws_transport.input(),
|
||||
rtvi,
|
||||
stt,
|
||||
]
|
||||
|
||||
if USE_DIAR:
|
||||
pipeline.append(diar)
|
||||
|
||||
pipeline.extend(
|
||||
[turn_taking, user_context_aggregator, llm, tts, ws_transport.output(), assistant_context_aggregator]
|
||||
)
|
||||
|
||||
pipeline = Pipeline(pipeline)
|
||||
|
||||
rtvi_params = RTVIObserverParams(bot_llm_enabled=False)
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
allow_interruptions=True,
|
||||
enable_metrics=False,
|
||||
enable_usage_metrics=False,
|
||||
send_initial_empty_metrics=True,
|
||||
report_only_initial_ttfb=True,
|
||||
idle_timeout=None, # Disable idle timeout
|
||||
),
|
||||
observers=[
|
||||
RTVIObserver(rtvi, params=rtvi_params),
|
||||
RTVIAudioLoggerObserver(audio_logger=audio_logger),
|
||||
],
|
||||
idle_timeout_secs=None,
|
||||
cancel_on_idle_timeout=False,
|
||||
)
|
||||
|
||||
# Track task state
|
||||
task_running = True
|
||||
|
||||
# Setup logging again to avoid logger from being overwritten during setting up the pipeline components
|
||||
setup_logging()
|
||||
|
||||
@rtvi.event_handler("on_client_ready")
|
||||
async def on_client_ready(rtvi: RTVIProcessor):
|
||||
logger.info("Pipecat client ready.")
|
||||
await rtvi.set_bot_ready()
|
||||
# Kick off the conversation.
|
||||
try:
|
||||
await task.queue_frames([user_context_aggregator.get_context_frame()])
|
||||
except Exception as e:
|
||||
logger.error(f"Error queuing context frame: {e}")
|
||||
|
||||
@ws_transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
logger.info(f"Pipecat Client connected from {client.remote_address}")
|
||||
# Reset RTVI state for new connection
|
||||
rtvi._client_ready = False
|
||||
rtvi._bot_ready = False
|
||||
|
||||
@ws_transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
logger.info(f"Pipecat Client disconnected from {client.remote_address}")
|
||||
# Finalize audio logger session if enabled
|
||||
if audio_logger:
|
||||
audio_logger.finalize_session()
|
||||
logger.info("Audio logger session finalized")
|
||||
# Don't cancel the task immediately - let it handle the disconnection gracefully
|
||||
# The task will continue running and can accept new connections
|
||||
# Only send an EndTaskFrame to clean up the current session
|
||||
if task_running:
|
||||
try:
|
||||
await task.queue_frames([EndTaskFrame()])
|
||||
except Exception as e:
|
||||
# Don't log warnings for normal connection closures
|
||||
if "ConnectionClosedOK" not in str(e) and "1005" not in str(e):
|
||||
logger.warning(f"Error sending EndTaskFrame: {e}")
|
||||
else:
|
||||
logger.info(f"Normal connection closure: {e}")
|
||||
|
||||
@ws_transport.event_handler("on_session_timeout")
|
||||
async def on_session_timeout(transport, client):
|
||||
logger.info(f"Session timeout for {client.remote_address}")
|
||||
# Don't cancel the task - keep server running indefinitely
|
||||
logger.info("Session timeout occurred but keeping server running")
|
||||
# Note: With session_timeout=None, this handler should never be called
|
||||
|
||||
logger.info("Starting pipeline runner...")
|
||||
|
||||
try:
|
||||
runner = PipelineRunner()
|
||||
# Run the task until shutdown is requested
|
||||
await asyncio.wait_for(runner.run(task), timeout=None) # No timeout - run indefinitely
|
||||
except asyncio.TimeoutError:
|
||||
logger.info("Pipeline runner timeout (should not happen with no timeout)")
|
||||
except Exception as e:
|
||||
logger.error(f"Pipeline runner error: {e}")
|
||||
task_running = False
|
||||
finally:
|
||||
# Finalize audio logger on shutdown
|
||||
if audio_logger:
|
||||
audio_logger.finalize_session()
|
||||
logger.info("Audio logger session finalized on shutdown")
|
||||
logger.info("Pipeline runner stopped")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Handles FastAPI startup and shutdown."""
|
||||
yield # Run app
|
||||
|
||||
|
||||
# Initialize FastAPI app with lifespan manager
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Configure CORS to allow requests from any origin
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
print("WebSocket connection accepted")
|
||||
try:
|
||||
# TODO: [heh] Implement FastAPI websocket endpoint
|
||||
# await run_bot_fastapi_server(websocket)
|
||||
raise NotImplementedError("FastAPI websocket endpoint is not implemented")
|
||||
except Exception as e:
|
||||
print(f"Exception in run_bot: {e}")
|
||||
|
||||
|
||||
@app.post("/connect")
|
||||
async def bot_connect() -> Dict[Any, Any]:
|
||||
print("Received /connect request")
|
||||
ws_url = build_websocket_url(SERVER_PUBLIC_HOST, WEBSOCKET_PORT, WEBSOCKET_SCHEME)
|
||||
print(f"Returning WebSocket URL: {ws_url}")
|
||||
return {"ws_url": ws_url}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main function to run both websocket server and FastAPI server concurrently."""
|
||||
logger.info(f"Starting servers - WebSocket on port {WEBSOCKET_PORT}, FastAPI on port {FASTAPI_PORT}")
|
||||
tasks = []
|
||||
try:
|
||||
# Start websocket server
|
||||
tasks.append(run_bot_websocket_server(host=SERVER_HOST, port=WEBSOCKET_PORT))
|
||||
|
||||
# Start FastAPI server
|
||||
config = uvicorn.Config(app, host=SERVER_HOST, port=FASTAPI_PORT)
|
||||
server = uvicorn.Server(config)
|
||||
tasks.append(server.serve())
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Tasks cancelled (probably due to shutdown).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user