mirror of
https://github.com/JezzWTF/vibepod.git
synced 2026-06-13 03:58:07 +00:00
Create VibePod TTS podcast generator application
Agent-Logs-Url: https://github.com/JezzWTF/vibepod/sessions/a78fcf03-e979-4777-a428-18cc8eccc095 Co-authored-by: LyAhn <27559362+LyAhn@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
ee85bece74
commit
3974a4cf69
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { text, cfg_scale, inference_steps } = body as {
|
||||
text: string;
|
||||
cfg_scale: number;
|
||||
inference_steps: number;
|
||||
};
|
||||
|
||||
if (!text || typeof text !== "string" || text.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or empty text field" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pythonServerUrl =
|
||||
process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
|
||||
|
||||
const upstream = await fetch(`${pythonServerUrl}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text: text.trim(),
|
||||
cfg_scale: cfg_scale ?? 2.5,
|
||||
inference_steps: inference_steps ?? 20,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errorText = await upstream.text().catch(() => "Unknown error");
|
||||
return NextResponse.json(
|
||||
{ error: `VibeVoice server error: ${errorText}` },
|
||||
{ status: upstream.status }
|
||||
);
|
||||
}
|
||||
|
||||
const audioBuffer = await upstream.arrayBuffer();
|
||||
|
||||
return new NextResponse(audioBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Disposition": 'attachment; filename="vibepod-output.wav"',
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to connect to VibeVoice server";
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const pythonServerUrl =
|
||||
process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
|
||||
|
||||
try {
|
||||
const res = await fetch(`${pythonServerUrl}/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return NextResponse.json({ status: "online" });
|
||||
}
|
||||
return NextResponse.json({ status: "offline" });
|
||||
} catch {
|
||||
return NextResponse.json({ status: "offline" });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,87 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #0d1117;
|
||||
--foreground: #e2e8f0;
|
||||
--card-bg: #161b22;
|
||||
--border: #21262d;
|
||||
--accent-teal: #2dd4bf;
|
||||
--accent-violet: #a78bfa;
|
||||
--accent-teal-dim: #0d9488;
|
||||
--accent-violet-dim: #7c3aed;
|
||||
--muted: #64748b;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Range input styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
background: var(--border);
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-teal);
|
||||
margin-top: -6px;
|
||||
box-shadow: 0 0 6px rgba(45, 212, 191, 0.4);
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
input[type="range"]:hover::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 10px rgba(45, 212, 191, 0.7);
|
||||
}
|
||||
input[type="range"]::-moz-range-track {
|
||||
background: var(--border);
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-teal);
|
||||
border: none;
|
||||
box-shadow: 0 0 6px rgba(45, 212, 191, 0.4);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "VibePod — TTS Podcast Generator",
|
||||
description: "Generate podcast audio using Microsoft VibeVoice 0.5B",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ background: "var(--background)", color: "var(--foreground)" }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useCallback } from "react";
|
||||
import Header from "@/components/Header";
|
||||
import TextInputPanel from "@/components/TextInputPanel";
|
||||
import GenerationControls from "@/components/GenerationControls";
|
||||
import AudioPlayer from "@/components/AudioPlayer";
|
||||
import StatusLog from "@/components/StatusLog";
|
||||
|
||||
interface AppState {
|
||||
script: string;
|
||||
cfgScale: number;
|
||||
inferenceSteps: number;
|
||||
isGenerating: boolean;
|
||||
audioUrl: string | null;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
type AppAction =
|
||||
| { type: "SET_SCRIPT"; payload: string }
|
||||
| { type: "SET_CFG_SCALE"; payload: number }
|
||||
| { type: "SET_INFERENCE_STEPS"; payload: number }
|
||||
| { type: "START_GENERATION" }
|
||||
| { type: "GENERATION_SUCCESS"; payload: string }
|
||||
| { type: "GENERATION_ERROR"; payload: string }
|
||||
| { type: "ADD_LOG"; payload: string };
|
||||
|
||||
function appReducer(state: AppState, action: AppAction): AppState {
|
||||
switch (action.type) {
|
||||
case "SET_SCRIPT":
|
||||
return { ...state, script: action.payload };
|
||||
case "SET_CFG_SCALE":
|
||||
return { ...state, cfgScale: action.payload };
|
||||
case "SET_INFERENCE_STEPS":
|
||||
return { ...state, inferenceSteps: action.payload };
|
||||
case "START_GENERATION":
|
||||
return {
|
||||
...state,
|
||||
isGenerating: true,
|
||||
audioUrl: null,
|
||||
logs: [],
|
||||
};
|
||||
case "GENERATION_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
isGenerating: false,
|
||||
audioUrl: action.payload,
|
||||
};
|
||||
case "GENERATION_ERROR":
|
||||
return {
|
||||
...state,
|
||||
isGenerating: false,
|
||||
};
|
||||
case "ADD_LOG":
|
||||
return { ...state, logs: [...state.logs, action.payload] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
script: "",
|
||||
cfgScale: 2.5,
|
||||
inferenceSteps: 20,
|
||||
isGenerating: false,
|
||||
audioUrl: null,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const [state, dispatch] = useReducer(appReducer, initialState);
|
||||
|
||||
const wordCount =
|
||||
state.script.trim() === ""
|
||||
? 0
|
||||
: state.script.trim().split(/\s+/).length;
|
||||
|
||||
const addLog = useCallback((msg: string) => {
|
||||
dispatch({ type: "ADD_LOG", payload: msg });
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!state.script.trim() || state.isGenerating) return;
|
||||
|
||||
dispatch({ type: "START_GENERATION" });
|
||||
addLog("Connecting to VibeVoice server...");
|
||||
|
||||
try {
|
||||
addLog(`Sending script (${wordCount} words) for synthesis...`);
|
||||
addLog(
|
||||
`Settings: CFG=${state.cfgScale.toFixed(1)}, Steps=${state.inferenceSteps}`
|
||||
);
|
||||
|
||||
const res = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
text: state.script,
|
||||
cfg_scale: state.cfgScale,
|
||||
inference_steps: state.inferenceSteps,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
addLog("Generating audio...");
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const sizeMB = (blob.size / 1024 / 1024).toFixed(2);
|
||||
addLog(`Audio received — ${sizeMB} MB`);
|
||||
addLog("Done — audio ready for playback.");
|
||||
|
||||
dispatch({ type: "GENERATION_SUCCESS", payload: url });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
addLog(`Error: ${message}`);
|
||||
dispatch({ type: "GENERATION_ERROR", payload: message });
|
||||
}
|
||||
}, [state.script, state.cfgScale, state.inferenceSteps, state.isGenerating, wordCount, addLog]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex flex-col"
|
||||
style={{ background: "var(--background)" }}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-6 max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column: script input */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-6">
|
||||
<TextInputPanel
|
||||
value={state.script}
|
||||
onChange={(text) =>
|
||||
dispatch({ type: "SET_SCRIPT", payload: text })
|
||||
}
|
||||
/>
|
||||
{state.audioUrl && <AudioPlayer audioUrl={state.audioUrl} />}
|
||||
</div>
|
||||
|
||||
{/* Right column: controls + log */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<GenerationControls
|
||||
cfgScale={state.cfgScale}
|
||||
onCfgScaleChange={(v) =>
|
||||
dispatch({ type: "SET_CFG_SCALE", payload: v })
|
||||
}
|
||||
inferenceSteps={state.inferenceSteps}
|
||||
onInferenceStepsChange={(v) =>
|
||||
dispatch({ type: "SET_INFERENCE_STEPS", payload: v })
|
||||
}
|
||||
onGenerate={handleGenerate}
|
||||
isGenerating={state.isGenerating}
|
||||
wordCount={wordCount}
|
||||
/>
|
||||
<StatusLog messages={state.logs} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user