ACloudCenter Claude Fable 5 commited on
Commit
e550e2e
·
1 Parent(s): ca8dac6

Redesign the studio UI from the voice-studio prototype

Browse files

Adopt the Claude Design prototype's direction while keeping all real
functionality (SSE progress, voice cloning + consent, script import,
editable turns):

- Instrument Serif / Plus Jakarta Sans, warm cream palette, floating
cards instead of full-height panes
- Cast panel: labeled speaker-count pills, voice slot cards with
preview + Change, auto-fill of empty slots with unused voices
- Voice library modal: search, gender/tag filter chips, per-voice
slot assignment, clone-a-voice entry
- Model select becomes a Fast/Best quality toggle; CFG scale becomes
an Expressiveness slider (same backend range)
- Output dock becomes a player: custom play button, seekable waveform
with progress, and a synced transcript that highlights the current
line using word-count-proportional timing
- /api/voices now returns per-voice tags and color

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (4) hide show
  1. app.py +16 -5
  2. static/app.js +440 -85
  3. static/index.html +68 -35
  4. static/styles.css +461 -253
app.py CHANGED
@@ -29,10 +29,15 @@ MODAL_STUB_NAME = "vibevoice-generator"
29
  MODAL_CLASS_NAME = "VibeVoiceModel"
30
 
31
  AVAILABLE_MODELS = ["VibeVoice-1.5B", "VibeVoice-7B"]
32
- VOICE_GENDERS = {
33
- "Cherry": "F", "Chicago": "M", "Janus": "M",
34
- "Mantis": "F", "Sponge": "M", "Starchild": "F",
 
 
 
 
35
  }
 
36
  AVAILABLE_VOICES = list(VOICE_GENDERS.keys())
37
  DEFAULT_SPEAKERS = ["Cherry", "Chicago", "Janus", "Mantis"]
38
 
@@ -566,8 +571,14 @@ async def api_models() -> list[str]:
566
  @app.get("/api/voices")
567
  async def api_voices() -> list[dict]:
568
  return [
569
- {"name": name, "gender": VOICE_GENDERS[name], "preview_url": f"/public/voices/{name}.mp3"}
570
- for name in AVAILABLE_VOICES
 
 
 
 
 
 
571
  ]
572
 
573
 
 
29
  MODAL_CLASS_NAME = "VibeVoiceModel"
30
 
31
  AVAILABLE_MODELS = ["VibeVoice-1.5B", "VibeVoice-7B"]
32
+ VOICE_INFO = {
33
+ "Cherry": {"gender": "F", "tags": ["Warm", "Storyteller"], "color": "#E2582A"},
34
+ "Chicago": {"gender": "M", "tags": ["Deep", "Narrator"], "color": "#2F6F63"},
35
+ "Janus": {"gender": "M", "tags": ["Bright", "Conversational"], "color": "#CC8A2E"},
36
+ "Mantis": {"gender": "F", "tags": ["Crisp", "Energetic"], "color": "#7B4B94"},
37
+ "Sponge": {"gender": "M", "tags": ["Playful", "Animated"], "color": "#3A7CA5"},
38
+ "Starchild": {"gender": "F", "tags": ["Airy", "Dreamy"], "color": "#B6558C"},
39
  }
40
+ VOICE_GENDERS = {name: info["gender"] for name, info in VOICE_INFO.items()}
41
  AVAILABLE_VOICES = list(VOICE_GENDERS.keys())
42
  DEFAULT_SPEAKERS = ["Cherry", "Chicago", "Janus", "Mantis"]
43
 
 
571
  @app.get("/api/voices")
572
  async def api_voices() -> list[dict]:
573
  return [
574
+ {
575
+ "name": name,
576
+ "gender": info["gender"],
577
+ "tags": info["tags"],
578
+ "color": info["color"],
579
+ "preview_url": f"/public/voices/{name}.mp3",
580
+ }
581
+ for name, info in VOICE_INFO.items()
582
  ]
583
 
584
 
static/app.js CHANGED
@@ -24,6 +24,12 @@ const PRIMARY_STAGE_MESSAGES = {
24
  error: ["Error", "Check the log for details."],
25
  };
26
 
 
 
 
 
 
 
