Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 51 additions & 5 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -73,6 +76,7 @@ class GenerateResponse(BaseModel):
class HealthResponse(BaseModel):
status: str
tts_server: bool
tts_base_url: str


class PresetInfo(BaseModel):
Expand All @@ -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."""
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand Down
55 changes: 51 additions & 4 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ 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";
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 示例剧本 — 播客对话
旁白: 欢迎来到今天的播客节目。今天我们请到了两位嘉宾。
Expand All @@ -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<TTSConfig | null>(null);
const [ttsApplying, setTtsApplying] = useState(false);
const [showTtsPanel, setShowTtsPanel] = useState(false);
const [presets, setPresets] = useState<PresetInfo[]>([]);
const [emotions, setEmotions] = useState<EmotionInfo[]>([]);
const [parsedLines, setParsedLines] = useState<LineResult[]>([]);
Expand All @@ -41,14 +48,39 @@ export default function Home() {
const [audio, setAudio] = useState<CompleteMessage | null>(null);
const [error, setError] = useState<string | null>(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(() => {
Expand Down Expand Up @@ -144,9 +176,24 @@ export default function Home() {
<span className="text-zinc-400">
TTS: {ttsStatus === "connected" ? "已连接" : ttsStatus === "checking" ? "检查中..." : "未连接"}
</span>
<button
onClick={() => setShowTtsPanel((value) => !value)}
className="ml-2 rounded border border-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-800"
>
算力
</button>
</div>
</header>

{showTtsPanel && (
<CloudTTSPanel
config={ttsConfig}
applying={ttsApplying}
onApply={applyTtsConfig}
onRefresh={refreshTtsStatus}
/>
)}

{/* Main */}
<main className="flex flex-col lg:flex-row gap-4 p-4 h-[calc(100vh-57px)]">
{/* Left: Editor */}
Expand Down
126 changes: 126 additions & 0 deletions frontend/components/CloudTTSPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
onRefresh: () => Promise<void>;
}

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 (
<section className="border-b border-zinc-800 bg-zinc-900/80 px-6 py-3">
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]">
<label className="min-w-0">
<span className="mb-1 block text-xs text-zinc-500">Kaggle URL</span>
<input
value={kaggleUrl}
onChange={(e) => {
setKaggleUrl(e.target.value);
window.localStorage.setItem("podforge:kaggleTtsUrl", e.target.value);
}}
placeholder="https://...ngrok-free.dev"
className="w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 outline-none focus:border-amber-500"
/>
</label>

<label className="min-w-0">
<span className="mb-1 block text-xs text-zinc-500">Colab URL</span>
<input
value={colabUrl}
onChange={(e) => {
setColabUrl(e.target.value);
window.localStorage.setItem("podforge:colabTtsUrl", e.target.value);
}}
placeholder="https://...ngrok-free.dev"
className="w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 outline-none focus:border-amber-500"
/>
</label>

<label className="min-w-0">
<span className="mb-1 block text-xs text-zinc-500">当前 URL</span>
<input
value={customUrl || config?.base_url || ""}
onChange={(e) => setCustomUrl(e.target.value)}
placeholder={config?.base_url || "http://localhost:8809"}
className="w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 outline-none focus:border-amber-500"
/>
</label>

<div className="flex flex-wrap items-end gap-2">
<button
onClick={() => applyUrl(kaggleUrl)}
disabled={applying}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-700 disabled:opacity-40"
>
用 Kaggle
</button>
<button
onClick={() => applyUrl(colabUrl)}
disabled={applying}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-700 disabled:opacity-40"
>
用 Colab
</button>
<button
onClick={() => applyUrl(customUrl || config?.base_url || "")}
disabled={applying}
className="rounded-lg bg-amber-500 px-3 py-2 text-xs font-medium text-zinc-950 hover:bg-amber-400 disabled:opacity-40"
>
应用
</button>
<button
onClick={onRefresh}
disabled={applying}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-700 disabled:opacity-40"
>
检查
</button>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-500">
<span>Backend: {config?.base_url || "未加载"}</span>
<span>Timeout: {timeoutSeconds}s</span>
{message && <span className="text-amber-400">{message}</span>}
</div>
</section>
);
}
22 changes: 22 additions & 0 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HealthResponse> {
Expand All @@ -55,6 +61,22 @@ export async function getEmotions(): Promise<EmotionInfo[]> {
return res.json();
}

export async function getTTSConfig(): Promise<TTSConfig> {
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<TTSConfig> {
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<string, string>
Expand Down
Loading
Loading