mirror of
https://github.com/JezzWTF/vibepod.git
synced 2026-06-01 15:22:14 +00:00
2d2ab26994
Co-authored-by: LyAhn <27559362+LyAhn@users.noreply.github.com>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
"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." },
|
|
};
|
|
|
|
|
|
function SpinnerIcon() {
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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 ? (
|
|
<>
|
|
<SpinnerIcon />
|
|
Generating...
|
|
</>
|
|
) : !serverReady ? (
|
|
<>
|
|
<SpinnerIcon />
|
|
{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>
|
|
);
|
|
}
|