techfreakworm commited on
Commit
96f2542
·
unverified ·
1 Parent(s): fa69513

feat(web): API client, IndexedDB store, Recorder state machine

Browse files
web/src/lib/api.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type Lang = { code: string; label: string };
2
+
3
+ export type ParamSpec = {
4
+ name: string;
5
+ label: string;
6
+ type: "float" | "int" | "bool" | "enum";
7
+ default: number | string | boolean;
8
+ min?: number;
9
+ max?: number;
10
+ step?: number;
11
+ choices?: string[];
12
+ help?: string;
13
+ };
14
+
15
+ export type ModelInfo = {
16
+ id: string;
17
+ label: string;
18
+ description: string;
19
+ languages: Lang[];
20
+ paralinguistic_tags: string[];
21
+ supports_voice_clone: boolean;
22
+ params: ParamSpec[];
23
+ };
24
+
25
+ export type ActiveStatus = {
26
+ id: string | null;
27
+ status: "idle" | "loading" | "loaded" | "error";
28
+ last_error: string | null;
29
+ };
30
+
31
+ export async function listModels(): Promise<ModelInfo[]> {
32
+ const r = await fetch("/api/models");
33
+ if (!r.ok) throw new Error(`listModels: ${r.status}`);
34
+ return r.json();
35
+ }
36
+
37
+ export async function getActiveModel(): Promise<ActiveStatus> {
38
+ const r = await fetch("/api/models/active");
39
+ if (!r.ok) throw new Error(`getActiveModel: ${r.status}`);
40
+ return r.json();
41
+ }
42
+
43
+ export async function activateModel(id: string): Promise<void> {
44
+ const r = await fetch(`/api/models/${encodeURIComponent(id)}/activate`, { method: "POST" });
45
+ if (!r.ok) {
46
+ const err = await r.json().catch(() => ({}));
47
+ throw new Error(err?.error?.code ?? `activateModel: ${r.status}`);
48
+ }
49
+ }
50
+
51
+ export type GenerateInput = {
52
+ modelId: string;
53
+ text: string;
54
+ language?: string;
55
+ params: Record<string, unknown>;
56
+ reference?: Blob;
57
+ };
58
+
59
+ export async function generate(input: GenerateInput): Promise<Blob> {
60
+ const fd = new FormData();
61
+ fd.set("text", input.text);
62
+ fd.set("model_id", input.modelId);
63
+ fd.set("params", JSON.stringify(input.params ?? {}));
64
+ if (input.language) fd.set("language", input.language);
65
+ if (input.reference) fd.set("reference_wav", input.reference, "ref.wav");
66
+ const r = await fetch("/api/generate", { method: "POST", body: fd });
67
+ if (!r.ok) {
68
+ const err = await r.json().catch(() => ({}));
69
+ throw new Error(err?.error?.code ?? `generate: ${r.status}`);
70
+ }
71
+ return r.blob();
72
+ }
73
+
74
+ export function streamActiveEvents(
75
+ onEvent: (e: { id: string | null; status: string; error?: string }) => void,
76
+ ) {
77
+ const es = new EventSource("/api/models/active/events");
78
+ es.onmessage = (m) => {
79
+ try {
80
+ onEvent(JSON.parse(m.data));
81
+ } catch {
82
+ /* ignore malformed */
83
+ }
84
+ };
85
+ return () => es.close();
86
+ }
web/src/lib/audio.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type RecorderState = "idle" | "requesting" | "recording" | "stopping" | "error";
2
+
3
+ type Deps = {
4
+ getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
5
+ };
6
+
7
+ export class Recorder {
8
+ state: RecorderState = "idle";
9
+ lastError: Error | null = null;
10
+ private chunks: BlobPart[] = [];
11
+ private rec: MediaRecorder | null = null;
12
+ private stream: MediaStream | null = null;
13
+ private getUserMedia: NonNullable<Deps["getUserMedia"]>;
14
+
15
+ constructor(deps: Deps = {}) {
16
+ this.getUserMedia =
17
+ deps.getUserMedia ?? ((c) => navigator.mediaDevices.getUserMedia(c));
18
+ }
19
+
20
+ requestStart() {
21
+ this.state = "requesting";
22
+ }
23
+
24
+ async start(): Promise<void> {
25
+ this.requestStart();
26
+ try {
27
+ this.stream = await this.getUserMedia({ audio: true });
28
+ } catch (e) {
29
+ this.lastError = e as Error;
30
+ this.state = "error";
31
+ throw e;
32
+ }
33
+ this.chunks = [];
34
+ this.rec = new MediaRecorder(this.stream);
35
+ this.rec.ondataavailable = (ev) => {
36
+ if (ev.data && ev.data.size > 0) this.chunks.push(ev.data);
37
+ };
38
+ this.rec.start();
39
+ this.state = "recording";
40
+ }
41
+
42
+ stop(): Promise<Blob | null> {
43
+ if (this.state === "idle") return Promise.resolve(null);
44
+ return new Promise((resolve) => {
45
+ if (!this.rec) {
46
+ this.state = "idle";
47
+ resolve(null);
48
+ return;
49
+ }
50
+ this.state = "stopping";
51
+ this.rec.onstop = () => {
52
+ const blob = new Blob(this.chunks, { type: this.rec?.mimeType ?? "audio/webm" });
53
+ this.chunks = [];
54
+ this.rec = null;
55
+ this.stream?.getTracks().forEach((t) => t.stop());
56
+ this.stream = null;
57
+ this.state = "idle";
58
+ resolve(blob);
59
+ };
60
+ this.rec.stop();
61
+ });
62
+ }
63
+ }
web/src/lib/idb.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Dexie, { type Table } from "dexie";
2
+
3
+ export const HISTORY_CAP = 50;
4
+
5
+ export type VoiceRecord = {
6
+ id?: number;
7
+ name: string;
8
+ blob: Blob;
9
+ sampleRate: number;
10
+ durationMs: number;
11
+ createdAt: number;
12
+ isFavorite: boolean;
13
+ };
14
+
15
+ export type HistoryRecord = {
16
+ id?: number;
17
+ text: string;
18
+ modelId: string;
19
+ voiceId?: number;
20
+ language?: string;
21
+ params: Record<string, unknown>;
22
+ audioBlob: Blob;
23
+ createdAt: number;
24
+ };
25
+
26
+ class DB extends Dexie {
27
+ voices!: Table<VoiceRecord, number>;
28
+ history!: Table<HistoryRecord, number>;
29
+
30
+ constructor() {
31
+ super("chatterbox-voice-studio");
32
+ this.version(1).stores({
33
+ voices: "++id, name, createdAt, isFavorite",
34
+ history: "++id, createdAt",
35
+ });
36
+ }
37
+ }
38
+
39
+ export const db = new DB();
40
+
41
+ export async function addVoice(
42
+ v: Omit<VoiceRecord, "id" | "createdAt" | "isFavorite"> & Partial<Pick<VoiceRecord, "isFavorite">>,
43
+ ): Promise<number> {
44
+ return db.voices.add({
45
+ ...v,
46
+ isFavorite: v.isFavorite ?? false,
47
+ createdAt: Date.now(),
48
+ });
49
+ }
50
+
51
+ export async function listVoices(): Promise<VoiceRecord[]> {
52
+ return db.voices.orderBy("createdAt").reverse().toArray();
53
+ }
54
+
55
+ export async function deleteVoice(id: number): Promise<void> {
56
+ await db.voices.delete(id);
57
+ }
58
+
59
+ export async function setFavorite(id: number, fav: boolean): Promise<void> {
60
+ await db.voices.update(id, { isFavorite: fav });
61
+ }
62
+
63
+ export async function addHistory(
64
+ h: Omit<HistoryRecord, "id" | "createdAt">,
65
+ ): Promise<number> {
66
+ const id = await db.history.add({ ...h, createdAt: Date.now() });
67
+ const count = await db.history.count();
68
+ if (count > HISTORY_CAP) {
69
+ const overflow = count - HISTORY_CAP;
70
+ const oldest = await db.history.orderBy("createdAt").limit(overflow).primaryKeys();
71
+ await db.history.bulkDelete(oldest);
72
+ }
73
+ return id;
74
+ }
75
+
76
+ export async function listHistory(): Promise<HistoryRecord[]> {
77
+ return db.history.orderBy("createdAt").reverse().toArray();
78
+ }
79
+
80
+ export async function clearHistory(): Promise<void> {
81
+ await db.history.clear();
82
+ }
web/src/test/api.test.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
6
+ beforeEach(() => {
7
+ vi.stubGlobal("fetch", fetchMock);
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.unstubAllGlobals();
12
+ fetchMock.mockReset();
13
+ });
14
+
15
+ describe("api", () => {
16
+ it("listModels GETs /api/models", async () => {
17
+ fetchMock.mockResolvedValue(new Response(JSON.stringify([{ id: "x" }])));
18
+ const out = await listModels();
19
+ expect(fetchMock).toHaveBeenCalledWith("/api/models");
20
+ expect(out[0].id).toBe("x");
21
+ });
22
+
23
+ it("getActiveModel returns status object", async () => {
24
+ fetchMock.mockResolvedValue(
25
+ new Response(JSON.stringify({ id: "x", status: "loaded" })),
26
+ );
27
+ const out = await getActiveModel();
28
+ expect(out.status).toBe("loaded");
29
+ });
30
+
31
+ it("activateModel posts to /api/models/{id}/activate", async () => {
32
+ fetchMock.mockResolvedValue(new Response("{}", { status: 202 }));
33
+ await activateModel("foo");
34
+ expect(fetchMock).toHaveBeenCalledWith(
35
+ "/api/models/foo/activate",
36
+ expect.objectContaining({ method: "POST" }),
37
+ );
38
+ });
39
+
40
+ it("generate posts multipart and returns response blob", async () => {
41
+ fetchMock.mockResolvedValue(new Response("RIFFFAKE", { status: 200 }));
42
+ const out = await generate({
43
+ modelId: "x",
44
+ text: "hi",
45
+ params: {},
46
+ });
47
+ expect(typeof out.size).toBe("number");
48
+ expect(fetchMock).toHaveBeenCalledWith(
49
+ "/api/generate",
50
+ expect.objectContaining({ method: "POST" }),
51
+ );
52
+ const call = fetchMock.mock.calls[0];
53
+ const body = call[1].body as FormData;
54
+ expect(body.get("text")).toBe("hi");
55
+ expect(body.get("model_id")).toBe("x");
56
+ });
57
+
58
+ it("generate surfaces error JSON on 4xx", async () => {
59
+ fetchMock.mockResolvedValue(
60
+ new Response(
61
+ JSON.stringify({ error: { code: "model_not_found", message: "x" } }),
62
+ { status: 404, headers: { "content-type": "application/json" } },
63
+ ),
64
+ );
65
+ await expect(
66
+ generate({ modelId: "x", text: "hi", params: {} }),
67
+ ).rejects.toThrow(/model_not_found/);
68
+ });
69
+ });
web/src/test/audio.test.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { Recorder } from "@/lib/audio";
3
+
4
+ describe("Recorder state machine", () => {
5
+ it("starts in idle", () => {
6
+ const r = new Recorder();
7
+ expect(r.state).toBe("idle");
8
+ });
9
+
10
+ it("transitions idle -> requesting on start()", () => {
11
+ const r = new Recorder();
12
+ r.requestStart();
13
+ expect(r.state).toBe("requesting");
14
+ });
15
+
16
+ it("transitions to error on permission denial", async () => {
17
+ const r = new Recorder({
18
+ getUserMedia: () => Promise.reject(new Error("denied")),
19
+ });
20
+ await r.start().catch(() => {});
21
+ expect(r.state).toBe("error");
22
+ expect(r.lastError?.message).toBe("denied");
23
+ });
24
+
25
+ it("ignores stop() in idle", async () => {
26
+ const r = new Recorder();
27
+ const result = await r.stop();
28
+ expect(r.state).toBe("idle");
29
+ expect(result).toBeNull();
30
+ });
31
+ });
web/src/test/idb.test.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ addHistory,
4
+ addVoice,
5
+ db,
6
+ deleteVoice,
7
+ listHistory,
8
+ listVoices,
9
+ setFavorite,
10
+ HISTORY_CAP,
11
+ } from "@/lib/idb";
12
+
13
+ beforeEach(async () => {
14
+ await db.voices.clear();
15
+ await db.history.clear();
16
+ });
17
+
18
+ describe("voices", () => {
19
+ it("adds and lists voices ordered by createdAt desc", async () => {
20
+ await addVoice({ name: "A", blob: new Blob(["a"]), sampleRate: 24000, durationMs: 1000 });
21
+ await new Promise((r) => setTimeout(r, 5));
22
+ await addVoice({ name: "B", blob: new Blob(["b"]), sampleRate: 24000, durationMs: 1500 });
23
+ const out = await listVoices();
24
+ expect(out.map((v) => v.name)).toEqual(["B", "A"]);
25
+ });
26
+
27
+ it("setFavorite toggles", async () => {
28
+ const id = await addVoice({ name: "A", blob: new Blob(["a"]), sampleRate: 24000, durationMs: 1000 });
29
+ await setFavorite(id, true);
30
+ const v = (await listVoices()).find((x) => x.id === id)!;
31
+ expect(v.isFavorite).toBe(true);
32
+ });
33
+
34
+ it("deleteVoice removes", async () => {
35
+ const id = await addVoice({ name: "A", blob: new Blob(["a"]), sampleRate: 24000, durationMs: 1000 });
36
+ await deleteVoice(id);
37
+ expect(await listVoices()).toEqual([]);
38
+ });
39
+ });
40
+
41
+ describe("history", () => {
42
+ it("caps at HISTORY_CAP entries (oldest evicted)", async () => {
43
+ for (let i = 0; i < HISTORY_CAP + 5; i++) {
44
+ await addHistory({
45
+ text: `t${i}`,
46
+ modelId: "x",
47
+ voiceId: undefined,
48
+ language: undefined,
49
+ params: {},
50
+ audioBlob: new Blob([`${i}`]),
51
+ });
52
+ }
53
+ const items = await listHistory();
54
+ expect(items.length).toBe(HISTORY_CAP);
55
+ expect(items[0].text).toBe(`t${HISTORY_CAP + 4}`);
56
+ });
57
+ });