Add WebRTC TURN configuration and related enhancements
- Introduce a new `.env.example` file for environment variable setup, including `PUBLIC_IP`, `TURN_SECRET`, and `TURN_URLS` for WebRTC TURN server configuration. - Update `docker-compose.yaml` to support TURN server deployment with necessary environment variables and commands. - Enhance backend configuration and routes to include WebRTC ICE server settings, allowing for STUN/TURN server integration. - Implement a new service for managing WebRTC ICE server configurations, providing credentials for TURN when configured. - Modify frontend API to fetch ICE server configurations dynamically, improving support for cross-network voice preview.
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# docker compose 变量(复制为项目根 .env)。公网 WebRTC 需配合 --profile remote。
|
||||||
|
PUBLIC_IP=182.92.86.220
|
||||||
|
TURN_SECRET=change-me-to-a-long-random-string
|
||||||
|
TURN_URLS=turn:182.92.86.220:3478?transport=udp,turn:182.92.86.220:3478?transport=tcp
|
||||||
@@ -31,5 +31,12 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
|
|||||||
# ---- 服务监听 & 跨域 ----
|
# ---- 服务监听 & 跨域 ----
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8000
|
PORT=8000
|
||||||
# 前端开发地址,允许跨域
|
# 前端开发地址,允许跨域(公网部署时加上实际前端 origin)
|
||||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|
||||||
|
# ---- WebRTC TURN(公网跨网语音预览;本地开发留空) ----
|
||||||
|
# 与 docker compose --profile remote 的 coturn 配套。云安全组需放行 UDP 3478 与 49152-49200。
|
||||||
|
# PUBLIC_IP 填云主机公网 IP(compose 里给 coturn --external-ip 用,见项目根 .env)。
|
||||||
|
# TURN_URLS=turn:182.92.86.220:3478?transport=udp,turn:182.92.86.220:3478?transport=tcp
|
||||||
|
# TURN_SECRET=your-turn-secret
|
||||||
|
# TURN_CREDENTIAL_TTL=86400
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
/api/model-resources 模型资源 CRUD
|
/api/model-resources 模型资源 CRUD
|
||||||
/ws/voice WebRTC 输出(浏览器)
|
/ws/voice WebRTC 输出(浏览器)
|
||||||
/ws/stream WS 输出(裸音频流)
|
/ws/stream WS 输出(裸音频流)
|
||||||
|
/api/webrtc/ice-servers WebRTC STUN/TURN 配置
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|||||||
@@ -43,3 +43,13 @@ PORT = int(os.getenv("PORT", "8000"))
|
|||||||
CORS_ORIGINS = _split(
|
CORS_ORIGINS = _split(
|
||||||
os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
|
os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---- WebRTC TURN(公网跨网语音预览;本地开发留空即可) ----
|
||||||
|
# TURN_URLS 示例:turn:182.92.86.220:3478?transport=udp,turn:182.92.86.220:3478?transport=tcp
|
||||||
|
TURN_URLS = _split(os.getenv("TURN_URLS", ""))
|
||||||
|
# coturn --use-auth-secret 时与 --static-auth-secret 一致;后端据此签发短时凭证
|
||||||
|
TURN_SECRET = os.getenv("TURN_SECRET", "")
|
||||||
|
# 不用 secret 时可改静态账号(需 coturn --user=...)
|
||||||
|
TURN_USERNAME = os.getenv("TURN_USERNAME", "")
|
||||||
|
TURN_PASSWORD = os.getenv("TURN_PASSWORD", "")
|
||||||
|
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ from models import AssistantConfig, SignalingOffer
|
|||||||
from services.config_resolver import resolve_runtime_config
|
from services.config_resolver import resolve_runtime_config
|
||||||
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
||||||
|
|
||||||
# 注意:pipecat / aiortc 都是重依赖(语音才用),改成函数内"惰性导入",
|
from services.webrtc_ice import aiortc_ice_servers, client_ice_servers
|
||||||
# 这样不装 pipecat 也能启动后端、验证 CRUD。语音真正用到时才加载。
|
|
||||||
|
|
||||||
router = APIRouter()
|
# 注意:pipecat 是重依赖(语音才用),在 _handle_offer 等处惰性导入。
|
||||||
|
|
||||||
|
router = APIRouter(tags=["voice"])
|
||||||
|
|
||||||
|
|
||||||
def _ice_servers():
|
@router.get("/api/webrtc/ice-servers")
|
||||||
from aiortc import RTCIceServer
|
async def ice_servers():
|
||||||
|
"""Browser fetches STUN/TURN config (with ephemeral TURN creds when configured)."""
|
||||||
# 本地只用 STUN;公网部署在此追加 TURN(参考 dograh get_ice_servers)
|
return {"iceServers": client_ice_servers()}
|
||||||
return [RTCIceServer(urls="stun:stun.l.google.com:19302")]
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/voice")
|
@router.websocket("/ws/voice")
|
||||||
@@ -87,7 +87,7 @@ async def _handle_offer(websocket, payload, peers):
|
|||||||
await pc.renegotiate(sdp=offer.sdp, type=offer.type, restart_pc=False)
|
await pc.renegotiate(sdp=offer.sdp, type=offer.type, restart_pc=False)
|
||||||
else:
|
else:
|
||||||
cfg = await _resolve_config(offer) # 解析放在建连前,配置错就别建连
|
cfg = await _resolve_config(offer) # 解析放在建连前,配置错就别建连
|
||||||
pc = SmallWebRTCConnection(ice_servers=_ice_servers())
|
pc = SmallWebRTCConnection(ice_servers=aiortc_ice_servers())
|
||||||
if pc_id:
|
if pc_id:
|
||||||
pc._pc_id = pc_id
|
pc._pc_id = pc_id
|
||||||
await pc.initialize(sdp=offer.sdp, type=offer.type)
|
await pc.initialize(sdp=offer.sdp, type=offer.type)
|
||||||
|
|||||||
60
backend/services/webrtc_ice.py
Normal file
60
backend/services/webrtc_ice.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""WebRTC ICE server config: STUN + optional TURN for cross-network voice preview."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
STUN_URL = "stun:stun.l.google.com:19302"
|
||||||
|
|
||||||
|
|
||||||
|
def _turn_credentials() -> tuple[str, str] | None:
|
||||||
|
"""Return (username, credential) for TURN, or None when TURN is not configured."""
|
||||||
|
if not config.TURN_URLS:
|
||||||
|
return None
|
||||||
|
if config.TURN_SECRET:
|
||||||
|
expiry = int(time.time()) + config.TURN_CREDENTIAL_TTL
|
||||||
|
username = f"{expiry}:ai-video"
|
||||||
|
credential = base64.b64encode(
|
||||||
|
hmac.new(
|
||||||
|
config.TURN_SECRET.encode("utf-8"),
|
||||||
|
username.encode("utf-8"),
|
||||||
|
hashlib.sha1,
|
||||||
|
).digest()
|
||||||
|
).decode("utf-8")
|
||||||
|
return username, credential
|
||||||
|
if config.TURN_USERNAME and config.TURN_PASSWORD:
|
||||||
|
return config.TURN_USERNAME, config.TURN_PASSWORD
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def client_ice_servers() -> list[dict]:
|
||||||
|
"""ICE servers for browser RTCPeerConnection (JSON-serializable)."""
|
||||||
|
servers: list[dict] = [{"urls": STUN_URL}]
|
||||||
|
creds = _turn_credentials()
|
||||||
|
if not creds:
|
||||||
|
return servers
|
||||||
|
username, credential = creds
|
||||||
|
for url in config.TURN_URLS:
|
||||||
|
servers.append({"urls": url, "username": username, "credential": credential})
|
||||||
|
return servers
|
||||||
|
|
||||||
|
|
||||||
|
def aiortc_ice_servers() -> list:
|
||||||
|
"""ICE servers for backend SmallWebRTCConnection / aiortc."""
|
||||||
|
from aiortc import RTCIceServer
|
||||||
|
|
||||||
|
servers = [RTCIceServer(urls=STUN_URL)]
|
||||||
|
creds = _turn_credentials()
|
||||||
|
if not creds:
|
||||||
|
return servers
|
||||||
|
username, credential = creds
|
||||||
|
for url in config.TURN_URLS:
|
||||||
|
servers.append(
|
||||||
|
RTCIceServer(urls=url, username=username, credential=credential)
|
||||||
|
)
|
||||||
|
return servers
|
||||||
@@ -49,6 +49,9 @@ services:
|
|||||||
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
|
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
|
||||||
# 3030 = docker ui 宿主端口;3000 = 宿主机裸跑 npm run dev 时的端口
|
# 3030 = docker ui 宿主端口;3000 = 宿主机裸跑 npm run dev 时的端口
|
||||||
CORS_ORIGINS: "http://localhost:3030,http://127.0.0.1:3030,http://localhost:3000,http://127.0.0.1:3000"
|
CORS_ORIGINS: "http://localhost:3030,http://127.0.0.1:3030,http://localhost:3000,http://127.0.0.1:3000"
|
||||||
|
# WebRTC TURN(公网部署:设 PUBLIC_IP + TURN_SECRET,并 docker compose --profile remote up)
|
||||||
|
TURN_URLS: "${TURN_URLS:-}"
|
||||||
|
TURN_SECRET: "${TURN_SECRET:-}"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -112,17 +115,24 @@ services:
|
|||||||
networks: [app-network]
|
networks: [app-network]
|
||||||
|
|
||||||
# ---- 可选(profile: remote):WebRTC 公网穿透 ----
|
# ---- 可选(profile: remote):WebRTC 公网穿透 ----
|
||||||
|
# 在项目根 .env 设置 PUBLIC_IP(云主机公网 IP)与 TURN_SECRET,与 backend TURN_SECRET 一致。
|
||||||
|
# 云安全组放行:UDP/TCP 3478,UDP 49152-49200。
|
||||||
coturn:
|
coturn:
|
||||||
image: coturn/coturn:4.8.0
|
image: coturn/coturn:4.8.0
|
||||||
profiles: ["remote"]
|
profiles: ["remote"]
|
||||||
network_mode: host # TURN 需直接占用 UDP 端口段
|
network_mode: host
|
||||||
command:
|
command:
|
||||||
- -n
|
- -n
|
||||||
|
- --log-file=stdout
|
||||||
|
- --listening-port=3478
|
||||||
|
- --listening-ip=0.0.0.0
|
||||||
|
- --external-ip=${PUBLIC_IP:?set PUBLIC_IP in .env for coturn}
|
||||||
- --realm=ai-video
|
- --realm=ai-video
|
||||||
- --use-auth-secret
|
- --use-auth-secret
|
||||||
- --static-auth-secret=${TURN_SECRET:-changeme}
|
- --static-auth-secret=${TURN_SECRET:-changeme}
|
||||||
- --min-port=49152
|
- --min-port=49152
|
||||||
- --max-port=49200
|
- --max-port=49200
|
||||||
|
- --no-cli
|
||||||
|
|
||||||
# ---- 可选(profile: tls):nginx 反代统一 TLS,局域网 https 调试语音预览 ----
|
# ---- 可选(profile: tls):nginx 反代统一 TLS,局域网 https 调试语音预览 ----
|
||||||
# 起前先生成证书:./deploy/setup-certs.sh(证书落在 deploy/certs/)
|
# 起前先生成证书:./deploy/setup-certs.sh(证书落在 deploy/certs/)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { API_BASE } from "@/lib/api";
|
import { API_BASE, webrtcApi } from "@/lib/api";
|
||||||
|
|
||||||
export type VoicePreviewStatus = "idle" | "connecting" | "connected" | "failed";
|
export type VoicePreviewStatus = "idle" | "connecting" | "connected" | "failed";
|
||||||
|
|
||||||
@@ -301,10 +301,12 @@ export function useVoicePreview(
|
|||||||
if (wsRef.current === ws) closeOnRemoteEnd("语音信令连接已断开。");
|
if (wsRef.current === ws) closeOnRemoteEnd("语音信令连接已断开。");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2) 建 PeerConnection(纯 STUN,本机/局域网够用)
|
// 2) 建 PeerConnection(STUN;公网跨网时后端会下发 TURN)
|
||||||
const pc = new RTCPeerConnection({
|
const iceServers = await webrtcApi
|
||||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
.iceServers()
|
||||||
});
|
.then((r) => r.iceServers)
|
||||||
|
.catch(() => [{ urls: "stun:stun.l.google.com:19302" }]);
|
||||||
|
const pc = new RTCPeerConnection({ iceServers });
|
||||||
pcRef.current = pc;
|
pcRef.current = pc;
|
||||||
|
|
||||||
pc.onicecandidate = (e) => {
|
pc.onicecandidate = (e) => {
|
||||||
|
|||||||
@@ -208,3 +208,14 @@ export type NodeTypesResponse = {
|
|||||||
export const nodeTypesApi = {
|
export const nodeTypesApi = {
|
||||||
list: () => request<NodeTypesResponse>("/api/node-types"),
|
list: () => request<NodeTypesResponse>("/api/node-types"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IceServerConfig = {
|
||||||
|
urls: string | string[];
|
||||||
|
username?: string;
|
||||||
|
credential?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webrtcApi = {
|
||||||
|
iceServers: () =>
|
||||||
|
request<{ iceServers: IceServerConfig[] }>("/api/webrtc/ice-servers"),
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user