feat(web): API client, IndexedDB store, Recorder state machine
Browse files- web/src/lib/api.ts +86 -0
- web/src/lib/audio.ts +63 -0
- web/src/lib/idb.ts +82 -0
- web/src/test/api.test.ts +69 -0
- web/src/test/audio.test.ts +31 -0
- web/src/test/idb.test.ts +57 -0
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 |
+
});
|