From 3974a4cf69d268b723566e32f588eec04a9d830a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:41:46 +0000 Subject: [PATCH] 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> --- podcast-forge/.gitignore | 41 + podcast-forge/README.md | 36 + podcast-forge/app/api/generate/route.ts | 55 + podcast-forge/app/api/health/route.ts | 20 + podcast-forge/app/favicon.ico | Bin 0 -> 25931 bytes podcast-forge/app/globals.css | 87 + podcast-forge/app/layout.tsx | 21 + podcast-forge/app/page.tsx | 168 ++ podcast-forge/components/AudioPlayer.tsx | 195 ++ .../components/GenerationControls.tsx | 193 ++ podcast-forge/components/Header.tsx | 106 ++ podcast-forge/components/StatusLog.tsx | 76 + podcast-forge/components/TextInputPanel.tsx | 112 ++ podcast-forge/hooks/useAudioPlayer.ts | 94 + podcast-forge/next.config.ts | 7 + podcast-forge/package-lock.json | 1651 +++++++++++++++++ podcast-forge/package.json | 23 + podcast-forge/postcss.config.mjs | 5 + podcast-forge/public/file.svg | 1 + podcast-forge/public/globe.svg | 1 + podcast-forge/public/next.svg | 1 + podcast-forge/public/vercel.svg | 1 + podcast-forge/public/window.svg | 1 + podcast-forge/server/requirements.txt | 11 + podcast-forge/server/vibevoice_server.py | 150 ++ podcast-forge/tsconfig.json | 27 + 26 files changed, 3083 insertions(+) create mode 100644 podcast-forge/.gitignore create mode 100644 podcast-forge/README.md create mode 100644 podcast-forge/app/api/generate/route.ts create mode 100644 podcast-forge/app/api/health/route.ts create mode 100644 podcast-forge/app/favicon.ico create mode 100644 podcast-forge/app/globals.css create mode 100644 podcast-forge/app/layout.tsx create mode 100644 podcast-forge/app/page.tsx create mode 100644 podcast-forge/components/AudioPlayer.tsx create mode 100644 podcast-forge/components/GenerationControls.tsx create mode 100644 podcast-forge/components/Header.tsx create mode 100644 podcast-forge/components/StatusLog.tsx create mode 100644 podcast-forge/components/TextInputPanel.tsx create mode 100644 podcast-forge/hooks/useAudioPlayer.ts create mode 100644 podcast-forge/next.config.ts create mode 100644 podcast-forge/package-lock.json create mode 100644 podcast-forge/package.json create mode 100644 podcast-forge/postcss.config.mjs create mode 100644 podcast-forge/public/file.svg create mode 100644 podcast-forge/public/globe.svg create mode 100644 podcast-forge/public/next.svg create mode 100644 podcast-forge/public/vercel.svg create mode 100644 podcast-forge/public/window.svg create mode 100644 podcast-forge/server/requirements.txt create mode 100644 podcast-forge/server/vibevoice_server.py create mode 100644 podcast-forge/tsconfig.json diff --git a/podcast-forge/.gitignore b/podcast-forge/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/podcast-forge/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/podcast-forge/README.md b/podcast-forge/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/podcast-forge/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/podcast-forge/app/api/generate/route.ts b/podcast-forge/app/api/generate/route.ts new file mode 100644 index 0000000..3643196 --- /dev/null +++ b/podcast-forge/app/api/generate/route.ts @@ -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 }); + } +} diff --git a/podcast-forge/app/api/health/route.ts b/podcast-forge/app/api/health/route.ts new file mode 100644 index 0000000..60d4347 --- /dev/null +++ b/podcast-forge/app/api/health/route.ts @@ -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" }); + } +} diff --git a/podcast-forge/app/favicon.ico b/podcast-forge/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/podcast-forge/app/globals.css b/podcast-forge/app/globals.css new file mode 100644 index 0000000..9388e7f --- /dev/null +++ b/podcast-forge/app/globals.css @@ -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); +} diff --git a/podcast-forge/app/layout.tsx b/podcast-forge/app/layout.tsx new file mode 100644 index 0000000..226e942 --- /dev/null +++ b/podcast-forge/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/podcast-forge/app/page.tsx b/podcast-forge/app/page.tsx new file mode 100644 index 0000000..81b2142 --- /dev/null +++ b/podcast-forge/app/page.tsx @@ -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 ( +
+
+ +
+
+ {/* Left column: script input */} +
+ + dispatch({ type: "SET_SCRIPT", payload: text }) + } + /> + {state.audioUrl && } +
+ + {/* Right column: controls + log */} +
+ + 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} + /> + +
+
+
+
+ ); +} diff --git a/podcast-forge/components/AudioPlayer.tsx b/podcast-forge/components/AudioPlayer.tsx new file mode 100644 index 0000000..f54f25e --- /dev/null +++ b/podcast-forge/components/AudioPlayer.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useAudioPlayer } from "@/hooks/useAudioPlayer"; + +interface AudioPlayerProps { + audioUrl: string | null; +} + +function formatTime(seconds: number): string { + if (!isFinite(seconds) || isNaN(seconds)) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export default function AudioPlayer({ audioUrl }: AudioPlayerProps) { + const { + isPlaying, + currentTime, + duration, + volume, + toggle, + seek, + setVolume, + } = useAudioPlayer(audioUrl); + + if (!audioUrl) return null; + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + const handleDownload = () => { + const a = document.createElement("a"); + a.href = audioUrl; + a.download = "vibepod-output.wav"; + a.click(); + }; + + return ( +
+
+

+ Audio Player +

+ +
+ + {/* Waveform / progress bar */} +
+
{ + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = (e.clientX - rect.left) / rect.width; + seek(ratio * duration); + }} + > +
+
+
+ {formatTime(currentTime)} + {formatTime(duration)} +
+
+ + {/* Controls row */} +
+ {/* Play/Pause */} + + + {/* Duration info */} +
+ + {formatTime(currentTime)} + + / + {formatTime(duration)} +
+ + {/* Volume control */} +
+ + {volume === 0 ? ( + <> + + + + + ) : volume < 0.5 ? ( + <> + + + + ) : ( + <> + + + + )} + + setVolume(parseFloat(e.target.value))} + className="w-20" + aria-label="Volume" + /> +
+
+
+ ); +} diff --git a/podcast-forge/components/GenerationControls.tsx b/podcast-forge/components/GenerationControls.tsx new file mode 100644 index 0000000..78e8681 --- /dev/null +++ b/podcast-forge/components/GenerationControls.tsx @@ -0,0 +1,193 @@ +"use client"; + +interface GenerationControlsProps { + cfgScale: number; + onCfgScaleChange: (v: number) => void; + inferenceSteps: number; + onInferenceStepsChange: (v: number) => void; + onGenerate: () => void; + isGenerating: boolean; + wordCount: number; +} + +export default function GenerationControls({ + cfgScale, + onCfgScaleChange, + inferenceSteps, + onInferenceStepsChange, + onGenerate, + isGenerating, + wordCount, +}: GenerationControlsProps) { + const estimatedSeconds = Math.ceil(wordCount / 50); + const estimatedDisplay = + wordCount === 0 + ? "—" + : estimatedSeconds < 60 + ? `~${estimatedSeconds}s` + : `~${Math.floor(estimatedSeconds / 60)}m ${estimatedSeconds % 60}s`; + + return ( +
+

+ Generation Settings +

+ + {/* CFG Scale slider */} +
+
+ + + {cfgScale.toFixed(1)} + +
+ onCfgScaleChange(parseFloat(e.target.value))} + className="w-full" + /> +
+ Flat (1.0) + CFG Scale + Expressive (3.0) +
+
+ + {/* Inference Steps slider */} +
+
+ + + {inferenceSteps} + +
+ onInferenceStepsChange(parseInt(e.target.value, 10))} + className="w-full" + style={ + { + "--thumb-color": "var(--accent-violet)", + } as React.CSSProperties + } + /> +
+ Faster (10) + Inference Steps + Higher quality (30) +
+
+ + {/* Estimated time */} +
+ Estimated generation time + + {estimatedDisplay} + +
+ + {/* Generate button */} + +
+ ); +} diff --git a/podcast-forge/components/Header.tsx b/podcast-forge/components/Header.tsx new file mode 100644 index 0000000..b60d278 --- /dev/null +++ b/podcast-forge/components/Header.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type ServerStatus = "checking" | "online" | "offline"; + +export default function Header() { + const [status, setStatus] = useState("checking"); + + useEffect(() => { + const checkHealth = async () => { + try { + const res = await fetch("/api/health"); + const data = await res.json(); + setStatus(data.status === "online" ? "online" : "offline"); + } catch { + setStatus("offline"); + } + }; + + checkHealth(); + const interval = setInterval(checkHealth, 30000); + return () => clearInterval(interval); + }, []); + + const statusConfig = { + checking: { + color: "bg-yellow-500", + label: "Checking...", + textColor: "text-yellow-400", + pulse: true, + }, + online: { + color: "bg-green-500", + label: "Server Online", + textColor: "text-green-400", + pulse: false, + }, + offline: { + color: "bg-red-500", + label: "Server Offline", + textColor: "text-red-400", + pulse: false, + }, + }; + + const cfg = statusConfig[status]; + + return ( +
+
+
+
+ 🎙 +
+
+

+ VibePod +

+

+ Powered by VibeVoice 0.5B +

+
+
+
+ +
+ + + + + {cfg.label} +
+
+ ); +} diff --git a/podcast-forge/components/StatusLog.tsx b/podcast-forge/components/StatusLog.tsx new file mode 100644 index 0000000..95155e5 --- /dev/null +++ b/podcast-forge/components/StatusLog.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface StatusLogProps { + messages: string[]; +} + +export default function StatusLog({ messages }: StatusLogProps) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + return ( +
+
+

+ Status Log +

+
+ + + +
+
+ +
+ {messages.length === 0 ? ( +

+ Waiting for input... + +

+ ) : ( + messages.map((msg, i) => { + const isError = + msg.toLowerCase().includes("error") || + msg.toLowerCase().includes("failed"); + const isSuccess = + msg.toLowerCase().includes("done") || + msg.toLowerCase().includes("complete") || + msg.toLowerCase().includes("ready"); + const color = isError + ? "var(--error)" + : isSuccess + ? "var(--success)" + : "var(--foreground)"; + + return ( +
+ + {String(i + 1).padStart(2, "0")} + + {msg} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/podcast-forge/components/TextInputPanel.tsx b/podcast-forge/components/TextInputPanel.tsx new file mode 100644 index 0000000..347564e --- /dev/null +++ b/podcast-forge/components/TextInputPanel.tsx @@ -0,0 +1,112 @@ +"use client"; + +const SAMPLE_SCRIPT = `Welcome to VibePod, your gateway to the future of audio content creation. Today, we're diving deep into the world of artificial intelligence and how it's transforming the way we produce and consume podcasts. + +Imagine being able to transform any written article, blog post, or essay into a professional-sounding audio experience in just seconds. That's exactly what VibeVoice 0.5B brings to the table — a compact yet powerful text-to-speech model that delivers remarkably natural-sounding voices. + +The technology behind modern TTS systems has evolved dramatically over the past few years. We've moved from robotic, stilted speech synthesis to voices that carry real emotional nuance and natural prosody. VibeVoice represents Microsoft's latest contribution to this rapidly advancing field. + +Whether you're a content creator looking to repurpose written material, an educator who wants to make content more accessible, or a developer building the next generation of audio applications, VibePod provides the tools you need. + +In today's episode, we'll explore the key features that make VibeVoice unique, discuss practical use cases across different industries, and look ahead to what the next generation of voice AI might bring. Let's get started.`; + +interface TextInputPanelProps { + value: string; + onChange: (text: string) => void; +} + +export default function TextInputPanel({ + value, + onChange, +}: TextInputPanelProps) { + const charCount = value.length; + const wordCount = value.trim() === "" ? 0 : value.trim().split(/\s+/).length; + + return ( +
+
+

+ Podcast Script +

+
+ + +
+
+ +