techfreakworm's picture
feat(web): responsive layout for phone and tablet (header stack, padding scale, seed-row wrap)
14c6f28 unverified
import { useEffect, useMemo, useRef, useState } from "react";
import {
activateModel,
generate,
generateDialog,
getActiveModel,
listModels,
streamActiveEvents,
type ModelInfo,
} from "@/lib/api";
import { addHistory, type HistoryRecord, type VoiceRecord } from "@/lib/idb";
import DeviceBadge from "@/components/DeviceBadge";
import DialogComposer, { type DialogSubmit } from "@/components/DialogComposer";
import HistoryList from "@/components/HistoryList";
import LoadingBanner from "@/components/LoadingBanner";
import MadeBy from "@/components/MadeBy";
import ModelPicker from "@/components/ModelPicker";
import ModeToggle, { type Mode } from "@/components/ModeToggle";
import ParamsPanel from "@/components/ParamsPanel";
import ProgressBar from "@/components/ProgressBar";
import TagBar from "@/components/TagBar";
import VoiceComposer from "@/components/VoiceComposer";
import VoiceLibrary from "@/components/VoiceLibrary";
import { cn } from "@/lib/utils";
function SectionHeader({ num, title, hint }: { num: string; title: string; hint?: string }) {
return (
<div className="space-y-1">
<div className="flex items-baseline gap-3">
<span className="marker-num">{num}</span>
<h2 className="display-serif text-[19px] sm:text-[22px] leading-tight">{title}</h2>
</div>
{hint && <p className="label-mono">{hint}</p>}
<div className="rule-dotted mt-2" />
</div>
);
}
export default function Studio() {
const [mode, setMode] = useState<Mode>("single");
const [models, setModels] = useState<ModelInfo[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [dialogEngineId, setDialogEngineId] = useState<string>("chatterbox-en");
const [loadingModel, setLoadingModel] = useState(false);
const [tab, setTab] = useState<"voices" | "history">("voices");
const [text, setText] = useState("");
const [language, setLanguage] = useState<string | undefined>(undefined);
const [params, setParams] = useState<Record<string, unknown>>({});
const [selectedVoice, setSelectedVoice] = useState<VoiceRecord | undefined>();
const [outputUrl, setOutputUrl] = useState<string | null>(null);
const [historyKey, setHistoryKey] = useState(0);
const [libraryKey, setLibraryKey] = useState(0);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const textRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
listModels().then((m) => {
setModels(m);
if (m[0]) {
setActiveId((cur) => cur ?? m[0].id);
setDialogEngineId((cur) => cur || m[0].id);
}
});
getActiveModel().then((s) => setActiveId((cur) => cur ?? s.id));
}, []);
useEffect(() => {
const close = streamActiveEvents((evt) => {
if (evt.status === "loading") setLoadingModel(true);
if (evt.status === "loaded" || evt.status === "error") setLoadingModel(false);
if (evt.status === "loaded" && evt.id) setActiveId(evt.id);
if (evt.status === "error" && evt.error) setErr(evt.error);
});
return close;
}, []);
const active = useMemo(
() => models.find((m) => m.id === activeId),
[models, activeId],
);
useEffect(() => {
setParams(
Object.fromEntries((active?.params ?? []).map((p) => [p.name, p.default])),
);
setLanguage(active?.languages[0]?.code);
}, [active?.id]);
async function pickModel(id: string) {
setLoadingModel(true);
setErr(null);
try {
await activateModel(id);
setActiveId(id);
} catch (e) {
setErr((e as Error).message);
} finally {
setLoadingModel(false);
}
}
async function onGenerate(reuse?: HistoryRecord) {
if (!active) return;
if (active.supports_voice_clone && !selectedVoice && !reuse?.voiceId) {
setErr("Pick or record a reference voice first.");
return;
}
setErr(null);
setBusy(true);
try {
const refBlob = selectedVoice?.blob;
const inputText = reuse?.text ?? text;
const inputLang = reuse?.language ?? language;
const inputParams = reuse?.params ?? params;
const result = await generate({
modelId: active.id,
text: inputText,
language: inputLang,
params: inputParams,
reference: refBlob,
});
setOutputUrl((u) => {
if (u) URL.revokeObjectURL(u);
return URL.createObjectURL(result.blob);
});
await addHistory({
text: inputText,
modelId: active.id,
voiceId: selectedVoice?.id,
language: inputLang,
params: inputParams,
audioBlob: result.blob,
kind: "single",
seedUsed: result.seedUsed ?? undefined,
});
setHistoryKey((k) => k + 1);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
async function onDialogSubmit(input: DialogSubmit) {
setErr(null);
setBusy(true);
try {
const result = await generateDialog({
engineId: input.engineId,
text: input.text,
language: input.language,
params: input.params,
speakers: input.speakers.map((s) => ({
letter: s.letter,
reference: s.voice.blob,
})),
});
setOutputUrl((u) => {
if (u) URL.revokeObjectURL(u);
return URL.createObjectURL(result.blob);
});
await addHistory({
text: input.text,
modelId: input.engineId,
language: input.language,
params: input.params,
audioBlob: result.blob,
kind: "dialog",
seedUsed: result.seedUsed ?? undefined,
speakers: input.speakers.map((s) => ({ letter: s.letter, voiceId: s.voice.id! })),
});
setHistoryKey((k) => k + 1);
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen relative-z animate-fade-up">
<header className="border-b border-border">
<div className="mx-auto max-w-[1280px] px-4 sm:px-8 py-4 sm:py-5 flex flex-col lg:flex-row lg:items-end lg:justify-between gap-3 lg:gap-6">
<div className="flex items-end gap-3 sm:gap-4">
<span className="display-serif text-[26px] sm:text-[34px] leading-none">Chatterbox</span>
<span className="label-mono pb-0.5 sm:pb-1 whitespace-nowrap">voice studio · v0.2</span>
</div>
<div className="flex flex-wrap items-center gap-3 sm:gap-6">
<ModeToggle mode={mode} onChange={setMode} />
{mode === "single" && (
<ModelPicker
models={models}
activeId={activeId}
loading={loadingModel || busy}
onPick={pickModel}
/>
)}
<DeviceBadge />
</div>
</div>
</header>
<LoadingBanner
visible={loadingModel}
message="Loading model — first activation can take 30–60s"
/>
<ProgressBar />
{err && (
<div className="border-b border-red-900/40 bg-red-950/30 px-4 sm:px-8 py-2.5">
<span className="label-mono text-red-400">error</span>
<span className="ml-3 text-sm text-red-300/90 break-words">{err}</span>
</div>
)}
<main className="mx-auto max-w-[1280px] px-4 sm:px-8 py-6 sm:py-10 grid lg:grid-cols-[minmax(0,1fr)_400px] gap-8 lg:gap-12">
<section className="space-y-12">
{mode === "single" ? (
<>
<div className="space-y-5">
<SectionHeader num="01" title="Reference voice" hint="upload, record, or pick from your library" />
<VoiceComposer onSaved={() => setLibraryKey((k) => k + 1)} />
<VoiceLibrary
selectedId={selectedVoice?.id}
onSelect={setSelectedVoice}
refreshKey={libraryKey}
/>
</div>
<div className="space-y-4">
<SectionHeader num="02" title="Script" hint="what should the voice say?" />
{active?.languages && active.languages.length > 1 && (
<div className="flex items-center gap-3">
<label htmlFor="lang-select" className="label-mono">language</label>
<select
id="lang-select"
value={language ?? ""}
onChange={(e) => setLanguage(e.target.value)}
className="field-input !w-auto font-mono text-[12px] py-1"
>
{active.languages.map((l) => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</select>
</div>
)}
<textarea
id="prompt"
ref={textRef}
value={text}
onChange={(e) => setText(e.target.value)}
rows={7}
className="field-input font-display text-[18px] leading-relaxed"
placeholder="Once upon a midnight dreary, while I pondered, weak and weary…"
/>
<div className="flex items-center justify-between">
<TagBar tags={active?.paralinguistic_tags ?? []} targetRef={textRef} />
<span className="label-mono">{text.length} chars</span>
</div>
</div>
{active && (
<div className="space-y-5">
<SectionHeader num="03" title="Parameters" hint={active.description} />
<ParamsPanel specs={active.params} values={params} onChange={setParams} />
</div>
)}
<div className="space-y-4 pt-2">
<button
type="button"
onClick={() => onGenerate()}
disabled={busy || loadingModel || !text.trim()}
className="btn-primary w-full flex items-center justify-center gap-3 ember-ring"
>
{busy ? (
<>
<span className="size-1.5 rounded-full bg-current animate-pulse-dot" />
Generating
</>
) : (
<>Generate <span className="opacity-60"></span></>
)}
</button>
{outputUrl && (
<div className="card-paper p-4 space-y-3">
<div className="flex items-baseline justify-between">
<span className="label-mono">latest output</span>
<a href={outputUrl} download="chatterbox.wav" className="label-mono hover:text-foreground">
↓ download
</a>
</div>
<audio controls src={outputUrl} className="w-full h-10" />
</div>
)}
</div>
</>
) : (
<DialogComposer
models={models}
engineId={dialogEngineId}
onEngineChange={setDialogEngineId}
onSubmit={onDialogSubmit}
loadingModel={loadingModel}
busy={busy}
libraryRefreshKey={libraryKey}
/>
)}
{mode === "dialog" && outputUrl && (
<div className="card-paper p-4 space-y-3">
<div className="flex items-baseline justify-between">
<span className="label-mono">latest output</span>
<a href={outputUrl} download="dialog.wav" className="label-mono hover:text-foreground">
↓ download
</a>
</div>
<audio controls src={outputUrl} className="w-full h-10" />
</div>
)}
</section>
<aside className="space-y-5 lg:sticky lg:top-8 self-start">
<div className="flex border-b border-border">
{(["voices", "history"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn(
"flex-1 label-mono py-2 transition-colors border-b-2",
tab === t
? "text-foreground border-[hsl(var(--ember))]"
: "border-transparent hover:text-foreground",
)}
>
{t}
</button>
))}
</div>
{tab === "voices" ? (
<VoiceLibrary
selectedId={selectedVoice?.id}
onSelect={setSelectedVoice}
refreshKey={libraryKey}
/>
) : (
<HistoryList
refreshKey={historyKey}
onRegenerate={onGenerate}
onReuseSeed={(seed) => setParams((p) => ({ ...p, seed }))}
/>
)}
</aside>
</main>
<footer className="border-t border-border mt-10 sm:mt-16">
<MadeBy />
<div className="rule-dotted mx-4 sm:mx-8" />
<div className="mx-auto max-w-[1280px] px-4 sm:px-8 py-5 sm:py-6 flex flex-col sm:flex-row items-center sm:justify-between gap-2 sm:gap-0">
<span className="label-mono">chatterbox · resemble ai</span>
<span className="label-mono">stateless · browser-persisted</span>
</div>
</footer>
</div>
);
}