techfreakworm commited on
Commit
65949d0
·
unverified ·
1 Parent(s): c3ddb06

feat(web): editorial-studio visual polish — Fraunces serif, IBM Plex mono, warm dark, ember accents

Browse files
web/index.html CHANGED
@@ -4,6 +4,12 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Chatterbox Voice Studio</title>
 
 
 
 
 
 
7
  </head>
8
  <body>
9
  <div id="root"></div>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Chatterbox Voice Studio</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
11
+ rel="stylesheet"
12
+ />
13
  </head>
14
  <body>
15
  <div id="root"></div>
web/src/components/DeviceBadge.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useEffect, useState } from "react";
2
 
3
  export default function DeviceBadge() {
4
- const [device, setDevice] = useState<string>("?");
5
  useEffect(() => {
6
  fetch("/api/health")
7
  .then((r) => r.json())
@@ -9,8 +9,11 @@ export default function DeviceBadge() {
9
  .catch(() => setDevice("offline"));
10
  }, []);
11
  return (
12
- <span className="text-xs px-2 py-0.5 rounded-md border border-border text-muted-foreground">
13
- {device}
14
- </span>
 
 
 
15
  );
16
  }
 
1
  import { useEffect, useState } from "react";
2
 
3
  export default function DeviceBadge() {
4
+ const [device, setDevice] = useState<string>("");
5
  useEffect(() => {
6
  fetch("/api/health")
7
  .then((r) => r.json())
 
9
  .catch(() => setDevice("offline"));
10
  }, []);
11
  return (
12
+ <div className="flex items-center gap-2">
13
+ <span className="label-mono">device</span>
14
+ <span className="font-mono text-[12px] tracking-wider text-foreground">
15
+ {device}
16
+ </span>
17
+ </div>
18
  );
19
  }
web/src/components/HistoryList.tsx CHANGED
@@ -6,6 +6,10 @@ type Props = {
6
  onRegenerate: (h: HistoryRecord) => void;
7
  };
8
 
 
 
 
 
9
  export default function HistoryList({ refreshKey, onRegenerate }: Props) {
10
  const [items, setItems] = useState<HistoryRecord[]>([]);
11
  useEffect(() => {
@@ -13,24 +17,43 @@ export default function HistoryList({ refreshKey, onRegenerate }: Props) {
13
  }, [refreshKey]);
14
 
15
  if (items.length === 0) {
16
- return <p className="text-sm text-muted-foreground">No generations yet.</p>;
 
 
 
 
17
  }
18
 
19
  return (
20
- <ul className="space-y-2">
21
- {items.map((h) => {
22
  const url = URL.createObjectURL(h.audioBlob);
23
  return (
24
- <li key={h.id} className="rounded-md border border-border p-2 space-y-2">
25
- <div className="text-sm line-clamp-2">{h.text}</div>
26
- <div className="text-xs text-muted-foreground">
27
- {h.modelId} · {h.language ?? ""} · {new Date(h.createdAt).toLocaleTimeString()}
 
 
 
 
28
  </div>
29
- <audio controls src={url} className="w-full" />
30
- <div className="flex justify-end gap-2">
31
- <a href={url} download={`${h.id}.wav`} className="text-xs underline">download</a>
32
- <button type="button" className="text-xs underline" onClick={() => onRegenerate(h)}>
33
- regenerate
 
 
 
 
 
 
 
 
 
 
 
34
  </button>
35
  </div>
36
  </li>
 
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(() => {
 
17
  }, [refreshKey]);
18
 
19
  if (items.length === 0) {
20
+ return (
21
+ <p className="text-sm text-muted-foreground italic">
22
+ Generations will be archived here.
23
+ </p>
24
+ );
25
  }
26
 
27
  return (
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>
web/src/components/LoadingBanner.tsx CHANGED
@@ -3,8 +3,11 @@ type Props = { visible: boolean; message: string };
3
  export default function LoadingBanner({ visible, message }: Props) {
4
  if (!visible) return null;
5
  return (
6
- <div className="bg-primary/15 text-primary text-sm px-6 py-2 border-b border-primary/30">
7
- {message}
 
 
 
8
  </div>
9
  );
10
  }
 
3
  export default function LoadingBanner({ visible, message }: Props) {
4
  if (!visible) return null;
5
  return (
6
+ <div className="border-b border-[hsl(var(--ember))]/30 bg-[hsl(var(--ember))]/10 px-8 py-2.5">
7
+ <div className="flex items-center gap-3">
8
+ <span className="size-1.5 rounded-full bg-[hsl(var(--ember))] animate-pulse-dot" />
9
+ <span className="label-mono text-[hsl(var(--ember))]">{message}</span>
10
+ </div>
11
  </div>
12
  );
13
  }
web/src/components/ModelPicker.tsx CHANGED
@@ -9,21 +9,20 @@ type Props = {
9
 
10
  export default function ModelPicker({ models, activeId, loading, onPick }: Props) {
11
  return (
12
- <select
13
- aria-label="Model"
14
- disabled={loading || models.length === 0}
15
- value={activeId ?? ""}
16
- onChange={(e) => onPick(e.target.value)}
17
- className="rounded-md border border-border bg-background px-2 py-1 text-sm"
18
- >
19
- <option value="" disabled>
20
- Choose model…
21
- </option>
22
- {models.map((m) => (
23
- <option key={m.id} value={m.id}>
24
- {m.label}
25
- </option>
26
- ))}
27
- </select>
28
  );
29
  }
 
9
 
10
  export default function ModelPicker({ models, activeId, loading, onPick }: Props) {
11
  return (
12
+ <div className="flex items-center gap-2">
13
+ <span className="label-mono">model</span>
14
+ <select
15
+ aria-label="Model"
16
+ disabled={loading || models.length === 0}
17
+ value={activeId ?? ""}
18
+ onChange={(e) => onPick(e.target.value)}
19
+ className="rounded-sm border border-border bg-paper/60 px-2.5 py-1 font-mono text-[12px] tracking-wider focus:outline-none focus:border-[hsl(var(--ember))]/60"
20
+ >
21
+ <option value="" disabled>choose…</option>
22
+ {models.map((m) => (
23
+ <option key={m.id} value={m.id}>{m.label}</option>
24
+ ))}
25
+ </select>
26
+ </div>
 
27
  );
28
  }
web/src/components/ParamsPanel.tsx CHANGED
@@ -11,14 +11,20 @@ export default function ParamsPanel({ specs, values, onChange }: Props) {
11
  onChange({ ...values, [name]: v });
12
  }
13
  return (
14
- <div className="space-y-4">
15
  {specs.map((s) => {
16
  const id = `param-${s.name}`;
17
- const current = (values[s.name] ?? s.default) as never;
18
  if (s.type === "float" || s.type === "int") {
 
19
  return (
20
- <label key={s.name} htmlFor={id} className="block space-y-1">
21
- <span className="text-sm">{s.label}</span>
 
 
 
 
 
22
  <input
23
  id={id}
24
  aria-label={s.label}
@@ -26,43 +32,50 @@ export default function ParamsPanel({ specs, values, onChange }: Props) {
26
  min={s.min}
27
  max={s.max}
28
  step={s.step ?? 0.01}
29
- value={current as number}
30
  onChange={(e) => set(s.name, Number(e.target.value))}
31
- className="w-full"
32
  />
33
- <span className="text-xs text-muted-foreground">{String(current)}</span>
34
- </label>
 
 
35
  );
36
  }
37
  if (s.type === "bool") {
38
  return (
39
- <label key={s.name} htmlFor={id} className="flex items-center justify-between text-sm">
40
- <span>{s.label}</span>
 
 
 
 
41
  <input
42
  id={id}
43
  aria-label={s.label}
44
  type="checkbox"
45
  checked={!!current}
46
  onChange={(e) => set(s.name, e.target.checked)}
 
47
  />
48
  </label>
49
  );
50
  }
51
  return (
52
- <label key={s.name} htmlFor={id} className="block space-y-1">
53
- <span className="text-sm">{s.label}</span>
54
  <select
55
  id={id}
56
  aria-label={s.label}
57
- value={current as string}
58
  onChange={(e) => set(s.name, e.target.value)}
59
- className="w-full rounded-md border border-border bg-background px-2 py-1"
60
  >
61
  {(s.choices ?? []).map((c) => (
62
  <option key={c} value={c}>{c}</option>
63
  ))}
64
  </select>
65
- </label>
66
  );
67
  })}
68
  </div>
 
11
  onChange({ ...values, [name]: v });
12
  }
13
  return (
14
+ <div className="space-y-5">
15
  {specs.map((s) => {
16
  const id = `param-${s.name}`;
17
+ const current: unknown = values[s.name] ?? s.default;
18
  if (s.type === "float" || s.type === "int") {
19
+ const n = typeof current === "number" ? current : Number(current);
20
  return (
21
+ <div key={s.name} className="space-y-1.5">
22
+ <div className="flex items-baseline justify-between">
23
+ <label htmlFor={id} className="label-mono">{s.label}</label>
24
+ <span className="font-mono text-[12px] text-foreground tracking-wider">
25
+ {Number.isFinite(n) ? n.toFixed(2) : String(current)}
26
+ </span>
27
+ </div>
28
  <input
29
  id={id}
30
  aria-label={s.label}
 
32
  min={s.min}
33
  max={s.max}
34
  step={s.step ?? 0.01}
35
+ value={Number.isFinite(n) ? n : 0}
36
  onChange={(e) => set(s.name, Number(e.target.value))}
37
+ className="w-full accent-[hsl(var(--ember))]"
38
  />
39
+ {s.help && (
40
+ <p className="text-[11px] text-muted-foreground/80 italic">{s.help}</p>
41
+ )}
42
+ </div>
43
  );
44
  }
45
  if (s.type === "bool") {
46
  return (
47
+ <label
48
+ key={s.name}
49
+ htmlFor={id}
50
+ className="flex items-center justify-between cursor-pointer"
51
+ >
52
+ <span className="label-mono">{s.label}</span>
53
  <input
54
  id={id}
55
  aria-label={s.label}
56
  type="checkbox"
57
  checked={!!current}
58
  onChange={(e) => set(s.name, e.target.checked)}
59
+ className="accent-[hsl(var(--ember))]"
60
  />
61
  </label>
62
  );
63
  }
64
  return (
65
+ <div key={s.name} className="space-y-1.5">
66
+ <label htmlFor={id} className="label-mono block">{s.label}</label>
67
  <select
68
  id={id}
69
  aria-label={s.label}
70
+ value={String(current)}
71
  onChange={(e) => set(s.name, e.target.value)}
72
+ className="field-input font-mono text-[12px]"
73
  >
74
  {(s.choices ?? []).map((c) => (
75
  <option key={c} value={c}>{c}</option>
76
  ))}
77
  </select>
78
+ </div>
79
  );
80
  })}
81
  </div>
web/src/components/TagBar.tsx CHANGED
@@ -25,13 +25,14 @@ export default function TagBar({ tags, targetRef }: Props) {
25
  el.focus();
26
  }
27
  return (
28
- <div className="flex flex-wrap gap-1.5">
 
29
  {tags.map((t) => (
30
  <button
31
  key={t}
32
  type="button"
33
  onClick={() => insert(t)}
34
- className="text-xs px-2 py-0.5 rounded-md border border-border hover:bg-muted"
35
  >
36
  {t}
37
  </button>
 
25
  el.focus();
26
  }
27
  return (
28
+ <div className="flex flex-wrap items-center gap-1.5">
29
+ <span className="label-mono mr-1">insert</span>
30
  {tags.map((t) => (
31
  <button
32
  key={t}
33
  type="button"
34
  onClick={() => insert(t)}
35
+ 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"
36
  >
37
  {t}
38
  </button>
web/src/components/VoiceComposer.tsx CHANGED
@@ -52,43 +52,46 @@ export default function VoiceComposer({ onSaved }: Props) {
52
  }
53
 
54
  return (
55
- <div className="space-y-2">
56
  <input
57
  type="text"
58
- placeholder="Voice name (optional)"
59
  value={name}
60
  onChange={(e) => setName(e.target.value)}
61
- className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm"
62
  />
63
  <div className="flex gap-2">
64
  <button
65
  type="button"
66
  onClick={() => fileRef.current?.click()}
67
- className="rounded-md border border-border px-3 py-1.5 text-sm"
68
  >
69
- Upload .wav/.mp3
70
  </button>
71
  <input ref={fileRef} type="file" accept="audio/*" hidden onChange={onFile} />
72
  {recState === "recording" ? (
73
  <button
74
  type="button"
75
  onClick={stopRec}
76
- className="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm"
77
  >
 
78
  Stop &amp; save
79
  </button>
80
  ) : (
81
  <button
82
  type="button"
83
  onClick={startRec}
84
- className="rounded-md border border-border px-3 py-1.5 text-sm"
85
  >
86
- Record
87
  </button>
88
  )}
89
  </div>
90
  {recState === "error" && (
91
- <p className="text-xs text-red-500">Microphone permission denied.</p>
 
 
92
  )}
93
  </div>
94
  );
 
52
  }
53
 
54
  return (
55
+ <div className="space-y-3">
56
  <input
57
  type="text"
58
+ placeholder="Name this voice"
59
  value={name}
60
  onChange={(e) => setName(e.target.value)}
61
+ className="field-input"
62
  />
63
  <div className="flex gap-2">
64
  <button
65
  type="button"
66
  onClick={() => fileRef.current?.click()}
67
+ className="btn-ghost flex-1"
68
  >
69
+ Upload
70
  </button>
71
  <input ref={fileRef} type="file" accept="audio/*" hidden onChange={onFile} />
72
  {recState === "recording" ? (
73
  <button
74
  type="button"
75
  onClick={stopRec}
76
+ className="btn-primary flex-1 !py-2 flex items-center justify-center gap-2"
77
  >
78
+ <span className="size-1.5 rounded-full bg-current animate-pulse-dot" />
79
  Stop &amp; save
80
  </button>
81
  ) : (
82
  <button
83
  type="button"
84
  onClick={startRec}
85
+ className="btn-ghost flex-1"
86
  >
87
+ Record
88
  </button>
89
  )}
90
  </div>
91
  {recState === "error" && (
92
+ <p className="text-[11px] text-red-400 font-mono uppercase tracking-wider">
93
+ microphone permission denied
94
+ </p>
95
  )}
96
  </div>
97
  );
web/src/components/VoiceLibrary.tsx CHANGED
@@ -15,46 +15,68 @@ export default function VoiceLibrary({ selectedId, onSelect, refreshKey }: Props
15
  }, [refreshKey]);
16
 
17
  if (voices.length === 0) {
18
- return <p className="text-sm text-muted-foreground">No saved voices yet.</p>;
 
 
 
 
19
  }
20
 
21
  return (
22
  <ul className="space-y-2">
23
- {voices.map((v) => (
24
  <li
25
  key={v.id}
26
  className={cn(
27
- "flex items-center justify-between rounded-md border border-border p-2",
28
- selectedId === v.id && "ring-1 ring-primary",
 
 
29
  )}
30
  >
31
- <button
32
- className="flex-1 text-left text-sm"
33
- onClick={() => onSelect(v)}
34
- type="button"
35
- >
36
- <div className="font-medium">{v.name}</div>
37
- <div className="text-xs text-muted-foreground">
38
- {(v.durationMs / 1000).toFixed(1)}s · {v.sampleRate} Hz
39
- </div>
40
- </button>
41
- <div className="flex items-center gap-1">
42
- <button
43
- type="button"
44
- aria-label={v.isFavorite ? "Unfavorite" : "Favorite"}
45
- onClick={() => setFavorite(v.id!, !v.isFavorite).then(() => listVoices().then(setVoices))}
46
- className="text-xs px-1"
47
- >
48
- {v.isFavorite ? "★" : "☆"}
49
- </button>
50
  <button
51
  type="button"
52
- aria-label="Delete"
53
- onClick={() => deleteVoice(v.id!).then(() => listVoices().then(setVoices))}
54
- className="text-xs px-1 text-muted-foreground"
55
  >
56
-
 
 
 
57
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  </div>
59
  </li>
60
  ))}
 
15
  }, [refreshKey]);
16
 
17
  if (voices.length === 0) {
18
+ return (
19
+ <p className="text-sm text-muted-foreground italic">
20
+ Voices will appear here once you upload or record one.
21
+ </p>
22
+ );
23
  }
24
 
25
  return (
26
  <ul className="space-y-2">
27
+ {voices.map((v, i) => (
28
  <li
29
  key={v.id}
30
  className={cn(
31
+ "card-paper p-3 transition-colors",
32
+ selectedId === v.id
33
+ ? "border-[hsl(var(--ember))]/60 bg-[hsl(var(--ember))]/5"
34
+ : "hover:border-foreground/30",
35
  )}
36
  >
37
+ <div className="flex items-start gap-3">
38
+ <span className="marker-num pt-0.5">
39
+ {String(i + 1).padStart(2, "0")}
40
+ </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  <button
42
  type="button"
43
+ className="flex-1 text-left"
44
+ onClick={() => onSelect(v)}
 
45
  >
46
+ <div className="display-serif text-[18px] leading-tight">{v.name}</div>
47
+ <div className="label-mono mt-1">
48
+ {(v.durationMs / 1000).toFixed(1)}s · {v.sampleRate} Hz
49
+ </div>
50
  </button>
51
+ <div className="flex items-center gap-1.5">
52
+ <button
53
+ type="button"
54
+ aria-label={v.isFavorite ? "Unfavorite" : "Favorite"}
55
+ onClick={() =>
56
+ setFavorite(v.id!, !v.isFavorite).then(() =>
57
+ listVoices().then(setVoices),
58
+ )
59
+ }
60
+ className={cn(
61
+ "text-base leading-none transition-colors",
62
+ v.isFavorite
63
+ ? "text-[hsl(var(--ember))]"
64
+ : "text-muted-foreground hover:text-foreground",
65
+ )}
66
+ >
67
+ {v.isFavorite ? "★" : "☆"}
68
+ </button>
69
+ <button
70
+ type="button"
71
+ aria-label="Delete"
72
+ onClick={() =>
73
+ deleteVoice(v.id!).then(() => listVoices().then(setVoices))
74
+ }
75
+ className="text-xs text-muted-foreground hover:text-red-400 transition-colors"
76
+ >
77
+
78
+ </button>
79
+ </div>
80
  </div>
81
  </li>
82
  ))}
web/src/pages/Studio.tsx CHANGED
@@ -16,6 +16,20 @@ import ParamsPanel from "@/components/ParamsPanel";
16
  import TagBar from "@/components/TagBar";
17
  import VoiceComposer from "@/components/VoiceComposer";
18
  import VoiceLibrary from "@/components/VoiceLibrary";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  export default function Studio() {
21
  const [models, setModels] = useState<ModelInfo[]>([]);
@@ -117,33 +131,43 @@ export default function Studio() {
117
  }
118
 
119
  return (
120
- <div className="min-h-screen flex flex-col">
121
- <header className="border-b border-border px-6 py-3 flex items-center justify-between">
122
- <div className="flex items-center gap-3">
123
- <span className="size-2.5 rounded-full bg-primary" />
124
- <span className="font-medium">Chatterbox Voice Studio</span>
125
- </div>
126
- <div className="flex items-center gap-3">
127
- <ModelPicker
128
- models={models}
129
- activeId={activeId}
130
- loading={loadingModel || busy}
131
- onPick={pickModel}
132
- />
133
- <DeviceBadge />
 
 
 
134
  </div>
135
  </header>
136
 
137
  <LoadingBanner
138
  visible={loadingModel}
139
- message="Loading model first activation can take 30–60s."
140
  />
141
- {err && <div className="bg-red-500/10 text-red-400 text-sm px-6 py-2">{err}</div>}
 
 
 
 
 
142
 
143
- <main className="flex-1 grid lg:grid-cols-[1fr_420px] gap-6 p-6">
144
- <section className="space-y-4">
145
- <div className="space-y-2">
146
- <h2 className="text-sm font-medium">Reference voice</h2>
 
 
147
  <VoiceComposer onSaved={() => setLibraryKey((k) => k + 1)} />
148
  <VoiceLibrary
149
  selectedId={selectedVoice?.id}
@@ -152,88 +176,97 @@ export default function Studio() {
152
  />
153
  </div>
154
 
155
- {active?.languages && active.languages.length > 1 && (
156
- <div className="space-y-1">
157
- <label htmlFor="lang-select" className="text-sm font-medium">
158
- Language
159
- </label>
160
- <select
161
- id="lang-select"
162
- value={language ?? ""}
163
- onChange={(e) => setLanguage(e.target.value)}
164
- className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm"
165
- >
166
- {active.languages.map((l) => (
167
- <option key={l.code} value={l.code}>
168
- {l.label}
169
- </option>
170
- ))}
171
- </select>
172
- </div>
173
- )}
174
-
175
- <div className="space-y-2">
176
- <label htmlFor="prompt" className="text-sm font-medium">
177
- Text
178
- </label>
179
  <textarea
180
  id="prompt"
181
  ref={textRef}
182
  value={text}
183
  onChange={(e) => setText(e.target.value)}
184
- rows={6}
185
- className="w-full rounded-md border border-border bg-background p-2 text-sm"
186
- placeholder="Type what the voice should say…"
187
  />
188
  <div className="flex items-center justify-between">
189
  <TagBar tags={active?.paralinguistic_tags ?? []} targetRef={textRef} />
190
- <span className="text-xs text-muted-foreground">{text.length} chars</span>
191
  </div>
192
  </div>
193
 
 
194
  {active && (
195
- <div className="space-y-2">
196
- <h2 className="text-sm font-medium">Parameters</h2>
197
  <ParamsPanel specs={active.params} values={params} onChange={setParams} />
198
  </div>
199
  )}
200
 
201
- <button
202
- type="button"
203
- onClick={() => onGenerate()}
204
- disabled={busy || loadingModel || !text.trim()}
205
- className="w-full rounded-md bg-primary text-primary-foreground py-2.5 text-sm font-medium disabled:opacity-50"
206
- >
207
- {busy ? "Generating…" : "Generate"}
208
- </button>
209
-
210
- {outputUrl && (
211
- <div className="space-y-1">
212
- <h2 className="text-sm font-medium">Output</h2>
213
- <audio controls src={outputUrl} className="w-full" />
214
- <a href={outputUrl} download="chatterbox.wav" className="text-xs underline">
215
- download
216
- </a>
217
- </div>
218
- )}
219
- </section>
220
-
221
- <aside className="space-y-3">
222
- <div className="flex gap-1">
223
- <button
224
- type="button"
225
- onClick={() => setTab("voices")}
226
- className={`flex-1 rounded-md px-2 py-1 text-sm ${tab === "voices" ? "bg-muted" : ""}`}
227
- >
228
- Voices
229
- </button>
230
  <button
231
  type="button"
232
- onClick={() => setTab("history")}
233
- className={`flex-1 rounded-md px-2 py-1 text-sm ${tab === "history" ? "bg-muted" : ""}`}
 
234
  >
235
- History
 
 
 
 
 
 
 
236
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  </div>
238
  {tab === "voices" ? (
239
  <VoiceLibrary
@@ -246,6 +279,13 @@ export default function Studio() {
246
  )}
247
  </aside>
248
  </main>
 
 
 
 
 
 
 
249
  </div>
250
  );
251
  }
 
16
  import TagBar from "@/components/TagBar";
17
  import VoiceComposer from "@/components/VoiceComposer";
18
  import VoiceLibrary from "@/components/VoiceLibrary";
19
+ import { cn } from "@/lib/utils";
20
+
21
+ function SectionHeader({ num, title, hint }: { num: string; title: string; hint?: string }) {
22
+ return (
23
+ <div className="space-y-1">
24
+ <div className="flex items-baseline gap-3">
25
+ <span className="marker-num">{num}</span>
26
+ <h2 className="display-serif text-[22px] leading-tight">{title}</h2>
27
+ </div>
28
+ {hint && <p className="label-mono">{hint}</p>}
29
+ <div className="rule-dotted mt-2" />
30
+ </div>
31
+ );
32
+ }
33
 
34
  export default function Studio() {
35
  const [models, setModels] = useState<ModelInfo[]>([]);
 
131
  }
132
 
133
  return (
134
+ <div className="min-h-screen relative-z animate-fade-up">
135
+ {/* Header */}
136
+ <header className="border-b border-border">
137
+ <div className="mx-auto max-w-[1280px] px-8 py-5 flex items-end justify-between">
138
+ <div className="flex items-end gap-4">
139
+ <span className="display-serif text-[34px] leading-none">Chatterbox</span>
140
+ <span className="label-mono pb-1">voice studio · v0.1</span>
141
+ </div>
142
+ <div className="flex items-center gap-6">
143
+ <ModelPicker
144
+ models={models}
145
+ activeId={activeId}
146
+ loading={loadingModel || busy}
147
+ onPick={pickModel}
148
+ />
149
+ <DeviceBadge />
150
+ </div>
151
  </div>
152
  </header>
153
 
154
  <LoadingBanner
155
  visible={loadingModel}
156
+ message="Loading model first activation can take 30–60s"
157
  />
158
+ {err && (
159
+ <div className="border-b border-red-900/40 bg-red-950/30 px-8 py-2.5">
160
+ <span className="label-mono text-red-400">error</span>
161
+ <span className="ml-3 text-sm text-red-300/90">{err}</span>
162
+ </div>
163
+ )}
164
 
165
+ <main className="mx-auto max-w-[1280px] px-8 py-10 grid lg:grid-cols-[minmax(0,1fr)_400px] gap-12">
166
+ {/* Composer column */}
167
+ <section className="space-y-12">
168
+ {/* 01 Voice */}
169
+ <div className="space-y-5">
170
+ <SectionHeader num="01" title="Reference voice" hint="upload, record, or pick from your library" />
171
  <VoiceComposer onSaved={() => setLibraryKey((k) => k + 1)} />
172
  <VoiceLibrary
173
  selectedId={selectedVoice?.id}
 
176
  />
177
  </div>
178
 
179
+ {/* 02 Script */}
180
+ <div className="space-y-4">
181
+ <SectionHeader num="02" title="Script" hint="what should the voice say?" />
182
+ {active?.languages && active.languages.length > 1 && (
183
+ <div className="flex items-center gap-3">
184
+ <label htmlFor="lang-select" className="label-mono">language</label>
185
+ <select
186
+ id="lang-select"
187
+ value={language ?? ""}
188
+ onChange={(e) => setLanguage(e.target.value)}
189
+ className="field-input !w-auto font-mono text-[12px] py-1"
190
+ >
191
+ {active.languages.map((l) => (
192
+ <option key={l.code} value={l.code}>{l.label}</option>
193
+ ))}
194
+ </select>
195
+ </div>
196
+ )}
 
 
 
 
 
 
197
  <textarea
198
  id="prompt"
199
  ref={textRef}
200
  value={text}
201
  onChange={(e) => setText(e.target.value)}
202
+ rows={7}
203
+ className="field-input font-display text-[18px] leading-relaxed"
204
+ placeholder="Once upon a midnight dreary, while I pondered, weak and weary…"
205
  />
206
  <div className="flex items-center justify-between">
207
  <TagBar tags={active?.paralinguistic_tags ?? []} targetRef={textRef} />
208
+ <span className="label-mono">{text.length} chars</span>
209
  </div>
210
  </div>
211
 
212
+ {/* 03 — Parameters */}
213
  {active && (
214
+ <div className="space-y-5">
215
+ <SectionHeader num="03" title="Parameters" hint={active.description} />
216
  <ParamsPanel specs={active.params} values={params} onChange={setParams} />
217
  </div>
218
  )}
219
 
220
+ {/* Generate */}
221
+ <div className="space-y-4 pt-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  <button
223
  type="button"
224
+ onClick={() => onGenerate()}
225
+ disabled={busy || loadingModel || !text.trim()}
226
+ className="btn-primary w-full flex items-center justify-center gap-3 ember-ring"
227
  >
228
+ {busy ? (
229
+ <>
230
+ <span className="size-1.5 rounded-full bg-current animate-pulse-dot" />
231
+ Generating
232
+ </>
233
+ ) : (
234
+ <>Generate <span className="opacity-60">→</span></>
235
+ )}
236
  </button>
237
+
238
+ {outputUrl && (
239
+ <div className="card-paper p-4 space-y-3">
240
+ <div className="flex items-baseline justify-between">
241
+ <span className="label-mono">latest output</span>
242
+ <a href={outputUrl} download="chatterbox.wav" className="label-mono hover:text-foreground">
243
+ ↓ download
244
+ </a>
245
+ </div>
246
+ <audio controls src={outputUrl} className="w-full h-10" />
247
+ </div>
248
+ )}
249
+ </div>
250
+ </section>
251
+
252
+ {/* Workspace column */}
253
+ <aside className="space-y-5 lg:sticky lg:top-8 self-start">
254
+ <div className="flex border-b border-border">
255
+ {(["voices", "history"] as const).map((t) => (
256
+ <button
257
+ key={t}
258
+ type="button"
259
+ onClick={() => setTab(t)}
260
+ className={cn(
261
+ "flex-1 label-mono py-2 transition-colors border-b-2",
262
+ tab === t
263
+ ? "text-foreground border-[hsl(var(--ember))]"
264
+ : "border-transparent hover:text-foreground",
265
+ )}
266
+ >
267
+ {t}
268
+ </button>
269
+ ))}
270
  </div>
271
  {tab === "voices" ? (
272
  <VoiceLibrary
 
279
  )}
280
  </aside>
281
  </main>
282
+
283
+ <footer className="border-t border-border mt-16">
284
+ <div className="mx-auto max-w-[1280px] px-8 py-6 flex items-center justify-between">
285
+ <span className="label-mono">chatterbox · resemble ai</span>
286
+ <span className="label-mono">stateless · browser-persisted</span>
287
+ </div>
288
+ </footer>
289
  </div>
290
  );
291
  }
web/src/styles/index.css CHANGED
@@ -4,31 +4,113 @@
4
 
5
  @layer base {
6
  :root {
7
- --background: 0 0% 100%;
8
- --foreground: 240 10% 3.9%;
9
- --muted: 240 4.8% 95.9%;
10
- --muted-foreground: 240 3.8% 46.1%;
11
- --border: 240 5.9% 90%;
12
- --ring: 240 5% 64.9%;
13
- --primary: 250 76% 62%;
14
- --primary-foreground: 0 0% 100%;
15
- --accent: 268 92% 70%;
16
- --accent-foreground: 0 0% 100%;
17
- --radius: 0.75rem;
 
 
 
 
18
  }
19
  .dark {
20
- --background: 240 10% 4%;
21
- --foreground: 0 0% 98%;
22
- --muted: 240 4% 12%;
23
- --muted-foreground: 240 5% 65%;
24
- --border: 240 6% 18%;
25
- --ring: 250 76% 62%;
26
- --primary: 250 76% 62%;
27
- --primary-foreground: 0 0% 100%;
28
- --accent: 268 92% 70%;
29
- --accent-foreground: 0 0% 100%;
 
 
 
 
30
  }
31
- body {
32
  @apply bg-background text-foreground antialiased;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  }
34
  }
 
4
 
5
  @layer base {
6
  :root {
7
+ /* parchment / warm light (unused but kept for future toggle) */
8
+ --background: 36 30% 96%;
9
+ --foreground: 25 30% 12%;
10
+ --muted: 36 22% 92%;
11
+ --muted-foreground: 25 15% 35%;
12
+ --border: 32 18% 82%;
13
+ --ring: 28 78% 52%;
14
+ --paper: 36 36% 98%;
15
+ --ink: 25 32% 10%;
16
+ --primary: 28 78% 52%;
17
+ --primary-foreground: 36 36% 98%;
18
+ --accent: 18 58% 38%;
19
+ --accent-foreground: 36 36% 98%;
20
+ --ember: 28 92% 60%;
21
+ --radius: 0.4rem;
22
  }
23
  .dark {
24
+ /* warm near-black, parchment text, single ember accent */
25
+ --background: 28 14% 7%;
26
+ --foreground: 38 28% 92%;
27
+ --muted: 28 10% 12%;
28
+ --muted-foreground: 35 14% 64%;
29
+ --border: 28 10% 18%;
30
+ --ring: 28 92% 60%;
31
+ --paper: 28 16% 9%;
32
+ --ink: 38 28% 92%;
33
+ --primary: 28 92% 60%;
34
+ --primary-foreground: 28 14% 7%;
35
+ --accent: 18 58% 48%;
36
+ --accent-foreground: 38 28% 92%;
37
+ --ember: 28 92% 60%;
38
  }
39
+ html, body {
40
  @apply bg-background text-foreground antialiased;
41
+ font-family: theme("fontFamily.sans");
42
+ font-feature-settings: "ss01", "cv11";
43
+ }
44
+ body {
45
+ background-image:
46
+ radial-gradient(ellipse 90% 60% at 100% 0%, hsl(28 70% 30% / 0.10), transparent 60%),
47
+ radial-gradient(ellipse 80% 50% at 0% 100%, hsl(18 50% 24% / 0.10), transparent 60%);
48
+ background-attachment: fixed;
49
+ }
50
+ /* subtle grain via CSS */
51
+ body::before {
52
+ content: "";
53
+ position: fixed;
54
+ inset: 0;
55
+ pointer-events: none;
56
+ z-index: 0;
57
+ opacity: 0.06;
58
+ mix-blend-mode: overlay;
59
+ background-image:
60
+ url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.55'/></svg>");
61
+ }
62
+ ::selection {
63
+ background: hsl(var(--ember) / 0.35);
64
+ color: hsl(var(--foreground));
65
+ }
66
+ }
67
+
68
+ @layer components {
69
+ .label-mono {
70
+ @apply font-mono text-[10.5px] tracking-widest2 uppercase text-muted-foreground;
71
+ }
72
+ .display-serif {
73
+ font-family: theme("fontFamily.display");
74
+ font-optical-sizing: auto;
75
+ font-variation-settings: "opsz" 144;
76
+ letter-spacing: -0.015em;
77
+ }
78
+ .rule-dotted {
79
+ background-image: radial-gradient(circle, hsl(var(--border)) 1px, transparent 1.4px);
80
+ background-size: 6px 1px;
81
+ background-repeat: repeat-x;
82
+ height: 1px;
83
+ }
84
+ .ember-ring:focus-visible {
85
+ outline: none;
86
+ box-shadow: 0 0 0 1px hsl(var(--background)), 0 0 0 3px hsl(var(--ember) / 0.7);
87
+ }
88
+ .btn-primary {
89
+ @apply font-mono uppercase tracking-widest2 text-[12px] py-3 px-4 rounded-sm
90
+ bg-primary text-primary-foreground transition-colors duration-200
91
+ hover:bg-[hsl(var(--ember))] disabled:opacity-40 disabled:cursor-not-allowed;
92
+ }
93
+ .btn-ghost {
94
+ @apply font-mono uppercase tracking-widest2 text-[11px] py-2 px-3 rounded-sm
95
+ border border-border text-muted-foreground transition-colors duration-200
96
+ hover:text-foreground hover:border-foreground/40;
97
+ }
98
+ .field-input {
99
+ @apply w-full rounded-sm border border-border bg-paper/60 px-3 py-2 text-sm
100
+ placeholder:text-muted-foreground focus:border-[hsl(var(--ember))]/60
101
+ focus:outline-none transition-colors;
102
+ }
103
+ .card-paper {
104
+ @apply rounded-sm border border-border bg-paper/40 backdrop-blur-[1px];
105
+ }
106
+ .marker-num {
107
+ @apply font-mono text-[11px] text-[hsl(var(--ember))] tracking-widest2;
108
+ }
109
+ }
110
+
111
+ @layer utilities {
112
+ .relative-z {
113
+ position: relative;
114
+ z-index: 1;
115
  }
116
  }
web/tailwind.config.ts CHANGED
@@ -12,6 +12,8 @@ const config: Config = {
12
  "muted-foreground": "hsl(var(--muted-foreground))",
13
  border: "hsl(var(--border))",
14
  ring: "hsl(var(--ring))",
 
 
15
  primary: {
16
  DEFAULT: "hsl(var(--primary))",
17
  foreground: "hsl(var(--primary-foreground))",
@@ -20,6 +22,7 @@ const config: Config = {
20
  DEFAULT: "hsl(var(--accent))",
21
  foreground: "hsl(var(--accent-foreground))",
22
  },
 
23
  },
24
  borderRadius: {
25
  lg: "var(--radius)",
@@ -27,7 +30,26 @@ const config: Config = {
27
  sm: "calc(var(--radius) - 4px)",
28
  },
29
  fontFamily: {
30
- sans: ["Inter", "ui-sans-serif", "system-ui"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  },
32
  },
33
  },
 
12
  "muted-foreground": "hsl(var(--muted-foreground))",
13
  border: "hsl(var(--border))",
14
  ring: "hsl(var(--ring))",
15
+ paper: "hsl(var(--paper))",
16
+ ink: "hsl(var(--ink))",
17
  primary: {
18
  DEFAULT: "hsl(var(--primary))",
19
  foreground: "hsl(var(--primary-foreground))",
 
22
  DEFAULT: "hsl(var(--accent))",
23
  foreground: "hsl(var(--accent-foreground))",
24
  },
25
+ ember: "hsl(var(--ember))",
26
  },
27
  borderRadius: {
28
  lg: "var(--radius)",
 
30
  sm: "calc(var(--radius) - 4px)",
31
  },
32
  fontFamily: {
33
+ display: ['"Fraunces"', "ui-serif", "Georgia", "serif"],
34
+ sans: ['"IBM Plex Sans"', "ui-sans-serif", "system-ui"],
35
+ mono: ['"IBM Plex Mono"', "ui-monospace", "SFMono-Regular", "monospace"],
36
+ },
37
+ letterSpacing: {
38
+ widest2: "0.18em",
39
+ },
40
+ keyframes: {
41
+ "fade-up": {
42
+ "0%": { opacity: "0", transform: "translateY(6px)" },
43
+ "100%": { opacity: "1", transform: "translateY(0)" },
44
+ },
45
+ pulse_dot: {
46
+ "0%,100%": { opacity: "1" },
47
+ "50%": { opacity: "0.4" },
48
+ },
49
+ },
50
+ animation: {
51
+ "fade-up": "fade-up 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both",
52
+ "pulse-dot": "pulse_dot 1.6s ease-in-out infinite",
53
  },
54
  },
55
  },