27
  const state = {
28
  turns: [],
29
  numSpeakers: 2,
@@ -31,25 +37,33 @@ const state = {
31
  voiceSelections: [null, null, null, null],
32
  customVoiceFiles: [null, null, null, null],
33
  models: [],
 
34
  examples: [],
35
  parodyLines: [],
36
  parodyIndex: 0,
37
  previewAudio: new Audio(),
38
  playingVoice: null,
 
 
 
 
 
39
  };
40
 
41
  const el = {};
42
  [
43
- "runtimeStatus", "runtimeLabel", "aboutBtn", "aboutDialog", "closeAboutBtn",
44
- "modelSelect", "speakerStepper", "voiceRows", "cfgScale", "cfgScaleValue",
45
  "voiceConsentRow", "voiceConsentCheckbox",
46
  "scriptPrompt", "durationSelect", "generateScriptBtn", "examplePills", "openImportBtn", "scriptGenStatus",
47
  "scriptTitle", "scriptDuration", "turnsList", "addTurnBtn",
48
  "generateBarMeta", "generateBtn",
49
  "statusCard", "statusTitle", "statusDesc",
50
  "dockEmpty", "resultBlock", "resultWaveform", "resultAudio",
 
51
  "generationTime", "audioDuration", "resultModel", "downloadBtn",
52
  "logToggleBtn", "logBox",
 
53
  "importDialog", "pastedScript", "scriptFileUpload", "cancelImportBtn", "loadScriptBtn",
54
  ].forEach((id) => { el[id] = document.getElementById(id); });
55
 
@@ -58,9 +72,27 @@ function autoGrow(textarea) {
58
  textarea.style.height = `${textarea.scrollHeight}px`;
59
  }
60
 
61
- function genderOf(name) {
62
- const v = state.voices.find((x) => x.name === name);
63
- return v ? v.gender : "?";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
65
 
66
  function estimateDuration(turns) {
@@ -75,16 +107,32 @@ function formatDuration(seconds) {
75
  return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
76
  }
77
 
78
- /* ---------------- About dialog ---------------- */
 
 
 
 
 
 
 
79
  el.aboutBtn.addEventListener("click", () => el.aboutDialog.showModal());
80
  el.closeAboutBtn.addEventListener("click", () => el.aboutDialog.close());
81
  el.aboutDialog.addEventListener("click", (e) => { if (e.target === el.aboutDialog) el.aboutDialog.close(); });
82
 
83
- /* ---------------- Import dialog ---------------- */
84
  el.openImportBtn.addEventListener("click", () => el.importDialog.showModal());
85
  el.cancelImportBtn.addEventListener("click", () => el.importDialog.close());
86
  el.importDialog.addEventListener("click", (e) => { if (e.target === el.importDialog) el.importDialog.close(); });
87
 
 
 
 
 
 
 
 
 
 
 
88
  /* ---------------- Status polling ---------------- */
89
  async function updateStatus() {
90
  try {
@@ -100,90 +148,112 @@ async function updateStatus() {
100
  }
101
 
102
  /* ---------------- Voice preview ---------------- */
103
- function playVoicePreview(name, button) {
 
 
 
 
 
 
 
 
 
 
 
104
  if (state.playingVoice === name) {
105
  state.previewAudio.pause();
 
 
106
  return;
107
  }
108
- const voice = state.voices.find((v) => v.name === name);
109
  if (!voice) return;
110
  state.previewAudio.src = voice.preview_url;
111
  state.playingVoice = name;
112
- document.querySelectorAll(".voice-play").forEach((b) => b.classList.toggle("playing", b === button));
113
  state.previewAudio.play().catch((err) => {
114
  state.playingVoice = null;
115
- document.querySelectorAll(".voice-play").forEach((b) => b.classList.remove("playing"));
116
  console.error("Voice preview failed to play:", err);
117
  });
118
  }
119
  state.previewAudio.addEventListener("ended", () => {
120
  state.playingVoice = null;
121
- document.querySelectorAll(".voice-play").forEach((b) => b.classList.remove("playing"));
122
  });
123
 
124
- /* ---------------- Sidebar: model / speakers / voices ---------------- */
125
- const CUSTOM_VOICE_VALUE = "__custom__";
126
- const MAX_CUSTOM_AUDIO_BYTES = 15 * 1024 * 1024;
127
-
128
- function isCustomVoice(i) {
129
- return state.voiceSelections[i] === CUSTOM_VOICE_VALUE;
130
- }
131
-
132
- function anyCustomVoiceActive() {
133
- return Array.from({ length: state.numSpeakers }, (_, i) => i).some(isCustomVoice);
134
- }
135
-
136
  function updateVoiceConsentVisibility() {
137
  el.voiceConsentRow.hidden = !anyCustomVoiceActive();
138
  }
139
 
140
- function renderSidebar() {
141
- el.modelSelect.innerHTML = state.models.map((m) => `<option value="${m}">${m}</option>`).join("");
 
 
 
 
 
 
 
142
 
 
 
143
  el.speakerStepper.querySelectorAll("button").forEach((btn) => {
144
  btn.classList.toggle("active", Number(btn.dataset.count) === state.numSpeakers);
145
  });
146
 
147
  el.voiceRows.innerHTML = "";
148
  for (let i = 0; i < state.numSpeakers; i += 1) {
149
- const row = document.createElement("div");
150
- row.className = "voice-row";
151
 
152
  const dot = document.createElement("span");
153
  dot.className = "voice-dot";
154
- dot.style.background = `var(--speaker-${i + 1})`;
155
-
156
- const select = document.createElement("select");
157
- select.innerHTML =
158
- `<option value="${CUSTOM_VOICE_VALUE}">🎙️ Clone a voice…</option>` +
159
- state.voices.map((v) => `<option value="${v.name}">${v.name} (${v.gender})</option>`).join("");
160
- if (state.voiceSelections[i]) select.value = state.voiceSelections[i];
161
-
162
- const playBtn = document.createElement("button");
163
- playBtn.type = "button";
164
- playBtn.className = "voice-play";
165
- playBtn.textContent = "▶";
166
- playBtn.title = "Preview voice";
167
- playBtn.addEventListener("click", () => playVoicePreview(select.value, playBtn));
168
-
169
- select.addEventListener("change", () => {
170
- state.voiceSelections[i] = select.value;
171
- if (select.value !== CUSTOM_VOICE_VALUE) state.customVoiceFiles[i] = null;
 
 
 
 
 
172
  playBtn.textContent = "▶";
173
- renderSidebar();
174
- renderTurns();
175
- });
 
 
 
 
 
 
 
 
176
 
177
- row.append(dot, select, playBtn);
178
- el.voiceRows.append(row);
179
 
180
  if (isCustomVoice(i)) {
181
- playBtn.hidden = true;
182
  const uploadRow = document.createElement("div");
183
  uploadRow.className = "custom-voice-row";
184
 
185
  const fileLabel = document.createElement("label");
186
- fileLabel.className = "btn btn-sm upload-mini-btn";
187
  fileLabel.textContent = state.customVoiceFiles[i] ? state.customVoiceFiles[i].name : "Choose audio file…";
188
  const fileInput = document.createElement("input");
189
  fileInput.type = "file";
@@ -198,7 +268,7 @@ function renderSidebar() {
198
  return;
199
  }
200
  state.customVoiceFiles[i] = file;
201
- renderSidebar();
202
  });
203
  fileLabel.append(fileInput);
204
  uploadRow.append(fileLabel);
@@ -206,12 +276,12 @@ function renderSidebar() {
206
  if (state.customVoiceFiles[i]) {
207
  const clearBtn = document.createElement("button");
208
  clearBtn.type = "button";
209
- clearBtn.className = "btn btn-sm btn-icon-only";
210
  clearBtn.textContent = "✕";
211
  clearBtn.title = "Remove file";
212
  clearBtn.addEventListener("click", () => {
213
  state.customVoiceFiles[i] = null;
214
- renderSidebar();
215
  });
216
  uploadRow.append(clearBtn);
217
  }
@@ -221,24 +291,199 @@ function renderSidebar() {
221
  }
222
 
223
  updateVoiceConsentVisibility();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  }
225
 
226
  el.speakerStepper.querySelectorAll("button").forEach((btn) => {
227
  btn.addEventListener("click", () => {
228
  state.numSpeakers = Number(btn.dataset.count);
229
- renderSidebar();
230
  });
231
  });
232
 
233
- el.cfgScale.addEventListener("input", () => {
234
- el.cfgScaleValue.textContent = Number(el.cfgScale.value).toFixed(2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  });
236
 
237
  /* ---------------- Turn editor ---------------- */
238
  function speakerChoiceLabel(i) {
239
  const sel = state.voiceSelections[i];
240
  if (sel === CUSTOM_VOICE_VALUE) return `Speaker ${i + 1} · Custom voice`;
241
- return sel ? `Speaker ${i + 1} · ${sel} (${genderOf(sel)})` : `Speaker ${i + 1}`;
242
  }
243
 
244
  function renderTurns() {
@@ -247,7 +492,9 @@ function renderTurns() {
247
  const empty = document.createElement("div");
248
  empty.className = "empty-transcript";
249
  empty.id = "emptyTurns";
250
- empty.innerHTML = "Nothing here yet. Type a scenario above and click <strong>Write with AI</strong>, or start typing your own line below.";
 
 
251
  el.turnsList.append(empty);
252
  updateMeta();
253
  return;
@@ -257,7 +504,7 @@ function renderTurns() {
257
  const spk = Math.min(4, Math.max(1, turn.speaker || 1));
258
  const card = document.createElement("div");
259
  card.className = "turn-card";
260
- card.dataset.speaker = String(spk);
261
 
262
  const head = document.createElement("div");
263
  head.className = "turn-head";
@@ -333,7 +580,7 @@ function loadScriptResult(result, titleFallback) {
333
  while (voices.length < 4) voices.push(null);
334
  state.voiceSelections = voices;
335
  el.scriptTitle.textContent = result.title || titleFallback || "Untitled conversation";
336
- renderSidebar();
337
  renderTurns();
338
  el.dockEmpty.hidden = false;
339
  el.resultBlock.classList.remove("visible");
@@ -419,18 +666,15 @@ el.generateScriptBtn.addEventListener("click", async () => {
419
  }
420
  });
421
 
422
- /* ---------------- Waveform ---------------- */
423
- async function drawWaveform(url, canvas, color = "#b5502e") {
424
  const response = await fetch(url);
425
- if (!response.ok) return;
426
  const data = await response.arrayBuffer();
427
  const context = new AudioContext();
428
  try {
429
  const buffer = await context.decodeAudioData(data.slice(0));
430
  const samples = buffer.getChannelData(0);
431
- const width = canvas.width;
432
- const height = canvas.height;
433
- const blocks = Math.min(110, Math.max(30, Math.floor(width / 6)));
434
  const blockSize = Math.max(1, Math.floor(samples.length / blocks));
435
  const peaks = [];
436
  for (let block = 0; block < blocks; block += 1) {
@@ -441,21 +685,118 @@ async function drawWaveform(url, canvas, color = "#b5502e") {
441
  peaks.push(peak);
442
  }
443
  const maxPeak = Math.max(...peaks, 0.001);
444
- const draw = canvas.getContext("2d");
445
- draw.clearRect(0, 0, width, height);
446
- draw.fillStyle = color;
447
- const barWidth = Math.max(2, width / blocks - 2);
448
- peaks.forEach((peak, i) => {
449
- const normalized = peak / maxPeak;
450
- const barHeight = Math.max(3, normalized * height * 0.9);
451
- const x = i * (width / blocks) + 1;
452
- draw.fillRect(x, (height - barHeight) / 2, barWidth, barHeight);
453
- });
454
  } finally {
455
  await context.close();
456
  }
457
  }
458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  /* ---------------- Generation status ---------------- */
460
  function nextParodyLine() {
461
  if (!state.parodyLines.length) return null;
@@ -507,10 +848,15 @@ el.generateBtn.addEventListener("click", async () => {
507
  return;
508
  }
509
 
 
 
 
 
510
  el.generateBtn.disabled = true;
511
  el.generateBtn.textContent = "Generating...";
512
  el.resultBlock.classList.remove("visible");
513
- el.dockEmpty.hidden = false;
 
514
  el.logBox.textContent = "";
515
  el.logBox.classList.remove("visible");
516
  el.logToggleBtn.hidden = true;
@@ -535,7 +881,7 @@ el.generateBtn.addEventListener("click", async () => {
535
  }
536
 
537
  const payload = {
538
- model: el.modelSelect.value,
539
  num_speakers: state.numSpeakers,
540
  turns: state.turns,
541
  speakers: state.voiceSelections.map((v) => (v === CUSTOM_VOICE_VALUE ? null : v)),
@@ -587,15 +933,20 @@ el.generateBtn.addEventListener("click", async () => {
587
  el.downloadBtn.href = url;
588
  el.generationTime.textContent = formatDuration((performance.now() - started) / 1000);
589
  el.audioDuration.textContent = formatDuration(evt.audio_duration);
590
- el.resultModel.textContent = el.modelSelect.value;
591
- await drawWaveform(url, el.resultWaveform);
 
 
592
  el.dockEmpty.hidden = true;
593
  el.resultBlock.classList.add("visible");
 
 
594
  }
595
  }
596
  }
597
  } catch (error) {
598
  setStatus("error", error.message);
 
599
  } finally {
600
  el.generateBtn.disabled = false;
601
  el.generateBtn.textContent = "Generate Audio";
@@ -611,16 +962,20 @@ async function init() {
611
  fetch("/api/duration-options").then((r) => r.json()),
612
  ]);
613
  state.models = models;
 
614
  state.voices = voices;
615
  state.examples = examples;
616
  state.voiceSelections = voices.slice(0, 4).map((v) => v.name);
617
  while (state.voiceSelections.length < 4) state.voiceSelections.push(null);
618
 
619
- el.durationSelect.innerHTML = durationOptions.map((m) => `<option value="${m}">${m} min</option>`).join("");
 
 
620
  const defaultDuration = durationOptions.includes(2) ? 2 : durationOptions[0];
621
  el.durationSelect.value = String(defaultDuration);
622
 
623
- renderSidebar();
 
624
  renderTurns();
625
  renderExamplePills();
626
  updateStatus();
 
24
  error: ["Error", "Check the log for details."],
25
  };
26
 
27
+ const QUALITY_LABELS = { "VibeVoice-1.5B": "Fast", "VibeVoice-7B": "Best" };
28
+ const GENDER_LABELS = { F: "Feminine", M: "Masculine" };
29
+ const SPEAKER_FALLBACK_COLORS = ["#e2582a", "#2f6f63", "#cc8a2e", "#7b4b94"];
30
+ const CUSTOM_VOICE_VALUE = "__custom__";
31
+ const MAX_CUSTOM_AUDIO_BYTES = 15 * 1024 * 1024;
32
+
33
  const state = {
34
  turns: [],
35
  numSpeakers: 2,
 
37
  voiceSelections: [null, null, null, null],
38
  customVoiceFiles: [null, null, null, null],
39
  models: [],
40
+ model: null,
41
  examples: [],
42
  parodyLines: [],
43
  parodyIndex: 0,
44
  previewAudio: new Audio(),
45
  playingVoice: null,
46
+ librarySearch: "",
47
+ libraryFilter: "all",
48
+ resultTurns: [], // snapshot of turns for the synced transcript
49
+ wavePeaks: null,
50
+ activeSyncIndex: -1,
51
  };
52
 
53
  const el = {};
54
  [
55
+ "runtimeStatus", "runtimeLabel", "browseVoicesBtn", "aboutBtn", "aboutDialog", "closeAboutBtn",
56
+ "speakerStepper", "voiceRows", "qualityPills", "cfgScale", "cfgScaleValue",
57
  "voiceConsentRow", "voiceConsentCheckbox",
58
  "scriptPrompt", "durationSelect", "generateScriptBtn", "examplePills", "openImportBtn", "scriptGenStatus",
59
  "scriptTitle", "scriptDuration", "turnsList", "addTurnBtn",
60
  "generateBarMeta", "generateBtn",
61
  "statusCard", "statusTitle", "statusDesc",
62
  "dockEmpty", "resultBlock", "resultWaveform", "resultAudio",
63
+ "playBtn", "playerTime", "syncedTranscript",
64
  "generationTime", "audioDuration", "resultModel", "downloadBtn",
65
  "logToggleBtn", "logBox",
66
+ "voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid",
67
  "importDialog", "pastedScript", "scriptFileUpload", "cancelImportBtn", "loadScriptBtn",
68
  ].forEach((id) => { el[id] = document.getElementById(id); });
69
 
 
72
  textarea.style.height = `${textarea.scrollHeight}px`;
73
  }
74
 
75
+ function voiceByName(name) {
76
+ return state.voices.find((v) => v.name === name) || null;
77
+ }
78
+
79
+ function isCustomVoice(i) {
80
+ return state.voiceSelections[i] === CUSTOM_VOICE_VALUE;
81
+ }
82
+
83
+ function anyCustomVoiceActive() {
84
+ return Array.from({ length: state.numSpeakers }, (_, i) => i).some(isCustomVoice);
85
+ }
86
+
87
+ function slotColor(i) {
88
+ if (isCustomVoice(i)) return SPEAKER_FALLBACK_COLORS[i];
89
+ const voice = voiceByName(state.voiceSelections[i]);
90
+ return voice ? voice.color : SPEAKER_FALLBACK_COLORS[i];
91
+ }
92
+
93
+ function slotVoiceLabel(i) {
94
+ if (isCustomVoice(i)) return "Custom voice";
95
+ return state.voiceSelections[i] || `Voice ${i + 1}`;
96
  }
97
 
98
  function estimateDuration(turns) {
 
107
  return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
108
  }
109
 
110
+ function formatClock(seconds) {
111
+ if (!Number.isFinite(seconds)) return "0:00";
112
+ const m = Math.floor(seconds / 60);
113
+ const s = Math.floor(seconds % 60);
114
+ return `${m}:${String(s).padStart(2, "0")}`;
115
+ }
116
+
117
+ /* ---------------- Dialogs ---------------- */
118
  el.aboutBtn.addEventListener("click", () => el.aboutDialog.showModal());
119
  el.closeAboutBtn.addEventListener("click", () => el.aboutDialog.close());
120
  el.aboutDialog.addEventListener("click", (e) => { if (e.target === el.aboutDialog) el.aboutDialog.close(); });
121
 
 
122
  el.openImportBtn.addEventListener("click", () => el.importDialog.showModal());
123
  el.cancelImportBtn.addEventListener("click", () => el.importDialog.close());
124
  el.importDialog.addEventListener("click", (e) => { if (e.target === el.importDialog) el.importDialog.close(); });
125
 
126
+ function openLibrary() {
127
+ renderLibrary();
128
+ el.voiceLibraryDialog.showModal();
129
+ }
130
+ el.browseVoicesBtn.addEventListener("click", openLibrary);
131
+ el.closeLibraryBtn.addEventListener("click", () => el.voiceLibraryDialog.close());
132
+ el.voiceLibraryDialog.addEventListener("click", (e) => {
133
+ if (e.target === el.voiceLibraryDialog) el.voiceLibraryDialog.close();
134
+ });
135
+
136
  /* ---------------- Status polling ---------------- */
137
  async function updateStatus() {
138
  try {
 
148
  }
149
 
150
  /* ---------------- Voice preview ---------------- */
151
+ function refreshPreviewButtons() {
152
+ document.querySelectorAll(".voice-play").forEach((b) => {
153
+ const playing = b.dataset.voice === state.playingVoice;
154
+ b.classList.toggle("playing", playing);
155
+ b.textContent = playing ? "❚❚" : "▶";
156
+ });
157
+ document.querySelectorAll(".voice-preview-link").forEach((b) => {
158
+ b.textContent = b.dataset.voice === state.playingVoice ? "Playing..." : "Preview";
159
+ });
160
+ }
161
+
162
+ function playVoicePreview(name) {
163
  if (state.playingVoice === name) {
164
  state.previewAudio.pause();
165
+ state.playingVoice = null;
166
+ refreshPreviewButtons();
167
  return;
168
  }
169
+ const voice = voiceByName(name);
170
  if (!voice) return;
171
  state.previewAudio.src = voice.preview_url;
172
  state.playingVoice = name;
173
+ refreshPreviewButtons();
174
  state.previewAudio.play().catch((err) => {
175
  state.playingVoice = null;
176
+ refreshPreviewButtons();
177
  console.error("Voice preview failed to play:", err);
178
  });
179
  }
180
  state.previewAudio.addEventListener("ended", () => {
181
  state.playingVoice = null;
182
+ refreshPreviewButtons();
183
  });
184
 
185
+ /* ---------------- Sidebar: cast / quality / expressiveness ---------------- */
 
 
 
 
 
 
 
 
 
 
 
186
  function updateVoiceConsentVisibility() {
187
  el.voiceConsentRow.hidden = !anyCustomVoiceActive();
188
  }
189
 
190
+ function fillEmptySlots() {
191
+ if (!state.voices.length) return;
192
+ for (let i = 0; i < state.numSpeakers; i += 1) {
193
+ if (state.voiceSelections[i]) continue;
194
+ const used = new Set(state.voiceSelections.filter(Boolean));
195
+ const pick = state.voices.find((v) => !used.has(v.name)) || state.voices[i % state.voices.length];
196
+ state.voiceSelections[i] = pick.name;
197
+ }
198
+ }
199
 
200
+ function renderCast() {
201
+ fillEmptySlots();
202
  el.speakerStepper.querySelectorAll("button").forEach((btn) => {
203
  btn.classList.toggle("active", Number(btn.dataset.count) === state.numSpeakers);
204
  });
205
 
206
  el.voiceRows.innerHTML = "";
207
  for (let i = 0; i < state.numSpeakers; i += 1) {
208
+ const card = document.createElement("div");
209
+ card.className = "slot-card";
210
 
211
  const dot = document.createElement("span");
212
  dot.className = "voice-dot";
213
+ dot.style.background = slotColor(i);
214
+
215
+ const info = document.createElement("div");
216
+ info.className = "slot-info";
217
+ const name = document.createElement("div");
218
+ name.className = "slot-name";
219
+ name.textContent = slotVoiceLabel(i);
220
+ const meta = document.createElement("div");
221
+ meta.className = "slot-meta";
222
+ if (isCustomVoice(i)) {
223
+ meta.textContent = state.customVoiceFiles[i] ? state.customVoiceFiles[i].name : "Upload a clip below";
224
+ } else {
225
+ const voice = voiceByName(state.voiceSelections[i]);
226
+ meta.textContent = voice ? [GENDER_LABELS[voice.gender], ...(voice.tags || [])].join(" · ") : "";
227
+ }
228
+ info.append(name, meta);
229
+ card.append(dot, info);
230
+
231
+ if (!isCustomVoice(i) && state.voiceSelections[i]) {
232
+ const playBtn = document.createElement("button");
233
+ playBtn.type = "button";
234
+ playBtn.className = "voice-play";
235
+ playBtn.dataset.voice = state.voiceSelections[i];
236
  playBtn.textContent = "▶";
237
+ playBtn.title = "Preview voice";
238
+ playBtn.addEventListener("click", () => playVoicePreview(playBtn.dataset.voice));
239
+ card.append(playBtn);
240
+ }
241
+
242
+ const changeBtn = document.createElement("button");
243
+ changeBtn.type = "button";
244
+ changeBtn.className = "slot-change";
245
+ changeBtn.textContent = "Change";
246
+ changeBtn.addEventListener("click", openLibrary);
247
+ card.append(changeBtn);
248
 
249
+ el.voiceRows.append(card);
 
250
 
251
  if (isCustomVoice(i)) {
 
252
  const uploadRow = document.createElement("div");
253
  uploadRow.className = "custom-voice-row";
254
 
255
  const fileLabel = document.createElement("label");
256
+ fileLabel.className = "btn upload-mini-btn";
257
  fileLabel.textContent = state.customVoiceFiles[i] ? state.customVoiceFiles[i].name : "Choose audio file…";
258
  const fileInput = document.createElement("input");
259
  fileInput.type = "file";
 
268
  return;
269
  }
270
  state.customVoiceFiles[i] = file;
271
+ renderCast();
272
  });
273
  fileLabel.append(fileInput);
274
  uploadRow.append(fileLabel);
 
276
  if (state.customVoiceFiles[i]) {
277
  const clearBtn = document.createElement("button");
278
  clearBtn.type = "button";
279
+ clearBtn.className = "btn btn-icon-only";
280
  clearBtn.textContent = "✕";
281
  clearBtn.title = "Remove file";
282
  clearBtn.addEventListener("click", () => {
283
  state.customVoiceFiles[i] = null;
284
+ renderCast();
285
  });
286
  uploadRow.append(clearBtn);
287
  }
 
291
  }
292
 
293
  updateVoiceConsentVisibility();
294
+ refreshPreviewButtons();
295
+ }
296
+
297
+ function renderQuality() {
298
+ el.qualityPills.innerHTML = "";
299
+ state.models.forEach((model) => {
300
+ const btn = document.createElement("button");
301
+ btn.type = "button";
302
+ btn.className = "single-line";
303
+ btn.title = model;
304
+ const label = document.createElement("span");
305
+ label.textContent = QUALITY_LABELS[model] || model;
306
+ btn.append(label);
307
+ btn.classList.toggle("active", model === state.model);
308
+ btn.addEventListener("click", () => {
309
+ state.model = model;
310
+ renderQuality();
311
+ });
312
+ el.qualityPills.append(btn);
313
+ });
314
  }
315
 
316
  el.speakerStepper.querySelectorAll("button").forEach((btn) => {
317
  btn.addEventListener("click", () => {
318
  state.numSpeakers = Number(btn.dataset.count);
319
+ renderCast();
320
  });
321
  });
322
 
323
+ function expressivenessWord(value) {
324
+ if (value < 1.35) return "Calm";
325
+ if (value < 1.7) return "Balanced";
326
+ return "Dynamic";
327
+ }
328
+
329
+ function updateCfgLabel() {
330
+ const value = Number(el.cfgScale.value);
331
+ el.cfgScaleValue.textContent = `${value.toFixed(2)} · ${expressivenessWord(value)}`;
332
+ }
333
+ el.cfgScale.addEventListener("input", updateCfgLabel);
334
+ updateCfgLabel();
335
+
336
+ /* ---------------- Voice library ---------------- */
337
+ function libraryFilterOptions() {
338
+ const tags = Array.from(new Set(state.voices.flatMap((v) => v.tags || [])));
339
+ return [
340
+ { key: "all", label: "All" },
341
+ { key: "F", label: "Feminine" },
342
+ { key: "M", label: "Masculine" },
343
+ ...tags.map((t) => ({ key: t, label: t })),
344
+ ];
345
+ }
346
+
347
+ function renderLibraryFilters() {
348
+ el.libraryFilters.innerHTML = "";
349
+ libraryFilterOptions().forEach((opt) => {
350
+ const chip = document.createElement("button");
351
+ chip.type = "button";
352
+ chip.className = "filter-chip";
353
+ chip.textContent = opt.label;
354
+ chip.classList.toggle("active", state.libraryFilter === opt.key);
355
+ chip.addEventListener("click", () => {
356
+ state.libraryFilter = opt.key;
357
+ renderLibraryFilters();
358
+ renderLibraryGrid();
359
+ });
360
+ el.libraryFilters.append(chip);
361
+ });
362
+ }
363
+
364
+ function makeSlotAssignRow(isAssigned, assignedColor, onAssign) {
365
+ const row = document.createElement("div");
366
+ row.className = "slot-assign";
367
+ const label = document.createElement("span");
368
+ label.className = "slot-assign-label";
369
+ label.textContent = "Assign to";
370
+ row.append(label);
371
+ for (let i = 0; i < state.numSpeakers; i += 1) {
372
+ const btn = document.createElement("button");
373
+ btn.type = "button";
374
+ btn.textContent = String(i + 1);
375
+ if (isAssigned(i)) {
376
+ btn.classList.add("assigned");
377
+ btn.style.background = assignedColor(i);
378
+ }
379
+ btn.addEventListener("click", () => onAssign(i));
380
+ row.append(btn);
381
+ }
382
+ return row;
383
+ }
384
+
385
+ function assignVoiceToSlot(i, value) {
386
+ state.voiceSelections[i] = value;
387
+ if (value !== CUSTOM_VOICE_VALUE) state.customVoiceFiles[i] = null;
388
+ renderCast();
389
+ renderTurns();
390
+ renderLibraryGrid();
391
+ }
392
+
393
+ function renderLibraryGrid() {
394
+ el.libraryGrid.innerHTML = "";
395
+ const q = state.librarySearch.trim().toLowerCase();
396
+ const filtered = state.voices.filter((v) => {
397
+ const matchesSearch = !q || v.name.toLowerCase().includes(q);
398
+ const matchesFilter =
399
+ state.libraryFilter === "all" ||
400
+ v.gender === state.libraryFilter ||
401
+ (v.tags || []).includes(state.libraryFilter);
402
+ return matchesSearch && matchesFilter;
403
+ });
404
+
405
+ filtered.forEach((voice) => {
406
+ const card = document.createElement("div");
407
+ card.className = "voice-card";
408
+
409
+ const avatar = document.createElement("span");
410
+ avatar.className = "voice-avatar";
411
+ avatar.style.background = voice.color;
412
+ avatar.textContent = voice.name[0];
413
+
414
+ const body = document.createElement("div");
415
+ body.className = "voice-card-body";
416
+
417
+ const head = document.createElement("div");
418
+ head.className = "voice-card-head";
419
+ const name = document.createElement("span");
420
+ name.className = "voice-card-name";
421
+ name.textContent = voice.name;
422
+ const preview = document.createElement("button");
423
+ preview.type = "button";
424
+ preview.className = "voice-preview-link";
425
+ preview.dataset.voice = voice.name;
426
+ preview.textContent = "Preview";
427
+ preview.addEventListener("click", () => playVoicePreview(voice.name));
428
+ head.append(name, preview);
429
+
430
+ const meta = document.createElement("div");
431
+ meta.className = "voice-card-meta";
432
+ meta.textContent = [GENDER_LABELS[voice.gender], ...(voice.tags || [])].join(" · ");
433
+
434
+ body.append(head, meta, makeSlotAssignRow(
435
+ (i) => state.voiceSelections[i] === voice.name,
436
+ () => voice.color,
437
+ (i) => assignVoiceToSlot(i, voice.name),
438
+ ));
439
+ card.append(avatar, body);
440
+ el.libraryGrid.append(card);
441
+ });
442
+
443
+ // Clone-a-voice card, always available
444
+ const clone = document.createElement("div");
445
+ clone.className = "voice-card clone-card";
446
+ const cloneAvatar = document.createElement("span");
447
+ cloneAvatar.className = "voice-avatar";
448
+ cloneAvatar.textContent = "🎙️";
449
+ const cloneBody = document.createElement("div");
450
+ cloneBody.className = "voice-card-body";
451
+ const cloneHead = document.createElement("div");
452
+ cloneHead.className = "voice-card-head";
453
+ const cloneName = document.createElement("span");
454
+ cloneName.className = "voice-card-name";
455
+ cloneName.textContent = "Clone a voice";
456
+ cloneHead.append(cloneName);
457
+ const cloneMeta = document.createElement("div");
458
+ cloneMeta.className = "voice-card-meta";
459
+ cloneMeta.textContent = "Upload a short clip of a voice you have rights to use";
460
+ cloneBody.append(cloneHead, cloneMeta, makeSlotAssignRow(
461
+ (i) => isCustomVoice(i),
462
+ () => "#2a2016",
463
+ (i) => assignVoiceToSlot(i, CUSTOM_VOICE_VALUE),
464
+ ));
465
+ clone.append(cloneAvatar, cloneBody);
466
+ el.libraryGrid.append(clone);
467
+
468
+ refreshPreviewButtons();
469
+ }
470
+
471
+ function renderLibrary() {
472
+ el.librarySearch.value = state.librarySearch;
473
+ renderLibraryFilters();
474
+ renderLibraryGrid();
475
+ }
476
+
477
+ el.librarySearch.addEventListener("input", () => {
478
+ state.librarySearch = el.librarySearch.value;
479
+ renderLibraryGrid();
480
  });
481
 
482
  /* ---------------- Turn editor ---------------- */
483
  function speakerChoiceLabel(i) {
484
  const sel = state.voiceSelections[i];
485
  if (sel === CUSTOM_VOICE_VALUE) return `Speaker ${i + 1} · Custom voice`;
486
+ return sel ? `Speaker ${i + 1} · ${sel}` : `Speaker ${i + 1}`;
487
  }
488
 
489
  function renderTurns() {
 
492
  const empty = document.createElement("div");
493
  empty.className = "empty-transcript";
494
  empty.id = "emptyTurns";
495
+ empty.innerHTML =
496
+ '<div class="empty-title">No scene yet</div>' +
497
+ "Type a scenario above and click <strong>Write with AI</strong>, pick an example, or start typing your own line below.";
498
  el.turnsList.append(empty);
499
  updateMeta();
500
  return;
 
504
  const spk = Math.min(4, Math.max(1, turn.speaker || 1));
505
  const card = document.createElement("div");
506
  card.className = "turn-card";
507
+ card.style.borderLeftColor = slotColor(spk - 1);
508
 
509
  const head = document.createElement("div");
510
  head.className = "turn-head";
 
580
  while (voices.length < 4) voices.push(null);
581
  state.voiceSelections = voices;
582
  el.scriptTitle.textContent = result.title || titleFallback || "Untitled conversation";
583
+ renderCast();
584
  renderTurns();
585
  el.dockEmpty.hidden = false;
586
  el.resultBlock.classList.remove("visible");
 
666
  }
667
  });
668
 
669
+ /* ---------------- Player: waveform + synced transcript ---------------- */
670
+ async function decodeWavePeaks(url, blocks) {
671
  const response = await fetch(url);
672
+ if (!response.ok) return null;
673
  const data = await response.arrayBuffer();
674
  const context = new AudioContext();
675
  try {
676
  const buffer = await context.decodeAudioData(data.slice(0));
677
  const samples = buffer.getChannelData(0);
 
 
 
678
  const blockSize = Math.max(1, Math.floor(samples.length / blocks));
679
  const peaks = [];
680
  for (let block = 0; block < blocks; block += 1) {
 
685
  peaks.push(peak);
686
  }
687
  const maxPeak = Math.max(...peaks, 0.001);
688
+ return peaks.map((p) => p / maxPeak);
 
 
 
 
 
 
 
 
 
689
  } finally {
690
  await context.close();
691
  }
692
  }
693
 
694
+ function renderWave(progress) {
695
+ const canvas = el.resultWaveform;
696
+ const cssWidth = canvas.clientWidth || 240;
697
+ const cssHeight = canvas.clientHeight || 44;
698
+ const dpr = window.devicePixelRatio || 1;
699
+ if (canvas.width !== Math.round(cssWidth * dpr)) {
700
+ canvas.width = Math.round(cssWidth * dpr);
701
+ canvas.height = Math.round(cssHeight * dpr);
702
+ }
703
+ const draw = canvas.getContext("2d");
704
+ draw.setTransform(dpr, 0, 0, dpr, 0, 0);
705
+ draw.clearRect(0, 0, cssWidth, cssHeight);
706
+ const peaks = state.wavePeaks || Array.from({ length: 48 }, () => 0.3);
707
+ const blocks = peaks.length;
708
+ const step = cssWidth / blocks;
709
+ const barWidth = Math.max(2, step - 2);
710
+ peaks.forEach((peak, i) => {
711
+ const barHeight = Math.max(3, peak * cssHeight * 0.9);
712
+ const x = i * step + 1;
713
+ const played = (i + 0.5) / blocks <= progress;
714
+ draw.fillStyle = played ? "#e2582a" : "#e4d8c2";
715
+ draw.fillRect(x, (cssHeight - barHeight) / 2, barWidth, barHeight);
716
+ });
717
+ }
718
+
719
+ function buildSyncedTranscript(snapshot) {
720
+ // Approximate per-line timing: apportion total duration by word count.
721
+ const words = snapshot.map((t) => t.text.split(/\s+/).filter(Boolean).length || 1);
722
+ const total = words.reduce((a, b) => a + b, 0);
723
+ let acc = 0;
724
+ state.resultTurns = snapshot.map((t, i) => {
725
+ const startRatio = acc / total;
726
+ acc += words[i];
727
+ return { ...t, startRatio, endRatio: acc / total };
728
+ });
729
+ state.activeSyncIndex = -1;
730
+
731
+ el.syncedTranscript.innerHTML = "";
732
+ state.resultTurns.forEach((turn, i) => {
733
+ const row = document.createElement("div");
734
+ row.className = "sync-line";
735
+ const dot = document.createElement("span");
736
+ dot.className = "sync-dot";
737
+ dot.style.background = slotColor(turn.speaker - 1);
738
+ const body = document.createElement("div");
739
+ const label = document.createElement("div");
740
+ label.className = "sync-label";
741
+ label.textContent = `Speaker ${turn.speaker} · ${slotVoiceLabel(turn.speaker - 1)}`;
742
+ const text = document.createElement("div");
743
+ text.className = "sync-text";
744
+ text.textContent = turn.text;
745
+ body.append(label, text);
746
+ row.append(dot, body);
747
+ row.addEventListener("click", () => {
748
+ const audio = el.resultAudio;
749
+ if (Number.isFinite(audio.duration) && audio.duration > 0) {
750
+ audio.currentTime = turn.startRatio * audio.duration;
751
+ if (audio.paused) audio.play().catch(() => {});
752
+ }
753
+ });
754
+ el.syncedTranscript.append(row);
755
+ state.resultTurns[i].row = row;
756
+ });
757
+ }
758
+
759
+ function updatePlaybackUI() {
760
+ const audio = el.resultAudio;
761
+ const duration = audio.duration;
762
+ if (!Number.isFinite(duration) || duration <= 0) return;
763
+ const ratio = audio.currentTime / duration;
764
+ renderWave(ratio);
765
+ el.playerTime.textContent = `${formatClock(audio.currentTime)} / ${formatClock(duration)}`;
766
+
767
+ let active = -1;
768
+ for (let i = 0; i < state.resultTurns.length; i += 1) {
769
+ if (ratio >= state.resultTurns[i].startRatio) active = i;
770
+ }
771
+ if (active !== state.activeSyncIndex) {
772
+ state.resultTurns.forEach((t, i) => t.row.classList.toggle("active", i === active));
773
+ state.activeSyncIndex = active;
774
+ const row = state.resultTurns[active] && state.resultTurns[active].row;
775
+ if (row) row.scrollIntoView({ block: "nearest", behavior: "smooth" });
776
+ }
777
+ }
778
+
779
+ el.playBtn.addEventListener("click", () => {
780
+ const audio = el.resultAudio;
781
+ if (!audio.src) return;
782
+ if (audio.paused) audio.play().catch(() => {});
783
+ else audio.pause();
784
+ });
785
+ el.resultAudio.addEventListener("play", () => { el.playBtn.textContent = "❚❚"; });
786
+ el.resultAudio.addEventListener("pause", () => { el.playBtn.textContent = "►"; });
787
+ el.resultAudio.addEventListener("ended", () => { el.playBtn.textContent = "►"; });
788
+ el.resultAudio.addEventListener("timeupdate", updatePlaybackUI);
789
+ el.resultAudio.addEventListener("loadedmetadata", updatePlaybackUI);
790
+
791
+ el.resultWaveform.addEventListener("click", (e) => {
792
+ const audio = el.resultAudio;
793
+ if (!Number.isFinite(audio.duration) || audio.duration <= 0) return;
794
+ const rect = el.resultWaveform.getBoundingClientRect();
795
+ const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
796
+ audio.currentTime = ratio * audio.duration;
797
+ updatePlaybackUI();
798
+ });
799
+
800
  /* ---------------- Generation status ---------------- */
801
  function nextParodyLine() {
802
  if (!state.parodyLines.length) return null;
 
848
  return;
849
  }
850
 
851
+ const turnsSnapshot = state.turns
852
+ .filter((t) => (t.text || "").trim())
853
+ .map((t) => ({ speaker: Math.min(4, Math.max(1, t.speaker || 1)), text: t.text.trim() }));
854
+
855
  el.generateBtn.disabled = true;
856
  el.generateBtn.textContent = "Generating...";
857
  el.resultBlock.classList.remove("visible");
858
+ el.resultAudio.pause();
859
+ el.dockEmpty.hidden = true;
860
  el.logBox.textContent = "";
861
  el.logBox.classList.remove("visible");
862
  el.logToggleBtn.hidden = true;
 
881
  }
882
 
883
  const payload = {
884
+ model: state.model,
885
  num_speakers: state.numSpeakers,
886
  turns: state.turns,
887
  speakers: state.voiceSelections.map((v) => (v === CUSTOM_VOICE_VALUE ? null : v)),
 
933
  el.downloadBtn.href = url;
934
  el.generationTime.textContent = formatDuration((performance.now() - started) / 1000);
935
  el.audioDuration.textContent = formatDuration(evt.audio_duration);
936
+ el.resultModel.textContent = state.model;
937
+ el.playerTime.textContent = `0:00 / ${formatClock(evt.audio_duration)}`;
938
+ el.playBtn.textContent = "►";
939
+ buildSyncedTranscript(turnsSnapshot);
940
  el.dockEmpty.hidden = true;
941
  el.resultBlock.classList.add("visible");
942
+ state.wavePeaks = await decodeWavePeaks(url, 48);
943
+ renderWave(0);
944
  }
945
  }
946
  }
947
  } catch (error) {
948
  setStatus("error", error.message);
949
+ el.dockEmpty.hidden = false;
950
  } finally {
951
  el.generateBtn.disabled = false;
952
  el.generateBtn.textContent = "Generate Audio";
 
962
  fetch("/api/duration-options").then((r) => r.json()),
963
  ]);
964
  state.models = models;
965
+ state.model = models[0] || null;
966
  state.voices = voices;
967
  state.examples = examples;
968
  state.voiceSelections = voices.slice(0, 4).map((v) => v.name);
969
  while (state.voiceSelections.length < 4) state.voiceSelections.push(null);
970
 
971
+ el.durationSelect.innerHTML = durationOptions
972
+ .map((m) => `<option value="${m}">${m >= 60 ? "1 hr" : `${m} min`}</option>`)
973
+ .join("");
974
  const defaultDuration = durationOptions.includes(2) ? 2 : durationOptions[0];
975
  el.durationSelect.value = String(defaultDuration);
976
 
977
+ renderCast();
978
+ renderQuality();
979
  renderTurns();
980
  renderExamplePills();
981
  updateStatus();
static/index.html CHANGED
@@ -3,12 +3,18 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <meta name="theme-color" content="#f7f4ee" />
7
  <meta
8
  name="description"
9
  content="Chorus — an AI voice studio for writing and generating natural multi-speaker dialogue, powered by VibeVoice."
10
  />
11
  <title>Chorus — AI Voice Studio</title>
 
 
 
 
 
 
12
  <link rel="stylesheet" href="/static/styles.css" />
13
  <script src="/static/app.js" defer></script>
14
  </head>
@@ -16,32 +22,28 @@
16
  <header class="topbar">
17
  <a class="wordmark" href="/">
18
  <span class="mark">Chorus</span>
19
- <span class="tagline">Voice Studio</span>
20
  </a>
21
- <div class="topbar-right">
 
22
  <button class="link-quiet" type="button" id="aboutBtn">How it works</button>
23
  <div class="runtime-status" id="runtimeStatus" aria-live="polite">
24
  <span class="status-dot"></span>
25
  <span id="runtimeLabel">Connecting</span>
26
  </div>
27
- </div>
28
  </header>
29
 
30
  <div class="app-shell">
31
  <!-- SIDEBAR: cast & model -->
32
  <aside class="pane sidebar">
33
  <div class="side-section">
34
- <span class="side-label">Model</span>
35
- <select id="modelSelect"></select>
36
- </div>
37
-
38
- <div class="side-section">
39
- <span class="side-label">Speakers</span>
40
- <div class="speaker-stepper" id="speakerStepper">
41
- <button type="button" data-count="1">1</button>
42
- <button type="button" data-count="2">2</button>
43
- <button type="button" data-count="3">3</button>
44
- <button type="button" data-count="4">4</button>
45
  </div>
46
  <div id="voiceRows"></div>
47
  <div id="voiceConsentRow" class="voice-consent-row" hidden>
@@ -53,11 +55,17 @@
53
  </div>
54
 
55
  <div class="side-section">
56
- <span class="side-label">CFG Scale</span>
57
- <div class="slider-row">
58
- <input id="cfgScale" type="range" min="1.0" max="2.0" step="0.05" value="2.0" />
59
- <span id="cfgScaleValue" class="slider-value">2.00</span>
 
 
 
 
60
  </div>
 
 
61
  </div>
62
 
63
  <div class="sidebar-footer">
@@ -67,8 +75,9 @@
67
  </aside>
68
 
69
  <!-- CANVAS: script composer + transcript -->
70
- <main class="pane canvas">
71
- <div class="canvas-inner">
 
72
  <div class="composer">
73
  <textarea
74
  id="scriptPrompt"
@@ -83,12 +92,14 @@
83
  <span aria-hidden="true">📋</span> Import script
84
  </button>
85
  </div>
86
- <button id="generateScriptBtn" class="btn btn-ink" type="button">Write with AI</button>
87
  </div>
88
  </div>
89
  <div class="example-chips" id="examplePills"></div>
90
- <div id="scriptGenStatus" class="transcript-meta" style="margin: 6px 0 4px;"></div>
 
91
 
 
92
  <div class="transcript-header">
93
  <h2 id="scriptTitle">Untitled conversation</h2>
94
  <span class="transcript-meta" id="scriptDuration"></span>
@@ -96,39 +107,48 @@
96
 
97
  <div id="turnsList">
98
  <div class="empty-transcript" id="emptyTurns">
99
- Nothing here yet. Type a scenario above and click <strong>Write with AI</strong>,
100
- or start typing your own line below.
 
101
  </div>
102
  </div>
103
  <button class="add-turn" id="addTurnBtn" type="button">+ Add a line</button>
 
104
 
105
- <div class="generate-bar">
106
- <span class="generate-bar-meta" id="generateBarMeta">Add dialogue to begin</span>
107
- <button id="generateBtn" class="btn btn-ink" type="button">Generate Audio</button>
108
- </div>
109
  </div>
110
  </main>
111
 
112
  <!-- DOCK: generation status + output -->
113
  <aside class="pane dock">
114
- <div class="dock-label">Output</div>
115
 
116
  <div id="statusCard" class="status-card">
 
117
  <div class="status-title"><span class="status-dot-pulse" id="statusDot"></span><span id="statusTitle">Working</span></div>
118
  <div class="status-desc" id="statusDesc"></div>
119
  </div>
120
 
121
- <div id="dockEmpty" class="dock-empty">Your generated audio will appear here.</div>
122
 
123
  <div id="resultBlock" class="result-block">
124
- <canvas id="resultWaveform" width="640" height="68"></canvas>
125
- <audio id="resultAudio" controls></audio>
 
 
 
 
 
 
126
  <div class="result-metrics">
127
  <div><span>Generation time</span> <strong id="generationTime">--</strong></div>
128
  <div><span>Audio length</span> <strong id="audioDuration">--</strong></div>
129
  <div><span>Model</span> <strong id="resultModel">--</strong></div>
130
  </div>
131
- <a id="downloadBtn" class="btn" download="chorus-audio.wav" style="display:block; text-align:center;">Download WAV</a>
 
132
  </div>
133
 
134
  <button class="log-toggle" id="logToggleBtn" type="button" hidden>View generation log</button>
@@ -136,6 +156,19 @@
136
  </aside>
137
  </div>
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  <!-- Import dialog -->
140
  <dialog id="importDialog">
141
  <div class="dialog-body">
@@ -170,7 +203,7 @@
170
  <li><strong>Multi-speaker</strong> — up to 4 distinct voices in one take</li>
171
  <li><strong>Long-form</strong> — up to 90 minutes of continuous audio</li>
172
  <li><strong>Natural flow</strong> — turn-taking, filler words, natural pacing</li>
173
- <li><strong>Two model sizes</strong> — 1.5B for fast iteration, 7B for higher fidelity</li>
174
  </ul>
175
  <img src="/public/images/diagram.jpg" alt="VibeVoice architecture diagram" />
176
  <div class="dialog-actions" style="justify-content: flex-end;">
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="theme-color" content="#faf3e7" />
7
  <meta
8
  name="description"
9
  content="Chorus — an AI voice studio for writing and generating natural multi-speaker dialogue, powered by VibeVoice."
10
  />
11
  <title>Chorus — AI Voice Studio</title>
12
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
13
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
14
+ <link
15
+ href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
16
+ rel="stylesheet"
17
+ />
18
  <link rel="stylesheet" href="/static/styles.css" />
19
  <script src="/static/app.js" defer></script>
20
  </head>
 
22
  <header class="topbar">
23
  <a class="wordmark" href="/">
24
  <span class="mark">Chorus</span>
25
+ <span class="brand-pill">VOICE STUDIO</span>
26
  </a>
27
+ <nav class="topbar-right">
28
+ <button class="link-quiet" type="button" id="browseVoicesBtn">Browse voices</button>
29
  <button class="link-quiet" type="button" id="aboutBtn">How it works</button>
30
  <div class="runtime-status" id="runtimeStatus" aria-live="polite">
31
  <span class="status-dot"></span>
32
  <span id="runtimeLabel">Connecting</span>
33
  </div>
34
+ </nav>
35
  </header>
36
 
37
  <div class="app-shell">
38
  <!-- SIDEBAR: cast & model -->
39
  <aside class="pane sidebar">
40
  <div class="side-section">
41
+ <span class="side-label">Your cast</span>
42
+ <div class="seg" id="speakerStepper">
43
+ <button type="button" data-count="1"><strong>1</strong><span>Solo</span></button>
44
+ <button type="button" data-count="2"><strong>2</strong><span>Duo</span></button>
45
+ <button type="button" data-count="3"><strong>3</strong><span>Trio</span></button>
46
+ <button type="button" data-count="4"><strong>4</strong><span>Cast</span></button>
 
 
 
 
 
47
  </div>
48
  <div id="voiceRows"></div>
49
  <div id="voiceConsentRow" class="voice-consent-row" hidden>
 
55
  </div>
56
 
57
  <div class="side-section">
58
+ <span class="side-label">Quality</span>
59
+ <div class="seg" id="qualityPills"></div>
60
+ </div>
61
+
62
+ <div class="side-section">
63
+ <div class="side-label-row">
64
+ <span class="side-label">Expressiveness</span>
65
+ <span id="cfgScaleValue" class="side-label-value">2.00 · Dynamic</span>
66
  </div>
67
+ <input id="cfgScale" type="range" min="1.0" max="2.0" step="0.05" value="2.0" />
68
+ <div class="slider-ends"><span>Calm &amp; steady</span><span>Big &amp; dynamic</span></div>
69
  </div>
70
 
71
  <div class="sidebar-footer">
 
75
  </aside>
76
 
77
  <!-- CANVAS: script composer + transcript -->
78
+ <main class="pane-col canvas">
79
+ <div class="card composer-card">
80
+ <h2 class="card-heading">What's the scene?</h2>
81
  <div class="composer">
82
  <textarea
83
  id="scriptPrompt"
 
92
  <span aria-hidden="true">📋</span> Import script
93
  </button>
94
  </div>
95
+ <button id="generateScriptBtn" class="btn btn-accent" type="button">Write with AI</button>
96
  </div>
97
  </div>
98
  <div class="example-chips" id="examplePills"></div>
99
+ <div id="scriptGenStatus" class="gen-status"></div>
100
+ </div>
101
 
102
+ <div class="card script-card">
103
  <div class="transcript-header">
104
  <h2 id="scriptTitle">Untitled conversation</h2>
105
  <span class="transcript-meta" id="scriptDuration"></span>
 
107
 
108
  <div id="turnsList">
109
  <div class="empty-transcript" id="emptyTurns">
110
+ <div class="empty-title">No scene yet</div>
111
+ Type a scenario above and click <strong>Write with AI</strong>, pick an example, or
112
+ start typing your own line below.
113
  </div>
114
  </div>
115
  <button class="add-turn" id="addTurnBtn" type="button">+ Add a line</button>
116
+ </div>
117
 
118
+ <div class="generate-bar">
119
+ <span class="generate-bar-meta" id="generateBarMeta">Add dialogue to begin</span>
120
+ <button id="generateBtn" class="btn btn-ink" type="button">Generate Audio</button>
 
121
  </div>
122
  </main>
123
 
124
  <!-- DOCK: generation status + output -->
125
  <aside class="pane dock">
126
+ <div class="side-label dock-label">Your recording</div>
127
 
128
  <div id="statusCard" class="status-card">
129
+ <div class="status-spinner" id="statusSpinner"></div>
130
  <div class="status-title"><span class="status-dot-pulse" id="statusDot"></span><span id="statusTitle">Working</span></div>
131
  <div class="status-desc" id="statusDesc"></div>
132
  </div>
133
 
134
+ <div id="dockEmpty" class="dock-empty">Your generated audio will appear here once you record a take.</div>
135
 
136
  <div id="resultBlock" class="result-block">
137
+ <div class="player-top">
138
+ <button id="playBtn" class="play-btn" type="button" aria-label="Play"></button>
139
+ <canvas id="resultWaveform" width="480" height="72"></canvas>
140
+ </div>
141
+ <div class="player-time" id="playerTime">0:00 / 0:00</div>
142
+
143
+ <div class="synced-transcript" id="syncedTranscript"></div>
144
+
145
  <div class="result-metrics">
146
  <div><span>Generation time</span> <strong id="generationTime">--</strong></div>
147
  <div><span>Audio length</span> <strong id="audioDuration">--</strong></div>
148
  <div><span>Model</span> <strong id="resultModel">--</strong></div>
149
  </div>
150
+ <a id="downloadBtn" class="btn btn-pill-outline" download="chorus-audio.wav">Download WAV</a>
151
+ <audio id="resultAudio" hidden></audio>
152
  </div>
153
 
154
  <button class="log-toggle" id="logToggleBtn" type="button" hidden>View generation log</button>
 
156
  </aside>
157
  </div>
158
 
159
+ <!-- Voice library dialog -->
160
+ <dialog id="voiceLibraryDialog" class="library-dialog">
161
+ <div class="dialog-body">
162
+ <div class="library-head">
163
+ <h3>Choose your voices</h3>
164
+ <button class="dialog-close" type="button" id="closeLibraryBtn" aria-label="Close">×</button>
165
+ </div>
166
+ <input id="librarySearch" class="library-search" placeholder="Search voices by name..." />
167
+ <div class="filter-chips" id="libraryFilters"></div>
168
+ <div class="library-grid" id="libraryGrid"></div>
169
+ </div>
170
+ </dialog>
171
+
172
  <!-- Import dialog -->
173
  <dialog id="importDialog">
174
  <div class="dialog-body">
 
203
  <li><strong>Multi-speaker</strong> — up to 4 distinct voices in one take</li>
204
  <li><strong>Long-form</strong> — up to 90 minutes of continuous audio</li>
205
  <li><strong>Natural flow</strong> — turn-taking, filler words, natural pacing</li>
206
+ <li><strong>Two model sizes</strong> — Fast (1.5B) for iteration, Best (7B) for fidelity</li>
207
  </ul>
208
  <img src="/public/images/diagram.jpg" alt="VibeVoice architecture diagram" />
209
  <div class="dialog-actions" style="justify-content: flex-end;">
static/styles.css CHANGED
@@ -1,30 +1,33 @@
1
  :root {
2
- --cream: #f7f4ee;
3
- --paper: #ffffff;
4
- --paper-soft: #fbf9f4;
5
- --field: #ece4d2;
6
- --border: #e7e1d3;
7
- --border-strong: #d8cfb9;
8
- --ink: #262019;
9
- --ink-dim: #6f6656;
10
- --ink-faint: #a89d87;
11
- --accent: #b5502e;
12
- --accent-hover: #9c4326;
13
- --accent-soft: rgba(181, 80, 46, 0.1);
14
- --success: #3f7d5c;
 
 
15
  --error: #b23b3b;
16
- --speaker-1: #b5502e;
17
- --speaker-2: #2f6b63;
18
- --speaker-3: #b8862b;
19
- --speaker-4: #7c5a83;
20
- --radius: 14px;
21
- --radius-sm: 9px;
22
- --shadow: 0 10px 34px rgba(38, 32, 25, 0.08);
23
- --font: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
24
- --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
25
  }
26
 
27
  * { box-sizing: border-box; }
 
28
 
29
  html, body {
30
  margin: 0;
@@ -33,60 +36,49 @@ html, body {
33
  color: var(--ink);
34
  font-family: var(--font-ui);
35
  -webkit-font-smoothing: antialiased;
36
- height: 100%;
37
- }
38
-
39
- body {
40
- min-height: 100vh;
41
- display: grid;
42
- grid-template-rows: 56px 1fr;
43
  }
44
 
45
  a { color: var(--accent); }
46
- .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
47
-
48
  button, select, input, textarea { font-family: inherit; }
49
 
50
  /* ---------------- Top bar ---------------- */
51
  .topbar {
52
- grid-row: 1;
53
  display: flex;
54
  align-items: center;
55
  justify-content: space-between;
56
- padding: 0 20px;
57
- border-bottom: 1px solid var(--border);
58
- background: var(--paper);
59
- z-index: 30;
60
  }
61
  .wordmark {
62
  display: flex;
63
  align-items: baseline;
64
- gap: 8px;
65
  text-decoration: none;
66
  color: var(--ink);
67
  }
68
  .wordmark .mark {
69
- font-family: var(--font);
70
- font-weight: 700;
71
  font-style: italic;
72
- font-size: 1.35rem;
73
- letter-spacing: 0.01em;
74
  }
75
- .wordmark .tagline {
76
- font-size: 0.72rem;
77
- color: var(--ink-faint);
78
- letter-spacing: 0.04em;
79
- text-transform: uppercase;
 
 
 
80
  }
81
- .topbar-right { display: flex; align-items: center; gap: 14px; }
82
  .link-quiet {
83
- font-size: 0.8rem;
 
84
  color: var(--ink-dim);
85
- text-decoration: none;
86
- border-bottom: 1px dotted var(--border-strong);
87
- cursor: pointer;
88
  background: none;
89
- border-top: none; border-left: none; border-right: none;
 
90
  padding: 0;
91
  }
92
  .link-quiet:hover { color: var(--accent); }
@@ -94,116 +86,155 @@ button, select, input, textarea { font-family: inherit; }
94
  display: flex;
95
  align-items: center;
96
  gap: 7px;
97
- font-size: 0.76rem;
98
- color: var(--ink-dim);
99
- border: 1px solid var(--border);
100
  border-radius: 999px;
101
- padding: 5px 11px;
102
- background: var(--paper-soft);
103
  }
104
  .status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--error); }
 
105
  .runtime-status.ready .status-dot { background: var(--success); }
106
 
107
  /* ---------------- App shell ---------------- */
108
  .app-shell {
109
- grid-row: 2;
 
 
110
  display: grid;
111
- grid-template-columns: 272px minmax(0, 1fr) 336px;
112
- min-height: 0;
 
113
  }
114
 
115
- .pane { overflow-y: auto; min-height: 0; }
 
 
 
 
 
 
 
 
 
116
  .pane::-webkit-scrollbar { width: 7px; }
117
  .pane::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
118
 
119
- /* ---------------- Sidebar ---------------- */
120
- .sidebar {
121
- border-right: 1px solid var(--border);
122
  background: var(--paper);
123
- padding: 20px 18px 90px;
 
 
124
  }
125
- .side-section { margin-bottom: 26px; }
 
 
 
 
 
 
 
 
126
  .side-label {
127
  font-size: 0.7rem;
128
  font-weight: 700;
129
- letter-spacing: 0.06em;
130
  text-transform: uppercase;
131
  color: var(--ink-faint);
132
  margin-bottom: 10px;
133
  display: block;
134
  }
135
- select {
136
- width: 100%;
137
- background-color: var(--paper-soft);
138
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%236f6656' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
139
- background-repeat: no-repeat;
140
- background-position: right 11px center;
141
- border: 1px solid var(--border);
142
- color: var(--ink);
143
- border-radius: var(--radius-sm);
144
- padding: 9px 28px 9px 10px;
145
- font-size: 0.86rem;
146
- appearance: none;
147
- cursor: pointer;
148
  }
149
- select:focus { outline: none; border-color: var(--accent); }
150
 
151
- .speaker-stepper { display: flex; gap: 6px; margin-bottom: 16px; }
152
- .speaker-stepper button {
 
 
 
 
 
 
 
 
153
  flex: 1;
154
- border: 1px solid var(--border);
155
- background: var(--paper-soft);
 
 
 
 
156
  color: var(--ink-dim);
157
- border-radius: var(--radius-sm);
158
- padding: 7px 0;
159
- font-size: 0.82rem;
160
- font-weight: 600;
161
  cursor: pointer;
162
  }
163
- .speaker-stepper button.active {
164
- background: var(--ink);
165
- border-color: var(--ink);
166
- color: var(--cream);
167
- }
168
 
169
- .voice-row {
 
 
 
 
170
  display: flex;
171
  align-items: center;
172
- gap: 8px;
173
- margin-bottom: 8px;
174
- padding: 8px;
175
- border: 1px solid var(--border);
176
- border-radius: var(--radius-sm);
177
- background: var(--paper-soft);
178
  }
179
- .voice-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
180
- .voice-row select {
181
- flex: 1;
182
- border: none;
183
- background-color: transparent;
184
- background-position: right 2px center;
185
- padding: 2px 20px 2px 0;
 
 
186
  }
187
  .voice-play {
188
- width: 26px; height: 26px;
189
  border-radius: 50%;
190
- border: 1px solid var(--border-strong);
191
- background: var(--paper);
192
  color: var(--ink-dim);
193
  cursor: pointer;
194
  flex-shrink: 0;
195
  display: flex; align-items: center; justify-content: center;
196
- font-size: 0.7rem;
197
  padding: 0;
198
  }
199
- .voice-play:hover { border-color: var(--accent); color: var(--accent); }
200
- .voice-play.playing { background: var(--accent); border-color: var(--accent); color: #fff; }
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
  .custom-voice-row {
203
  display: flex;
204
  align-items: center;
205
  gap: 6px;
206
- margin: -4px 0 8px 17px;
207
  }
208
  .upload-mini-btn {
209
  font-size: 0.76rem;
@@ -220,7 +251,7 @@ select:focus { outline: none; border-color: var(--accent); }
220
 
221
  .voice-consent-row {
222
  border: 1px solid var(--speaker-3);
223
- background: rgba(184, 134, 43, 0.08);
224
  border-radius: var(--radius-sm);
225
  padding: 10px 12px;
226
  margin-top: 4px;
@@ -229,70 +260,79 @@ select:focus { outline: none; border-color: var(--accent); }
229
  display: flex;
230
  align-items: flex-start;
231
  gap: 8px;
232
- font-size: 0.78rem;
233
  color: var(--ink-dim);
234
  cursor: pointer;
235
  }
236
  .voice-consent-row input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
237
 
238
- .slider-row { display: flex; align-items: center; gap: 10px; }
239
- input[type="range"] { flex: 1; accent-color: var(--accent); }
240
- .slider-value { font-size: 0.8rem; color: var(--ink-dim); min-width: 32px; text-align: right; }
 
 
 
 
241
 
242
  .sidebar-footer {
243
- font-size: 0.72rem;
244
- color: var(--ink-faint);
245
- line-height: 1.5;
246
  border-top: 1px solid var(--border);
247
  padding-top: 14px;
248
- margin-top: 6px;
249
  }
250
 
251
- /* ---------------- Main canvas ---------------- */
252
- .canvas { background: var(--cream); padding: 26px 32px 140px; position: relative; }
253
- .canvas-inner { max-width: 720px; margin: 0 auto; }
254
-
255
- /* ---- Composer: a single input "shell" (textarea + toolbar), the
256
- chat-composer pattern from ChatGPT/Claude.ai — unmistakable as an
257
- input at a glance, with secondary controls grouped in a footer strip
258
- instead of competing with the textarea as loose sibling elements. ---- */
259
  .composer {
260
  background: var(--paper);
261
  border: 1px solid var(--border-strong);
262
- border-radius: var(--radius);
263
- box-shadow: var(--shadow);
264
  overflow: hidden;
265
- margin-bottom: 12px;
266
  }
 
267
  .composer-input {
268
  display: block;
269
  width: 100%;
270
  border: none;
271
  resize: none;
272
- background: transparent;
273
- padding: 18px 20px 8px;
274
- font-size: 1.05rem;
275
- line-height: 1.5;
276
- font-family: var(--font);
277
  color: var(--ink);
278
- min-height: 100px;
279
  max-height: 240px;
280
  cursor: text;
281
  }
282
  .composer-input:focus { outline: none; }
283
- .composer-input::placeholder { color: var(--ink-faint); font-style: italic; }
284
 
285
  .composer-toolbar {
286
  display: flex;
287
  align-items: center;
288
  justify-content: space-between;
289
  gap: 10px;
290
- padding: 9px 12px;
291
  border-top: 1px solid var(--border);
292
  background: var(--paper-soft);
293
  }
294
  .composer-toolbar-left { display: flex; align-items: center; gap: 2px; }
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  .toolbar-select {
297
  width: auto;
298
  background-color: transparent;
@@ -305,13 +345,8 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
305
  font-weight: 600;
306
  padding: 7px 24px 7px 11px;
307
  }
308
- .toolbar-select:hover { background-color: var(--paper); border-color: var(--border); }
309
- .toolbar-select:focus {
310
- outline: none;
311
- background-color: var(--paper);
312
- border-color: var(--border-strong);
313
- box-shadow: 0 0 0 3px var(--accent-soft);
314
- }
315
 
316
  .toolbar-btn {
317
  display: inline-flex;
@@ -327,18 +362,13 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
327
  cursor: pointer;
328
  white-space: nowrap;
329
  }
330
- .toolbar-btn:hover { background: var(--paper); border-color: var(--border); color: var(--accent); }
331
-
332
- .composer-toolbar .btn-ink {
333
- border-radius: 999px;
334
- padding: 9px 18px;
335
- font-size: 0.84rem;
336
- }
337
 
 
338
  .btn {
339
  font-family: var(--font-ui);
340
- font-size: 0.85rem;
341
- font-weight: 600;
342
  border-radius: var(--radius-sm);
343
  border: 1px solid var(--border-strong);
344
  background: var(--paper);
@@ -355,8 +385,29 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
355
  background: var(--ink);
356
  border-color: var(--ink);
357
  color: var(--cream);
 
 
358
  }
359
  .btn-ink:hover { background: #3a3226; border-color: #3a3226; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  .btn-icon-only {
361
  width: 34px; height: 34px;
362
  padding: 0;
@@ -364,75 +415,78 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
364
  display: flex; align-items: center; justify-content: center;
365
  }
366
 
367
- .example-chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
 
368
  .chip {
369
- font-size: 0.78rem;
370
- border: 1px solid var(--border);
371
- background: var(--paper);
 
372
  color: var(--ink-dim);
373
  border-radius: 999px;
374
- padding: 5px 12px;
375
  cursor: pointer;
376
  }
377
- .chip:hover { border-color: var(--accent); color: var(--accent); }
 
378
 
379
- /* ---------------- Transcript ---------------- */
380
  .transcript-header {
381
  display: flex;
382
  align-items: baseline;
383
  justify-content: space-between;
384
- margin: 26px 0 12px;
385
  }
386
  .transcript-header h2 {
387
  margin: 0;
388
- font-family: var(--font);
389
- font-weight: 700;
390
- font-size: 1.3rem;
391
  }
392
- .transcript-meta { font-size: 0.78rem; color: var(--ink-faint); }
393
 
394
  .empty-transcript {
395
  text-align: center;
396
- padding: 56px 20px;
 
 
 
 
 
 
 
 
397
  color: var(--ink-faint);
398
- font-size: 0.9rem;
399
- border: 1px dashed var(--border-strong);
400
- border-radius: var(--radius);
401
- background: var(--paper-soft);
402
  }
403
  .empty-transcript strong { color: var(--ink-dim); }
404
 
405
  .turn-card {
406
- background: var(--paper);
407
- border: 1px solid var(--border);
408
  border-left: 3px solid var(--speaker-1);
409
- border-radius: var(--radius-sm);
410
- padding: 12px 14px;
411
- margin-bottom: 8px;
412
  position: relative;
413
  }
414
- .turn-card[data-speaker="2"] { border-left-color: var(--speaker-2); }
415
- .turn-card[data-speaker="3"] { border-left-color: var(--speaker-3); }
416
- .turn-card[data-speaker="4"] { border-left-color: var(--speaker-4); }
417
-
418
  .turn-head {
419
  display: flex;
420
  align-items: center;
421
  justify-content: space-between;
422
- margin-bottom: 6px;
423
  }
424
  .turn-speaker-select {
425
- font-size: 0.74rem;
426
- font-weight: 600;
427
  border: none;
428
- background-color: var(--paper-soft);
429
- background-position: right 8px center;
430
  background-size: 8px 5px;
431
  border-radius: 999px;
432
- padding: 3px 22px 3px 10px;
433
- color: var(--ink-dim);
 
434
  cursor: pointer;
435
  }
 
436
  .turn-remove {
437
  border: none;
438
  background: none;
@@ -451,22 +505,24 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
451
  border: none;
452
  background: transparent;
453
  resize: none;
454
- font-family: var(--font);
455
- font-size: 0.98rem;
456
- line-height: 1.5;
457
  color: var(--ink);
458
  min-height: 24px;
 
459
  }
460
  .turn-card textarea:focus { outline: none; }
461
 
462
  .add-turn {
463
  width: 100%;
464
- border: 1px dashed var(--border-strong);
465
  background: transparent;
466
  color: var(--ink-faint);
467
  border-radius: var(--radius-sm);
468
  padding: 10px;
469
  font-size: 0.84rem;
 
470
  cursor: pointer;
471
  margin-top: 4px;
472
  }
@@ -476,7 +532,6 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
476
  .generate-bar {
477
  position: sticky;
478
  bottom: 22px;
479
- margin-top: 22px;
480
  background: var(--paper);
481
  border: 1px solid var(--border-strong);
482
  border-radius: 999px;
@@ -487,43 +542,40 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
487
  justify-content: space-between;
488
  gap: 12px;
489
  }
490
- .generate-bar-meta { font-size: 0.8rem; color: var(--ink-dim); }
491
- .generate-bar .btn-ink { padding: 11px 24px; border-radius: 999px; }
492
 
493
  /* ---------------- Output dock ---------------- */
494
- .dock {
495
- border-left: 1px solid var(--border);
496
- background: var(--paper);
497
- padding: 20px 18px 40px;
498
- }
499
- .dock-label {
500
- font-size: 0.7rem;
501
- font-weight: 700;
502
- letter-spacing: 0.06em;
503
- text-transform: uppercase;
504
- color: var(--ink-faint);
505
- margin-bottom: 14px;
506
- }
507
  .dock-empty {
508
  text-align: center;
509
- padding: 40px 12px;
510
- color: var(--ink-faint);
511
- font-size: 0.85rem;
 
512
  }
513
 
514
  .status-card {
515
- border: 1px solid var(--border);
516
- border-radius: var(--radius-sm);
517
- padding: 14px;
518
- margin-bottom: 14px;
519
  display: none;
 
 
 
 
 
 
520
  }
521
- .status-card.visible { display: block; }
522
- .status-card.active { border-color: var(--accent); background: var(--accent-soft); }
523
- .status-card.complete { border-color: var(--success); background: rgba(63, 125, 92, 0.08); }
524
- .status-card.error { border-color: var(--error); background: rgba(178, 59, 59, 0.08); }
525
- .status-title { font-size: 0.9rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
526
- .status-desc { font-size: 0.78rem; color: var(--ink-dim); margin-top: 4px; }
 
 
 
 
 
 
 
527
  .status-dot-pulse {
528
  width: 8px; height: 8px; border-radius: 50%;
529
  background: var(--accent);
@@ -533,21 +585,77 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
533
  .status-card.error .status-dot-pulse { background: var(--error); animation: none; }
534
  @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } }
535
 
536
- .result-block { display: none; }
 
537
  .result-block.visible { display: block; }
538
- #resultWaveform { width: 100%; height: 68px; border-radius: var(--radius-sm); background: var(--paper-soft); display: block; margin-bottom: 10px; }
539
- .result-block audio { width: 100%; margin-bottom: 12px; height: 34px; }
540
- .result-metrics { display: flex; flex-direction: column; gap: 6px; font-size: 0.78rem; color: var(--ink-dim); margin-bottom: 12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  .result-metrics span { color: var(--ink-faint); }
542
  .result-metrics strong { color: var(--ink); font-weight: 600; }
543
 
544
  .log-toggle {
545
- font-size: 0.76rem;
546
  color: var(--ink-faint);
547
  background: none;
548
  border: none;
549
  cursor: pointer;
550
  padding: 0;
 
551
  text-decoration: underline dotted;
552
  }
553
  .log-box {
@@ -566,40 +674,139 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
566
  }
567
  .log-box.visible { display: block; }
568
 
569
- /* ---------------- Import dialog ---------------- */
570
  dialog {
571
  border: none;
572
- border-radius: var(--radius);
573
  padding: 0;
574
  max-width: 520px;
575
  width: 92vw;
576
- box-shadow: 0 24px 60px rgba(38, 32, 25, 0.25);
577
  background: var(--paper);
578
  color: var(--ink);
579
  }
580
- dialog::backdrop { background: rgba(38, 32, 25, 0.35); }
581
- .dialog-body { padding: 22px; }
582
- .dialog-body h3 { margin: 0 0 4px; font-family: var(--font); }
583
- .dialog-body p { font-size: 0.82rem; color: var(--ink-dim); margin: 0 0 14px; }
 
 
 
 
 
584
  .dialog-body textarea {
585
  width: 100%;
586
  border: 1px solid var(--border-strong);
587
  border-radius: var(--radius-sm);
588
- padding: 10px;
589
  font-size: 0.86rem;
590
- background: var(--field);
591
- box-shadow: inset 0 1px 3px rgba(38, 32, 25, 0.08);
592
  resize: vertical;
593
  color: var(--ink);
594
  }
595
- .dialog-body textarea:focus {
596
- outline: none;
597
- border-color: var(--accent);
598
- background: var(--paper);
599
- box-shadow: 0 0 0 3px var(--accent-soft);
600
- }
601
  .dialog-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; gap: 10px; }
602
  .dialog-actions input[type="file"] { font-size: 0.78rem; max-width: 190px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
 
604
  /* ---------------- About dialog ---------------- */
605
  #aboutDialog .dialog-body { max-height: 70vh; overflow-y: auto; }
@@ -608,11 +815,12 @@ dialog::backdrop { background: rgba(38, 32, 25, 0.35); }
608
  #aboutDialog li { margin-bottom: 4px; }
609
 
610
  /* ---------------- Responsive ---------------- */
611
- @media (max-width: 1100px) {
612
- .app-shell { grid-template-columns: 240px minmax(0, 1fr) 300px; }
613
- }
614
- @media (max-width: 880px) {
615
- .app-shell { grid-template-columns: 1fr; grid-auto-rows: auto; }
616
- .sidebar, .dock { border: none; border-bottom: 1px solid var(--border); padding-bottom: 20px; }
617
- .canvas { padding: 22px 18px 120px; }
 
618
  }
 
1
  :root {
2
+ --cream: #faf3e7;
3
+ --paper: #fffdf9;
4
+ --paper-soft: #fbf6ec;
5
+ --field: #f3eada;
6
+ --border: #efe4d2;
7
+ --border-strong: #e4d8c2;
8
+ --ink: #2a2016;
9
+ --ink-dim: #6b5f52;
10
+ --ink-faint: #9c8e7b;
11
+ --ink-ghost: #b4a691;
12
+ --accent: #e2582a;
13
+ --accent-hover: #c1481f;
14
+ --accent-soft: #fbe3d6;
15
+ --success: #3f6b4e;
16
+ --success-soft: #e7efe3;
17
  --error: #b23b3b;
18
+ --speaker-1: #e2582a;
19
+ --speaker-2: #2f6f63;
20
+ --speaker-3: #cc8a2e;
21
+ --speaker-4: #7b4b94;
22
+ --radius: 20px;
23
+ --radius-sm: 12px;
24
+ --shadow: 0 8px 24px rgba(42, 32, 22, 0.08);
25
+ --font-serif: "Instrument Serif", "Iowan Old Style", Georgia, serif;
26
+ --font-ui: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
27
  }
28
 
29
  * { box-sizing: border-box; }
30
+ ::selection { background: #f6d9c9; }
31
 
32
  html, body {
33
  margin: 0;
 
36
  color: var(--ink);
37
  font-family: var(--font-ui);
38
  -webkit-font-smoothing: antialiased;
 
 
 
 
 
 
 
39
  }
40
 
41
  a { color: var(--accent); }
 
 
42
  button, select, input, textarea { font-family: inherit; }
43
 
44
  /* ---------------- Top bar ---------------- */
45
  .topbar {
 
46
  display: flex;
47
  align-items: center;
48
  justify-content: space-between;
49
+ padding: 18px 44px;
50
+ border-bottom: 1px solid #ebdfc9;
 
 
51
  }
52
  .wordmark {
53
  display: flex;
54
  align-items: baseline;
55
+ gap: 12px;
56
  text-decoration: none;
57
  color: var(--ink);
58
  }
59
  .wordmark .mark {
60
+ font-family: var(--font-serif);
 
61
  font-style: italic;
62
+ font-size: 1.8rem;
63
+ letter-spacing: 0.02em;
64
  }
65
+ .brand-pill {
66
+ font-size: 0.66rem;
67
+ font-weight: 700;
68
+ letter-spacing: 0.14em;
69
+ color: var(--accent);
70
+ background: var(--accent-soft);
71
+ padding: 5px 10px;
72
+ border-radius: 999px;
73
  }
74
+ .topbar-right { display: flex; align-items: center; gap: 22px; }
75
  .link-quiet {
76
+ font-size: 0.84rem;
77
+ font-weight: 600;
78
  color: var(--ink-dim);
 
 
 
79
  background: none;
80
+ border: none;
81
+ cursor: pointer;
82
  padding: 0;
83
  }
84
  .link-quiet:hover { color: var(--accent); }
 
86
  display: flex;
87
  align-items: center;
88
  gap: 7px;
89
+ font-size: 0.78rem;
90
+ font-weight: 600;
91
+ color: var(--error);
92
  border-radius: 999px;
93
+ padding: 7px 14px;
94
+ background: rgba(178, 59, 59, 0.08);
95
  }
96
  .status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--error); }
97
+ .runtime-status.ready { color: var(--success); background: var(--success-soft); }
98
  .runtime-status.ready .status-dot { background: var(--success); }
99
 
100
  /* ---------------- App shell ---------------- */
101
  .app-shell {
102
+ max-width: 1560px;
103
+ margin: 0 auto;
104
+ padding: 32px 44px 80px;
105
  display: grid;
106
+ grid-template-columns: 320px minmax(0, 1fr) 320px;
107
+ gap: 26px;
108
+ align-items: start;
109
  }
110
 
111
+ .pane {
112
+ background: var(--paper);
113
+ border: 1px solid var(--border);
114
+ border-radius: var(--radius);
115
+ padding: 22px;
116
+ position: sticky;
117
+ top: 24px;
118
+ max-height: calc(100vh - 48px);
119
+ overflow-y: auto;
120
+ }
121
  .pane::-webkit-scrollbar { width: 7px; }
122
  .pane::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
123
 
124
+ .pane-col { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
125
+
126
+ .card {
127
  background: var(--paper);
128
+ border: 1px solid var(--border);
129
+ border-radius: var(--radius);
130
+ padding: 24px;
131
  }
132
+ .card-heading {
133
+ margin: 0 0 14px;
134
+ font-family: var(--font-serif);
135
+ font-weight: 400;
136
+ font-size: 1.5rem;
137
+ }
138
+
139
+ /* ---------------- Sidebar ---------------- */
140
+ .side-section { margin-bottom: 24px; }
141
  .side-label {
142
  font-size: 0.7rem;
143
  font-weight: 700;
144
+ letter-spacing: 0.12em;
145
  text-transform: uppercase;
146
  color: var(--ink-faint);
147
  margin-bottom: 10px;
148
  display: block;
149
  }
150
+ .side-label-row {
151
+ display: flex;
152
+ align-items: baseline;
153
+ justify-content: space-between;
 
 
 
 
 
 
 
 
 
154
  }
155
+ .side-label-value { font-size: 0.78rem; font-weight: 700; color: var(--accent); }
156
 
157
+ /* Segmented pill control (speaker count, quality) */
158
+ .seg {
159
+ display: flex;
160
+ background: var(--field);
161
+ border-radius: var(--radius-sm);
162
+ padding: 4px;
163
+ gap: 4px;
164
+ margin-bottom: 14px;
165
+ }
166
+ .seg button {
167
  flex: 1;
168
+ display: flex;
169
+ flex-direction: column;
170
+ align-items: center;
171
+ gap: 1px;
172
+ border: none;
173
+ background: transparent;
174
  color: var(--ink-dim);
175
+ border-radius: 8px;
176
+ padding: 7px 4px;
 
 
177
  cursor: pointer;
178
  }
179
+ .seg button strong { font-size: 0.84rem; font-weight: 800; }
180
+ .seg button span { font-size: 0.64rem; font-weight: 600; letter-spacing: 0.02em; }
181
+ .seg button.single-line { flex-direction: row; justify-content: center; padding: 9px 4px; }
182
+ .seg button.single-line span { font-size: 0.78rem; font-weight: 700; }
183
+ .seg button.active { background: var(--ink); color: #fff; }
184
 
185
+ /* Speaker slot cards */
186
+ .slot-card {
187
+ border: 1px solid var(--border);
188
+ border-radius: 14px;
189
+ padding: 12px;
190
  display: flex;
191
  align-items: center;
192
+ gap: 10px;
193
+ margin-bottom: 10px;
194
+ background: var(--paper);
 
 
 
195
  }
196
+ .voice-dot { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; }
197
+ .slot-info { flex: 1; min-width: 0; }
198
+ .slot-name { font-size: 0.86rem; font-weight: 700; }
199
+ .slot-meta {
200
+ font-size: 0.72rem;
201
+ color: var(--ink-faint);
202
+ white-space: nowrap;
203
+ overflow: hidden;
204
+ text-overflow: ellipsis;
205
  }
206
  .voice-play {
207
+ width: 30px; height: 30px;
208
  border-radius: 50%;
209
+ border: none;
210
+ background: var(--field);
211
  color: var(--ink-dim);
212
  cursor: pointer;
213
  flex-shrink: 0;
214
  display: flex; align-items: center; justify-content: center;
215
+ font-size: 0.66rem;
216
  padding: 0;
217
  }
218
+ .voice-play:hover { background: var(--accent-soft); color: var(--accent); }
219
+ .voice-play.playing { background: var(--accent); color: #fff; }
220
+ .slot-change {
221
+ border: 1px solid var(--border-strong);
222
+ background: #fff;
223
+ color: var(--ink-dim);
224
+ font-size: 0.74rem;
225
+ font-weight: 700;
226
+ border-radius: 8px;
227
+ padding: 7px 10px;
228
+ cursor: pointer;
229
+ flex-shrink: 0;
230
+ }
231
+ .slot-change:hover { border-color: var(--accent); color: var(--accent); }
232
 
233
  .custom-voice-row {
234
  display: flex;
235
  align-items: center;
236
  gap: 6px;
237
+ margin: -4px 0 10px 21px;
238
  }
239
  .upload-mini-btn {
240
  font-size: 0.76rem;
 
251
 
252
  .voice-consent-row {
253
  border: 1px solid var(--speaker-3);
254
+ background: rgba(204, 138, 46, 0.08);
255
  border-radius: var(--radius-sm);
256
  padding: 10px 12px;
257
  margin-top: 4px;
 
260
  display: flex;
261
  align-items: flex-start;
262
  gap: 8px;
263
+ font-size: 0.76rem;
264
  color: var(--ink-dim);
265
  cursor: pointer;
266
  }
267
  .voice-consent-row input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
268
 
269
+ input[type="range"] { width: 100%; accent-color: var(--accent); margin: 6px 0 2px; }
270
+ .slider-ends {
271
+ display: flex;
272
+ justify-content: space-between;
273
+ font-size: 0.7rem;
274
+ color: var(--ink-ghost);
275
+ }
276
 
277
  .sidebar-footer {
278
+ font-size: 0.74rem;
279
+ color: var(--ink-ghost);
280
+ line-height: 1.55;
281
  border-top: 1px solid var(--border);
282
  padding-top: 14px;
 
283
  }
284
 
285
+ /* ---------------- Composer ---------------- */
 
 
 
 
 
 
 
286
  .composer {
287
  background: var(--paper);
288
  border: 1px solid var(--border-strong);
289
+ border-radius: 14px;
 
290
  overflow: hidden;
 
291
  }
292
+ .composer:focus-within { border-color: #d9a98c; }
293
  .composer-input {
294
  display: block;
295
  width: 100%;
296
  border: none;
297
  resize: none;
298
+ background: var(--paper-soft);
299
+ padding: 16px 18px 10px;
300
+ font-size: 0.95rem;
301
+ line-height: 1.55;
 
302
  color: var(--ink);
303
+ min-height: 92px;
304
  max-height: 240px;
305
  cursor: text;
306
  }
307
  .composer-input:focus { outline: none; }
308
+ .composer-input::placeholder { color: var(--ink-ghost); font-style: italic; }
309
 
310
  .composer-toolbar {
311
  display: flex;
312
  align-items: center;
313
  justify-content: space-between;
314
  gap: 10px;
315
+ padding: 9px 10px;
316
  border-top: 1px solid var(--border);
317
  background: var(--paper-soft);
318
  }
319
  .composer-toolbar-left { display: flex; align-items: center; gap: 2px; }
320
 
321
+ select {
322
+ background-color: var(--paper-soft);
323
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%236b5f52' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
324
+ background-repeat: no-repeat;
325
+ background-position: right 11px center;
326
+ border: 1px solid var(--border);
327
+ color: var(--ink);
328
+ border-radius: var(--radius-sm);
329
+ padding: 9px 28px 9px 10px;
330
+ font-size: 0.86rem;
331
+ appearance: none;
332
+ cursor: pointer;
333
+ }
334
+ select:focus { outline: none; border-color: #d9a98c; }
335
+
336
  .toolbar-select {
337
  width: auto;
338
  background-color: transparent;
 
345
  font-weight: 600;
346
  padding: 7px 24px 7px 11px;
347
  }
348
+ .toolbar-select:hover { background-color: #fff; border-color: var(--border); }
349
+ .toolbar-select:focus { background-color: #fff; border-color: var(--border-strong); }
 
 
 
 
 
350
 
351
  .toolbar-btn {
352
  display: inline-flex;
 
362
  cursor: pointer;
363
  white-space: nowrap;
364
  }
365
+ .toolbar-btn:hover { background: #fff; border-color: var(--border); color: var(--accent); }
 
 
 
 
 
 
366
 
367
+ /* ---------------- Buttons ---------------- */
368
  .btn {
369
  font-family: var(--font-ui);
370
+ font-size: 0.84rem;
371
+ font-weight: 700;
372
  border-radius: var(--radius-sm);
373
  border: 1px solid var(--border-strong);
374
  background: var(--paper);
 
385
  background: var(--ink);
386
  border-color: var(--ink);
387
  color: var(--cream);
388
+ border-radius: 999px;
389
+ padding: 10px 22px;
390
  }
391
  .btn-ink:hover { background: #3a3226; border-color: #3a3226; }
392
+ .btn-accent {
393
+ background: var(--accent);
394
+ border-color: var(--accent);
395
+ color: #fff;
396
+ border-radius: 999px;
397
+ padding: 9px 18px;
398
+ }
399
+ .btn-accent:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
400
+ .btn-accent:disabled { background: #d9a98c; border-color: #d9a98c; opacity: 1; }
401
+ .btn-pill-outline {
402
+ display: block;
403
+ text-align: center;
404
+ text-decoration: none;
405
+ background: transparent;
406
+ border: 1px solid var(--border-strong);
407
+ color: var(--ink-dim);
408
+ border-radius: 999px;
409
+ padding: 10px 16px;
410
+ }
411
  .btn-icon-only {
412
  width: 34px; height: 34px;
413
  padding: 0;
 
415
  display: flex; align-items: center; justify-content: center;
416
  }
417
 
418
+ /* ---------------- Example chips ---------------- */
419
+ .example-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
420
  .chip {
421
+ font-size: 0.8rem;
422
+ font-weight: 600;
423
+ border: 1px solid var(--border-strong);
424
+ background: #fff;
425
  color: var(--ink-dim);
426
  border-radius: 999px;
427
+ padding: 7px 14px;
428
  cursor: pointer;
429
  }
430
+ .chip:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
431
+ .gen-status { font-size: 0.78rem; color: var(--ink-faint); margin-top: 8px; min-height: 1em; }
432
 
433
+ /* ---------------- Transcript / turn editor ---------------- */
434
  .transcript-header {
435
  display: flex;
436
  align-items: baseline;
437
  justify-content: space-between;
438
+ margin: 0 0 16px;
439
  }
440
  .transcript-header h2 {
441
  margin: 0;
442
+ font-family: var(--font-serif);
443
+ font-weight: 400;
444
+ font-size: 1.6rem;
445
  }
446
+ .transcript-meta { font-size: 0.8rem; color: var(--ink-ghost); }
447
 
448
  .empty-transcript {
449
  text-align: center;
450
+ padding: 44px 20px;
451
+ color: var(--ink-ghost);
452
+ font-size: 0.88rem;
453
+ border: 1.5px dashed var(--border-strong);
454
+ border-radius: 16px;
455
+ }
456
+ .empty-transcript .empty-title {
457
+ font-family: var(--font-serif);
458
+ font-size: 1.25rem;
459
  color: var(--ink-faint);
460
+ margin-bottom: 6px;
 
 
 
461
  }
462
  .empty-transcript strong { color: var(--ink-dim); }
463
 
464
  .turn-card {
 
 
465
  border-left: 3px solid var(--speaker-1);
466
+ padding: 2px 0 2px 14px;
467
+ margin-bottom: 14px;
 
468
  position: relative;
469
  }
 
 
 
 
470
  .turn-head {
471
  display: flex;
472
  align-items: center;
473
  justify-content: space-between;
474
+ margin-bottom: 4px;
475
  }
476
  .turn-speaker-select {
477
+ font-size: 0.72rem;
478
+ font-weight: 700;
479
  border: none;
480
+ background-color: transparent;
481
+ background-position: right 6px center;
482
  background-size: 8px 5px;
483
  border-radius: 999px;
484
+ padding: 3px 20px 3px 8px;
485
+ margin-left: -8px;
486
+ color: var(--ink-faint);
487
  cursor: pointer;
488
  }
489
+ .turn-speaker-select:hover { background-color: var(--paper-soft); }
490
  .turn-remove {
491
  border: none;
492
  background: none;
 
505
  border: none;
506
  background: transparent;
507
  resize: none;
508
+ font-family: var(--font-ui);
509
+ font-size: 0.94rem;
510
+ line-height: 1.6;
511
  color: var(--ink);
512
  min-height: 24px;
513
+ padding: 0;
514
  }
515
  .turn-card textarea:focus { outline: none; }
516
 
517
  .add-turn {
518
  width: 100%;
519
+ border: 1.5px dashed var(--border-strong);
520
  background: transparent;
521
  color: var(--ink-faint);
522
  border-radius: var(--radius-sm);
523
  padding: 10px;
524
  font-size: 0.84rem;
525
+ font-weight: 600;
526
  cursor: pointer;
527
  margin-top: 4px;
528
  }
 
532
  .generate-bar {
533
  position: sticky;
534
  bottom: 22px;
 
535
  background: var(--paper);
536
  border: 1px solid var(--border-strong);
537
  border-radius: 999px;
 
542
  justify-content: space-between;
543
  gap: 12px;
544
  }
545
+ .generate-bar-meta { font-size: 0.8rem; font-weight: 600; color: var(--ink-dim); }
 
546
 
547
  /* ---------------- Output dock ---------------- */
548
+ .dock-label { margin-bottom: 16px; }
 
 
 
 
 
 
 
 
 
 
 
 
549
  .dock-empty {
550
  text-align: center;
551
+ padding: 30px 8px;
552
+ color: #c9bca5;
553
+ font-size: 0.82rem;
554
+ line-height: 1.6;
555
  }
556
 
557
  .status-card {
 
 
 
 
558
  display: none;
559
+ flex-direction: column;
560
+ align-items: center;
561
+ gap: 10px;
562
+ text-align: center;
563
+ padding: 18px 6px;
564
+ margin-bottom: 14px;
565
  }
566
+ .status-card.visible { display: flex; }
567
+ .status-spinner {
568
+ width: 34px; height: 34px;
569
+ border-radius: 50%;
570
+ border: 3px solid var(--border);
571
+ border-top-color: var(--accent);
572
+ animation: spin 0.8s linear infinite;
573
+ }
574
+ .status-card.complete .status-spinner, .status-card.error .status-spinner { display: none; }
575
+ @keyframes spin { to { transform: rotate(360deg); } }
576
+ .status-title { font-size: 0.88rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
577
+ .status-desc { font-size: 0.78rem; color: var(--ink-dim); }
578
+ .status-card.error .status-title { color: var(--error); }
579
  .status-dot-pulse {
580
  width: 8px; height: 8px; border-radius: 50%;
581
  background: var(--accent);
 
585
  .status-card.error .status-dot-pulse { background: var(--error); animation: none; }
586
  @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.7); } }
587
 
588
+ /* Player */
589
+ .result-block { display: none; animation: fadeIn 0.3s ease; }
590
  .result-block.visible { display: block; }
591
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
592
+
593
+ .player-top { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
594
+ .play-btn {
595
+ width: 44px; height: 44px;
596
+ border-radius: 50%;
597
+ background: var(--accent);
598
+ border: none;
599
+ color: #fff;
600
+ font-size: 0.85rem;
601
+ cursor: pointer;
602
+ flex-shrink: 0;
603
+ display: flex; align-items: center; justify-content: center;
604
+ padding: 0;
605
+ }
606
+ .play-btn:hover { background: var(--accent-hover); }
607
+ #resultWaveform { flex: 1; min-width: 0; height: 44px; display: block; cursor: pointer; }
608
+ .player-time { font-size: 0.76rem; color: var(--ink-faint); margin-bottom: 12px; text-align: right; }
609
+
610
+ .synced-transcript {
611
+ display: flex;
612
+ flex-direction: column;
613
+ gap: 6px;
614
+ max-height: 300px;
615
+ overflow-y: auto;
616
+ margin-bottom: 14px;
617
+ padding-right: 4px;
618
+ }
619
+ .synced-transcript::-webkit-scrollbar { width: 6px; }
620
+ .synced-transcript::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
621
+ .sync-line {
622
+ display: flex;
623
+ gap: 9px;
624
+ padding: 7px 9px;
625
+ border-radius: 10px;
626
+ cursor: pointer;
627
+ transition: background 0.25s;
628
+ }
629
+ .sync-line:hover { background: var(--paper-soft); }
630
+ .sync-line .sync-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
631
+ .sync-line .sync-label { font-size: 0.68rem; font-weight: 700; color: var(--ink-faint); margin-bottom: 2px; }
632
+ .sync-line .sync-text { font-size: 0.8rem; line-height: 1.5; color: var(--ink-ghost); }
633
+ .sync-line.active { background: var(--accent-soft); }
634
+ .sync-line.active .sync-label { color: var(--accent-hover); }
635
+ .sync-line.active .sync-text { color: var(--ink); font-weight: 600; }
636
+
637
+ .result-metrics {
638
+ display: flex;
639
+ flex-direction: column;
640
+ gap: 6px;
641
+ font-size: 0.78rem;
642
+ color: var(--ink-dim);
643
+ border-top: 1px solid var(--border);
644
+ padding-top: 12px;
645
+ margin-bottom: 12px;
646
+ }
647
+ .result-metrics > div { display: flex; justify-content: space-between; }
648
  .result-metrics span { color: var(--ink-faint); }
649
  .result-metrics strong { color: var(--ink); font-weight: 600; }
650
 
651
  .log-toggle {
652
+ font-size: 0.74rem;
653
  color: var(--ink-faint);
654
  background: none;
655
  border: none;
656
  cursor: pointer;
657
  padding: 0;
658
+ margin-top: 12px;
659
  text-decoration: underline dotted;
660
  }
661
  .log-box {
 
674
  }
675
  .log-box.visible { display: block; }
676
 
677
+ /* ---------------- Dialogs ---------------- */
678
  dialog {
679
  border: none;
680
+ border-radius: 22px;
681
  padding: 0;
682
  max-width: 520px;
683
  width: 92vw;
684
+ box-shadow: 0 24px 64px rgba(42, 32, 22, 0.25);
685
  background: var(--paper);
686
  color: var(--ink);
687
  }
688
+ dialog::backdrop { background: rgba(42, 32, 22, 0.45); }
689
+ .dialog-body { padding: 24px; }
690
+ .dialog-body h3 {
691
+ margin: 0 0 4px;
692
+ font-family: var(--font-serif);
693
+ font-weight: 400;
694
+ font-size: 1.5rem;
695
+ }
696
+ .dialog-body p { font-size: 0.82rem; color: var(--ink-dim); margin: 8px 0 14px; }
697
  .dialog-body textarea {
698
  width: 100%;
699
  border: 1px solid var(--border-strong);
700
  border-radius: var(--radius-sm);
701
+ padding: 12px;
702
  font-size: 0.86rem;
703
+ background: var(--paper-soft);
 
704
  resize: vertical;
705
  color: var(--ink);
706
  }
707
+ .dialog-body textarea:focus { outline: none; border-color: #d9a98c; }
 
 
 
 
 
708
  .dialog-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; gap: 10px; }
709
  .dialog-actions input[type="file"] { font-size: 0.78rem; max-width: 190px; }
710
+ .dialog-close {
711
+ background: none;
712
+ border: none;
713
+ font-size: 1.4rem;
714
+ color: var(--ink-faint);
715
+ cursor: pointer;
716
+ padding: 0 4px;
717
+ line-height: 1;
718
+ }
719
+ .dialog-close:hover { color: var(--ink); }
720
+
721
+ /* Voice library */
722
+ .library-dialog { max-width: 780px; }
723
+ .library-dialog .dialog-body { max-height: 82vh; overflow-y: auto; }
724
+ .library-head {
725
+ display: flex;
726
+ justify-content: space-between;
727
+ align-items: baseline;
728
+ margin-bottom: 16px;
729
+ }
730
+ .library-search {
731
+ width: 100%;
732
+ border: 1px solid var(--border-strong);
733
+ background: var(--paper-soft);
734
+ border-radius: var(--radius-sm);
735
+ padding: 11px 14px;
736
+ font-size: 0.88rem;
737
+ margin-bottom: 12px;
738
+ color: var(--ink);
739
+ }
740
+ .library-search:focus { outline: none; border-color: #d9a98c; }
741
+ .filter-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
742
+ .filter-chip {
743
+ padding: 6px 12px;
744
+ border-radius: 999px;
745
+ font-size: 0.76rem;
746
+ font-weight: 600;
747
+ cursor: pointer;
748
+ border: 1px solid var(--border-strong);
749
+ background: #fff;
750
+ color: var(--ink-dim);
751
+ }
752
+ .filter-chip.active {
753
+ border: 1.5px solid var(--accent);
754
+ background: var(--accent-soft);
755
+ color: var(--accent);
756
+ }
757
+
758
+ .library-grid {
759
+ display: grid;
760
+ grid-template-columns: repeat(2, 1fr);
761
+ gap: 12px;
762
+ }
763
+ .voice-card {
764
+ border: 1px solid var(--border);
765
+ border-radius: 16px;
766
+ padding: 14px;
767
+ display: flex;
768
+ gap: 12px;
769
+ align-items: flex-start;
770
+ }
771
+ .voice-card.clone-card { border-style: dashed; border-color: var(--border-strong); }
772
+ .voice-avatar {
773
+ width: 40px; height: 40px;
774
+ border-radius: 50%;
775
+ color: #fff;
776
+ font-weight: 700;
777
+ font-size: 1.05rem;
778
+ display: flex; align-items: center; justify-content: center;
779
+ flex-shrink: 0;
780
+ font-family: var(--font-serif);
781
+ }
782
+ .clone-card .voice-avatar { background: var(--field); font-size: 1.1rem; }
783
+ .voice-card-body { flex: 1; min-width: 0; }
784
+ .voice-card-head { display: flex; justify-content: space-between; align-items: baseline; }
785
+ .voice-card-name { font-size: 0.92rem; font-weight: 700; }
786
+ .voice-preview-link {
787
+ background: none;
788
+ border: none;
789
+ color: var(--accent);
790
+ font-size: 0.74rem;
791
+ font-weight: 700;
792
+ cursor: pointer;
793
+ padding: 0;
794
+ }
795
+ .voice-card-meta { font-size: 0.74rem; color: var(--ink-faint); margin: 2px 0 9px; }
796
+ .slot-assign { display: flex; gap: 6px; align-items: center; }
797
+ .slot-assign-label { font-size: 0.68rem; color: var(--ink-ghost); margin-right: 2px; }
798
+ .slot-assign button {
799
+ width: 26px; height: 26px;
800
+ border-radius: 50%;
801
+ border: 1px solid var(--border-strong);
802
+ background: #fff;
803
+ color: var(--ink-dim);
804
+ font-size: 0.74rem;
805
+ font-weight: 700;
806
+ cursor: pointer;
807
+ padding: 0;
808
+ }
809
+ .slot-assign button.assigned { border: none; color: #fff; }
810
 
811
  /* ---------------- About dialog ---------------- */
812
  #aboutDialog .dialog-body { max-height: 70vh; overflow-y: auto; }
 
815
  #aboutDialog li { margin-bottom: 4px; }
816
 
817
  /* ---------------- Responsive ---------------- */
818
+ @media (max-width: 1400px) {
819
+ .app-shell { grid-template-columns: 1fr; max-width: 760px; }
820
+ .pane { position: static; max-height: none; }
821
+ }
822
+ @media (max-width: 640px) {
823
+ .topbar { padding: 14px 18px; }
824
+ .app-shell { padding: 20px 16px 80px; }
825
+ .library-grid { grid-template-columns: 1fr; }
826
  }