feat(web): generateDialog client with per-speaker multipart
Browse files- web/src/lib/api.ts +40 -0
- web/src/test/api.test.ts +43 -0
web/src/lib/api.ts
CHANGED
|
@@ -95,3 +95,43 @@ export function streamActiveEvents(
|
|
| 95 |
};
|
| 96 |
return () => es.close();
|
| 97 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
};
|
| 96 |
return () => es.close();
|
| 97 |
}
|
| 98 |
+
|
| 99 |
+
export type DialogSpeakerInput = {
|
| 100 |
+
letter: "A" | "B" | "C" | "D";
|
| 101 |
+
reference: Blob;
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
export type DialogInput = {
|
| 105 |
+
engineId: string;
|
| 106 |
+
text: string;
|
| 107 |
+
language?: string;
|
| 108 |
+
params: Record<string, unknown>;
|
| 109 |
+
speakers: DialogSpeakerInput[];
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
export type DialogResult = {
|
| 113 |
+
blob: Blob;
|
| 114 |
+
seedUsed: number | null;
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
export async function generateDialog(input: DialogInput): Promise<DialogResult> {
|
| 118 |
+
const fd = new FormData();
|
| 119 |
+
fd.set("text", input.text);
|
| 120 |
+
fd.set("engine_id", input.engineId);
|
| 121 |
+
fd.set("params", JSON.stringify(input.params ?? {}));
|
| 122 |
+
if (input.language) fd.set("language", input.language);
|
| 123 |
+
for (const s of input.speakers) {
|
| 124 |
+
fd.set(`reference_wav_${s.letter.toLowerCase()}`, s.reference, `${s.letter}.wav`);
|
| 125 |
+
}
|
| 126 |
+
const r = await fetch("/api/generate/dialog", { method: "POST", body: fd });
|
| 127 |
+
if (!r.ok) {
|
| 128 |
+
const err = await r.json().catch(() => ({}));
|
| 129 |
+
const code = err?.error?.code ?? `dialog: ${r.status}`;
|
| 130 |
+
const msg = err?.error?.message;
|
| 131 |
+
throw new Error(msg ? `${code}: ${msg}` : code);
|
| 132 |
+
}
|
| 133 |
+
const seedHeader = r.headers.get("x-seed-used");
|
| 134 |
+
const seedUsed = seedHeader != null ? Number(seedHeader) : null;
|
| 135 |
+
const blob = await r.blob();
|
| 136 |
+
return { blob, seedUsed };
|
| 137 |
+
}
|
web/src/test/api.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
import { activateModel, generate, getActiveModel, listModels } from "@/lib/api";
|
|
|
|
| 3 |
|
| 4 |
const fetchMock = vi.fn();
|
| 5 |
|
|
@@ -65,3 +66,45 @@ describe("api", () => {
|
|
| 65 |
).rejects.toThrow(/model_not_found/);
|
| 66 |
});
|
| 67 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
import { activateModel, generate, getActiveModel, listModels } from "@/lib/api";
|
| 3 |
+
import { generateDialog } from "@/lib/api";
|
| 4 |
|
| 5 |
const fetchMock = vi.fn();
|
| 6 |
|
|
|
|
| 66 |
).rejects.toThrow(/model_not_found/);
|
| 67 |
});
|
| 68 |
});
|
| 69 |
+
|
| 70 |
+
describe("generateDialog", () => {
|
| 71 |
+
it("posts multipart with engine_id and per-speaker clips", async () => {
|
| 72 |
+
fetchMock.mockResolvedValue(
|
| 73 |
+
new Response("RIFFOK", {
|
| 74 |
+
status: 200,
|
| 75 |
+
headers: { "X-Seed-Used": "33" },
|
| 76 |
+
}),
|
| 77 |
+
);
|
| 78 |
+
const out = await generateDialog({
|
| 79 |
+
engineId: "x",
|
| 80 |
+
text: "SPEAKER A: hi\nSPEAKER B: hi",
|
| 81 |
+
params: { temperature: 0.8 },
|
| 82 |
+
speakers: [
|
| 83 |
+
{ letter: "A", reference: new Blob(["a"], { type: "audio/wav" }) },
|
| 84 |
+
{ letter: "B", reference: new Blob(["b"], { type: "audio/wav" }) },
|
| 85 |
+
],
|
| 86 |
+
});
|
| 87 |
+
expect(out.seedUsed).toBe(33);
|
| 88 |
+
expect(typeof out.blob.size).toBe("number");
|
| 89 |
+
const call = fetchMock.mock.calls[0];
|
| 90 |
+
expect(call[0]).toBe("/api/generate/dialog");
|
| 91 |
+
const body = call[1].body as FormData;
|
| 92 |
+
expect(body.get("engine_id")).toBe("x");
|
| 93 |
+
expect(body.get("text")).toContain("SPEAKER A:");
|
| 94 |
+
expect(body.get("reference_wav_a")).toBeInstanceOf(Blob);
|
| 95 |
+
expect(body.get("reference_wav_b")).toBeInstanceOf(Blob);
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it("forwards language only when provided", async () => {
|
| 99 |
+
fetchMock.mockResolvedValue(new Response("RIFF", { status: 200 }));
|
| 100 |
+
await generateDialog({
|
| 101 |
+
engineId: "x",
|
| 102 |
+
text: "SPEAKER A: hi",
|
| 103 |
+
language: "fr",
|
| 104 |
+
params: {},
|
| 105 |
+
speakers: [{ letter: "A", reference: new Blob(["a"]) }],
|
| 106 |
+
});
|
| 107 |
+
const body = fetchMock.mock.calls[0][1].body as FormData;
|
| 108 |
+
expect(body.get("language")).toBe("fr");
|
| 109 |
+
});
|
| 110 |
+
});
|