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: ... 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 |
+
});
|