techfreakworm commited on
Commit
3dd591f
·
unverified ·
1 Parent(s): 422829d

feat(web): DialogComposer — speaker slots, engine radio, script + params

Browse files
web/src/components/DialogComposer.tsx ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import type { ModelInfo } from "@/lib/api";
3
+ import { type VoiceRecord } from "@/lib/idb";
4
+ import ParamsPanel from "@/components/ParamsPanel";
5
+ import SpeakerSlot from "@/components/SpeakerSlot";
6
+ import TagBar from "@/components/TagBar";
7
+
8
+ export type DialogSubmit = {
9
+ text: string;
10
+ engineId: string;
11
+ language?: string;
12
+ params: Record<string, unknown>;
13
+ speakers: { letter: "A" | "B" | "C" | "D"; voice: VoiceRecord }[];
14
+ };
15
+
16
+ type Props = {
17
+ models: ModelInfo[];
18
+ engineId: string;
19
+ onEngineChange: (id: string) => void;
20
+ onSubmit: (input: DialogSubmit) => void;
21
+ loadingModel: boolean;
22
+ busy: boolean;
23
+ libraryRefreshKey?: number;
24
+ };
25
+
26
+ const ALL_LETTERS = ["A", "B", "C", "D"] as const;
27
+
28
+ export default function DialogComposer({
29
+ models,
30
+ engineId,
31
+ onEngineChange,
32
+ onSubmit,
33
+ loadingModel,
34
+ busy,
35
+ libraryRefreshKey,
36
+ }: Props) {
37
+ const [count, setCount] = useState(2);
38
+ const [speakers, setSpeakers] = useState<Record<string, VoiceRecord | undefined>>({});
39
+ const [text, setText] = useState("SPEAKER A: \nSPEAKER B: \n");
40
+ const [language, setLanguage] = useState<string | undefined>(undefined);
41
+ const [params, setParams] = useState<Record<string, unknown>>({});
42
+ const textRef = useRef<HTMLTextAreaElement>(null);
43
+
44
+ const engine = useMemo(() => models.find((m) => m.id === engineId), [models, engineId]);
45
+
46
+ useEffect(() => {
47
+ setParams(
48
+ Object.fromEntries((engine?.params ?? []).map((p) => [p.name, p.default])),
49
+ );
50
+ setLanguage(engine?.languages[0]?.code);
51
+ }, [engine?.id]);
52
+
53
+ function setSpeaker(letter: string, v: VoiceRecord | undefined) {
54
+ setSpeakers((s) => ({ ...s, [letter]: v }));
55
+ }
56
+
57
+ function addSpeaker() {
58
+ setCount((c) => Math.min(4, c + 1));
59
+ }
60
+
61
+ function removeSpeaker(letter: string) {
62
+ setSpeakers((s) => ({ ...s, [letter]: undefined }));
63
+ setCount((c) => Math.max(2, c - 1));
64
+ }
65
+
66
+ function insertPrefix(letter: string) {
67
+ const el = textRef.current;
68
+ if (!el) return;
69
+ const tag = `SPEAKER ${letter}: `;
70
+ const start = el.selectionStart ?? el.value.length;
71
+ const end = el.selectionEnd ?? start;
72
+ const before = el.value.slice(0, start);
73
+ const after = el.value.slice(end);
74
+ const native = Object.getOwnPropertyDescriptor(
75
+ window.HTMLTextAreaElement.prototype,
76
+ "value",
77
+ )?.set;
78
+ native?.call(el, before + tag + after);
79
+ el.dispatchEvent(new Event("input", { bubbles: true }));
80
+ const cursor = start + tag.length;
81
+ el.setSelectionRange(cursor, cursor);
82
+ el.focus();
83
+ }
84
+
85
+ function handleSubmit() {
86
+ if (!engine) return;
87
+ const speakerList: DialogSubmit["speakers"] = [];
88
+ for (let i = 0; i < count; i++) {
89
+ const letter = ALL_LETTERS[i];
90
+ const v = speakers[letter];
91
+ if (v) speakerList.push({ letter, voice: v });
92
+ }
93
+ onSubmit({
94
+ text,
95
+ engineId: engine.id,
96
+ language,
97
+ params,
98
+ speakers: speakerList,
99
+ });
100
+ }
101
+
102
+ const visibleLetters = ALL_LETTERS.slice(0, count);
103
+ const canSubmit = !!engine && !busy && !loadingModel && text.trim().length > 0;
104
+
105
+ return (
106
+ <div className="space-y-8">
107
+ <div className="space-y-3">
108
+ <h3 className="label-mono">Speakers</h3>
109
+ <div className="space-y-2">
110
+ {visibleLetters.map((letter) => (
111
+ <SpeakerSlot
112
+ key={letter}
113
+ letter={letter}
114
+ voice={speakers[letter]}
115
+ onChange={(v) => setSpeaker(letter, v)}
116
+ onRemove={count > 2 ? () => removeSpeaker(letter) : undefined}
117
+ refreshKey={libraryRefreshKey}
118
+ />
119
+ ))}
120
+ </div>
121
+ {count < 4 && (
122
+ <button
123
+ type="button"
124
+ onClick={addSpeaker}
125
+ className="btn-ghost"
126
+ >
127
+ + add speaker
128
+ </button>
129
+ )}
130
+ </div>
131
+
132
+ <div className="space-y-2">
133
+ <h3 className="label-mono">Engine</h3>
134
+ <div className="flex flex-col gap-1">
135
+ {models.map((m) => (
136
+ <label key={m.id} className="flex items-center gap-2 text-sm cursor-pointer">
137
+ <input
138
+ type="radio"
139
+ name="dialog-engine"
140
+ checked={engineId === m.id}
141
+ onChange={() => onEngineChange(m.id)}
142
+ className="accent-[hsl(var(--ember))]"
143
+ />
144
+ {m.label}
145
+ </label>
146
+ ))}
147
+ </div>
148
+ {engine?.languages && engine.languages.length > 1 && (
149
+ <div className="flex items-center gap-3 pt-2">
150
+ <label htmlFor="dialog-lang" className="label-mono">Language</label>
151
+ <select
152
+ id="dialog-lang"
153
+ value={language ?? ""}
154
+ onChange={(e) => setLanguage(e.target.value)}
155
+ className="field-input !w-auto font-mono text-[12px] py-1"
156
+ >
157
+ {engine.languages.map((l) => (
158
+ <option key={l.code} value={l.code}>{l.label}</option>
159
+ ))}
160
+ </select>
161
+ </div>
162
+ )}
163
+ </div>
164
+
165
+ <div className="space-y-2">
166
+ <h3 className="label-mono">Script</h3>
167
+ <textarea
168
+ ref={textRef}
169
+ value={text}
170
+ onChange={(e) => setText(e.target.value)}
171
+ rows={10}
172
+ className="field-input font-mono text-[13px] leading-relaxed"
173
+ placeholder="SPEAKER A: ...&#10;SPEAKER B: ..."
174
+ />
175
+ <div className="flex items-center justify-between flex-wrap gap-2">
176
+ <div className="flex flex-wrap items-center gap-1.5">
177
+ <span className="label-mono mr-1">insert</span>
178
+ {visibleLetters.map((letter) => (
179
+ <button
180
+ key={letter}
181
+ type="button"
182
+ onClick={() => insertPrefix(letter)}
183
+ className="font-mono text-[11px] px-2 py-0.5 rounded-sm border border-border text-muted-foreground hover:text-[hsl(var(--ember))] hover:border-[hsl(var(--ember))]/50 transition-colors"
184
+ >
185
+ SPEAKER {letter}:
186
+ </button>
187
+ ))}
188
+ </div>
189
+ <TagBar tags={engine?.paralinguistic_tags ?? []} targetRef={textRef} />
190
+ </div>
191
+ </div>
192
+
193
+ {engine && (
194
+ <div className="space-y-2">
195
+ <h3 className="label-mono">Parameters</h3>
196
+ <ParamsPanel specs={engine.params} values={params} onChange={setParams} />
197
+ </div>
198
+ )}
199
+
200
+ <button
201
+ type="button"
202
+ onClick={handleSubmit}
203
+ disabled={!canSubmit}
204
+ className="btn-primary w-full flex items-center justify-center gap-3 ember-ring"
205
+ >
206
+ {busy ? (
207
+ <>
208
+ <span className="size-1.5 rounded-full bg-current animate-pulse-dot" />
209
+ Generating dialog
210
+ </>
211
+ ) : (
212
+ <>Generate dialog <span className="opacity-60">→</span></>
213
+ )}
214
+ </button>
215
+ </div>
216
+ );
217
+ }
web/src/test/DialogComposer.test.tsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import DialogComposer from "@/components/DialogComposer";
4
+ import type { ModelInfo } from "@/lib/api";
5
+
6
+ const models: ModelInfo[] = [
7
+ {
8
+ id: "chatterbox-en",
9
+ label: "Chatterbox (English)",
10
+ description: "",
11
+ languages: [{ code: "en", label: "English" }],
12
+ paralinguistic_tags: [],
13
+ supports_voice_clone: true,
14
+ params: [
15
+ { name: "temperature", label: "Temperature", type: "float", default: 0.8, min: 0.1, max: 1.5, step: 0.05, group: "basic" },
16
+ ],
17
+ },
18
+ {
19
+ id: "chatterbox-mtl",
20
+ label: "Chatterbox Multilingual",
21
+ description: "",
22
+ languages: [
23
+ { code: "en", label: "English" },
24
+ { code: "fr", label: "French" },
25
+ ],
26
+ paralinguistic_tags: [],
27
+ supports_voice_clone: true,
28
+ params: [
29
+ { name: "exaggeration", label: "Exaggeration", type: "float", default: 0.5, min: 0, max: 2, step: 0.05, group: "basic" },
30
+ ],
31
+ },
32
+ ];
33
+
34
+ describe("DialogComposer", () => {
35
+ it("starts with two speaker slots A and B", () => {
36
+ render(
37
+ <DialogComposer
38
+ models={models}
39
+ engineId="chatterbox-en"
40
+ onEngineChange={() => {}}
41
+ onSubmit={() => {}}
42
+ loadingModel={false}
43
+ busy={false}
44
+ />,
45
+ );
46
+ expect(screen.getByLabelText(/speaker a voice/i)).toBeInTheDocument();
47
+ expect(screen.getByLabelText(/speaker b voice/i)).toBeInTheDocument();
48
+ expect(screen.queryByLabelText(/speaker c voice/i)).toBeNull();
49
+ });
50
+
51
+ it("adds speaker C when + add speaker is clicked", () => {
52
+ render(
53
+ <DialogComposer
54
+ models={models}
55
+ engineId="chatterbox-en"
56
+ onEngineChange={() => {}}
57
+ onSubmit={() => {}}
58
+ loadingModel={false}
59
+ busy={false}
60
+ />,
61
+ );
62
+ fireEvent.click(screen.getByRole("button", { name: /add speaker/i }));
63
+ expect(screen.getByLabelText(/speaker c voice/i)).toBeInTheDocument();
64
+ });
65
+
66
+ it("does not allow more than 4 speakers", () => {
67
+ render(
68
+ <DialogComposer
69
+ models={models}
70
+ engineId="chatterbox-en"
71
+ onEngineChange={() => {}}
72
+ onSubmit={() => {}}
73
+ loadingModel={false}
74
+ busy={false}
75
+ />,
76
+ );
77
+ fireEvent.click(screen.getByRole("button", { name: /add speaker/i })); // C
78
+ fireEvent.click(screen.getByRole("button", { name: /add speaker/i })); // D
79
+ expect(screen.queryByRole("button", { name: /add speaker/i })).toBeNull();
80
+ });
81
+
82
+ it("renders the language picker only when mtl engine is active", () => {
83
+ const { rerender } = render(
84
+ <DialogComposer
85
+ models={models}
86
+ engineId="chatterbox-en"
87
+ onEngineChange={() => {}}
88
+ onSubmit={() => {}}
89
+ loadingModel={false}
90
+ busy={false}
91
+ />,
92
+ );
93
+ expect(screen.queryByLabelText(/^language$/i)).toBeNull();
94
+
95
+ rerender(
96
+ <DialogComposer
97
+ models={models}
98
+ engineId="chatterbox-mtl"
99
+ onEngineChange={() => {}}
100
+ onSubmit={() => {}}
101
+ loadingModel={false}
102
+ busy={false}
103
+ />,
104
+ );
105
+ expect(screen.getByLabelText(/^language$/i)).toBeInTheDocument();
106
+ });
107
+ });