techfreakworm commited on
Commit
e3066e0
·
unverified ·
1 Parent(s): 512fc03

feat(web): Studio mode toggle wires DialogComposer + dialog generate flow

Browse files
Files changed (1) hide show
  1. web/src/pages/Studio.tsx +152 -86
web/src/pages/Studio.tsx CHANGED
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
2
  import {
3
  activateModel,
4
  generate,
 
5
  getActiveModel,
6
  listModels,
7
  streamActiveEvents,
@@ -9,9 +10,11 @@ import {
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";
@@ -32,8 +35,10 @@ function SectionHeader({ num, title, hint }: { num: string; title: string; hint?
32
  }
33
 
34
  export default function Studio() {
 
35
  const [models, setModels] = useState<ModelInfo[]>([]);
36
  const [activeId, setActiveId] = useState<string | null>(null);
 
37
  const [loadingModel, setLoadingModel] = useState(false);
38
  const [tab, setTab] = useState<"voices" | "history">("voices");
39
  const [text, setText] = useState("");
@@ -50,7 +55,10 @@ export default function Studio() {
50
  useEffect(() => {
51
  listModels().then((m) => {
52
  setModels(m);
53
- if (m[0]) setActiveId((cur) => cur ?? m[0].id);
 
 
 
54
  });
55
  getActiveModel().then((s) => setActiveId((cur) => cur ?? s.id));
56
  }, []);
@@ -132,22 +140,60 @@ export default function Studio() {
132
  }
133
  }
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  return (
136
  <div className="min-h-screen relative-z animate-fade-up">
137
- {/* Header */}
138
  <header className="border-b border-border">
139
  <div className="mx-auto max-w-[1280px] px-8 py-5 flex items-end justify-between">
140
  <div className="flex items-end gap-4">
141
  <span className="display-serif text-[34px] leading-none">Chatterbox</span>
142
- <span className="label-mono pb-1">voice studio · v0.1</span>
143
  </div>
144
  <div className="flex items-center gap-6">
145
- <ModelPicker
146
- models={models}
147
- activeId={activeId}
148
- loading={loadingModel || busy}
149
- onPick={pickModel}
150
- />
 
 
 
151
  <DeviceBadge />
152
  </div>
153
  </div>
@@ -165,93 +211,113 @@ export default function Studio() {
165
  )}
166
 
167
  <main className="mx-auto max-w-[1280px] px-8 py-10 grid lg:grid-cols-[minmax(0,1fr)_400px] gap-12">
168
- {/* Composer column */}
169
  <section className="space-y-12">
170
- {/* 01 Voice */}
171
- <div className="space-y-5">
172
- <SectionHeader num="01" title="Reference voice" hint="upload, record, or pick from your library" />
173
- <VoiceComposer onSaved={() => setLibraryKey((k) => k + 1)} />
174
- <VoiceLibrary
175
- selectedId={selectedVoice?.id}
176
- onSelect={setSelectedVoice}
177
- refreshKey={libraryKey}
178
- />
179
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
- {/* 02 — Script */}
182
- <div className="space-y-4">
183
- <SectionHeader num="02" title="Script" hint="what should the voice say?" />
184
- {active?.languages && active.languages.length > 1 && (
185
- <div className="flex items-center gap-3">
186
- <label htmlFor="lang-select" className="label-mono">language</label>
187
- <select
188
- id="lang-select"
189
- value={language ?? ""}
190
- onChange={(e) => setLanguage(e.target.value)}
191
- className="field-input !w-auto font-mono text-[12px] py-1"
 
 
192
  >
193
- {active.languages.map((l) => (
194
- <option key={l.code} value={l.code}>{l.label}</option>
195
- ))}
196
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  </div>
198
- )}
199
- <textarea
200
- id="prompt"
201
- ref={textRef}
202
- value={text}
203
- onChange={(e) => setText(e.target.value)}
204
- rows={7}
205
- className="field-input font-display text-[18px] leading-relaxed"
206
- placeholder="Once upon a midnight dreary, while I pondered, weak and weary…"
 
207
  />
208
- <div className="flex items-center justify-between">
209
- <TagBar tags={active?.paralinguistic_tags ?? []} targetRef={textRef} />
210
- <span className="label-mono">{text.length} chars</span>
211
- </div>
212
- </div>
213
-
214
- {/* 03 — Parameters */}
215
- {active && (
216
- <div className="space-y-5">
217
- <SectionHeader num="03" title="Parameters" hint={active.description} />
218
- <ParamsPanel specs={active.params} values={params} onChange={setParams} />
219
- </div>
220
  )}
221
 
222
- {/* Generate */}
223
- <div className="space-y-4 pt-2">
224
- <button
225
- type="button"
226
- onClick={() => onGenerate()}
227
- disabled={busy || loadingModel || !text.trim()}
228
- className="btn-primary w-full flex items-center justify-center gap-3 ember-ring"
229
- >
230
- {busy ? (
231
- <>
232
- <span className="size-1.5 rounded-full bg-current animate-pulse-dot" />
233
- Generating
234
- </>
235
- ) : (
236
- <>Generate <span className="opacity-60">→</span></>
237
- )}
238
- </button>
239
-
240
- {outputUrl && (
241
- <div className="card-paper p-4 space-y-3">
242
- <div className="flex items-baseline justify-between">
243
- <span className="label-mono">latest output</span>
244
- <a href={outputUrl} download="chatterbox.wav" className="label-mono hover:text-foreground">
245
- ↓ download
246
- </a>
247
- </div>
248
- <audio controls src={outputUrl} className="w-full h-10" />
249
  </div>
250
- )}
251
- </div>
 
252
  </section>
253
 
254
- {/* Workspace column */}
255
  <aside className="space-y-5 lg:sticky lg:top-8 self-start">
256
  <div className="flex border-b border-border">
257
  {(["voices", "history"] as const).map((t) => (
 
2
  import {
3
  activateModel,
4
  generate,
5
+ generateDialog,
6
  getActiveModel,
7
  listModels,
8
  streamActiveEvents,
 
10
  } from "@/lib/api";
11
  import { addHistory, type HistoryRecord, type VoiceRecord } from "@/lib/idb";
12
  import DeviceBadge from "@/components/DeviceBadge";
13
+ import DialogComposer, { type DialogSubmit } from "@/components/DialogComposer";
14
  import HistoryList from "@/components/HistoryList";
15
  import LoadingBanner from "@/components/LoadingBanner";
16
  import ModelPicker from "@/components/ModelPicker";
17
+ import ModeToggle, { type Mode } from "@/components/ModeToggle";
18
  import ParamsPanel from "@/components/ParamsPanel";
19
  import TagBar from "@/components/TagBar";
20
  import VoiceComposer from "@/components/VoiceComposer";
 
35
  }
36
 
37
  export default function Studio() {
38
+ const [mode, setMode] = useState<Mode>("single");
39
  const [models, setModels] = useState<ModelInfo[]>([]);
40
  const [activeId, setActiveId] = useState<string | null>(null);
41
+ const [dialogEngineId, setDialogEngineId] = useState<string>("chatterbox-en");
42
  const [loadingModel, setLoadingModel] = useState(false);
43
  const [tab, setTab] = useState<"voices" | "history">("voices");
44
  const [text, setText] = useState("");
 
55
  useEffect(() => {
56
  listModels().then((m) => {
57
  setModels(m);
58
+ if (m[0]) {
59
+ setActiveId((cur) => cur ?? m[0].id);
60
+ setDialogEngineId((cur) => cur || m[0].id);
61
+ }
62
  });
63
  getActiveModel().then((s) => setActiveId((cur) => cur ?? s.id));
64
  }, []);
 
140
  }
141
  }
142
 
143
+ async function onDialogSubmit(input: DialogSubmit) {
144
+ setErr(null);
145
+ setBusy(true);
146
+ try {
147
+ const result = await generateDialog({
148
+ engineId: input.engineId,
149
+ text: input.text,
150
+ language: input.language,
151
+ params: input.params,
152
+ speakers: input.speakers.map((s) => ({
153
+ letter: s.letter,
154
+ reference: s.voice.blob,
155
+ })),
156
+ });
157
+ setOutputUrl((u) => {
158
+ if (u) URL.revokeObjectURL(u);
159
+ return URL.createObjectURL(result.blob);
160
+ });
161
+ await addHistory({
162
+ text: input.text,
163
+ modelId: input.engineId,
164
+ language: input.language,
165
+ params: input.params,
166
+ audioBlob: result.blob,
167
+ kind: "dialog",
168
+ seedUsed: result.seedUsed ?? undefined,
169
+ speakers: input.speakers.map((s) => ({ letter: s.letter, voiceId: s.voice.id! })),
170
+ });
171
+ setHistoryKey((k) => k + 1);
172
+ } catch (e) {
173
+ setErr((e as Error).message);
174
+ } finally {
175
+ setBusy(false);
176
+ }
177
+ }
178
+
179
  return (
180
  <div className="min-h-screen relative-z animate-fade-up">
 
181
  <header className="border-b border-border">
182
  <div className="mx-auto max-w-[1280px] px-8 py-5 flex items-end justify-between">
183
  <div className="flex items-end gap-4">
184
  <span className="display-serif text-[34px] leading-none">Chatterbox</span>
185
+ <span className="label-mono pb-1">voice studio · v0.2</span>
186
  </div>
187
  <div className="flex items-center gap-6">
188
+ <ModeToggle mode={mode} onChange={setMode} />
189
+ {mode === "single" && (
190
+ <ModelPicker
191
+ models={models}
192
+ activeId={activeId}
193
+ loading={loadingModel || busy}
194
+ onPick={pickModel}
195
+ />
196
+ )}
197
  <DeviceBadge />
198
  </div>
199
  </div>
 
211
  )}
212
 
213
  <main className="mx-auto max-w-[1280px] px-8 py-10 grid lg:grid-cols-[minmax(0,1fr)_400px] gap-12">
 
214
  <section className="space-y-12">
215
+ {mode === "single" ? (
216
+ <>
217
+ <div className="space-y-5">
218
+ <SectionHeader num="01" title="Reference voice" hint="upload, record, or pick from your library" />
219
+ <VoiceComposer onSaved={() => setLibraryKey((k) => k + 1)} />
220
+ <VoiceLibrary
221
+ selectedId={selectedVoice?.id}
222
+ onSelect={setSelectedVoice}
223
+ refreshKey={libraryKey}
224
+ />
225
+ </div>
226
+
227
+ <div className="space-y-4">
228
+ <SectionHeader num="02" title="Script" hint="what should the voice say?" />
229
+ {active?.languages && active.languages.length > 1 && (
230
+ <div className="flex items-center gap-3">
231
+ <label htmlFor="lang-select" className="label-mono">language</label>
232
+ <select
233
+ id="lang-select"
234
+ value={language ?? ""}
235
+ onChange={(e) => setLanguage(e.target.value)}
236
+ className="field-input !w-auto font-mono text-[12px] py-1"
237
+ >
238
+ {active.languages.map((l) => (
239
+ <option key={l.code} value={l.code}>{l.label}</option>
240
+ ))}
241
+ </select>
242
+ </div>
243
+ )}
244
+ <textarea
245
+ id="prompt"
246
+ ref={textRef}
247
+ value={text}
248
+ onChange={(e) => setText(e.target.value)}
249
+ rows={7}
250
+ className="field-input font-display text-[18px] leading-relaxed"
251
+ placeholder="Once upon a midnight dreary, while I pondered, weak and weary…"
252
+ />
253
+ <div className="flex items-center justify-between">
254
+ <TagBar tags={active?.paralinguistic_tags ?? []} targetRef={textRef} />
255
+ <span className="label-mono">{text.length} chars</span>
256
+ </div>
257
+ </div>
258
 
259
+ {active && (
260
+ <div className="space-y-5">
261
+ <SectionHeader num="03" title="Parameters" hint={active.description} />
262
+ <ParamsPanel specs={active.params} values={params} onChange={setParams} />
263
+ </div>
264
+ )}
265
+
266
+ <div className="space-y-4 pt-2">
267
+ <button
268
+ type="button"
269
+ onClick={() => onGenerate()}
270
+ disabled={busy || loadingModel || !text.trim()}
271
+ className="btn-primary w-full flex items-center justify-center gap-3 ember-ring"
272
  >
273
+ {busy ? (
274
+ <>
275
+ <span className="size-1.5 rounded-full bg-current animate-pulse-dot" />
276
+ Generating
277
+ </>
278
+ ) : (
279
+ <>Generate <span className="opacity-60">→</span></>
280
+ )}
281
+ </button>
282
+
283
+ {outputUrl && (
284
+ <div className="card-paper p-4 space-y-3">
285
+ <div className="flex items-baseline justify-between">
286
+ <span className="label-mono">latest output</span>
287
+ <a href={outputUrl} download="chatterbox.wav" className="label-mono hover:text-foreground">
288
+ ↓ download
289
+ </a>
290
+ </div>
291
+ <audio controls src={outputUrl} className="w-full h-10" />
292
+ </div>
293
+ )}
294
  </div>
295
+ </>
296
+ ) : (
297
+ <DialogComposer
298
+ models={models}
299
+ engineId={dialogEngineId}
300
+ onEngineChange={setDialogEngineId}
301
+ onSubmit={onDialogSubmit}
302
+ loadingModel={loadingModel}
303
+ busy={busy}
304
+ libraryRefreshKey={libraryKey}
305
  />
 
 
 
 
 
 
 
 
 
 
 
 
306
  )}
307
 
308
+ {mode === "dialog" && outputUrl && (
309
+ <div className="card-paper p-4 space-y-3">
310
+ <div className="flex items-baseline justify-between">
311
+ <span className="label-mono">latest output</span>
312
+ <a href={outputUrl} download="dialog.wav" className="label-mono hover:text-foreground">
313
+ download
314
+ </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  </div>
316
+ <audio controls src={outputUrl} className="w-full h-10" />
317
+ </div>
318
+ )}
319
  </section>
320
 
 
321
  <aside className="space-y-5 lg:sticky lg:top-8 self-start">
322
  <div className="flex border-b border-border">
323
  {(["voices", "history"] as const).map((t) => (