mirror of
https://github.com/JezzWTF/vibepod.git
synced 2026-06-13 03:58:07 +00:00
feat: add studio roadmap and streaming cleanup
This commit is contained in:
@@ -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 (
|
||||
<div
|
||||
className="rounded-xl border p-5 flex flex-col gap-4"
|
||||
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--accent-teal)" }}
|
||||
>
|
||||
Audio Player
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-colors cursor-pointer"
|
||||
style={{
|
||||
borderColor: "var(--accent-teal-dim)",
|
||||
color: "var(--accent-teal)",
|
||||
background: "rgba(45, 212, 191, 0.05)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(45, 212, 191, 0.15)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background =
|
||||
"rgba(45, 212, 191, 0.05)";
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
Download WAV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Waveform / progress bar */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className="relative h-2 rounded-full cursor-pointer overflow-hidden"
|
||||
style={{ background: "var(--border)" }}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
seek(ratio * duration);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--accent-teal-dim), var(--accent-violet-dim))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between text-xs font-mono"
|
||||
style={{ color: "var(--muted)" }}
|
||||
>
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--accent-teal-dim), var(--accent-violet-dim))",
|
||||
boxShadow: "0 4px 12px rgba(45, 212, 191, 0.3)",
|
||||
}}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Duration info */}
|
||||
<div className="flex-1 flex items-center gap-1 text-sm">
|
||||
<span style={{ color: "var(--foreground)" }}>
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<span style={{ color: "var(--muted)" }}>/</span>
|
||||
<span style={{ color: "var(--muted)" }}>{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Volume control */}
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
style={{ color: "var(--muted)" }}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
{volume === 0 ? (
|
||||
<>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</>
|
||||
) : volume < 0.5 ? (
|
||||
<>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 010 7.07" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-20"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import type { ServerStatus, DownloadProgress } from "@/app/page";
|
||||
|
||||
const FALLBACK_VOICES = ["carter", "davis", "emma", "frank", "grace", "mike"];
|
||||
|
||||
interface GenerationControlsProps {
|
||||
speaker: string;
|
||||
availableVoices: string[];
|
||||
onSpeakerChange: (v: string) => void;
|
||||
cfgScale: number;
|
||||
onCfgScaleChange: (v: number) => void;
|
||||
inferenceSteps: number;
|
||||
onInferenceStepsChange: (v: number) => void;
|
||||
onGenerate: () => void;
|
||||
onStop: () => void;
|
||||
onPauseStream: () => void;
|
||||
onResumeStream: () => void;
|
||||
isStreamPaused: boolean;
|
||||
isGenerating: boolean;
|
||||
genElapsed: number;
|
||||
genPct: number | null;
|
||||
wordCount: number;
|
||||
serverStatus: ServerStatus;
|
||||
downloadProgress: DownloadProgress | null;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
Exclude<ServerStatus, "online">,
|
||||
{ color: string; label: (p: DownloadProgress | null) => string }
|
||||
> = {
|
||||
offline: { color: "var(--error)", label: () => "Server offline — waiting for connection..." },
|
||||
downloading: { color: "#60a5fa", label: (p) => p && p.total > 0 ? `Downloading model... (${p.done} / ${p.total} files)` : "Downloading model (~1 GB)..." },
|
||||
loading: { color: "#fbbf24", label: () => "Loading model into memory..." },
|
||||
error: { color: "var(--error)", label: () => "Server error — check the terminal for details." },
|
||||
};
|
||||
|
||||
export default function GenerationControls({
|
||||
speaker,
|
||||
availableVoices,
|
||||
onSpeakerChange,
|
||||
cfgScale,
|
||||
onCfgScaleChange,
|
||||
inferenceSteps,
|
||||
onInferenceStepsChange,
|
||||
onGenerate,
|
||||
onStop,
|
||||
onPauseStream,
|
||||
onResumeStream,
|
||||
isStreamPaused,
|
||||
isGenerating,
|
||||
genElapsed,
|
||||
genPct,
|
||||
wordCount,
|
||||
serverStatus,
|
||||
downloadProgress,
|
||||
}: GenerationControlsProps) {
|
||||
const voices = availableVoices.length > 0 ? availableVoices : FALLBACK_VOICES;
|
||||
const serverReady = serverStatus === "online";
|
||||
const buttonDisabled = isGenerating || wordCount === 0 || !serverReady;
|
||||
|
||||
const downloadPct =
|
||||
downloadProgress && downloadProgress.total > 0
|
||||
? Math.round((downloadProgress.done / downloadProgress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-5 flex flex-col gap-5"
|
||||
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
|
||||
>
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--accent-teal)" }}
|
||||
>
|
||||
Generation Settings
|
||||
</h2>
|
||||
|
||||
{/* Voice selector */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
|
||||
Voice
|
||||
</label>
|
||||
<select
|
||||
value={speaker}
|
||||
onChange={(e) => onSpeakerChange(e.target.value)}
|
||||
disabled={!serverReady}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-medium appearance-none cursor-pointer disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "1px solid var(--border)",
|
||||
color: serverReady ? "var(--foreground)" : "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{voices.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v.charAt(0).toUpperCase() + v.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* CFG Scale slider */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
|
||||
Voice Expressiveness
|
||||
</label>
|
||||
<span
|
||||
className="text-sm font-mono px-2 py-0.5 rounded"
|
||||
style={{ background: "var(--background)", color: "var(--accent-teal)" }}
|
||||
>
|
||||
{cfgScale.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={4.0}
|
||||
step={0.1}
|
||||
value={cfgScale}
|
||||
onChange={(e) => onCfgScaleChange(parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs" style={{ color: "var(--muted)" }}>
|
||||
<span>Flat (0.5)</span>
|
||||
<span>CFG Scale</span>
|
||||
<span>Expressive (4.0)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inference Steps slider */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
|
||||
Quality vs Speed
|
||||
</label>
|
||||
<span
|
||||
className="text-sm font-mono px-2 py-0.5 rounded"
|
||||
style={{ background: "var(--background)", color: "var(--accent-violet)" }}
|
||||
>
|
||||
{inferenceSteps}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={20}
|
||||
step={1}
|
||||
value={inferenceSteps}
|
||||
onChange={(e) => onInferenceStepsChange(parseInt(e.target.value, 10))}
|
||||
className="w-full"
|
||||
style={{ "--thumb-color": "var(--accent-violet)" } as React.CSSProperties}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs" style={{ color: "var(--muted)" }}>
|
||||
<span>Faster (5)</span>
|
||||
<span>Diffusion Steps</span>
|
||||
<span>Better (20)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server status banner */}
|
||||
{!serverReady && (
|
||||
<div
|
||||
className="flex flex-col gap-2 px-3 py-3 rounded-lg text-sm"
|
||||
style={{ background: "var(--background)", border: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${serverStatus === "offline" || serverStatus === "error" ? "" : "animate-pulse"}`}
|
||||
style={{ background: STATUS_CONFIG[serverStatus].color }}
|
||||
/>
|
||||
<span style={{ color: STATUS_CONFIG[serverStatus].color }}>
|
||||
{STATUS_CONFIG[serverStatus].label(downloadProgress)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{serverStatus === "downloading" && (
|
||||
<div className="w-full rounded-full h-1.5 overflow-hidden" style={{ background: "var(--border)" }}>
|
||||
<div
|
||||
className="h-1.5 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${downloadPct}%`,
|
||||
background: "linear-gradient(90deg, #60a5fa, var(--accent-teal))",
|
||||
minWidth: downloadPct > 0 ? "4px" : "0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverStatus === "loading" && (
|
||||
<div className="w-full rounded-full h-1.5 overflow-hidden" style={{ background: "var(--border)" }}>
|
||||
<div
|
||||
className="h-1.5 rounded-full animate-pulse"
|
||||
style={{ width: "60%", background: "linear-gradient(90deg, #fbbf24, var(--accent-teal))" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generation progress bar */}
|
||||
{isGenerating && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-xs" style={{ color: "var(--muted)" }}>
|
||||
<span>{genElapsed}s elapsed</span>
|
||||
<span>{genPct !== null ? `${genPct}%` : "starting..."}</span>
|
||||
</div>
|
||||
<div className="w-full rounded-full h-1.5 overflow-hidden" style={{ background: "var(--border)" }}>
|
||||
<div
|
||||
className="h-1.5 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: genPct !== null ? `${genPct}%` : "0%",
|
||||
background: "linear-gradient(90deg, var(--accent-teal), var(--accent-violet))",
|
||||
minWidth: genPct !== null && genPct > 0 ? "4px" : "0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate / Stop buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={buttonDisabled}
|
||||
className="flex-1 py-3 rounded-xl font-semibold text-sm transition-all cursor-pointer disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
style={
|
||||
buttonDisabled
|
||||
? { background: "var(--border)", color: "var(--muted)" }
|
||||
: {
|
||||
background: "linear-gradient(135deg, var(--accent-teal-dim), var(--accent-violet-dim))",
|
||||
color: "#fff",
|
||||
boxShadow: "0 4px 15px rgba(45, 212, 191, 0.2)",
|
||||
}
|
||||
}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Generating...
|
||||
</>
|
||||
) : !serverReady ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{serverStatus === "downloading" ? "Downloading model..." : "Waiting for server..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Generate Audio
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isGenerating && (
|
||||
<>
|
||||
<button
|
||||
onClick={isStreamPaused ? onResumeStream : onPauseStream}
|
||||
className="px-4 py-3 rounded-xl font-semibold text-sm transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: `1px solid ${isStreamPaused ? "var(--accent-teal)" : "#fbbf24"}`,
|
||||
color: isStreamPaused ? "var(--accent-teal)" : "#fbbf24",
|
||||
}}
|
||||
>
|
||||
{isStreamPaused ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
Pause
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="px-4 py-3 rounded-xl font-semibold text-sm transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "1px solid var(--error)",
|
||||
color: "var(--error)",
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type ServerStatus = "checking" | "downloading" | "loading" | "online" | "error" | "offline";
|
||||
|
||||
// Polling intervals: poll quickly until the server is online, then slow down.
|
||||
const FAST_INTERVAL_MS = 3000; // while checking / loading
|
||||
const SLOW_INTERVAL_MS = 30000; // once online
|
||||
|
||||
export default function Header() {
|
||||
const [status, setStatus] = useState<ServerStatus>("checking");
|
||||
const [message, setMessage] = useState<string | undefined>();
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/health", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const newStatus: ServerStatus = (data.status as ServerStatus) ?? "offline";
|
||||
setStatus(newStatus);
|
||||
setMessage(data.message);
|
||||
|
||||
// Switch to slow polling once we know the server is online
|
||||
if (newStatus === "online" && intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = setInterval(checkHealth, SLOW_INTERVAL_MS);
|
||||
}
|
||||
// Switch to fast polling if we detect the server went offline/loading
|
||||
if ((newStatus === "offline" || newStatus === "downloading" || newStatus === "loading") && intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = setInterval(checkHealth, FAST_INTERVAL_MS);
|
||||
}
|
||||
} catch {
|
||||
setStatus("offline");
|
||||
setMessage(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// Start with a fast poll — the server may still be loading the model
|
||||
checkHealth();
|
||||
intervalRef.current = setInterval(checkHealth, FAST_INTERVAL_MS);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const statusConfig: Record<
|
||||
ServerStatus,
|
||||
{ color: string; label: string; pulse: boolean; ring: string }
|
||||
> = {
|
||||
checking: {
|
||||
color: "bg-yellow-500",
|
||||
label: "Checking…",
|
||||
pulse: true,
|
||||
ring: "border-yellow-500/30",
|
||||
},
|
||||
loading: {
|
||||
color: "bg-blue-400",
|
||||
label: "Loading model…",
|
||||
pulse: true,
|
||||
ring: "border-blue-400/30",
|
||||
},
|
||||
downloading: {
|
||||
color: "bg-sky-400",
|
||||
label: "Downloading model…",
|
||||
pulse: true,
|
||||
ring: "border-sky-400/30",
|
||||
},
|
||||
online: {
|
||||
color: "bg-green-500",
|
||||
label: "Server Online",
|
||||
pulse: false,
|
||||
ring: "border-green-500/30",
|
||||
},
|
||||
error: {
|
||||
color: "bg-orange-500",
|
||||
label: "Model Error",
|
||||
pulse: false,
|
||||
ring: "border-orange-500/30",
|
||||
},
|
||||
offline: {
|
||||
color: "bg-red-500",
|
||||
label: "Server Offline",
|
||||
pulse: false,
|
||||
ring: "border-red-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
const cfg = statusConfig[status];
|
||||
|
||||
return (
|
||||
<header
|
||||
className="border-b px-6 py-4 flex items-center justify-between"
|
||||
style={{
|
||||
background: "var(--card-bg)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center text-lg font-bold"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--accent-teal-dim), var(--accent-violet-dim))",
|
||||
}}
|
||||
>
|
||||
🎙
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
className="text-xl font-bold tracking-tight"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--accent-teal), var(--accent-violet))",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
VibePod
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: "var(--muted)" }}>
|
||||
Powered by VibeVoice 0.5B
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border ${cfg.ring}`}
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
title={message}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
{cfg.pulse && (
|
||||
<span
|
||||
className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${cfg.color}`}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`relative inline-flex rounded-full h-2 w-2 ${cfg.color}`}
|
||||
/>
|
||||
</span>
|
||||
<span style={{ color: "var(--foreground)" }}>{cfg.label}</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface StatusLogProps {
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
export default function StatusLog({ messages }: StatusLogProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-5 flex flex-col gap-3"
|
||||
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--accent-teal)" }}
|
||||
>
|
||||
Status Log
|
||||
</h2>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-red-500 opacity-70" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500 opacity-70" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-green-500 opacity-70" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-lg p-4 h-40 overflow-y-auto font-mono text-xs leading-relaxed"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<p style={{ color: "var(--muted)" }}>
|
||||
Waiting for input...
|
||||
<span className="animate-pulse">▌</span>
|
||||
</p>
|
||||
) : (
|
||||
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 (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span style={{ color: "var(--muted)" }} className="select-none">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span style={{ color }}>{msg}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="rounded-xl border p-5 flex flex-col gap-4"
|
||||
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--accent-teal)" }}
|
||||
>
|
||||
Podcast Script
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChange(SAMPLE_SCRIPT)}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border transition-colors cursor-pointer"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.target as HTMLButtonElement).style.color =
|
||||
"var(--accent-violet)";
|
||||
(e.target as HTMLButtonElement).style.borderColor =
|
||||
"var(--accent-violet)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.target as HTMLButtonElement).style.color = "var(--muted)";
|
||||
(e.target as HTMLButtonElement).style.borderColor =
|
||||
"var(--border)";
|
||||
}}
|
||||
>
|
||||
Load sample script
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border transition-colors cursor-pointer"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.target as HTMLButtonElement).style.color = "var(--error)";
|
||||
(e.target as HTMLButtonElement).style.borderColor = "var(--error)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.target as HTMLButtonElement).style.color = "var(--muted)";
|
||||
(e.target as HTMLButtonElement).style.borderColor =
|
||||
"var(--border)";
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Paste or type your podcast script here..."
|
||||
rows={12}
|
||||
className="w-full rounded-lg p-4 text-sm resize-y outline-none transition-colors font-sans leading-relaxed"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--foreground)",
|
||||
minHeight: "200px",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "var(--accent-teal)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "var(--border)";
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-between text-xs"
|
||||
style={{ color: "var(--muted)" }}
|
||||
>
|
||||
<span>
|
||||
{wordCount} word{wordCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span>{charCount.toLocaleString()} characters</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user