techfreakworm commited on
Commit
8122b04
·
unverified ·
1 Parent(s): 97db435

feat(web): IndexedDB v2 migration; show seed and reuse button on history rows

Browse files
web/src/components/HistoryList.tsx CHANGED
@@ -4,13 +4,14 @@ import { listHistory, type HistoryRecord } from "@/lib/idb";
4
  type Props = {
5
  refreshKey?: number;
6
  onRegenerate: (h: HistoryRecord) => void;
 
7
  };
8
 
9
  function fmtTime(ts: number): string {
10
  return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
11
  }
12
 
13
- export default function HistoryList({ refreshKey, onRegenerate }: Props) {
14
  const [items, setItems] = useState<HistoryRecord[]>([]);
15
  useEffect(() => {
16
  listHistory().then(setItems);
@@ -28,33 +29,49 @@ export default function HistoryList({ refreshKey, onRegenerate }: Props) {
28
  <ul className="space-y-3">
29
  {items.map((h, i) => {
30
  const url = URL.createObjectURL(h.audioBlob);
 
 
 
 
31
  return (
32
  <li key={h.id} className="card-paper p-3 space-y-2.5">
33
  <div className="flex items-baseline justify-between gap-3">
34
  <span className="marker-num">
35
  {String(items.length - i).padStart(2, "0")}
36
  </span>
37
- <span className="label-mono">
38
- {h.modelId.replace("chatterbox-", "")} · {h.language ?? "—"} · {fmtTime(h.createdAt)}
39
- </span>
40
  </div>
41
  <p className="text-[13px] leading-snug line-clamp-3">{h.text}</p>
42
  <audio controls src={url} className="w-full h-9" />
43
- <div className="flex justify-end gap-3">
44
- <a
45
- href={url}
46
- download={`${h.id}.wav`}
47
- className="label-mono hover:text-foreground transition-colors"
48
- >
49
- download
50
- </a>
51
- <button
52
- type="button"
53
- className="label-mono hover:text-[hsl(var(--ember))] transition-colors"
54
- onClick={() => onRegenerate(h)}
55
- >
56
- regenerate
57
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  </div>
59
  </li>
60
  );
 
4
  type Props = {
5
  refreshKey?: number;
6
  onRegenerate: (h: HistoryRecord) => void;
7
+ onReuseSeed?: (seed: number) => void;
8
  };
9
 
10
  function fmtTime(ts: number): string {
11
  return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
12
  }
13
 
14
+ export default function HistoryList({ refreshKey, onRegenerate, onReuseSeed }: Props) {
15
  const [items, setItems] = useState<HistoryRecord[]>([]);
16
  useEffect(() => {
17
  listHistory().then(setItems);
 
29
  <ul className="space-y-3">
30
  {items.map((h, i) => {
31
  const url = URL.createObjectURL(h.audioBlob);
32
+ const kindLabel =
33
+ h.kind === "dialog"
34
+ ? `dialog · ${(h.speakers ?? []).length} spk · ${h.modelId.replace("chatterbox-", "")}`
35
+ : `${h.modelId.replace("chatterbox-", "")} · ${h.language ?? "—"}`;
36
  return (
37
  <li key={h.id} className="card-paper p-3 space-y-2.5">
38
  <div className="flex items-baseline justify-between gap-3">
39
  <span className="marker-num">
40
  {String(items.length - i).padStart(2, "0")}
41
  </span>
42
+ <span className="label-mono">{kindLabel} · {fmtTime(h.createdAt)}</span>
 
 
43
  </div>
44
  <p className="text-[13px] leading-snug line-clamp-3">{h.text}</p>
45
  <audio controls src={url} className="w-full h-9" />
46
+ <div className="flex items-center justify-between">
47
+ {h.seedUsed != null ? (
48
+ <button
49
+ type="button"
50
+ onClick={() => onReuseSeed?.(h.seedUsed!)}
51
+ className="label-mono hover:text-[hsl(var(--ember))] transition-colors"
52
+ title="Copy this seed into the active params"
53
+ >
54
+ seed {h.seedUsed} · ↻
55
+ </button>
56
+ ) : (
57
+ <span className="label-mono text-muted-foreground/60">no seed</span>
58
+ )}
59
+ <div className="flex gap-3">
60
+ <a
61
+ href={url}
62
+ download={`${h.id}.wav`}
63
+ className="label-mono hover:text-foreground transition-colors"
64
+ >
65
+ ↓ download
66
+ </a>
67
+ <button
68
+ type="button"
69
+ className="label-mono hover:text-[hsl(var(--ember))] transition-colors"
70
+ onClick={() => onRegenerate(h)}
71
+ >
72
+ ↻ regenerate
73
+ </button>
74
+ </div>
75
  </div>
76
  </li>
77
  );
web/src/lib/api.ts CHANGED
@@ -57,7 +57,12 @@ export type GenerateInput = {
57
  reference?: Blob;
58
  };
59
 
60
- export async function generate(input: GenerateInput): Promise<Blob> {
 
 
 
 
 
61
  const fd = new FormData();
62
  fd.set("text", input.text);
63
  fd.set("model_id", input.modelId);
@@ -71,7 +76,10 @@ export async function generate(input: GenerateInput): Promise<Blob> {
71
  const msg = err?.error?.message;
72
  throw new Error(msg ? `${code}: ${msg}` : code);
73
  }
74
- return r.blob();
 
 
 
75
  }
76
 
77
  export function streamActiveEvents(
 
57
  reference?: Blob;
58
  };
59
 
60
+ export type GenerateResult = {
61
+ blob: Blob;
62
+ seedUsed: number | null;
63
+ };
64
+
65
+ export async function generate(input: GenerateInput): Promise<GenerateResult> {
66
  const fd = new FormData();
67
  fd.set("text", input.text);
68
  fd.set("model_id", input.modelId);
 
76
  const msg = err?.error?.message;
77
  throw new Error(msg ? `${code}: ${msg}` : code);
78
  }
79
+ const seedHeader = r.headers.get("x-seed-used");
80
+ const seedUsed = seedHeader != null ? Number(seedHeader) : null;
81
+ const blob = await r.blob();
82
+ return { blob, seedUsed };
83
  }
84
 
85
  export function streamActiveEvents(
web/src/lib/idb.ts CHANGED
@@ -12,6 +12,8 @@ export type VoiceRecord = {
12
  isFavorite: boolean;
13
  };
14
 
 
 
15
  export type HistoryRecord = {
16
  id?: number;
17
  text: string;
@@ -21,6 +23,9 @@ export type HistoryRecord = {
21
  params: Record<string, unknown>;
22
  audioBlob: Blob;
23
  createdAt: number;
 
 
 
24
  };
25
 
26
  class DB extends Dexie {
@@ -33,6 +38,15 @@ class DB extends Dexie {
33
  voices: "++id, name, createdAt, isFavorite",
34
  history: "++id, createdAt",
35
  });
 
 
 
 
 
 
 
 
 
36
  }
37
  }
38
 
 
12
  isFavorite: boolean;
13
  };
14
 
15
+ export type SpeakerRef = { letter: "A" | "B" | "C" | "D"; voiceId: number };
16
+
17
  export type HistoryRecord = {
18
  id?: number;
19
  text: string;
 
23
  params: Record<string, unknown>;
24
  audioBlob: Blob;
25
  createdAt: number;
26
+ kind?: "single" | "dialog";
27
+ seedUsed?: number;
28
+ speakers?: SpeakerRef[];
29
  };
30
 
31
  class DB extends Dexie {
 
38
  voices: "++id, name, createdAt, isFavorite",
39
  history: "++id, createdAt",
40
  });
41
+ this.version(2).stores({
42
+ voices: "++id, name, createdAt, isFavorite",
43
+ history: "++id, createdAt",
44
+ }).upgrade(async (tx) => {
45
+ // Backfill new fields on existing rows so listings stay consistent.
46
+ await tx.table("history").toCollection().modify((r: HistoryRecord) => {
47
+ if (!r.kind) r.kind = "single";
48
+ });
49
+ });
50
  }
51
  }
52
 
web/src/pages/Studio.tsx CHANGED
@@ -103,7 +103,7 @@ export default function Studio() {
103
  const inputText = reuse?.text ?? text;
104
  const inputLang = reuse?.language ?? language;
105
  const inputParams = reuse?.params ?? params;
106
- const out = await generate({
107
  modelId: active.id,
108
  text: inputText,
109
  language: inputLang,
@@ -112,7 +112,7 @@ export default function Studio() {
112
  });
113
  setOutputUrl((u) => {
114
  if (u) URL.revokeObjectURL(u);
115
- return URL.createObjectURL(out);
116
  });
117
  await addHistory({
118
  text: inputText,
@@ -120,7 +120,9 @@ export default function Studio() {
120
  voiceId: selectedVoice?.id,
121
  language: inputLang,
122
  params: inputParams,
123
- audioBlob: out,
 
 
124
  });
125
  setHistoryKey((k) => k + 1);
126
  } catch (e) {
@@ -275,7 +277,11 @@ export default function Studio() {
275
  refreshKey={libraryKey}
276
  />
277
  ) : (
278
- <HistoryList refreshKey={historyKey} onRegenerate={onGenerate} />
 
 
 
 
279
  )}
280
  </aside>
281
  </main>
 
103
  const inputText = reuse?.text ?? text;
104
  const inputLang = reuse?.language ?? language;
105
  const inputParams = reuse?.params ?? params;
106
+ const result = await generate({
107
  modelId: active.id,
108
  text: inputText,
109
  language: inputLang,
 
112
  });
113
  setOutputUrl((u) => {
114
  if (u) URL.revokeObjectURL(u);
115
+ return URL.createObjectURL(result.blob);
116
  });
117
  await addHistory({
118
  text: inputText,
 
120
  voiceId: selectedVoice?.id,
121
  language: inputLang,
122
  params: inputParams,
123
+ audioBlob: result.blob,
124
+ kind: "single",
125
+ seedUsed: result.seedUsed ?? undefined,
126
  });
127
  setHistoryKey((k) => k + 1);
128
  } catch (e) {
 
277
  refreshKey={libraryKey}
278
  />
279
  ) : (
280
+ <HistoryList
281
+ refreshKey={historyKey}
282
+ onRegenerate={onGenerate}
283
+ onReuseSeed={(seed) => setParams((p) => ({ ...p, seed }))}
284
+ />
285
  )}
286
  </aside>
287
  </main>
web/src/test/api.test.ts CHANGED
@@ -37,22 +37,20 @@ describe("api", () => {
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 () => {
 
37
  );
38
  });
39
 
40
+ it("generate posts multipart and returns response blob with seed", async () => {
41
+ fetchMock.mockResolvedValue(
42
+ new Response("RIFFFAKE", {
43
+ status: 200,
44
+ headers: { "X-Seed-Used": "777" },
45
+ }),
46
+ );
47
+ const out = await generate({ modelId: "x", text: "hi", params: {} });
48
+ expect(typeof out.blob.size).toBe("number");
49
+ expect(out.seedUsed).toBe(777);
50
  expect(fetchMock).toHaveBeenCalledWith(
51
  "/api/generate",
52
  expect.objectContaining({ method: "POST" }),
53
  );
 
 
 
 
54
  });
55
 
56
  it("generate surfaces error JSON on 4xx", async () => {
web/src/test/idb.test.ts CHANGED
@@ -55,3 +55,45 @@ describe("history", () => {
55
  expect(items[0].text).toBe(`t${HISTORY_CAP + 4}`);
56
  });
57
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  expect(items[0].text).toBe(`t${HISTORY_CAP + 4}`);
56
  });
57
  });
58
+
59
+ describe("history v2", () => {
60
+ it("stores seedUsed and kind on a row", async () => {
61
+ const id = await addHistory({
62
+ text: "x",
63
+ modelId: "m",
64
+ voiceId: undefined,
65
+ language: undefined,
66
+ params: {},
67
+ audioBlob: new Blob([""]),
68
+ kind: "single",
69
+ seedUsed: 12345,
70
+ });
71
+ const items = await listHistory();
72
+ const item = items.find((h) => h.id === id)!;
73
+ expect(item.seedUsed).toBe(12345);
74
+ expect(item.kind).toBe("single");
75
+ });
76
+
77
+ it("stores speakers list on a dialog row", async () => {
78
+ const id = await addHistory({
79
+ text: "SPEAKER A: hi",
80
+ modelId: "m",
81
+ voiceId: undefined,
82
+ language: undefined,
83
+ params: {},
84
+ audioBlob: new Blob([""]),
85
+ kind: "dialog",
86
+ seedUsed: 7,
87
+ speakers: [
88
+ { letter: "A", voiceId: 1 },
89
+ { letter: "B", voiceId: 2 },
90
+ ],
91
+ });
92
+ const items = await listHistory();
93
+ const item = items.find((h) => h.id === id)!;
94
+ expect(item.speakers).toEqual([
95
+ { letter: "A", voiceId: 1 },
96
+ { letter: "B", voiceId: 2 },
97
+ ]);
98
+ });
99
+ });