mirror of
https://github.com/zenlm/zen-live.git
synced 2026-10-30 14:57:52 +00:00
- Fix undefined 'headers' variable in start_up method - Add verbose logging for API key being used - Should resolve HTTP 401 errors Note: Update .env locally to set HANZO_API_KEY to DashScope key
2388 lines
88 KiB
Python
2388 lines
88 KiB
Python
"""
|
|
Zen Live - Real-time Speech Translation for Broadcast
|
|
|
|
A low-latency simultaneous translation service for news control rooms.
|
|
Powered by Hanzo AI infrastructure with Zen Omni backend.
|
|
|
|
Backend options:
|
|
1. Hanzo Node API (recommended) - Set HANZO_NODE_URL
|
|
2. Direct Hanzo API - Set HANZO_API_KEY
|
|
3. Local Zen Omni model - Set ZEN_OMNI_PATH
|
|
|
|
Usage:
|
|
export HANZO_NODE_URL=http://localhost:3690 # or
|
|
export HANZO_API_KEY=your_hanzo_key
|
|
python app.py
|
|
|
|
Endpoints:
|
|
/ - Control room web portal
|
|
/monitor - Simplified broadcast monitor view
|
|
/api/status - Service health check
|
|
/broadcast/info - Integration guide for engineers
|
|
|
|
Part of the Zen AI model family: https://github.com/zenlm
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import os
|
|
import secrets
|
|
import shutil
|
|
import signal
|
|
import ssl
|
|
import subprocess
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
import certifi
|
|
import cv2
|
|
import gradio as gr
|
|
import numpy as np
|
|
from dotenv import load_dotenv
|
|
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
from fastrtc import (
|
|
AdditionalOutputs,
|
|
# AsyncStreamHandler,
|
|
AsyncAudioVideoStreamHandler,
|
|
Stream,
|
|
get_cloudflare_turn_credentials_async,
|
|
wait_for_item,
|
|
)
|
|
from fastrtc.stream import Body as RTCBody
|
|
from gradio.utils import get_space
|
|
from pydantic import BaseModel
|
|
from websockets.asyncio.client import connect
|
|
|
|
load_dotenv()
|
|
|
|
os.environ["MODE"] = os.environ.get("MODE", "")
|
|
cur_dir = Path(__file__).parent
|
|
|
|
# Backend configuration
|
|
HANZO_NODE_URL = os.environ.get("HANZO_NODE_URL") # Preferred: Hanzo Node backend
|
|
# Support both HANZO_API_KEY and API_KEY (for backwards compatibility)
|
|
API_KEY = os.environ.get("API_KEY") # Alternative: DashScope or other API key
|
|
HANZO_API_KEY = os.environ.get("HANZO_API_KEY") or API_KEY # Fallback: Direct Hanzo API
|
|
ZEN_OMNI_PATH = os.environ.get("ZEN_OMNI_PATH") # Optional: Local model path
|
|
|
|
# Server configuration
|
|
BASE_URL = os.environ.get("BASE_URL", "https://zen-live.hanzo.ai") # Public URL for docs
|
|
PORT = int(os.environ.get("PORT", 8000))
|
|
|
|
# Authentication (optional - set both to enable)
|
|
AUTH_USER = os.environ.get("ZEN_LIVE_USER") # e.g., admin
|
|
AUTH_PASS = os.environ.get("ZEN_LIVE_PASS") # e.g., livedemo2025
|
|
AUTH_ENABLED = bool(AUTH_USER and AUTH_PASS)
|
|
|
|
# Input source configuration (defaults, can be overridden via web UI localStorage)
|
|
SRT_INPUT_URL = os.environ.get("SRT_INPUT_URL") # e.g., srt://source:9000?mode=caller
|
|
RTMP_INPUT_URL = os.environ.get("RTMP_INPUT_URL") # e.g., rtmp://source/live/stream
|
|
WHIP_ENABLED = os.environ.get("WHIP_ENABLED", "true").lower() == "true" # WebRTC WHIP input
|
|
|
|
# Support both TRANSLATE_API_URL and API_URL (for backwards compatibility)
|
|
TRANSLATE_API_URL = os.environ.get("TRANSLATE_API_URL") or os.environ.get("API_URL", "")
|
|
ASR_API_URL = os.environ.get("ASR_API_URL", "")
|
|
|
|
# HTTP Basic Auth setup
|
|
security = HTTPBasic()
|
|
|
|
|
|
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)):
|
|
"""Verify HTTP Basic Auth credentials if authentication is enabled."""
|
|
if not AUTH_ENABLED:
|
|
return True # Auth disabled, allow all
|
|
|
|
# Use constant-time comparison to prevent timing attacks
|
|
correct_username = secrets.compare_digest(credentials.username, AUTH_USER)
|
|
correct_password = secrets.compare_digest(credentials.password, AUTH_PASS)
|
|
|
|
if not (correct_username and correct_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid credentials",
|
|
headers={"WWW-Authenticate": "Basic realm='Zen Live'"},
|
|
)
|
|
return True
|
|
|
|
|
|
def optional_auth(request: Request):
|
|
"""Optional auth - only require if AUTH_ENABLED is True."""
|
|
if not AUTH_ENABLED:
|
|
return True
|
|
|
|
# Check for Authorization header
|
|
auth_header = request.headers.get("Authorization")
|
|
if not auth_header or not auth_header.startswith("Basic "):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authentication required",
|
|
headers={"WWW-Authenticate": "Basic realm='Zen Live'"},
|
|
)
|
|
|
|
try:
|
|
credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
|
|
username, password = credentials.split(":", 1)
|
|
|
|
correct_username = secrets.compare_digest(username, AUTH_USER)
|
|
correct_password = secrets.compare_digest(password, AUTH_PASS)
|
|
|
|
if not (correct_username and correct_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid credentials",
|
|
headers={"WWW-Authenticate": "Basic realm='Zen Live'"},
|
|
)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid authorization header",
|
|
headers={"WWW-Authenticate": "Basic realm='Zen Live'"},
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
async def verify_websocket_auth(websocket: WebSocket) -> bool:
|
|
"""Verify WebSocket authentication via query param or header."""
|
|
if not AUTH_ENABLED:
|
|
return True
|
|
|
|
# Check for auth in query params (for WebSocket clients that can't set headers)
|
|
token = websocket.query_params.get("token")
|
|
if token:
|
|
try:
|
|
credentials = base64.b64decode(token).decode("utf-8")
|
|
username, password = credentials.split(":", 1)
|
|
if secrets.compare_digest(username, AUTH_USER) and secrets.compare_digest(
|
|
password, AUTH_PASS
|
|
):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
# Check for Authorization header
|
|
auth_header = websocket.headers.get("authorization")
|
|
if auth_header and auth_header.startswith("Basic "):
|
|
try:
|
|
credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
|
|
username, password = credentials.split(":", 1)
|
|
if secrets.compare_digest(username, AUTH_USER) and secrets.compare_digest(
|
|
password, AUTH_PASS
|
|
):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
return False
|
|
|
|
|
|
VOICES = ["Cherry", "Nofish", "Jada", "Dylan", "Sunny", "Peter", "Kiki", "Eric"]
|
|
|
|
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
|
|
|
# Determine backend and get credentials
|
|
if HANZO_NODE_URL:
|
|
print(f"🔗 Zen Live: Using Hanzo Node backend at {HANZO_NODE_URL}")
|
|
BACKEND_TYPE = "hanzo_node"
|
|
print("🔗 Zen Live: Using direct Hanzo API")
|
|
BACKEND_TYPE = "hanzo_api"
|
|
print("⚠️ Zen Live: No backend configured")
|
|
print(" Set HANZO_NODE_URL (recommended) or HANZO_API_KEY")
|
|
BACKEND_TYPE = None
|
|
|
|
# Set authentication headers - WebSocket APIs use Authorization Bearer
|
|
headers = {"Authorization": f"Bearer {HANZO_API_KEY}"}
|
|
headers = {}
|
|
LANG_MAP = {
|
|
"en": "English",
|
|
"zh": "Chinese",
|
|
"ru": "Russian",
|
|
"fr": "French",
|
|
"de": "German",
|
|
"pt": "Portuguese",
|
|
"es": "Spanish",
|
|
"it": "Italian",
|
|
"ko": "Korean",
|
|
"ja": "Japanese",
|
|
"yue": "Cantonese",
|
|
"id": "Indonesian",
|
|
"vi": "Vietnamese",
|
|
"th": "Thai",
|
|
"ar": "Arabic",
|
|
"hi": "Hindi",
|
|
"el": "Greek",
|
|
"tr": "Turkish",
|
|
}
|
|
LANG_MAP_REVERSE = {v: k for k, v in LANG_MAP.items()}
|
|
|
|
# Handler registry to track session_id -> handler mapping for concurrent users
|
|
handler_registry = {} # session_id -> LiveTranslateHandler
|
|
# Pending session ID to be assigned to the next handler that starts
|
|
pending_session_id = None
|
|
# SRC_LANGUAGES = ["en", "zh", "ru", "fr", "de", "pt", "es", "it", "ko", "ja", "yue", "id", "vi", "th", "ar", "hi", "el", "tr"]
|
|
# TARGET_LANGUAGES = ["en", "zh", "ru", "fr", "de", "pt", "es", "it", "ko", "ja", "yue", "id", "vi", "th", "ar"]
|
|
|
|
# Spanish first for news monitoring default
|
|
SRC_LANGUAGES = [
|
|
LANG_MAP[code]
|
|
for code in [
|
|
"es",
|
|
"en",
|
|
"zh",
|
|
"pt",
|
|
"fr",
|
|
"de",
|
|
"ru",
|
|
"it",
|
|
"ko",
|
|
"ja",
|
|
"yue",
|
|
"id",
|
|
"vi",
|
|
"th",
|
|
"ar",
|
|
"hi",
|
|
"el",
|
|
"tr",
|
|
]
|
|
]
|
|
TARGET_LANGUAGES = [
|
|
LANG_MAP[code]
|
|
for code in [
|
|
"en",
|
|
"zh",
|
|
"ru",
|
|
"fr",
|
|
"de",
|
|
"pt",
|
|
"es",
|
|
"it",
|
|
"ko",
|
|
"ja",
|
|
"yue",
|
|
"id",
|
|
"vi",
|
|
"th",
|
|
"ar",
|
|
]
|
|
]
|
|
|
|
|
|
class LiveTranslateHandler(AsyncAudioVideoStreamHandler):
|
|
# Pre-computed silent audio (100ms at 16kHz, mono, int16) - class constant
|
|
SILENT_AUDIO_100MS = base64.b64encode(np.zeros(1600, dtype=np.int16).tobytes()).decode()
|
|
|
|
def __init__(self, session_config: dict = None, session_id: str = None) -> None:
|
|
print("🔧 LiveTranslateHandler.__init__() called")
|
|
super().__init__(
|
|
expected_layout="mono",
|
|
output_sample_rate=24_000,
|
|
input_sample_rate=16_000,
|
|
)
|
|
self.connection = None
|
|
# LOW LATENCY: Reduced queue sizes to minimize buffering delay
|
|
# 5 audio chunks max (~100-250ms at typical chunk rates)
|
|
self.output_queue = asyncio.Queue(maxsize=5)
|
|
# 2 video frames max (~1 second at 2fps)
|
|
self.video_queue = asyncio.Queue(maxsize=2)
|
|
|
|
self.last_send_time = 0.0 # Last send timestamp
|
|
self.video_interval = 0.5 # Interval in seconds (match reference: 2fps for context)
|
|
self.latest_frame = None
|
|
|
|
self.awaiting_new_message = True
|
|
self.stable_text = "" # Confirmed text (black)
|
|
self.temp_text = "" # Interim text (gray)
|
|
# Source language transcript (English input)
|
|
self.source_text = ""
|
|
self.source_temp = ""
|
|
|
|
# Session ID for tracking and audio routing
|
|
self.session_id = session_id
|
|
|
|
# Keepalive: track last audio receive time
|
|
self.last_audio_time = 0.0
|
|
self.keepalive_task = None
|
|
self.running = False # Flag to stop keepalive on shutdown
|
|
|
|
# Latency tracking
|
|
self.last_input_time = 0.0 # Last time we sent audio to API
|
|
self.last_output_time = 0.0 # Last time we received audio from API
|
|
self.latency_samples = [] # Store recent latency measurements
|
|
self.max_latency_samples = 10 # Keep last 10 measurements
|
|
|
|
# Per-instance message counter (not shared across sessions)
|
|
self._msg_counter = 0
|
|
|
|
# Session-specific config (avoids race conditions with concurrent users)
|
|
self.session_config = session_config or {
|
|
"src_language": "Spanish",
|
|
"target_language": "English",
|
|
"voice": "Nofish",
|
|
}
|
|
print(
|
|
f"🔧 LiveTranslateHandler.__init__() complete, session_id: {session_id}, config: {self.session_config}"
|
|
)
|
|
|
|
def copy(self):
|
|
global pending_session_id
|
|
print(
|
|
f"🔧 LiveTranslateHandler.copy() called - creating new handler instance, pending_session_id: {pending_session_id}"
|
|
)
|
|
# Pass session config and pending session_id to the new handler instance
|
|
new_handler = LiveTranslateHandler(
|
|
session_config=self.session_config.copy() if self.session_config else None,
|
|
session_id=pending_session_id,
|
|
)
|
|
# Register handler if we have a session_id
|
|
if pending_session_id:
|
|
handler_registry[pending_session_id] = new_handler
|
|
pending_session_id = None # Clear pending
|
|
return new_handler
|
|
|
|
def msg_id(self) -> str:
|
|
"""Generate unique event ID for this session."""
|
|
self._msg_counter += 1
|
|
return f"evt_{self._msg_counter}"
|
|
|
|
async def _keepalive_loop(self):
|
|
"""Send silent audio regularly when no real audio is flowing.
|
|
|
|
This keeps the API connection alive and the translation pipeline primed,
|
|
ensuring instant response when real audio starts.
|
|
"""
|
|
print("🔄 Keepalive loop started")
|
|
while self.running:
|
|
try:
|
|
await asyncio.sleep(0.5) # Check frequently
|
|
# Capture connection locally to avoid race condition with shutdown
|
|
conn = self.connection
|
|
if not self.running or conn is None:
|
|
break
|
|
|
|
# If no real audio in last 0.5 seconds, send keepalive silence
|
|
# This keeps the pipeline warm and ready for instant response
|
|
now = time.time()
|
|
if now - self.last_audio_time > 0.5:
|
|
# Send 100ms of silence to keep connection alive
|
|
self._msg_counter += 1
|
|
msg = f'{{"event_id":"evt_{self._msg_counter}","type":"input_audio_buffer.append","audio":"{self.SILENT_AUDIO_100MS}"}}'
|
|
await conn.send(msg)
|
|
# Only log occasionally to reduce noise
|
|
if self._msg_counter % 20 == 0:
|
|
print("🔇 Keepalive active (no real audio)")
|
|
except Exception as e:
|
|
if self.running:
|
|
print(f"⚠️ Keepalive error: {e}")
|
|
break
|
|
print("🔄 Keepalive loop ended")
|
|
|
|
async def start_up(self):
|
|
try:
|
|
# Use global current_session_config (set by webrtc_offer endpoint)
|
|
# This is set just before stream.offer() is called
|
|
config = current_session_config
|
|
print(f"🎤 Handler starting with session_config: {config}")
|
|
|
|
src_language_name = config.get("src_language", "Spanish")
|
|
target_language_name = config.get("target_language", "English")
|
|
voice_id = config.get("voice", "Nofish")
|
|
|
|
src_language_code = LANG_MAP_REVERSE.get(src_language_name, "en")
|
|
target_language_code = LANG_MAP_REVERSE.get(target_language_name, "es")
|
|
|
|
print(
|
|
f"🌐 Translation: {src_language_name} ({src_language_code}) -> {target_language_name} ({target_language_code}), Voice: {voice_id}"
|
|
)
|
|
|
|
if src_language_code == target_language_code:
|
|
print(
|
|
f"⚠️ Source and target language are the same ({target_language_name}), running in echo mode"
|
|
)
|
|
|
|
# Set auth headers for WebSocket connection
|
|
auth_headers = {"Authorization": f"Bearer {HANZO_API_KEY}"} if HANZO_API_KEY else {}
|
|
|
|
print(f"🔌 Connecting to Hanzo WebSocket: {TRANSLATE_API_URL[:50]}...")
|
|
print(f"🔑 Using API key: {HANZO_API_KEY[:20] if HANZO_API_KEY else 'NONE'}...")
|
|
async with connect(
|
|
TRANSLATE_API_URL, additional_headers=auth_headers, ssl=ssl_context
|
|
) as conn:
|
|
print("✅ Hanzo WebSocket connected!")
|
|
self.client = conn
|
|
session_update_msg = {
|
|
"event_id": self.msg_id(),
|
|
"type": "session.update",
|
|
"session": {
|
|
"modalities": ["text", "audio"],
|
|
"voice": voice_id,
|
|
"input_audio_format": "pcm16",
|
|
"output_audio_format": "pcm16",
|
|
"input_audio_transcription": {
|
|
"language": src_language_code,
|
|
"model": "whisper-1" # Explicit ASR model
|
|
},
|
|
"translation": {
|
|
"source_language": src_language_code,
|
|
"language": target_language_code
|
|
},
|
|
# LOW LATENCY: Enable streaming with minimal buffering
|
|
"turn_detection": None, # Disable VAD for continuous streaming
|
|
"max_response_output_tokens": "inf" # No artificial limits
|
|
},
|
|
}
|
|
print(f"📤 Sending session.update: {json.dumps(session_update_msg, indent=2)}")
|
|
await conn.send(json.dumps(session_update_msg))
|
|
self.connection = conn
|
|
self.running = True
|
|
self.last_audio_time = time.time() # Initialize
|
|
|
|
# Start keepalive loop to prevent timeout disconnections
|
|
self.keepalive_task = asyncio.create_task(self._keepalive_loop())
|
|
|
|
print("🎧 Now listening for Hanzo responses...")
|
|
|
|
# Each WebSocket response (data) is a JSON event representing translation progress
|
|
async for data in self.connection:
|
|
print(
|
|
f"📨 Hanzo response: {data[:200]}..."
|
|
if len(data) > 200
|
|
else f"📨 Hanzo response: {data}"
|
|
)
|
|
event = json.loads(data)
|
|
if "type" not in event:
|
|
continue
|
|
event_type = event["type"]
|
|
|
|
# Handle source language transcription (input)
|
|
if event_type in (
|
|
"input_audio_transcript.delta",
|
|
"input_audio_transcript.text",
|
|
):
|
|
self.source_text = event.get("text", "") or ""
|
|
self.source_temp = event.get("stash", "") or ""
|
|
print(f"[SOURCE] {self.source_text} {self.source_temp}")
|
|
# Send source transcript to client
|
|
await self.output_queue.put(
|
|
AdditionalOutputs(
|
|
{
|
|
"role": "source",
|
|
"source_content": f"{self.source_text}{self.source_temp}",
|
|
"update": True,
|
|
}
|
|
)
|
|
)
|
|
|
|
elif event_type == "input_audio_transcript.done":
|
|
transcript = event.get("transcript", "")
|
|
print(f"[SOURCE DONE] {transcript}")
|
|
if transcript:
|
|
self.source_text = transcript
|
|
self.source_temp = ""
|
|
await self.output_queue.put(
|
|
AdditionalOutputs(
|
|
{"role": "source", "source_content": transcript, "update": True}
|
|
)
|
|
)
|
|
|
|
elif event_type in ("response.text.text", "response.audio_transcript.text"):
|
|
# Update stable text (stash/text are confirmed)
|
|
self.stable_text = event.get("text", "") or ""
|
|
self.temp_text = event.get("stash", "") or ""
|
|
# self.stable_text = event.get("stash", "") or ""
|
|
# self.temp_text = event.get("text", "") or ""
|
|
|
|
print(f"[STABLE] {self.stable_text}")
|
|
print(f"[TEMP] {self.temp_text}")
|
|
|
|
# Calculate current average latency for UI display
|
|
avg_latency = 0
|
|
if self.latency_samples:
|
|
avg_latency = int(sum(self.latency_samples) / len(self.latency_samples))
|
|
|
|
await self.output_queue.put(
|
|
AdditionalOutputs(
|
|
{
|
|
"role": "assistant",
|
|
# Stable text is black, interim text is gray
|
|
"content": f"<span style='color:black'>{self.stable_text}</span>"
|
|
f"<span style='color:gray'>{self.temp_text}</span>",
|
|
"update": True,
|
|
"new_message": self.awaiting_new_message,
|
|
"latency_ms": avg_latency # Send latency to UI
|
|
}
|
|
)
|
|
)
|
|
self.awaiting_new_message = False
|
|
|
|
elif event_type == "response.audio_transcript.done":
|
|
transcript = event.get("transcript", "")
|
|
print(f"[DONE] {transcript}")
|
|
if transcript:
|
|
self.stable_text = transcript
|
|
self.temp_text = ""
|
|
await self.output_queue.put(
|
|
AdditionalOutputs(
|
|
{
|
|
"role": "assistant",
|
|
"content": f"<span style='color:black'>{self.stable_text}</span>",
|
|
"update": True,
|
|
"new_message": self.awaiting_new_message,
|
|
}
|
|
)
|
|
)
|
|
# Start new message bubble
|
|
self.awaiting_new_message = True
|
|
self.stable_text = ""
|
|
self.temp_text = ""
|
|
|
|
elif event_type == "response.audio.delta":
|
|
audio_b64 = event.get("delta", "")
|
|
if audio_b64:
|
|
# Measure latency: time from last input to this output
|
|
now = time.time()
|
|
if self.last_input_time > 0:
|
|
latency_ms = (now - self.last_input_time) * 1000
|
|
self.latency_samples.append(latency_ms)
|
|
if len(self.latency_samples) > self.max_latency_samples:
|
|
self.latency_samples.pop(0)
|
|
avg_latency = sum(self.latency_samples) / len(self.latency_samples)
|
|
print(f"🕐 Latency: {latency_ms:.0f}ms (avg: {avg_latency:.0f}ms)")
|
|
self.last_output_time = now
|
|
|
|
audio_data = base64.b64decode(audio_b64)
|
|
audio_array = np.frombuffer(audio_data, dtype=np.int16).reshape(1, -1)
|
|
|
|
# LOW LATENCY: Drop oldest chunk if queue is full (backpressure)
|
|
if self.output_queue.full():
|
|
try:
|
|
self.output_queue.get_nowait()
|
|
print("⚠️ Audio queue full, dropped oldest chunk for low latency")
|
|
except asyncio.QueueEmpty:
|
|
pass
|
|
|
|
await self.output_queue.put(
|
|
(self.output_sample_rate, audio_array)
|
|
) # Publish to HTTP audio stream subscribers for broadcast output
|
|
# Uses session_id for proper routing to concurrent sessions
|
|
await publish_audio_to_subscribers(audio_data, self.session_id)
|
|
|
|
except Exception as e:
|
|
print(f"Connection error: {e}")
|
|
await self.shutdown()
|
|
|
|
# Client -> Server: receive video frame
|
|
async def video_receive(self, frame: np.ndarray):
|
|
self.latest_frame = frame
|
|
if self.connection is None:
|
|
return
|
|
|
|
# Push frame to local queue for display - drop oldest if full (keep newest)
|
|
while self.video_queue.full():
|
|
try:
|
|
self.video_queue.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
try:
|
|
self.video_queue.put_nowait(frame)
|
|
except asyncio.QueueFull:
|
|
pass
|
|
|
|
now = time.time()
|
|
if now - self.last_send_time < self.video_interval:
|
|
return
|
|
self.last_send_time = now
|
|
|
|
# Process video in background task to not block audio
|
|
asyncio.create_task(self._send_video_frame(frame))
|
|
|
|
def _encode_frame_sync(self, frame: np.ndarray) -> bytes:
|
|
"""CPU-bound frame encoding - runs in thread pool."""
|
|
frame_resized = cv2.resize(frame, (640, 360))
|
|
_, buf = cv2.imencode(".jpg", frame_resized, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
|
return buf.tobytes()
|
|
|
|
async def _send_video_frame(self, frame: np.ndarray):
|
|
"""Send video frame to API - runs in background to not block audio."""
|
|
try:
|
|
if self.connection is None:
|
|
return
|
|
# Run CPU-bound encoding in thread pool to not block event loop
|
|
jpeg_bytes = await asyncio.to_thread(self._encode_frame_sync, frame)
|
|
img_b64 = base64.b64encode(jpeg_bytes).decode()
|
|
|
|
# Publish to video stream subscribers for remote viewers
|
|
if self.session_id:
|
|
await publish_video_to_subscribers(jpeg_bytes, self.session_id)
|
|
|
|
# Send to API
|
|
msg = f'{{"event_id":"evt_{self._msg_counter}","type":"input_image_buffer.append","image":"{img_b64}"}}'
|
|
self._msg_counter += 1
|
|
await self.connection.send(msg)
|
|
except Exception:
|
|
pass # Don't let video errors affect audio
|
|
|
|
# Server -> Client: emit video frame
|
|
async def video_emit(self):
|
|
if self.latest_frame is not None:
|
|
return self.latest_frame
|
|
return np.zeros((100, 100, 3), dtype=np.uint8)
|
|
|
|
async def receive(self, frame):
|
|
# Audio receive - send immediately with minimal overhead
|
|
# Capture connection locally to avoid race condition
|
|
conn = self.connection
|
|
if conn is None:
|
|
return
|
|
# Update last audio time for keepalive tracking and latency measurement
|
|
now = time.time()
|
|
self.last_audio_time = now
|
|
self.last_input_time = now # Track when we send audio for latency calculation
|
|
|
|
sr, array = frame
|
|
audio_data = array.squeeze()
|
|
|
|
# Skip silent frames to prevent buffer buildup when source is paused
|
|
# This ensures instant response when audio resumes (no backlog)
|
|
# RMS threshold 50 for int16 audio (~-56 dBFS, well below quiet speech)
|
|
rms = np.sqrt(np.mean(audio_data.astype(np.float32) ** 2))
|
|
if rms < 50:
|
|
# Silent frame - don't send, let keepalive handle connection
|
|
return
|
|
|
|
self._msg_counter += 1
|
|
audio_b64 = base64.b64encode(audio_data.tobytes()).decode()
|
|
# Use string formatting instead of json.dumps for speed
|
|
msg = f'{{"event_id":"evt_{self._msg_counter}","type":"input_audio_buffer.append","audio":"{audio_b64}"}}'
|
|
await conn.send(msg)
|
|
|
|
async def emit(self) -> tuple[int, np.ndarray] | AdditionalOutputs | None:
|
|
return await wait_for_item(self.output_queue)
|
|
|
|
async def shutdown(self) -> None:
|
|
"""Close connection and cleanup resources"""
|
|
# Stop keepalive loop first
|
|
self.running = False
|
|
if self.keepalive_task:
|
|
self.keepalive_task.cancel()
|
|
try:
|
|
await self.keepalive_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self.keepalive_task = None
|
|
|
|
# Cleanup registries
|
|
if self.session_id:
|
|
handler_registry.pop(self.session_id, None)
|
|
audio_subscribers.pop(self.session_id, None)
|
|
video_subscribers.pop(self.session_id, None)
|
|
|
|
if self.connection:
|
|
try:
|
|
await self.connection.close()
|
|
except Exception:
|
|
pass # Connection may already be closed
|
|
self.connection = None
|
|
|
|
# Clear queues
|
|
while not self.output_queue.empty():
|
|
try:
|
|
self.output_queue.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
while not self.video_queue.empty():
|
|
try:
|
|
self.video_queue.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
|
|
|
|
def update_chatbot_and_latency(chatbot: list[dict], latency_md: str, response: dict):
|
|
"""Update both chatbot and latency display from response."""
|
|
is_update = response.pop("update", False)
|
|
new_message_flag = response.pop("new_message", False)
|
|
stable_html = response["content"]
|
|
latency_ms = response.pop("latency_ms", 0) # Get latency from response
|
|
|
|
# Update latency display
|
|
if latency_ms > 0:
|
|
# Color-code based on latency thresholds
|
|
if latency_ms < 300:
|
|
color = "green"
|
|
status = "🟢 Excellent"
|
|
elif latency_ms < 500:
|
|
color = "orange"
|
|
status = "🟡 Good"
|
|
color = "red"
|
|
status = "🔴 High"
|
|
latency_md = f"**Latency:** <span style='color:{color}'>{latency_ms}ms</span> {status}"
|
|
latency_md = "**Latency:** --ms"
|
|
|
|
if is_update:
|
|
if new_message_flag or not chatbot:
|
|
chatbot.append({"role": "assistant", "content": stable_html})
|
|
if chatbot[-1]["role"] == "assistant":
|
|
chatbot[-1]["content"] = stable_html
|
|
chatbot.append({"role": "assistant", "content": stable_html})
|
|
chatbot.append(response)
|
|
|
|
return chatbot, latency_md
|
|
|
|
|
|
chatbot = gr.Chatbot(type="messages", allow_tags=False)
|
|
src_language = gr.Dropdown(
|
|
choices=SRC_LANGUAGES,
|
|
value="Spanish", # Default to Spanish for news monitoring
|
|
type="value",
|
|
label="Source Language",
|
|
)
|
|
language = gr.Dropdown(
|
|
choices=TARGET_LANGUAGES,
|
|
value="English", # Default to English output
|
|
type="value",
|
|
label="Target Language",
|
|
)
|
|
voice = gr.Dropdown(choices=VOICES, value=VOICES[0], type="value", label="Voice")
|
|
# video_flag = gr.Dropdown(
|
|
# choices=["True", "False"],
|
|
# value="False",
|
|
# label="Use Video"
|
|
# )
|
|
|
|
latest_message = gr.Textbox(type="text", visible=False)
|
|
latency_display = gr.Markdown(value="**Latency:** --ms", visible=True, label="Translation Latency")
|
|
|
|
# Optional: Temporarily disable TURN configuration for testing
|
|
rtc_config = get_cloudflare_turn_credentials_async if get_space() else None
|
|
# rtc_config = None # Uncomment to disable TURN for testing
|
|
|
|
|
|
stream = Stream(
|
|
LiveTranslateHandler(),
|
|
mode="send-receive",
|
|
modality="audio-video",
|
|
additional_inputs=[src_language, language, voice, chatbot, latency_display],
|
|
additional_outputs=[chatbot, latency_display],
|
|
additional_outputs_handler=update_chatbot_and_latency,
|
|
rtc_configuration=rtc_config,
|
|
# High limits - FastRTC treats None as 1, so set explicit high values
|
|
concurrency_limit=1000,
|
|
time_limit=86400, # 24 hours
|
|
)
|
|
|
|
|
|
# Frontend UI customization
|
|
def enhance_ui():
|
|
with stream.ui as demo:
|
|
gr.HTML("""
|
|
<style>
|
|
.gradio-container .wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
.gradio-container video{
|
|
width: 100%;
|
|
max-width: 500px;
|
|
max-height: 500px;
|
|
aspect-ratio: 1/1;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
margin: 7% 10px;
|
|
}
|
|
|
|
.button-wrap.svelte-g69fcx.svelte-g69fcx {
|
|
position: absolute;
|
|
background-color: color-mix(in srgb,var(--block-background-fill) 50%,transparent);
|
|
border: 1px solid var(--border-color-primary);
|
|
padding: var(--size-1-5);
|
|
display: flex;
|
|
bottom: 300px;
|
|
left: 50%;
|
|
transform: translate(-50%);
|
|
box-shadow: var(--shadow-drop-lg);
|
|
border-radius: var(--radius-xl);
|
|
line-height: var(--size-3);
|
|
color: var(--button-secondary-text-color)
|
|
}
|
|
label.svelte-j0zqjt.svelte-j0zqjt {
|
|
display: flex;
|
|
align-items: center;
|
|
z-index: var(--layer-2);
|
|
box-shadow: var(--block-label-shadow);
|
|
border: var(--block-label-border-width) solid var(--block-label-border-color);
|
|
border-top: none;
|
|
border-left: none;
|
|
border-radius: var(--block-label-radius);
|
|
background: var(--block-label-background-fill);
|
|
padding: var(--block-label-padding);
|
|
pointer-events: none;
|
|
color: var(--block-label-text-color);
|
|
font-weight: var(--block-label-text-weight);
|
|
font-size: var(--block-label-text-size);
|
|
line-height: var(--line-sm)
|
|
}
|
|
</style>
|
|
""")
|
|
return demo
|
|
|
|
|
|
# Generate dynamic documentation based on environment
|
|
auth_header = (
|
|
f"Authorization: Basic {base64.b64encode(f'{AUTH_USER}:{AUTH_PASS}'.encode()).decode()}"
|
|
if AUTH_ENABLED
|
|
else "No authentication required"
|
|
)
|
|
ws_url = BASE_URL.replace("https", "wss").replace("http", "ws")
|
|
|
|
# Build documentation string separately to avoid f-string issues
|
|
auth_section = ""
|
|
if AUTH_ENABLED:
|
|
auth_section = f"""HTTP Basic Auth is required for all protected endpoints.
|
|
|
|
**Using the Authorization Header:**
|
|
```
|
|
{auth_header}
|
|
```
|
|
|
|
**Examples:**
|
|
```bash
|
|
# API Status
|
|
curl -H '{auth_header}' \\
|
|
{BASE_URL}/api/status
|
|
|
|
# WebSocket connection
|
|
wscat -c '{ws_url}/v1/realtime' \\
|
|
-H '{auth_header}'
|
|
|
|
# Stream audio output (replace SESSION_ID with actual session)
|
|
curl -H '{auth_header}' \\
|
|
{BASE_URL}/audio/stream/SESSION_ID
|
|
```"""
|
|
auth_section = f"""No authentication required - open access
|
|
|
|
## API Examples:
|
|
```bash
|
|
# API Status
|
|
curl {BASE_URL}/api/status
|
|
|
|
# WebSocket connection
|
|
wscat -c '{ws_url}/v1/realtime'
|
|
|
|
# Stream audio output (replace SESSION_ID with actual session)
|
|
curl {BASE_URL}/audio/stream/SESSION_ID
|
|
```"""
|
|
|
|
app_description = f"""
|
|
# Zen Live - Real-time Speech Translation API
|
|
|
|
Low-latency simultaneous translation service for broadcast news monitoring.
|
|
|
|
## Features
|
|
- **WebRTC streaming** - Browser-based real-time translation
|
|
- **WHIP/WHEP** - Standard broadcast ingestion/egress protocols
|
|
- **Audio streaming** - PCM/WAV endpoints for broadcast integration
|
|
- **SSE transcripts** - Real-time transcript streaming
|
|
|
|
## Quick Start
|
|
1. Open the [Control Room UI](/) in your browser
|
|
2. Select source language (default: Spanish)
|
|
3. Select target language (default: English)
|
|
4. Click Start to begin translation
|
|
|
|
## Authentication
|
|
{auth_section}
|
|
|
|
## Session Management
|
|
When you connect via WebRTC or WHIP, you receive a `webrtc_id` (session ID) in the response.
|
|
Use this SESSION_ID to access:
|
|
- Transcripts: `{BASE_URL}/outputs?webrtc_id=SESSION_ID`
|
|
- Audio stream: `{BASE_URL}/audio/stream/SESSION_ID`
|
|
- WAV audio: `{BASE_URL}/audio/wav/SESSION_ID`
|
|
|
|
## Broadcast Integration
|
|
See [/broadcast/info](/broadcast/info) for ffmpeg, SRT, RTMP, and NDI integration examples.
|
|
|
|
Powered by [Hanzo AI](https://hanzo.ai) | [GitHub](https://github.com/zenlm/zen-live)
|
|
"""
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Lifespan context manager for startup and shutdown events."""
|
|
# Startup
|
|
asyncio.create_task(cleanup_stale_sessions())
|
|
print("🚀 Started session cleanup background task")
|
|
yield
|
|
# Shutdown (cleanup if needed)
|
|
print("👋 Zen Live shutting down...")
|
|
|
|
|
|
app = FastAPI(
|
|
title="Zen Live",
|
|
description=app_description,
|
|
version="1.0.0",
|
|
docs_url="/docs",
|
|
redoc_url="/redoc",
|
|
openapi_url="/openapi.json",
|
|
license_info={"name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0"},
|
|
contact={
|
|
"name": "Zen Live Support",
|
|
"url": "https://github.com/zenlm/zen-live",
|
|
"email": "[email protected]",
|
|
},
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# CORS for control room access from different domains
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
stream.mount(app)
|
|
|
|
|
|
# WebRTC offer model
|
|
class WebRTCOffer(BaseModel):
|
|
sdp: str
|
|
type: str
|
|
src_language: str = "Spanish"
|
|
target_language: str = "English"
|
|
voice: str = "Nofish"
|
|
|
|
|
|
# Store active sessions for control room
|
|
active_sessions = {}
|
|
# Session configs indexed by session_id - used by handlers to get their config
|
|
session_configs = {}
|
|
# Current session config for single-instance mode (fallback)
|
|
current_session_config = {
|
|
"src_language": "Spanish",
|
|
"target_language": "English",
|
|
"voice": "Nofish",
|
|
}
|
|
# Session timeout in seconds (cleanup after 1 hour of inactivity)
|
|
SESSION_TIMEOUT = 3600
|
|
|
|
|
|
async def cleanup_stale_sessions():
|
|
"""Background task to cleanup stale sessions and prevent memory leak."""
|
|
while True:
|
|
await asyncio.sleep(300) # Run every 5 minutes
|
|
current_time = time.time()
|
|
stale_sessions = []
|
|
|
|
# Find stale active_sessions
|
|
for session_id, session_data in list(active_sessions.items()):
|
|
created_at = session_data.get("created_at", 0)
|
|
if current_time - created_at > SESSION_TIMEOUT:
|
|
stale_sessions.append(session_id)
|
|
|
|
# Remove stale sessions
|
|
for session_id in stale_sessions:
|
|
active_sessions.pop(session_id, None)
|
|
session_configs.pop(session_id, None)
|
|
handler_registry.pop(session_id, None) # Also cleanup handler registry
|
|
audio_subscribers.pop(session_id, None) # Cleanup audio subscribers
|
|
print(f"🧹 Cleaned up stale session: {session_id[:8]}...")
|
|
|
|
if stale_sessions:
|
|
print(f"🧹 Cleaned up {len(stale_sessions)} stale session(s)")
|
|
|
|
|
|
@app.post("/api/webrtc/offer")
|
|
async def webrtc_offer(offer: WebRTCOffer):
|
|
"""Handle WebRTC offer from control room portal.
|
|
|
|
This endpoint receives SDP offers and returns SDP answers
|
|
for establishing low-latency WebRTC connections.
|
|
"""
|
|
try:
|
|
# Generate session ID
|
|
session_id = secrets.token_hex(16)
|
|
|
|
# Create session config for this specific user
|
|
session_config = {
|
|
"src_language": offer.src_language,
|
|
"target_language": offer.target_language,
|
|
"voice": offer.voice,
|
|
}
|
|
|
|
# Store session config
|
|
active_sessions[session_id] = {**session_config, "created_at": time.time()}
|
|
|
|
# Store session config by ID so handler can look it up
|
|
session_configs[session_id] = session_config.copy()
|
|
|
|
# Also update global fallback for single-user mode
|
|
global current_session_config, pending_session_id
|
|
current_session_config = session_config.copy()
|
|
# Set pending session_id so handler.copy() can pick it up
|
|
pending_session_id = session_id
|
|
|
|
print(
|
|
f"📡 WebRTC offer received: {offer.src_language} -> {offer.target_language}, voice: {offer.voice}"
|
|
)
|
|
|
|
# Use FastRTC's internal offer handling with proper Body object
|
|
rtc_body = RTCBody(sdp=offer.sdp, type=offer.type, webrtc_id=session_id, candidate=None)
|
|
print(f"📤 Calling stream.offer with session_id: {session_id}")
|
|
response = await stream.offer(rtc_body)
|
|
print(f"📥 stream.offer response type: {type(response)}")
|
|
print(f"📥 stream.offer response: {response}")
|
|
|
|
# Handle response - FastRTC returns dict on success, JSONResponse on error
|
|
if isinstance(response, JSONResponse):
|
|
# FastRTC returned an error response, pass it through
|
|
print("📥 FastRTC returned JSONResponse (error case)")
|
|
return response
|
|
elif isinstance(response, dict):
|
|
sdp = response.get("sdp", "")
|
|
resp_type = response.get("type", "answer")
|
|
webrtc_id = session_id
|
|
# Unexpected response type - try to handle it
|
|
print(f"⚠️ Unexpected response type: {type(response)}")
|
|
if hasattr(response, "body"):
|
|
# It might be a Response object
|
|
import json
|
|
|
|
body = response.body.decode() if isinstance(response.body, bytes) else response.body
|
|
try:
|
|
data = json.loads(body)
|
|
if "error" in data or "status" in data:
|
|
return response
|
|
sdp = data.get("sdp", "")
|
|
resp_type = data.get("type", "answer")
|
|
except:
|
|
sdp = ""
|
|
resp_type = "answer"
|
|
sdp = getattr(response, "sdp", "")
|
|
resp_type = getattr(response, "type", "answer")
|
|
webrtc_id = session_id
|
|
|
|
print(f"📥 Extracted SDP (first 200 chars): {sdp[:200] if sdp else 'EMPTY'}")
|
|
|
|
if not sdp:
|
|
raise ValueError("No SDP in response - FastRTC may have returned an error")
|
|
|
|
return JSONResponse({"sdp": sdp, "type": resp_type, "webrtc_id": webrtc_id})
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
print(f"WebRTC offer error: {e}")
|
|
return JSONResponse({"error": str(e), "type": "error"}, status_code=500)
|
|
|
|
|
|
# WHIP endpoint for external WebRTC ingestion (broadcaster-friendly)
|
|
whip_sessions = {} # session_id -> session config
|
|
|
|
|
|
class WHIPOffer(BaseModel):
|
|
"""WHIP offer model for external stream ingestion."""
|
|
|
|
sdp: str
|
|
type: str = "offer"
|
|
|
|
|
|
@app.post("/whip")
|
|
async def whip_ingest(
|
|
request: Request,
|
|
src_language: str = "Spanish",
|
|
target_language: str = "English",
|
|
voice: str = "Cherry",
|
|
_auth: bool = Depends(optional_auth),
|
|
):
|
|
"""
|
|
WHIP (WebRTC-HTTP Ingestion Protocol) endpoint for broadcaster ingestion.
|
|
|
|
This allows external encoders/broadcasters to push WebRTC streams for translation.
|
|
Compatible with OBS, FFmpeg, GStreamer, and professional broadcast encoders.
|
|
|
|
Usage with FFmpeg:
|
|
ffmpeg -re -i input.mp4 -c:v libvpx -c:a opus -f webrtc http://host/whip
|
|
|
|
Usage with GStreamer:
|
|
gst-launch-1.0 ... ! webrtcsink signaller::uri=http://host/whip
|
|
|
|
Query params:
|
|
src_language: Source language (default: Spanish)
|
|
target_language: Target language (default: English)
|
|
voice: TTS voice (default: Cherry)
|
|
"""
|
|
if not WHIP_ENABLED:
|
|
raise HTTPException(status_code=403, detail="WHIP ingestion is disabled")
|
|
|
|
# Read SDP from request body
|
|
body = await request.body()
|
|
content_type = request.headers.get("Content-Type", "")
|
|
|
|
if "application/sdp" in content_type:
|
|
sdp = body.decode("utf-8")
|
|
# Try to parse as JSON
|
|
try:
|
|
data = json.loads(body)
|
|
sdp = data.get("sdp", body.decode("utf-8"))
|
|
except json.JSONDecodeError:
|
|
sdp = body.decode("utf-8")
|
|
|
|
session_id = secrets.token_hex(16)
|
|
|
|
# Store session config
|
|
whip_sessions[session_id] = {
|
|
"src_language": src_language,
|
|
"target_language": target_language,
|
|
"voice": voice,
|
|
"created_at": time.time(),
|
|
"type": "whip_ingest",
|
|
}
|
|
|
|
try:
|
|
# Use FastRTC's internal offer handling with proper Body object
|
|
whip_session_id = secrets.token_hex(16)
|
|
rtc_body = RTCBody(sdp=sdp, type="offer", webrtc_id=whip_session_id, candidate=None)
|
|
response = await stream.offer(rtc_body)
|
|
|
|
# Return SDP answer per WHIP spec
|
|
return StreamingResponse(
|
|
content=response["sdp"],
|
|
media_type="application/sdp",
|
|
status_code=201,
|
|
headers={
|
|
"Location": f"/whip/{session_id}",
|
|
"ETag": f'"{session_id}"',
|
|
"Accept-Patch": "application/trickle-ice-sdpfrag",
|
|
},
|
|
)
|
|
except Exception as e:
|
|
print(f"WHIP error: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.delete("/whip/{session_id}")
|
|
async def whip_terminate(session_id: str, _auth: bool = Depends(optional_auth)):
|
|
"""Terminate a WHIP session."""
|
|
if session_id in whip_sessions:
|
|
del whip_sessions[session_id]
|
|
return JSONResponse({"status": "terminated"}, status_code=200)
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
|
|
@app.patch("/whip/{session_id}")
|
|
async def whip_ice_trickle(session_id: str, request: Request, _auth: bool = Depends(optional_auth)):
|
|
"""Handle ICE trickle for WHIP session."""
|
|
if session_id not in whip_sessions:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
# For now, acknowledge the ICE candidate
|
|
# Full implementation would forward to WebRTC peer connection
|
|
return JSONResponse({"status": "accepted"}, status_code=204)
|
|
|
|
|
|
# WHEP endpoint for external WebRTC consumption
|
|
@app.post("/whep")
|
|
async def whep_consume(
|
|
request: Request, session_id: str = None, _auth: bool = Depends(optional_auth)
|
|
):
|
|
"""
|
|
WHEP (WebRTC-HTTP Egress Protocol) endpoint for stream consumption.
|
|
|
|
This allows external players to receive translated WebRTC streams.
|
|
|
|
Query params:
|
|
session_id: Optional specific session to consume (default: first available)
|
|
"""
|
|
body = await request.body()
|
|
content_type = request.headers.get("Content-Type", "")
|
|
|
|
if "application/sdp" in content_type:
|
|
sdp = body.decode("utf-8")
|
|
try:
|
|
data = json.loads(body)
|
|
sdp = data.get("sdp", body.decode("utf-8"))
|
|
except json.JSONDecodeError:
|
|
sdp = body.decode("utf-8")
|
|
|
|
consumer_id = secrets.token_hex(16)
|
|
|
|
try:
|
|
# Create answer for consumer with proper Body object
|
|
rtc_body = RTCBody(sdp=sdp, type="offer", webrtc_id=consumer_id, candidate=None)
|
|
response = await stream.offer(rtc_body)
|
|
|
|
return StreamingResponse(
|
|
content=response["sdp"],
|
|
media_type="application/sdp",
|
|
status_code=201,
|
|
headers={"Location": f"/whep/{consumer_id}", "ETag": f'"{consumer_id}"'},
|
|
)
|
|
except Exception as e:
|
|
print(f"WHEP error: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/sessions")
|
|
async def list_sessions():
|
|
"""List active translation sessions for control room monitoring."""
|
|
all_sessions = {**active_sessions, **whip_sessions}
|
|
return JSONResponse(
|
|
{
|
|
"sessions": [
|
|
{
|
|
"id": sid,
|
|
"src_language": data["src_language"],
|
|
"target_language": data["target_language"],
|
|
"voice": data["voice"],
|
|
"uptime": time.time() - data["created_at"],
|
|
"type": data.get("type", "webrtc"),
|
|
}
|
|
for sid, data in all_sessions.items()
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
@app.get("/api/status")
|
|
async def api_status():
|
|
"""API health check for control room monitoring."""
|
|
return JSONResponse(
|
|
{
|
|
"status": "healthy",
|
|
"service": "zen-live-translate",
|
|
"version": "1.0.0",
|
|
"base_url": BASE_URL,
|
|
"active_sessions": len(active_sessions),
|
|
"authentication": {
|
|
"enabled": AUTH_ENABLED,
|
|
"type": "HTTP Basic Auth" if AUTH_ENABLED else "None",
|
|
"header": "Authorization: Basic <base64(username:password)>"
|
|
if AUTH_ENABLED
|
|
else None,
|
|
"curl_example": f"curl -u '<username>:<password>' {BASE_URL}/api/status"
|
|
if AUTH_ENABLED
|
|
else f"curl {BASE_URL}/api/status",
|
|
"websocket_example": f"wscat -c '{BASE_URL.replace('https', 'wss').replace('http', 'ws')}/v1/realtime' -H 'Authorization: Basic <base64>'"
|
|
if AUTH_ENABLED
|
|
else f"wscat -c '{BASE_URL.replace('https', 'wss').replace('http', 'ws')}/v1/realtime'",
|
|
},
|
|
"supported_languages": {"source": SRC_LANGUAGES, "target": TARGET_LANGUAGES},
|
|
"voices": VOICES,
|
|
}
|
|
)
|
|
|
|
|
|
@app.get("/monitor")
|
|
async def monitor_page(request: Request, _auth: bool = Depends(optional_auth)):
|
|
"""Simplified monitor-only view for control room displays."""
|
|
rtc_config = await get_cloudflare_turn_credentials_async() if get_space() else None
|
|
html_content = (
|
|
(cur_dir / "monitor.html").read_text()
|
|
if (cur_dir / "monitor.html").exists()
|
|
else (cur_dir / "index.html").read_text()
|
|
)
|
|
html_content = html_content.replace("__RTC_CONFIGURATION__", json.dumps(rtc_config))
|
|
return HTMLResponse(content=html_content)
|
|
|
|
|
|
@app.get("/view")
|
|
async def viewer_page(request: Request, session: str = None):
|
|
"""
|
|
Remote viewer page - same as main page but in view-only mode.
|
|
Share link: /view?session=SESSION_ID
|
|
No authentication required for viewers.
|
|
"""
|
|
rtc_config = await get_cloudflare_turn_credentials_async() if get_space() else None
|
|
html_content = (cur_dir / "index.html").read_text()
|
|
html_content = html_content.replace("__RTC_CONFIGURATION__", json.dumps(rtc_config))
|
|
return HTMLResponse(content=html_content)
|
|
|
|
|
|
# Audio streaming for broadcast integration (can be ingested via NDI/SDI converters)
|
|
audio_subscribers = {} # webrtc_id -> list of asyncio.Queue
|
|
# Global audio publisher for broadcast - publishes to all subscribers
|
|
all_audio_subscribers = [] # list of asyncio.Queue for /audio/broadcast
|
|
|
|
# Video streaming for remote viewers
|
|
video_subscribers = {} # session_id -> list of asyncio.Queue
|
|
|
|
|
|
async def publish_video_to_subscribers(frame_jpeg: bytes, session_id: str):
|
|
"""Publish video frame to HTTP stream subscribers."""
|
|
if session_id and session_id in video_subscribers:
|
|
for queue in video_subscribers[session_id]:
|
|
try:
|
|
# Replace old frame - only keep latest
|
|
while not queue.empty():
|
|
try:
|
|
queue.get_nowait()
|
|
except:
|
|
break
|
|
queue.put_nowait(frame_jpeg)
|
|
except asyncio.QueueFull:
|
|
pass # Drop if queue is full
|
|
|
|
|
|
async def publish_audio_to_subscribers(audio_data: bytes, webrtc_id: str = None):
|
|
"""Publish translated audio to HTTP stream subscribers."""
|
|
# Publish to specific session subscribers
|
|
if webrtc_id and webrtc_id in audio_subscribers:
|
|
for queue in audio_subscribers[webrtc_id]:
|
|
try:
|
|
queue.put_nowait(audio_data)
|
|
except asyncio.QueueFull:
|
|
pass # Drop if queue is full
|
|
|
|
# Also publish to broadcast subscribers (any session)
|
|
for queue in all_audio_subscribers:
|
|
try:
|
|
queue.put_nowait(audio_data)
|
|
except asyncio.QueueFull:
|
|
pass # Drop if queue is full
|
|
|
|
|
|
@app.get("/audio/stream/{webrtc_id}")
|
|
async def audio_stream(webrtc_id: str):
|
|
"""
|
|
Raw PCM audio stream for broadcast integration.
|
|
|
|
This endpoint streams translated audio as raw PCM16 at 24kHz.
|
|
Can be converted to SDI/NDI using tools like:
|
|
- ffmpeg -f s16le -ar 24000 -ac 1 -i http://host/audio/stream/ID -f alsa default
|
|
- OBS with browser source + audio capture
|
|
- Blackmagic Web Presenter
|
|
- NDI Tools with HTTP input
|
|
|
|
For SRT output, pipe through:
|
|
ffmpeg -f s16le -ar 24000 -ac 1 -i http://host/audio/stream/ID \
|
|
-c:a aac -f mpegts srt://dest:port
|
|
"""
|
|
|
|
async def generate_audio():
|
|
queue = asyncio.Queue(maxsize=50) # ~2 seconds of audio buffer
|
|
if webrtc_id not in audio_subscribers:
|
|
audio_subscribers[webrtc_id] = []
|
|
audio_subscribers[webrtc_id].append(queue)
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
audio_data = await asyncio.wait_for(queue.get(), timeout=30.0)
|
|
yield audio_data
|
|
except TimeoutError:
|
|
# Send silence to keep connection alive
|
|
yield b"\x00" * 4800 # 100ms of silence at 24kHz
|
|
finally:
|
|
if webrtc_id in audio_subscribers:
|
|
try:
|
|
audio_subscribers[webrtc_id].remove(queue)
|
|
except ValueError:
|
|
pass # Already removed
|
|
|
|
return StreamingResponse(
|
|
generate_audio(),
|
|
media_type="audio/pcm",
|
|
headers={
|
|
"Content-Type": "audio/L16;rate=24000;channels=1",
|
|
"Cache-Control": "no-cache",
|
|
"X-Audio-Format": "PCM16 24kHz Mono",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/audio/wav/{webrtc_id}")
|
|
async def audio_wav_stream(webrtc_id: str):
|
|
"""
|
|
WAV-wrapped audio stream for easier playback/ingestion.
|
|
|
|
Includes WAV header for compatibility with more players/converters.
|
|
Useful for direct monitoring or conversion to broadcast formats.
|
|
"""
|
|
|
|
async def generate_wav():
|
|
# Send WAV header for streaming (size = max)
|
|
wav_header = bytes(
|
|
[
|
|
0x52,
|
|
0x49,
|
|
0x46,
|
|
0x46, # "RIFF"
|
|
0xFF,
|
|
0xFF,
|
|
0xFF,
|
|
0x7F, # Size (max for streaming)
|
|
0x57,
|
|
0x41,
|
|
0x56,
|
|
0x45, # "WAVE"
|
|
0x66,
|
|
0x6D,
|
|
0x74,
|
|
0x20, # "fmt "
|
|
0x10,
|
|
0x00,
|
|
0x00,
|
|
0x00, # Subchunk1Size (16)
|
|
0x01,
|
|
0x00, # AudioFormat (1 = PCM)
|
|
0x01,
|
|
0x00, # NumChannels (1)
|
|
0xC0,
|
|
0x5D,
|
|
0x00,
|
|
0x00, # SampleRate (24000)
|
|
0x80,
|
|
0xBB,
|
|
0x00,
|
|
0x00, # ByteRate (48000)
|
|
0x02,
|
|
0x00, # BlockAlign (2)
|
|
0x10,
|
|
0x00, # BitsPerSample (16)
|
|
0x64,
|
|
0x61,
|
|
0x74,
|
|
0x61, # "data"
|
|
0xFF,
|
|
0xFF,
|
|
0xFF,
|
|
0x7F, # Subchunk2Size (max)
|
|
]
|
|
)
|
|
yield wav_header
|
|
|
|
queue = asyncio.Queue(maxsize=50) # ~2 seconds of audio buffer
|
|
if webrtc_id not in audio_subscribers:
|
|
audio_subscribers[webrtc_id] = []
|
|
audio_subscribers[webrtc_id].append(queue)
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
audio_data = await asyncio.wait_for(queue.get(), timeout=30.0)
|
|
yield audio_data
|
|
except TimeoutError:
|
|
yield b"\x00" * 4800
|
|
finally:
|
|
if webrtc_id in audio_subscribers:
|
|
try:
|
|
audio_subscribers[webrtc_id].remove(queue)
|
|
except ValueError:
|
|
pass
|
|
|
|
return StreamingResponse(
|
|
generate_wav(),
|
|
media_type="audio/wav",
|
|
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
|
|
)
|
|
|
|
|
|
@app.get("/audio/broadcast")
|
|
async def audio_broadcast_stream():
|
|
"""
|
|
Broadcast audio stream - receives translated audio from any active session.
|
|
|
|
This endpoint is useful for NDI/SRT/RTMP output where you want to capture
|
|
translated audio regardless of the specific session ID.
|
|
|
|
Usage:
|
|
ffmpeg -f s16le -ar 24000 -ac 1 -i http://host/audio/broadcast -c:a aac -f mpegts srt://dest:port
|
|
"""
|
|
|
|
async def generate_audio():
|
|
queue = asyncio.Queue(maxsize=100) # Buffer up to 100 chunks
|
|
all_audio_subscribers.append(queue)
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
audio_data = await asyncio.wait_for(queue.get(), timeout=30.0)
|
|
yield audio_data
|
|
except TimeoutError:
|
|
# Send silence to keep connection alive
|
|
yield b"\x00" * 4800 # 100ms of silence at 24kHz
|
|
finally:
|
|
all_audio_subscribers.remove(queue)
|
|
|
|
return StreamingResponse(
|
|
generate_audio(),
|
|
media_type="audio/pcm",
|
|
headers={
|
|
"Content-Type": "audio/L16;rate=24000;channels=1",
|
|
"Cache-Control": "no-cache",
|
|
"X-Audio-Format": "PCM16 24kHz Mono",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/video/stream/{session_id}")
|
|
async def video_stream(session_id: str):
|
|
"""
|
|
MJPEG video stream for remote viewers.
|
|
|
|
Streams the source video as Motion JPEG for low-latency viewing.
|
|
Can be embedded in <img> tags or used with video players.
|
|
|
|
Usage:
|
|
<img src="/video/stream/SESSION_ID">
|
|
ffplay -f mjpeg http://host/video/stream/SESSION_ID
|
|
"""
|
|
|
|
async def generate_mjpeg():
|
|
queue = asyncio.Queue(maxsize=5) # Small buffer for low latency
|
|
if session_id not in video_subscribers:
|
|
video_subscribers[session_id] = []
|
|
video_subscribers[session_id].append(queue)
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
jpeg_data = await asyncio.wait_for(queue.get(), timeout=30.0)
|
|
# MJPEG multipart format
|
|
yield b"--frame\r\n"
|
|
yield b"Content-Type: image/jpeg\r\n\r\n"
|
|
yield jpeg_data
|
|
yield b"\r\n"
|
|
except TimeoutError:
|
|
# Keep connection alive with empty boundary
|
|
yield b"--frame\r\n\r\n"
|
|
finally:
|
|
if session_id in video_subscribers:
|
|
video_subscribers[session_id].remove(queue)
|
|
|
|
return StreamingResponse(
|
|
generate_mjpeg(),
|
|
media_type="multipart/x-mixed-replace; boundary=frame",
|
|
headers={
|
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
"Pragma": "no-cache",
|
|
"Expires": "0",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/broadcast/info")
|
|
async def broadcast_info():
|
|
"""
|
|
Information for broadcast engineers on how to integrate.
|
|
"""
|
|
return JSONResponse(
|
|
{
|
|
"service": "Zen Live Translate - Broadcast Integration",
|
|
"endpoints": {
|
|
"control_room_ui": f"{BASE_URL}/",
|
|
"monitor_only": f"{BASE_URL}/monitor?autostart=1",
|
|
"remote_view": f"{BASE_URL}/view?session={{SESSION_ID}}",
|
|
"webrtc_offer": f"{BASE_URL}/webrtc/offer",
|
|
"whip_ingest": f"{BASE_URL}/whip?src_language=Spanish&target_language=English",
|
|
"whep_consume": f"{BASE_URL}/whep",
|
|
"transcript_sse": f"{BASE_URL}/outputs?webrtc_id={{SESSION_ID}}",
|
|
"video_mjpeg": f"{BASE_URL}/video/stream/{{SESSION_ID}}",
|
|
"audio_pcm": f"{BASE_URL}/audio/stream/{{SESSION_ID}}",
|
|
"audio_wav": f"{BASE_URL}/audio/wav/{{SESSION_ID}}",
|
|
"audio_broadcast": f"{BASE_URL}/audio/broadcast",
|
|
"api_status": f"{BASE_URL}/api/status",
|
|
"api_sessions": f"{BASE_URL}/api/sessions",
|
|
},
|
|
"whip_whep": {
|
|
"description": "Standard WebRTC ingestion/egress protocols for professional broadcast",
|
|
"whip_enabled": WHIP_ENABLED,
|
|
"whip_post": "POST /whip with SDP offer (Content-Type: application/sdp)",
|
|
"whip_params": "?src_language=Spanish&target_language=English&voice=Cherry",
|
|
"whep_post": "POST /whep with SDP offer to consume translated stream",
|
|
},
|
|
"audio_format": {
|
|
"encoding": "PCM16 signed little-endian",
|
|
"sample_rate": 24000,
|
|
"channels": 1,
|
|
"bits_per_sample": 16,
|
|
},
|
|
"integration_examples": {
|
|
"note": "All examples use HTTPS - works through Cloudflare tunnel!",
|
|
"ffplay_direct": f"ffplay -f s16le -ar 24000 -ac 1 {BASE_URL}/audio/stream/SESSION_ID",
|
|
"ffplay_broadcast": f"ffplay -f s16le -ar 24000 -ac 1 {BASE_URL}/audio/broadcast",
|
|
"ffmpeg_to_srt": f"ffmpeg -f s16le -ar 24000 -ac 1 -i {BASE_URL}/audio/broadcast -c:a aac -f mpegts 'srt://your-dest:port'",
|
|
"ffmpeg_to_rtmp": f"ffmpeg -f s16le -ar 24000 -ac 1 -i {BASE_URL}/audio/broadcast -c:a aac -f flv rtmp://your-server/live/stream",
|
|
"ffmpeg_to_ndi": f"ffmpeg -f s16le -ar 24000 -ac 1 -i {BASE_URL}/audio/broadcast -f libndi_newtek -clock_video true 'ZEN-LIVE-TRANSLATED'",
|
|
"obs_browser": f"Add Browser Source with URL: {BASE_URL}/monitor?autostart=1",
|
|
"vlc": f"vlc {BASE_URL}/audio/wav/SESSION_ID",
|
|
"curl_test": f"curl -N {BASE_URL}/audio/broadcast | ffplay -f s16le -ar 24000 -ac 1 -",
|
|
},
|
|
"cloudflare_tunnel_notes": {
|
|
"https_works": "All HTTP/HTTPS endpoints work through Cloudflare tunnel",
|
|
"websocket_works": "WebSocket (wss://) works through Cloudflare tunnel",
|
|
"srt_rtmp_input_needs_direct": "SRT/RTMP INPUT requires direct server access (not via tunnel)",
|
|
"srt_rtmp_output_works": "SRT/RTMP OUTPUT works - ffmpeg pulls from HTTPS and pushes to your destination",
|
|
"recommended_workflow": "Use WHIP for input (WebRTC over HTTPS), use /audio/broadcast for output",
|
|
},
|
|
"authentication": {
|
|
"enabled": AUTH_ENABLED,
|
|
"type": "HTTP Basic Auth" if AUTH_ENABLED else "None",
|
|
"header": "Authorization: Basic <base64(username:password)>"
|
|
if AUTH_ENABLED
|
|
else None,
|
|
"username": AUTH_USER if AUTH_ENABLED else None,
|
|
"example_header": f"Authorization: Basic {base64.b64encode(f'{AUTH_USER}:{AUTH_PASS}'.encode()).decode()}"
|
|
if AUTH_ENABLED
|
|
else None,
|
|
"curl_example": f"curl -u '{AUTH_USER}:{AUTH_PASS}' {BASE_URL}/"
|
|
if AUTH_ENABLED
|
|
else f"curl {BASE_URL}/",
|
|
"websocket_example": f"wscat -c '{BASE_URL.replace('https', 'wss').replace('http', 'ws')}/v1/realtime' -H 'Authorization: Basic {base64.b64encode(f'{AUTH_USER}:{AUTH_PASS}'.encode()).decode()}'"
|
|
if AUTH_ENABLED
|
|
else f"wscat -c '{BASE_URL.replace('https', 'wss').replace('http', 'ws')}/v1/realtime'",
|
|
},
|
|
"session_management": {
|
|
"description": "Each connection creates a unique session identified by webrtc_id/SESSION_ID",
|
|
"how_to_get_session_id": {
|
|
"webrtc": "Returned in response to /api/webrtc/offer as 'webrtc_id'",
|
|
"whip": "Returned in Location header after POST to /whip",
|
|
"example_response": {"webrtc_id": "abc123def456..."},
|
|
},
|
|
"using_session_id": {
|
|
"transcripts": "GET /outputs?webrtc_id={your_session_id}",
|
|
"audio_stream": "GET /audio/stream/{your_session_id}",
|
|
"audio_wav": "GET /audio/wav/{your_session_id}",
|
|
"note": "Replace {your_session_id} with actual session ID from connection response",
|
|
},
|
|
},
|
|
"latency": {
|
|
"typical": "200-500ms end-to-end",
|
|
"factors": ["network RTT", "AI processing", "TTS generation"],
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@app.get("/")
|
|
async def root_page(request: Request, _auth: bool = Depends(optional_auth)):
|
|
"""Main control room UI - requires authentication if enabled."""
|
|
rtc_config = await get_cloudflare_turn_credentials_async() if get_space() else None
|
|
html_content = (cur_dir / "index.html").read_text()
|
|
html_content = html_content.replace("__RTC_CONFIGURATION__", json.dumps(rtc_config))
|
|
return HTMLResponse(content=html_content)
|
|
|
|
|
|
@app.get("/outputs")
|
|
def _(webrtc_id: str):
|
|
async def output_stream():
|
|
import json
|
|
|
|
async for output in stream.output_stream(webrtc_id):
|
|
s = json.dumps(output.args[0])
|
|
yield f"event: output\ndata: {s}\n\n"
|
|
|
|
return StreamingResponse(output_stream(), media_type="text/event-stream")
|
|
|
|
|
|
# =============================================================================
|
|
# Streaming Input/Output Management (SRT, RTMP, NDI)
|
|
# =============================================================================
|
|
|
|
# Active ffmpeg processes for streaming
|
|
streaming_processes = {} # session_id -> {"type": "srt_in"|"srt_out"|"rtmp_out", "process": subprocess.Popen}
|
|
|
|
|
|
def check_ffmpeg():
|
|
"""Check if ffmpeg is available."""
|
|
return shutil.which("ffmpeg") is not None
|
|
|
|
|
|
class StreamingConfig(BaseModel):
|
|
"""Configuration for streaming input/output."""
|
|
|
|
session_id: str | None = None
|
|
url: str
|
|
src_language: str = "Spanish"
|
|
target_language: str = "English"
|
|
voice: str = "Cherry"
|
|
|
|
|
|
@app.get("/api/streaming/status")
|
|
async def streaming_status(_auth: bool = Depends(optional_auth)):
|
|
"""Get status of all active streaming processes."""
|
|
ffmpeg_available = check_ffmpeg()
|
|
|
|
active_streams = []
|
|
for sid, info in streaming_processes.items():
|
|
proc = info.get("process")
|
|
active_streams.append(
|
|
{
|
|
"session_id": sid,
|
|
"type": info.get("type"),
|
|
"url": info.get("url"),
|
|
"running": proc.poll() is None if proc else False,
|
|
}
|
|
)
|
|
|
|
return JSONResponse(
|
|
{
|
|
"ffmpeg_available": ffmpeg_available,
|
|
"active_streams": active_streams,
|
|
"supported_inputs": ["srt", "rtmp"] if ffmpeg_available else [],
|
|
"supported_outputs": ["srt", "rtmp", "ndi"] if ffmpeg_available else [],
|
|
}
|
|
)
|
|
|
|
|
|
@app.post("/api/streaming/output/start")
|
|
async def start_streaming_output(
|
|
session_id: str,
|
|
output_type: str, # "srt", "rtmp", or "ndi"
|
|
output_url: str,
|
|
_auth: bool = Depends(optional_auth),
|
|
):
|
|
"""
|
|
Start streaming translated audio to SRT/RTMP/NDI destination.
|
|
|
|
This spawns an ffmpeg process that reads from /audio/broadcast and outputs
|
|
to the specified destination.
|
|
"""
|
|
if not check_ffmpeg():
|
|
raise HTTPException(500, "ffmpeg not available - install ffmpeg to enable streaming")
|
|
|
|
# Build audio input URL
|
|
audio_url = f"http://localhost:{PORT}/audio/broadcast"
|
|
|
|
# Build ffmpeg command based on output type
|
|
if output_type == "srt":
|
|
if not output_url.startswith("srt://"):
|
|
raise HTTPException(400, "SRT URL must start with srt://")
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-re",
|
|
"-f",
|
|
"s16le",
|
|
"-ar",
|
|
"24000",
|
|
"-ac",
|
|
"1",
|
|
"-i",
|
|
audio_url,
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"128k",
|
|
"-f",
|
|
"mpegts",
|
|
output_url,
|
|
]
|
|
elif output_type == "rtmp":
|
|
if not output_url.startswith("rtmp://"):
|
|
raise HTTPException(400, "RTMP URL must start with rtmp://")
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-re",
|
|
"-f",
|
|
"s16le",
|
|
"-ar",
|
|
"24000",
|
|
"-ac",
|
|
"1",
|
|
"-i",
|
|
audio_url,
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"128k",
|
|
"-f",
|
|
"flv",
|
|
output_url,
|
|
]
|
|
elif output_type == "ndi":
|
|
# NDI requires libndi - output_url is the NDI source name
|
|
ndi_name = output_url if output_url else "ZEN-LIVE-TRANSLATED"
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-re",
|
|
"-f",
|
|
"s16le",
|
|
"-ar",
|
|
"24000",
|
|
"-ac",
|
|
"1",
|
|
"-i",
|
|
audio_url,
|
|
"-f",
|
|
"libndi_newtek",
|
|
"-clock_video",
|
|
"true",
|
|
ndi_name,
|
|
]
|
|
raise HTTPException(400, f"Unsupported output type: {output_type}")
|
|
|
|
# Start ffmpeg process
|
|
try:
|
|
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
output_id = f"{output_type}_{session_id}_{secrets.token_hex(4)}"
|
|
streaming_processes[output_id] = {
|
|
"type": f"{output_type}_out",
|
|
"url": output_url,
|
|
"process": process,
|
|
"session_id": session_id,
|
|
}
|
|
|
|
print(f"📤 Started {output_type} output: {output_url} (PID: {process.pid})")
|
|
|
|
return JSONResponse(
|
|
{
|
|
"status": "started",
|
|
"output_id": output_id,
|
|
"output_type": output_type,
|
|
"output_url": output_url,
|
|
"pid": process.pid,
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Failed to start ffmpeg: {str(e)}")
|
|
|
|
|
|
@app.delete("/api/streaming/output/{output_id}")
|
|
async def stop_streaming_output(output_id: str, _auth: bool = Depends(optional_auth)):
|
|
"""Stop a streaming output process."""
|
|
if output_id not in streaming_processes:
|
|
raise HTTPException(404, "Output stream not found")
|
|
|
|
info = streaming_processes[output_id]
|
|
proc = info.get("process")
|
|
|
|
if proc and proc.poll() is None:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
del streaming_processes[output_id]
|
|
print(f"📤 Stopped output: {output_id}")
|
|
|
|
return JSONResponse({"status": "stopped", "output_id": output_id})
|
|
|
|
|
|
@app.post("/api/streaming/input/start")
|
|
async def start_streaming_input(config: StreamingConfig, _auth: bool = Depends(optional_auth)):
|
|
"""
|
|
Start ingesting from SRT/RTMP source for translation.
|
|
|
|
Note: This is for server-side ingestion. The audio will be fed into a
|
|
translation session. For browser-based input, use the WebRTC endpoints.
|
|
"""
|
|
if not check_ffmpeg():
|
|
raise HTTPException(500, "ffmpeg not available - install ffmpeg to enable streaming")
|
|
|
|
url = config.url
|
|
|
|
# Validate URL
|
|
if url.startswith("srt://"):
|
|
input_type = "srt"
|
|
elif url.startswith("rtmp://"):
|
|
input_type = "rtmp"
|
|
raise HTTPException(400, "URL must start with srt:// or rtmp://")
|
|
|
|
session_id = config.session_id or secrets.token_hex(16)
|
|
|
|
# Build ffmpeg command to ingest and convert to PCM
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-re",
|
|
"-i",
|
|
url,
|
|
"-c:a",
|
|
"pcm_s16le",
|
|
"-ar",
|
|
"16000",
|
|
"-ac",
|
|
"1",
|
|
"-f",
|
|
"s16le",
|
|
"pipe:1",
|
|
]
|
|
|
|
try:
|
|
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
input_id = f"{input_type}_in_{session_id}"
|
|
streaming_processes[input_id] = {
|
|
"type": f"{input_type}_in",
|
|
"url": url,
|
|
"process": process,
|
|
"session_id": session_id,
|
|
"config": config.model_dump(),
|
|
}
|
|
|
|
print(f"📥 Started {input_type} input: {url} (PID: {process.pid})")
|
|
|
|
# TODO: Feed audio from process.stdout to a translation handler
|
|
# This requires creating a handler instance and feeding audio chunks
|
|
# For now, this sets up the process - full integration TBD
|
|
|
|
return JSONResponse(
|
|
{
|
|
"status": "started",
|
|
"input_id": input_id,
|
|
"input_type": input_type,
|
|
"session_id": session_id,
|
|
"input_url": url,
|
|
"pid": process.pid,
|
|
"note": "Audio ingestion started. Full translation integration in progress.",
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(500, f"Failed to start ffmpeg: {str(e)}")
|
|
|
|
|
|
@app.delete("/api/streaming/input/{input_id}")
|
|
async def stop_streaming_input(input_id: str, _auth: bool = Depends(optional_auth)):
|
|
"""Stop a streaming input process."""
|
|
if input_id not in streaming_processes:
|
|
raise HTTPException(404, "Input stream not found")
|
|
|
|
info = streaming_processes[input_id]
|
|
proc = info.get("process")
|
|
|
|
if proc and proc.poll() is None:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
del streaming_processes[input_id]
|
|
print(f"📥 Stopped input: {input_id}")
|
|
|
|
return JSONResponse({"status": "stopped", "input_id": input_id})
|
|
|
|
|
|
# =============================================================================
|
|
# WebSocket Proxy API - Proxies to Hanzo
|
|
# =============================================================================
|
|
|
|
# Full language support per Hanzo docs
|
|
SUPPORTED_LANGUAGES = {
|
|
"en": {"name": "English", "audio": True},
|
|
"zh": {"name": "Chinese", "audio": True},
|
|
"ru": {"name": "Russian", "audio": True},
|
|
"fr": {"name": "French", "audio": True},
|
|
"de": {"name": "German", "audio": True},
|
|
"pt": {"name": "Portuguese", "audio": True},
|
|
"es": {"name": "Spanish", "audio": True},
|
|
"it": {"name": "Italian", "audio": True},
|
|
"ko": {"name": "Korean", "audio": True},
|
|
"ja": {"name": "Japanese", "audio": True},
|
|
"yue": {"name": "Cantonese", "audio": True},
|
|
"id": {"name": "Indonesian", "audio": False}, # Text only
|
|
"vi": {"name": "Vietnamese", "audio": False}, # Text only
|
|
"th": {"name": "Thai", "audio": False}, # Text only
|
|
"ar": {"name": "Arabic", "audio": False}, # Text only
|
|
"hi": {"name": "Hindi", "audio": False}, # Text only
|
|
"el": {"name": "Greek", "audio": False}, # Text only
|
|
"tr": {"name": "Turkish", "audio": False}, # Text only
|
|
}
|
|
|
|
# Full voice support per Hanzo docs
|
|
SUPPORTED_VOICES = {
|
|
"Cherry": {
|
|
"description": "Sunny, positive, friendly female",
|
|
"languages": ["zh", "en", "fr", "de", "ru", "it", "es", "pt", "ja", "ko"],
|
|
},
|
|
"Nofish": {
|
|
"description": "Designer voice, casual male",
|
|
"languages": ["zh", "en", "fr", "de", "ru", "it", "es", "pt", "ja", "ko"],
|
|
},
|
|
"Jada": {"description": "Lively Shanghainese woman", "languages": ["zh"]},
|
|
"Dylan": {"description": "Young Beijing man", "languages": ["zh"]},
|
|
"Sunny": {"description": "Sweet Sichuanese girl", "languages": ["zh"]},
|
|
"Peter": {"description": "Tianjin crosstalk style", "languages": ["zh"]},
|
|
"Kiki": {"description": "Sweet Hong Kong best friend", "languages": ["yue"]},
|
|
"Eric": {"description": "Sichuan male voice", "languages": ["zh"]},
|
|
}
|
|
|
|
|
|
@app.websocket("/v1/realtime")
|
|
async def websocket_proxy(websocket: WebSocket):
|
|
"""
|
|
WebSocket proxy to Hanzo
|
|
|
|
This endpoint proxies all WebSocket messages to Hanzo's real-time
|
|
translation API.
|
|
|
|
Client Events (send to server):
|
|
- session.update: Configure session (language, voice, modalities)
|
|
- input_audio_buffer.append: Send Base64-encoded PCM16 audio
|
|
- input_image_buffer.append: Send Base64-encoded image for context
|
|
|
|
Server Events (receive from server):
|
|
- session.created: Initial session confirmation
|
|
- session.updated: Configuration update confirmation
|
|
- response.created: Translation started
|
|
- response.audio.delta: Incremental translated audio (Base64)
|
|
- response.audio_transcript.delta: Incremental transcript text
|
|
- response.audio_transcript.done: Complete transcript
|
|
- response.done: Response complete with usage stats
|
|
- error: Error details
|
|
|
|
See /api/spec for full API specification.
|
|
"""
|
|
# Use either HANZO_API_KEY or API_KEY (for DashScope)
|
|
api_key = HANZO_API_KEY or API_KEY
|
|
if not api_key:
|
|
await websocket.close(code=4001, reason="API_KEY not configured")
|
|
return
|
|
|
|
# Accept WebSocket connection (auth already handled by API key proxy)
|
|
await websocket.accept()
|
|
|
|
# WebSocket APIs use Authorization Bearer (both DashScope and Hanzo)
|
|
auth_headers = {"Authorization": f"Bearer {api_key}"}
|
|
|
|
upstream_ws = None
|
|
try:
|
|
# Connect to translation API (Hanzo or DashScope)
|
|
upstream_ws = await connect(
|
|
TRANSLATE_API_URL,
|
|
additional_headers=auth_headers,
|
|
ssl=ssl_context,
|
|
)
|
|
|
|
async def forward_to_upstream():
|
|
"""Forward client messages to Hanzo."""
|
|
try:
|
|
while True:
|
|
data = await websocket.receive_text()
|
|
await upstream_ws.send(data)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception as e:
|
|
print(f"Client->Upstream error: {e}")
|
|
|
|
async def forward_to_client():
|
|
"""Forward Hanzo responses to client."""
|
|
try:
|
|
async for message in upstream_ws:
|
|
await websocket.send_text(message)
|
|
except Exception as e:
|
|
print(f"Upstream->Client error: {e}")
|
|
|
|
# Run both directions concurrently
|
|
await asyncio.gather(forward_to_upstream(), forward_to_client(), return_exceptions=True)
|
|
|
|
except Exception as e:
|
|
print(f"WebSocket proxy error: {e}")
|
|
try:
|
|
await websocket.send_text(
|
|
json.dumps({"type": "error", "error": {"message": str(e), "type": "proxy_error"}})
|
|
)
|
|
except:
|
|
pass
|
|
finally:
|
|
if upstream_ws:
|
|
await upstream_ws.close()
|
|
|
|
|
|
@app.get("/api/spec")
|
|
async def api_specification():
|
|
"""
|
|
Full API specification for Zen Live.
|
|
|
|
This documents the WebSocket protocol for real-time translation.
|
|
"""
|
|
ws_url = BASE_URL.replace("https", "wss").replace("http", "ws")
|
|
|
|
return JSONResponse(
|
|
{
|
|
"name": "Zen Live Translation API",
|
|
"version": "1.0.0",
|
|
"description": "Real-time audio/video translation powered by Zen Live",
|
|
"base_url": BASE_URL,
|
|
"websocket_endpoint": f"{ws_url}/v1/realtime",
|
|
"protocol": "WebSocket",
|
|
"authentication": {
|
|
"http_basic": {
|
|
"enabled": AUTH_ENABLED,
|
|
"type": "HTTP Basic Auth" if AUTH_ENABLED else "None",
|
|
"username": AUTH_USER if AUTH_ENABLED else "Not required",
|
|
"header_format": "Authorization: Basic <base64(username:password)>",
|
|
"example": f"Authorization: Basic {base64.b64encode(f'{AUTH_USER}:{AUTH_PASS}'.encode()).decode()}"
|
|
if AUTH_ENABLED
|
|
else "No auth required",
|
|
"curl": f"curl -u '{AUTH_USER}:{AUTH_PASS}' {BASE_URL}/api/status"
|
|
if AUTH_ENABLED
|
|
else f"curl {BASE_URL}/api/status",
|
|
"websocket": f"wscat -c '{ws_url}/v1/realtime' -H 'Authorization: Basic {base64.b64encode(f'{AUTH_USER}:{AUTH_PASS}'.encode()).decode()}'"
|
|
if AUTH_ENABLED
|
|
else f"wscat -c '{ws_url}/v1/realtime'",
|
|
},
|
|
"api_key": {
|
|
"type": "Bearer Token (server-side)",
|
|
"note": "API key for Hanzo is configured server-side via HANZO_API_KEY env var",
|
|
"configured": bool(HANZO_API_KEY),
|
|
},
|
|
},
|
|
"client_events": {
|
|
"session.update": {
|
|
"description": "Update session configuration",
|
|
"example": {
|
|
"event_id": "event_xxx",
|
|
"type": "session.update",
|
|
"session": {
|
|
"modalities": ["text", "audio"],
|
|
"voice": "Cherry",
|
|
"input_audio_format": "pcm16",
|
|
"output_audio_format": "pcm24",
|
|
"input_audio_transcription": {"language": "es"},
|
|
"translation": {"language": "en"},
|
|
},
|
|
},
|
|
"parameters": {
|
|
"modalities": "['text'] or ['text', 'audio']",
|
|
"voice": "Voice ID (see /api/voices)",
|
|
"input_audio_transcription.language": "Source language code",
|
|
"translation.language": "Target language code",
|
|
},
|
|
},
|
|
"input_audio_buffer.append": {
|
|
"description": "Send audio data for translation",
|
|
"example": {
|
|
"event_id": "event_xxx",
|
|
"type": "input_audio_buffer.append",
|
|
"audio": "<base64_pcm16_audio>",
|
|
},
|
|
"parameters": {"audio": "Base64-encoded PCM16 audio (16kHz, mono)"},
|
|
},
|
|
"input_image_buffer.append": {
|
|
"description": "Send image for visual context (improves accuracy)",
|
|
"example": {
|
|
"event_id": "event_xxx",
|
|
"type": "input_image_buffer.append",
|
|
"image": "<base64_jpeg_image>",
|
|
},
|
|
"parameters": {"image": "Base64-encoded JPEG image (max 1080p, 500KB)"},
|
|
"notes": [
|
|
"Max 2 images per second",
|
|
"Must send audio first before images",
|
|
"Helps with homonyms and proper nouns",
|
|
],
|
|
},
|
|
},
|
|
"server_events": {
|
|
"session.created": "Session initialized with default config",
|
|
"session.updated": "Session config updated successfully",
|
|
"response.created": "Model started generating response",
|
|
"response.audio.delta": "Incremental audio chunk (Base64 PCM24)",
|
|
"response.audio.done": "Audio generation complete",
|
|
"response.audio_transcript.delta": "Incremental transcript text",
|
|
"response.audio_transcript.done": "Complete transcript with final text",
|
|
"response.text.delta": "Incremental text (text-only mode)",
|
|
"response.text.done": "Complete text (text-only mode)",
|
|
"response.done": "Response complete with usage statistics",
|
|
"error": "Error occurred",
|
|
},
|
|
"audio_format": {
|
|
"input": {"encoding": "pcm16", "sample_rate": 16000, "channels": 1},
|
|
"output": {"encoding": "pcm24", "sample_rate": 24000, "channels": 1},
|
|
},
|
|
"languages": SUPPORTED_LANGUAGES,
|
|
"voices": SUPPORTED_VOICES,
|
|
"billing": {
|
|
"audio": "12.5 tokens per second (input or output)",
|
|
"image": "0.5 tokens per 28x28 pixels",
|
|
},
|
|
"latency": "~3 seconds for simultaneous interpretation",
|
|
"links": {
|
|
"docs": "/docs",
|
|
"languages": "/api/languages",
|
|
"voices": "/api/voices",
|
|
"github": "https://github.com/zenlm/zen-live",
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# WebSocket Proxy for ASR
|
|
# =============================================================================
|
|
|
|
|
|
@app.websocket("/v1/asr")
|
|
async def asr_websocket_proxy(websocket: WebSocket):
|
|
"""
|
|
WebSocket proxy to Hanzo.
|
|
|
|
This endpoint proxies ASR requests to Hanzo's real-time ASR API,
|
|
keeping the API key secure on the server side.
|
|
|
|
Client sends:
|
|
- session.update: Configure ASR session (language, model)
|
|
- input_audio_buffer.append: Send Base64-encoded PCM16 audio
|
|
|
|
Server returns:
|
|
- session.created/updated: Session confirmations
|
|
- conversation.item.input_audio_transcription.text: Interim transcription
|
|
- conversation.item.input_audio_transcription.completed: Final transcription
|
|
- input_audio_buffer.speech_started/stopped: VAD events
|
|
- error: Error details
|
|
"""
|
|
# Use either HANZO_API_KEY or API_KEY (for DashScope)
|
|
api_key = HANZO_API_KEY or API_KEY
|
|
if not api_key:
|
|
await websocket.close(code=4001, reason="API_KEY not configured")
|
|
return
|
|
|
|
# Accept WebSocket connection (auth already handled by API key proxy)
|
|
# No need for additional HTTP Basic Auth since this endpoint proxies with API key
|
|
await websocket.accept()
|
|
|
|
# WebSocket APIs use Authorization Bearer (both DashScope and Hanzo)
|
|
auth_headers = {"Authorization": f"Bearer {api_key}"}
|
|
|
|
print(f"🔌 ASR: Connecting to {ASR_API_URL[:60]}...")
|
|
print(f"🔑 ASR: Using API key: {api_key[:20]}...")
|
|
|
|
upstream_ws = None
|
|
try:
|
|
# Connect to ASR endpoint (Hanzo or DashScope)
|
|
|
|
upstream_ws = await connect(
|
|
ASR_API_URL,
|
|
additional_headers=auth_headers,
|
|
ssl=ssl_context,
|
|
)
|
|
print("🎙️ ASR WebSocket proxy connected to", ASR_API_URL[:50])
|
|
|
|
async def forward_to_upstream():
|
|
"""Forward client messages to Hanzo ASR."""
|
|
try:
|
|
while True:
|
|
data = await websocket.receive_text()
|
|
await upstream_ws.send(data)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception as e:
|
|
print(f"ASR Client->Upstream error: {e}")
|
|
|
|
async def forward_to_client():
|
|
"""Forward Hanzo ASR responses to client."""
|
|
try:
|
|
async for message in upstream_ws:
|
|
await websocket.send_text(message)
|
|
except Exception as e:
|
|
print(f"ASR Upstream->Client error: {e}")
|
|
|
|
# Run both directions concurrently
|
|
await asyncio.gather(forward_to_upstream(), forward_to_client(), return_exceptions=True)
|
|
|
|
except Exception as e:
|
|
print(f"ASR WebSocket proxy error: {e}")
|
|
try:
|
|
await websocket.send_text(
|
|
json.dumps(
|
|
{"type": "error", "error": {"message": str(e), "type": "asr_proxy_error"}}
|
|
)
|
|
)
|
|
except:
|
|
pass
|
|
finally:
|
|
if upstream_ws:
|
|
await upstream_ws.close()
|
|
print("🎙️ ASR WebSocket proxy disconnected")
|
|
|
|
|
|
@app.get("/api/languages")
|
|
async def list_languages():
|
|
"""List all supported languages with audio capability info."""
|
|
return JSONResponse(
|
|
{
|
|
"languages": [
|
|
{
|
|
"code": code,
|
|
"name": info["name"],
|
|
"audio_output": info["audio"],
|
|
"text_output": True,
|
|
}
|
|
for code, info in SUPPORTED_LANGUAGES.items()
|
|
],
|
|
"source_languages": list(SUPPORTED_LANGUAGES.keys()),
|
|
"target_languages_audio": [k for k, v in SUPPORTED_LANGUAGES.items() if v["audio"]],
|
|
"target_languages_text_only": [
|
|
k for k, v in SUPPORTED_LANGUAGES.items() if not v["audio"]
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
@app.get("/api/voices")
|
|
async def list_voices():
|
|
"""List all supported TTS voices with language compatibility."""
|
|
return JSONResponse(
|
|
{
|
|
"voices": [
|
|
{
|
|
"id": voice_id,
|
|
"description": info["description"],
|
|
"supported_languages": info["languages"],
|
|
}
|
|
for voice_id, info in SUPPORTED_VOICES.items()
|
|
],
|
|
"default": "Nofish",
|
|
"multilingual": ["Cherry", "Nofish"],
|
|
"regional": ["Jada", "Dylan", "Sunny", "Peter", "Kiki", "Eric"],
|
|
}
|
|
)
|
|
|
|
|
|
def handle_exit(sig, frame):
|
|
print("\n👋 Zen Live shutting down...")
|
|
exit(0)
|
|
|
|
|
|
signal.signal(signal.SIGINT, handle_exit)
|
|
signal.signal(signal.SIGTERM, handle_exit)
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
mode = os.getenv("MODE", "").upper()
|
|
|
|
# Display startup banner with proper URLs
|
|
local_url = f"http://localhost:{PORT}"
|
|
prod_note = f" ({BASE_URL})" if "hanzo.ai" in BASE_URL else ""
|
|
|
|
print(f"""
|
|
╔═══════════════════════════════════════════════════════════╗
|
|
║ ZEN LIVE ║
|
|
║ Real-time Speech Translation Service ║
|
|
║ Powered by Hanzo AI ║
|
|
╠═══════════════════════════════════════════════════════════╣
|
|
║ Control Room: {local_url:<42} ║
|
|
║ Monitor View: {local_url}/monitor{" " * (36 - len(str(PORT)))} ║
|
|
║ API Docs: {local_url}/docs{" " * (39 - len(str(PORT)))} ║
|
|
║ Broadcast: {local_url}/broadcast/info{" " * (27 - len(str(PORT)))} ║
|
|
║ ║
|
|
║ Production: {BASE_URL:<42} ║
|
|
╚═══════════════════════════════════════════════════════════╝
|
|
""")
|
|
|
|
if mode == "UI":
|
|
demo = enhance_ui()
|
|
demo.launch(server_port=PORT)
|
|
elif mode == "PHONE":
|
|
stream.fastphone(host="0.0.0.0", port=PORT)
|
|
else:
|
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|