From 0e3a1a8c4af5a7901746d697e45a0e73dd45d238 Mon Sep 17 00:00:00 2001 From: jackie Date: Sun, 31 May 2026 13:49:11 +0800 Subject: [PATCH] feat: add cloud tts switcher --- README.md | 11 ++- backend/main.py | 56 +++++++++++- frontend/app/page.tsx | 55 ++++++++++- frontend/components/CloudTTSPanel.tsx | 126 ++++++++++++++++++++++++++ frontend/lib/api.ts | 22 +++++ tests/test_tts_config.py | 42 +++++++++ 6 files changed, 302 insertions(+), 10 deletions(-) create mode 100644 frontend/components/CloudTTSPanel.tsx create mode 100644 tests/test_tts_config.py diff --git a/README.md b/README.md index acaf384..850053a 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,20 @@ npm run dev -- --hostname 127.0.0.1 --port 3000 Open `http://127.0.0.1:3000`. The header should show `TTS: 已连接`. +To switch between Kaggle and Colab without restarting the backend: + +1. Click **算力** in the web UI header. +2. Paste your Kaggle and Colab ngrok URLs into their fields. +3. Click **用 Kaggle** or **用 Colab**. +4. Click **检查** if you want to re-run the TTS health check. + +The backend updates its active TTS URL in memory. If you restart the backend, use the **算力** panel again or start it with `PODFORGE_TTS_URL=...`. + **Remote TTS lifecycle** - You do not need to keep Kaggle or Colab running when you are not generating audio. - If you stop the notebook, the ngrok URL becomes invalid. -- Next time you run the notebook, copy the new ngrok URL and restart the local PodForge backend with the new `PODFORGE_TTS_URL`. +- Next time you run the notebook, copy the new ngrok URL and apply it from the **算力** panel or restart the local PodForge backend with the new `PODFORGE_TTS_URL`. - Keep the final notebook cell running while generating audio. - Long scripts may take several minutes; avoid refreshing the browser while generation is in progress. - `PODFORGE_TTS_TIMEOUT=600` is recommended for remote GPU notebooks because some lines can take longer than the default HTTP timeout. diff --git a/backend/main.py b/backend/main.py index ec98c12..e959fc4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -39,9 +39,12 @@ allow_headers=["*"], ) -# ── Config ────────────────────────────────────────────────────────────────── +# -- Config ------------------------------------------------------------------ -TTS_BASE_URL = os.environ.get("PODFORGE_TTS_URL", "http://localhost:8809") +DEFAULT_TTS_BASE_URL = os.environ.get("PODFORGE_TTS_URL", "http://localhost:8809").rstrip("/") +DEFAULT_TTS_TIMEOUT = float(os.environ.get("PODFORGE_TTS_TIMEOUT", "300")) +_tts_base_url = DEFAULT_TTS_BASE_URL +_tts_timeout = DEFAULT_TTS_TIMEOUT SAMPLE_RATE = 48000 @@ -73,6 +76,7 @@ class GenerateResponse(BaseModel): class HealthResponse(BaseModel): status: str tts_server: bool + tts_base_url: str class PresetInfo(BaseModel): @@ -88,19 +92,61 @@ class EmotionInfo(BaseModel): emoji: str +class TTSConfig(BaseModel): + base_url: str + timeout_seconds: float = DEFAULT_TTS_TIMEOUT + + +# -- Runtime config helpers --------------------------------------------------- + + +def get_tts_config() -> TTSConfig: + return TTSConfig(base_url=_tts_base_url, timeout_seconds=_tts_timeout) + + +def set_tts_config(config: TTSConfig) -> TTSConfig: + global _tts_base_url, _tts_timeout + base_url = config.base_url.strip().rstrip("/") + if not base_url: + raise HTTPException(400, "TTS base URL cannot be empty.") + if config.timeout_seconds <= 0: + raise HTTPException(400, "TTS timeout must be greater than 0.") + + _tts_base_url = base_url + _tts_timeout = config.timeout_seconds + return get_tts_config() + + +def create_tts_client() -> VoxCPMClient: + config = get_tts_config() + return VoxCPMClient(config.base_url, timeout=config.timeout_seconds) + + # ── Routes ────────────────────────────────────────────────────────────────── @app.get("/health", response_model=HealthResponse) async def health(): - async with VoxCPMClient(TTS_BASE_URL) as tts: + config = get_tts_config() + async with create_tts_client() as tts: tts_ok = await tts.health() return HealthResponse( status="ok" if tts_ok else "degraded", tts_server=tts_ok, + tts_base_url=config.base_url, ) +@app.get("/tts-config", response_model=TTSConfig) +async def read_tts_config(): + return get_tts_config() + + +@app.post("/tts-config", response_model=TTSConfig) +async def update_tts_config(config: TTSConfig): + return set_tts_config(config) + + @app.get("/presets", response_model=list[PresetInfo]) async def get_presets(): """Return all preset voice descriptions with categories.""" @@ -152,7 +198,7 @@ async def generate(req: GenerateRequest): vm = VoiceManager(overrides=req.voice_overrides or {}) vm.assign_all(script.characters) - async with VoxCPMClient(TTS_BASE_URL) as tts: + async with create_tts_client() as tts: all_audio: list[np.ndarray] = [] silence = np.zeros(int(SAMPLE_RATE * 0.3)) @@ -223,7 +269,7 @@ async def generate_stream(ws: WebSocket): silence = np.zeros(int(SAMPLE_RATE * 0.3)) t0 = time.monotonic() - async with VoxCPMClient(TTS_BASE_URL) as tts: + async with create_tts_client() as tts: for i, line in enumerate(script.lines): desc = vm.get_description(line.character) text = line.text diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 9cfdf92..e41262c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -5,10 +5,13 @@ import { checkHealth, getPresets, getEmotions, + getTTSConfig, parseScript, + updateTTSConfig, PresetInfo, EmotionInfo, LineResult, + TTSConfig, } from "@/lib/api"; import { generateStream, CompleteMessage } from "@/lib/ws"; import ScriptEditor from "@/components/ScriptEditor"; @@ -16,6 +19,7 @@ import EmotionPicker from "@/components/EmotionPicker"; import VoicePanel from "@/components/VoicePanel"; import AudioPlayer from "@/components/AudioPlayer"; import ProgressTracker from "@/components/ProgressTracker"; +import CloudTTSPanel from "@/components/CloudTTSPanel"; const DEMO_SCRIPT = `# PodForge 示例剧本 — 播客对话 旁白: 欢迎来到今天的播客节目。今天我们请到了两位嘉宾。 @@ -31,6 +35,9 @@ const DEMO_SCRIPT = `# PodForge 示例剧本 — 播客对话 export default function Home() { const [script, setScript] = useState(""); const [ttsStatus, setTtsStatus] = useState<"connected" | "disconnected" | "checking">("checking"); + const [ttsConfig, setTtsConfig] = useState(null); + const [ttsApplying, setTtsApplying] = useState(false); + const [showTtsPanel, setShowTtsPanel] = useState(false); const [presets, setPresets] = useState([]); const [emotions, setEmotions] = useState([]); const [parsedLines, setParsedLines] = useState([]); @@ -41,14 +48,39 @@ export default function Home() { const [audio, setAudio] = useState(null); const [error, setError] = useState(null); - // Init + const refreshTtsStatus = useCallback(async () => { + setTtsStatus("checking"); + const [config, health] = await Promise.all([ + getTTSConfig(), + checkHealth(), + ]); + setTtsConfig(config); + setTtsStatus(health.tts_server ? "connected" : "disconnected"); + }, []); + + const applyTtsConfig = useCallback( + async (baseUrl: string, timeoutSeconds: number) => { + setTtsApplying(true); + try { + const config = await updateTTSConfig({ + base_url: baseUrl, + timeout_seconds: timeoutSeconds, + }); + setTtsConfig(config); + await refreshTtsStatus(); + } finally { + setTtsApplying(false); + } + }, + [refreshTtsStatus] + ); + useEffect(() => { - checkHealth() - .then((h) => setTtsStatus(h.tts_server ? "connected" : "disconnected")) + refreshTtsStatus() .catch(() => setTtsStatus("disconnected")); getPresets().then(setPresets).catch(console.error); getEmotions().then(setEmotions).catch(console.error); - }, []); + }, [refreshTtsStatus]); // Derived state from script (handles empty case) const { lines, chars } = useMemo(() => { @@ -144,9 +176,24 @@ export default function Home() { TTS: {ttsStatus === "connected" ? "已连接" : ttsStatus === "checking" ? "检查中..." : "未连接"} + + {showTtsPanel && ( + + )} + {/* Main */}
{/* Left: Editor */} diff --git a/frontend/components/CloudTTSPanel.tsx b/frontend/components/CloudTTSPanel.tsx new file mode 100644 index 0000000..55b8283 --- /dev/null +++ b/frontend/components/CloudTTSPanel.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState } from "react"; +import { TTSConfig } from "@/lib/api"; + +interface Props { + config: TTSConfig | null; + applying: boolean; + onApply: (baseUrl: string, timeoutSeconds: number) => Promise; + onRefresh: () => Promise; +} + +const DEFAULT_TIMEOUT = 600; + +function loadSavedUrl(key: string): string { + if (typeof window === "undefined") return ""; + return window.localStorage.getItem(key) || ""; +} + +export default function CloudTTSPanel({ + config, + applying, + onApply, + onRefresh, +}: Props) { + const [kaggleUrl, setKaggleUrl] = useState(() => loadSavedUrl("podforge:kaggleTtsUrl")); + const [colabUrl, setColabUrl] = useState(() => loadSavedUrl("podforge:colabTtsUrl")); + const [customUrl, setCustomUrl] = useState(config?.base_url || ""); + const [message, setMessage] = useState(""); + + const timeoutSeconds = config?.timeout_seconds || DEFAULT_TIMEOUT; + + async function applyUrl(baseUrl: string) { + const trimmed = baseUrl.trim(); + if (!trimmed) { + setMessage("请输入 TTS URL"); + return; + } + setMessage("切换中..."); + try { + await onApply(trimmed, timeoutSeconds); + setCustomUrl(trimmed); + setMessage("已切换"); + } catch (error) { + setMessage(error instanceof Error ? error.message : "切换失败"); + } + } + + return ( +
+
+ + + + + + +
+ + + + +
+
+
+ Backend: {config?.base_url || "未加载"} + Timeout: {timeoutSeconds}s + {message && {message}} +
+
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 0a73b75..89cdeb0 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -38,6 +38,12 @@ export interface EmotionInfo { export interface HealthResponse { status: string; tts_server: boolean; + tts_base_url: string; +} + +export interface TTSConfig { + base_url: string; + timeout_seconds: number; } export async function checkHealth(): Promise { @@ -55,6 +61,22 @@ export async function getEmotions(): Promise { return res.json(); } +export async function getTTSConfig(): Promise { + const res = await fetch(`${API_BASE}/tts-config`); + if (!res.ok) throw new Error(`TTS config fetch failed: ${res.statusText}`); + return res.json(); +} + +export async function updateTTSConfig(config: TTSConfig): Promise { + const res = await fetch(`${API_BASE}/tts-config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + if (!res.ok) throw new Error(`TTS config update failed: ${res.statusText}`); + return res.json(); +} + export async function parseScript( script: string, voice_overrides?: Record diff --git a/tests/test_tts_config.py b/tests/test_tts_config.py new file mode 100644 index 0000000..99c5529 --- /dev/null +++ b/tests/test_tts_config.py @@ -0,0 +1,42 @@ +"""Tests for runtime TTS configuration.""" + +from fastapi.testclient import TestClient + +from backend.main import app + + +def test_tts_config_can_be_updated_and_restored(): + client = TestClient(app) + original = client.get("/tts-config").json() + + try: + res = client.post( + "/tts-config", + json={ + "base_url": "https://example.ngrok-free.dev/", + "timeout_seconds": 600, + }, + ) + assert res.status_code == 200 + assert res.json() == { + "base_url": "https://example.ngrok-free.dev", + "timeout_seconds": 600.0, + } + + current = client.get("/tts-config") + assert current.status_code == 200 + assert current.json()["base_url"] == "https://example.ngrok-free.dev" + finally: + client.post("/tts-config", json=original) + + +def test_tts_config_rejects_empty_url(): + client = TestClient(app) + + res = client.post( + "/tts-config", + json={"base_url": " ", "timeout_seconds": 600}, + ) + + assert res.status_code == 400 + assert res.json()["detail"] == "TTS base URL cannot be empty."