diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8dc1de --- /dev/null +++ b/.env.example @@ -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 diff --git a/backend/.env.example b/backend/.env.example index bac1f9d..439df45 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -31,5 +31,12 @@ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/postgres # ---- 服务监听 & 跨域 ---- HOST=0.0.0.0 PORT=8000 -# 前端开发地址,允许跨域 +# 前端开发地址,允许跨域(公网部署时加上实际前端 origin) 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 diff --git a/backend/app.py b/backend/app.py index 9610708..7e40b49 100644 --- a/backend/app.py +++ b/backend/app.py @@ -9,6 +9,7 @@ /api/model-resources 模型资源 CRUD /ws/voice WebRTC 输出(浏览器) /ws/stream WS 输出(裸音频流) + /api/webrtc/ice-servers WebRTC STUN/TURN 配置 """ from contextlib import asynccontextmanager diff --git a/backend/config.py b/backend/config.py index 5fa3473..5463cee 100644 --- a/backend/config.py +++ b/backend/config.py @@ -43,3 +43,13 @@ PORT = int(os.getenv("PORT", "8000")) CORS_ORIGINS = _split( 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")) diff --git a/backend/routes/voice_webrtc.py b/backend/routes/voice_webrtc.py index 5eff219..d0e131c 100644 --- a/backend/routes/voice_webrtc.py +++ b/backend/routes/voice_webrtc.py @@ -17,17 +17,17 @@ from models import AssistantConfig, SignalingOffer from services.config_resolver import resolve_runtime_config from starlette.websockets import WebSocketDisconnect, WebSocketState -# 注意:pipecat / aiortc 都是重依赖(语音才用),改成函数内"惰性导入", -# 这样不装 pipecat 也能启动后端、验证 CRUD。语音真正用到时才加载。 +from services.webrtc_ice import aiortc_ice_servers, client_ice_servers -router = APIRouter() +# 注意:pipecat 是重依赖(语音才用),在 _handle_offer 等处惰性导入。 + +router = APIRouter(tags=["voice"]) -def _ice_servers(): - from aiortc import RTCIceServer - - # 本地只用 STUN;公网部署在此追加 TURN(参考 dograh get_ice_servers) - return [RTCIceServer(urls="stun:stun.l.google.com:19302")] +@router.get("/api/webrtc/ice-servers") +async def ice_servers(): + """Browser fetches STUN/TURN config (with ephemeral TURN creds when configured).""" + return {"iceServers": client_ice_servers()} @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) else: cfg = await _resolve_config(offer) # 解析放在建连前,配置错就别建连 - pc = SmallWebRTCConnection(ice_servers=_ice_servers()) + pc = SmallWebRTCConnection(ice_servers=aiortc_ice_servers()) if pc_id: pc._pc_id = pc_id await pc.initialize(sdp=offer.sdp, type=offer.type) diff --git a/backend/services/webrtc_ice.py b/backend/services/webrtc_ice.py new file mode 100644 index 0000000..5b64ed6 --- /dev/null +++ b/backend/services/webrtc_ice.py @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 3f6142e..e9638f8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -49,6 +49,9 @@ services: DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres" # 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" + # WebRTC TURN(公网部署:设 PUBLIC_IP + TURN_SECRET,并 docker compose --profile remote up) + TURN_URLS: "${TURN_URLS:-}" + TURN_SECRET: "${TURN_SECRET:-}" ports: - "8000:8000" depends_on: @@ -112,17 +115,24 @@ services: networks: [app-network] # ---- 可选(profile: remote):WebRTC 公网穿透 ---- + # 在项目根 .env 设置 PUBLIC_IP(云主机公网 IP)与 TURN_SECRET,与 backend TURN_SECRET 一致。 + # 云安全组放行:UDP/TCP 3478,UDP 49152-49200。 coturn: image: coturn/coturn:4.8.0 profiles: ["remote"] - network_mode: host # TURN 需直接占用 UDP 端口段 + network_mode: host command: - -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 - --use-auth-secret - --static-auth-secret=${TURN_SECRET:-changeme} - --min-port=49152 - --max-port=49200 + - --no-cli # ---- 可选(profile: tls):nginx 反代统一 TLS,局域网 https 调试语音预览 ---- # 起前先生成证书:./deploy/setup-certs.sh(证书落在 deploy/certs/) diff --git a/frontend/src/hooks/use-voice-preview.ts b/frontend/src/hooks/use-voice-preview.ts index f1059f4..ca39044 100644 --- a/frontend/src/hooks/use-voice-preview.ts +++ b/frontend/src/hooks/use-voice-preview.ts @@ -19,7 +19,7 @@ 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"; @@ -301,10 +301,12 @@ export function useVoicePreview( if (wsRef.current === ws) closeOnRemoteEnd("语音信令连接已断开。"); }; - // 2) 建 PeerConnection(纯 STUN,本机/局域网够用) - const pc = new RTCPeerConnection({ - iceServers: [{ urls: "stun:stun.l.google.com:19302" }], - }); + // 2) 建 PeerConnection(STUN;公网跨网时后端会下发 TURN) + const iceServers = await webrtcApi + .iceServers() + .then((r) => r.iceServers) + .catch(() => [{ urls: "stun:stun.l.google.com:19302" }]); + const pc = new RTCPeerConnection({ iceServers }); pcRef.current = pc; pc.onicecandidate = (e) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1fec7a6..2820422 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -208,3 +208,14 @@ export type NodeTypesResponse = { export const nodeTypesApi = { list: () => request("/api/node-types"), }; + +export type IceServerConfig = { + urls: string | string[]; + username?: string; + credential?: string; +}; + +export const webrtcApi = { + iceServers: () => + request<{ iceServers: IceServerConfig[] }>("/api/webrtc/ice-servers"), +};