techfreakworm commited on
Commit
482daf6
·
unverified ·
1 Parent(s): b2d4d0e

feat(web): Studio composes generate flow + SSE-driven loading banner

Browse files
web/src/components/LoadingBanner.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ type Props = { visible: boolean; message: string };
2
+
3
+ export default function LoadingBanner({ visible, message }: Props) {
4
+ if (!visible) return null;
5
+ return (
6
+ <div className="bg-primary/15 text-primary text-sm px-6 py-2 border-b border-primary/30">
7
+ {message}
8
+ </div>
9
+ );
10
+ }
web/src/pages/Studio.tsx CHANGED
@@ -1,4 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  export default function Studio() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  return (
3
  <div className="min-h-screen flex flex-col">
4
  <header className="border-b border-border px-6 py-3 flex items-center justify-between">
@@ -6,11 +123,128 @@ export default function Studio() {
6
  <span className="size-2.5 rounded-full bg-primary" />
7
  <span className="font-medium">Chatterbox Voice Studio</span>
8
  </div>
9
- <div className="text-sm text-muted-foreground">stub</div>
 
 
 
 
 
 
 
 
10
  </header>
 
 
 
 
 
 
 
11
  <main className="flex-1 grid lg:grid-cols-[1fr_420px] gap-6 p-6">
12
- <section className="space-y-4">Composer goes here</section>
13
- <aside className="space-y-4">Workspace goes here</aside>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </main>
15
  </div>
16
  );
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ activateModel,
4
+ generate,
5
+ getActiveModel,
6
+ listModels,
7
+ streamActiveEvents,
8
+ type ModelInfo,
9
+ } from "@/lib/api";
10
+ import { addHistory, type HistoryRecord, type VoiceRecord } from "@/lib/idb";
11
+ import DeviceBadge from "@/components/DeviceBadge";
12
+ import HistoryList from "@/components/HistoryList";
13
+ import LoadingBanner from "@/components/LoadingBanner";
14
+ import ModelPicker from "@/components/ModelPicker";
15
+ import ParamsPanel from "@/components/ParamsPanel";
16
+ import TagBar from "@/components/TagBar";
17
+ import VoiceComposer from "@/components/VoiceComposer";
18
+ import VoiceLibrary from "@/components/VoiceLibrary";
19
+
20
  export default function Studio() {
21
+ const [models, setModels] = useState<ModelInfo[]>([]);
22
+ const [activeId, setActiveId] = useState<string | null>(null);
23
+ const [loadingModel, setLoadingModel] = useState(false);
24
+ const [tab, setTab] = useState<"voices" | "history">("voices");
25
+ const [text, setText] = useState("");
26
+ const [language, setLanguage] = useState<string | undefined>(undefined);
27
+ const [params, setParams] = useState<Record<string, unknown>>({});
28
+ const [selectedVoice, setSelectedVoice] = useState<VoiceRecord | undefined>();
29
+ const [outputUrl, setOutputUrl] = useState<string | null>(null);
30
+ const [historyKey, setHistoryKey] = useState(0);
31
+ const [libraryKey, setLibraryKey] = useState(0);
32
+ const [busy, setBusy] = useState(false);
33
+ const [err, setErr] = useState<string | null>(null);
34
+ const textRef = useRef<HTMLTextAreaElement>(null);
35
+
36
+ useEffect(() => {
37
+ listModels().then((m) => {
38
+ setModels(m);
39
+ if (m[0]) setActiveId((cur) => cur ?? m[0].id);
40
+ });
41
+ getActiveModel().then((s) => setActiveId((cur) => cur ?? s.id));
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ const close = streamActiveEvents((evt) => {
46
+ if (evt.status === "loading") setLoadingModel(true);
47
+ if (evt.status === "loaded" || evt.status === "error") setLoadingModel(false);
48
+ if (evt.status === "loaded" && evt.id) setActiveId(evt.id);
49
+ if (evt.status === "error" && evt.error) setErr(evt.error);
50
+ });
51
+ return close;
52
+ }, []);
53
+
54
+ const active = useMemo(
55
+ () => models.find((m) => m.id === activeId),
56
+ [models, activeId],
57
+ );
58
+
59
+ useEffect(() => {
60
+ setParams(
61
+ Object.fromEntries((active?.params ?? []).map((p) => [p.name, p.default])),
62
+ );
63
+ setLanguage(active?.languages[0]?.code);
64
+ }, [active?.id]);
65
+
66
+ async function pickModel(id: string) {
67
+ setLoadingModel(true);
68
+ setErr(null);
69
+ try {
70
+ await activateModel(id);
71
+ setActiveId(id);
72
+ } catch (e) {
73
+ setErr((e as Error).message);
74
+ } finally {
75
+ setLoadingModel(false);
76
+ }
77
+ }
78
+
79
+ async function onGenerate(reuse?: HistoryRecord) {
80
+ if (!active) return;
81
+ if (active.supports_voice_clone && !selectedVoice && !reuse?.voiceId) {
82
+ setErr("Pick or record a reference voice first.");
83
+ return;
84
+ }
85
+ setErr(null);
86
+ setBusy(true);
87
+ try {
88
+ const refBlob = selectedVoice?.blob;
89
+ const inputText = reuse?.text ?? text;
90
+ const inputLang = reuse?.language ?? language;
91
+ const inputParams = reuse?.params ?? params;
92
+ const out = await generate({
93
+ modelId: active.id,
94
+ text: inputText,
95
+ language: inputLang,
96
+ params: inputParams,
97
+ reference: refBlob,
98
+ });
99
+ setOutputUrl((u) => {
100
+ if (u) URL.revokeObjectURL(u);
101
+ return URL.createObjectURL(out);
102
+ });
103
+ await addHistory({
104
+ text: inputText,
105
+ modelId: active.id,
106
+ voiceId: selectedVoice?.id,
107
+ language: inputLang,
108
+ params: inputParams,
109
+ audioBlob: out,
110
+ });
111
+ setHistoryKey((k) => k + 1);
112
+ } catch (e) {
113
+ setErr((e as Error).message);
114
+ } finally {
115
+ setBusy(false);
116
+ }
117
+ }
118
+
119
  return (
120
  <div className="min-h-screen flex flex-col">
121
  <header className="border-b border-border px-6 py-3 flex items-center justify-between">
 
123
  <span className="size-2.5 rounded-full bg-primary" />
124
  <span className="font-medium">Chatterbox Voice Studio</span>
125
  </div>
126
+ <div className="flex items-center gap-3">
127
+ <ModelPicker
128
+ models={models}
129
+ activeId={activeId}
130
+ loading={loadingModel || busy}
131
+ onPick={pickModel}
132
+ />
133
+ <DeviceBadge />
134
+ </div>
135
  </header>
136
+
137
+ <LoadingBanner
138
+ visible={loadingModel}
139
+ message="Loading model… first activation can take 30–60s."
140
+ />
141
+ {err && <div className="bg-red-500/10 text-red-400 text-sm px-6 py-2">{err}</div>}
142
+
143
  <main className="flex-1 grid lg:grid-cols-[1fr_420px] gap-6 p-6">
144
+ <section className="space-y-4">
145
+ <div className="space-y-2">
146
+ <h2 className="text-sm font-medium">Reference voice</h2>
147
+ <VoiceComposer onSaved={() => setLibraryKey((k) => k + 1)} />
148
+ <VoiceLibrary
149
+ selectedId={selectedVoice?.id}
150
+ onSelect={setSelectedVoice}
151
+ refreshKey={libraryKey}
152
+ />
153
+ </div>
154
+
155
+ {active?.languages && active.languages.length > 1 && (
156
+ <div className="space-y-1">
157
+ <label htmlFor="lang-select" className="text-sm font-medium">
158
+ Language
159
+ </label>
160
+ <select
161
+ id="lang-select"
162
+ value={language ?? ""}
163
+ onChange={(e) => setLanguage(e.target.value)}
164
+ className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm"
165
+ >
166
+ {active.languages.map((l) => (
167
+ <option key={l.code} value={l.code}>
168
+ {l.label}
169
+ </option>
170
+ ))}
171
+ </select>
172
+ </div>
173
+ )}
174
+
175
+ <div className="space-y-2">
176
+ <label htmlFor="prompt" className="text-sm font-medium">
177
+ Text
178
+ </label>
179
+ <textarea
180
+ id="prompt"
181
+ ref={textRef}
182
+ value={text}
183
+ onChange={(e) => setText(e.target.value)}
184
+ rows={6}
185
+ className="w-full rounded-md border border-border bg-background p-2 text-sm"
186
+ placeholder="Type what the voice should say…"
187
+ />
188
+ <div className="flex items-center justify-between">
189
+ <TagBar tags={active?.paralinguistic_tags ?? []} targetRef={textRef} />
190
+ <span className="text-xs text-muted-foreground">{text.length} chars</span>
191
+ </div>
192
+ </div>
193
+
194
+ {active && (
195
+ <div className="space-y-2">
196
+ <h2 className="text-sm font-medium">Parameters</h2>
197
+ <ParamsPanel specs={active.params} values={params} onChange={setParams} />
198
+ </div>
199
+ )}
200
+
201
+ <button
202
+ type="button"
203
+ onClick={() => onGenerate()}
204
+ disabled={busy || loadingModel || !text.trim()}
205
+ className="w-full rounded-md bg-primary text-primary-foreground py-2.5 text-sm font-medium disabled:opacity-50"
206
+ >
207
+ {busy ? "Generating…" : "Generate"}
208
+ </button>
209
+
210
+ {outputUrl && (
211
+ <div className="space-y-1">
212
+ <h2 className="text-sm font-medium">Output</h2>
213
+ <audio controls src={outputUrl} className="w-full" />
214
+ <a href={outputUrl} download="chatterbox.wav" className="text-xs underline">
215
+ download
216
+ </a>
217
+ </div>
218
+ )}
219
+ </section>
220
+
221
+ <aside className="space-y-3">
222
+ <div className="flex gap-1">
223
+ <button
224
+ type="button"
225
+ onClick={() => setTab("voices")}
226
+ className={`flex-1 rounded-md px-2 py-1 text-sm ${tab === "voices" ? "bg-muted" : ""}`}
227
+ >
228
+ Voices
229
+ </button>
230
+ <button
231
+ type="button"
232
+ onClick={() => setTab("history")}
233
+ className={`flex-1 rounded-md px-2 py-1 text-sm ${tab === "history" ? "bg-muted" : ""}`}
234
+ >
235
+ History
236
+ </button>
237
+ </div>
238
+ {tab === "voices" ? (
239
+ <VoiceLibrary
240
+ selectedId={selectedVoice?.id}
241
+ onSelect={setSelectedVoice}
242
+ refreshKey={libraryKey}
243
+ />
244
+ ) : (
245
+ <HistoryList refreshKey={historyKey} onRegenerate={onGenerate} />
246
+ )}
247
+ </aside>
248
  </main>
249
  </div>
250
  );