Redesign the studio UI from the voice-studio prototype
Browse filesAdopt 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>
- app.py +16 -5
- static/app.js +440 -85
- static/index.html +68 -35
- static/styles.css +461 -253
|
@@ -29,10 +29,15 @@ MODAL_STUB_NAME = "vibevoice-generator"
|
|
| 29 |
MODAL_CLASS_NAME = "VibeVoiceModel"
|
| 30 |
|
| 31 |
AVAILABLE_MODELS = ["VibeVoice-1.5B", "VibeVoice-7B"]
|
| 32 |
-
|
| 33 |
-
"Cherry": "F", "
|
| 34 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
-
"
|
| 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
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
if (state.playingVoice === name) {
|
| 105 |
state.previewAudio.pause();
|
|
|
|
|
|
|
| 106 |
return;
|
| 107 |
}
|
| 108 |
-
const voice =
|
| 109 |
if (!voice) return;
|
| 110 |
state.previewAudio.src = voice.preview_url;
|
| 111 |
state.playingVoice = name;
|
| 112 |
-
|
| 113 |
state.previewAudio.play().catch((err) => {
|
| 114 |
state.playingVoice = null;
|
| 115 |
-
|
| 116 |
console.error("Voice preview failed to play:", err);
|
| 117 |
});
|
| 118 |
}
|
| 119 |
state.previewAudio.addEventListener("ended", () => {
|
| 120 |
state.playingVoice = null;
|
| 121 |
-
|
| 122 |
});
|
| 123 |
|
| 124 |
-
/* ---------------- Sidebar:
|
| 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
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 150 |
-
|
| 151 |
|
| 152 |
const dot = document.createElement("span");
|
| 153 |
dot.className = "voice-dot";
|
| 154 |
-
dot.style.background =
|
| 155 |
-
|
| 156 |
-
const
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
playBtn.textContent = "▶";
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
-
|
| 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
|
| 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 |
-
|
| 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-
|
| 210 |
clearBtn.textContent = "✕";
|
| 211 |
clearBtn.title = "Remove file";
|
| 212 |
clearBtn.addEventListener("click", () => {
|
| 213 |
state.customVoiceFiles[i] = null;
|
| 214 |
-
|
| 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 |
-
|
| 230 |
});
|
| 231 |
});
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 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 =
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 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 |
-
/* ----------------
|
| 423 |
-
async function
|
| 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 |
-
|
| 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.
|
|
|
|
| 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:
|
| 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 =
|
| 591 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 620 |
const defaultDuration = durationOptions.includes(2) ? 2 : durationOptions[0];
|
| 621 |
el.durationSelect.value = String(defaultDuration);
|
| 622 |
|
| 623 |
-
|
|
|
|
| 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();
|
|
@@ -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="#
|
| 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="
|
| 20 |
</a>
|
| 21 |
-
<
|
|
|
|
| 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 |
-
</
|
| 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">
|
| 35 |
-
<
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 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">
|
| 57 |
-
<div class="
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
|
|
|
| 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-
|
| 87 |
</div>
|
| 88 |
</div>
|
| 89 |
<div class="example-chips" id="examplePills"></div>
|
| 90 |
-
<div id="scriptGenStatus" class="
|
|
|
|
| 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 |
-
|
| 100 |
-
|
|
|
|
| 101 |
</div>
|
| 102 |
</div>
|
| 103 |
<button class="add-turn" id="addTurnBtn" type="button">+ Add a line</button>
|
|
|
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
</div>
|
| 109 |
</div>
|
| 110 |
</main>
|
| 111 |
|
| 112 |
<!-- DOCK: generation status + output -->
|
| 113 |
<aside class="pane dock">
|
| 114 |
-
<div class="dock-label">
|
| 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 |
-
<
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
| 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
|
| 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 & steady</span><span>Big & 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;">
|
|
@@ -1,30 +1,33 @@
|
|
| 1 |
:root {
|
| 2 |
-
--cream: #
|
| 3 |
-
--paper: #
|
| 4 |
-
--paper-soft: #
|
| 5 |
-
--field: #
|
| 6 |
-
--border: #
|
| 7 |
-
--border-strong: #
|
| 8 |
-
--ink: #
|
| 9 |
-
--ink-dim: #
|
| 10 |
-
--ink-faint: #
|
| 11 |
-
--
|
| 12 |
-
--accent
|
| 13 |
-
--accent-
|
| 14 |
-
--
|
|
|
|
|
|
|
| 15 |
--error: #b23b3b;
|
| 16 |
-
--speaker-1: #
|
| 17 |
-
--speaker-2: #
|
| 18 |
-
--speaker-3: #
|
| 19 |
-
--speaker-4: #
|
| 20 |
-
--radius:
|
| 21 |
-
--radius-sm:
|
| 22 |
-
--shadow: 0
|
| 23 |
-
--font: "
|
| 24 |
-
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
| 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:
|
| 57 |
-
border-bottom: 1px solid
|
| 58 |
-
background: var(--paper);
|
| 59 |
-
z-index: 30;
|
| 60 |
}
|
| 61 |
.wordmark {
|
| 62 |
display: flex;
|
| 63 |
align-items: baseline;
|
| 64 |
-
gap:
|
| 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.
|
| 73 |
-
letter-spacing: 0.
|
| 74 |
}
|
| 75 |
-
.
|
| 76 |
-
font-size: 0.
|
| 77 |
-
|
| 78 |
-
letter-spacing: 0.
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
-
.topbar-right { display: flex; align-items: center; gap:
|
| 82 |
.link-quiet {
|
| 83 |
-
font-size: 0.
|
|
|
|
| 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
|
|
|
|
| 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.
|
| 98 |
-
|
| 99 |
-
|
| 100 |
border-radius: 999px;
|
| 101 |
-
padding:
|
| 102 |
-
background:
|
| 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 |
-
|
|
|
|
|
|
|
| 110 |
display: grid;
|
| 111 |
-
grid-template-columns:
|
| 112 |
-
|
|
|
|
| 113 |
}
|
| 114 |
|
| 115 |
-
.pane {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
.pane::-webkit-scrollbar { width: 7px; }
|
| 117 |
.pane::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
background: var(--paper);
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
}
|
| 125 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
.side-label {
|
| 127 |
font-size: 0.7rem;
|
| 128 |
font-weight: 700;
|
| 129 |
-
letter-spacing: 0.
|
| 130 |
text-transform: uppercase;
|
| 131 |
color: var(--ink-faint);
|
| 132 |
margin-bottom: 10px;
|
| 133 |
display: block;
|
| 134 |
}
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 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 |
-
|
| 150 |
|
| 151 |
-
|
| 152 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
flex: 1;
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
color: var(--ink-dim);
|
| 157 |
-
border-radius:
|
| 158 |
-
padding: 7px
|
| 159 |
-
font-size: 0.82rem;
|
| 160 |
-
font-weight: 600;
|
| 161 |
cursor: pointer;
|
| 162 |
}
|
| 163 |
-
.
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
}
|
| 168 |
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
display: flex;
|
| 171 |
align-items: center;
|
| 172 |
-
gap:
|
| 173 |
-
margin-bottom:
|
| 174 |
-
|
| 175 |
-
border: 1px solid var(--border);
|
| 176 |
-
border-radius: var(--radius-sm);
|
| 177 |
-
background: var(--paper-soft);
|
| 178 |
}
|
| 179 |
-
.voice-dot { width:
|
| 180 |
-
.
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
.voice-play {
|
| 188 |
-
width:
|
| 189 |
border-radius: 50%;
|
| 190 |
-
border:
|
| 191 |
-
background: var(--
|
| 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.
|
| 197 |
padding: 0;
|
| 198 |
}
|
| 199 |
-
.voice-play:hover {
|
| 200 |
-
.voice-play.playing { background: var(--accent);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
.custom-voice-row {
|
| 203 |
display: flex;
|
| 204 |
align-items: center;
|
| 205 |
gap: 6px;
|
| 206 |
-
margin: -4px 0
|
| 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(
|
| 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.
|
| 233 |
color: var(--ink-dim);
|
| 234 |
cursor: pointer;
|
| 235 |
}
|
| 236 |
.voice-consent-row input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
.sidebar-footer {
|
| 243 |
-
font-size: 0.
|
| 244 |
-
color: var(--ink-
|
| 245 |
-
line-height: 1.
|
| 246 |
border-top: 1px solid var(--border);
|
| 247 |
padding-top: 14px;
|
| 248 |
-
margin-top: 6px;
|
| 249 |
}
|
| 250 |
|
| 251 |
-
/* ----------------
|
| 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:
|
| 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:
|
| 273 |
-
padding: 18px
|
| 274 |
-
font-size:
|
| 275 |
-
line-height: 1.
|
| 276 |
-
font-family: var(--font);
|
| 277 |
color: var(--ink);
|
| 278 |
-
min-height:
|
| 279 |
max-height: 240px;
|
| 280 |
cursor: text;
|
| 281 |
}
|
| 282 |
.composer-input:focus { outline: none; }
|
| 283 |
-
.composer-input::placeholder { color: var(--ink-
|
| 284 |
|
| 285 |
.composer-toolbar {
|
| 286 |
display: flex;
|
| 287 |
align-items: center;
|
| 288 |
justify-content: space-between;
|
| 289 |
gap: 10px;
|
| 290 |
-
padding: 9px
|
| 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:
|
| 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:
|
| 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.
|
| 341 |
-
font-weight:
|
| 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 |
-
|
|
|
|
| 368 |
.chip {
|
| 369 |
-
font-size: 0.
|
| 370 |
-
|
| 371 |
-
|
|
|
|
| 372 |
color: var(--ink-dim);
|
| 373 |
border-radius: 999px;
|
| 374 |
-
padding:
|
| 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:
|
| 385 |
}
|
| 386 |
.transcript-header h2 {
|
| 387 |
margin: 0;
|
| 388 |
-
font-family: var(--font);
|
| 389 |
-
font-weight:
|
| 390 |
-
font-size: 1.
|
| 391 |
}
|
| 392 |
-
.transcript-meta { font-size: 0.
|
| 393 |
|
| 394 |
.empty-transcript {
|
| 395 |
text-align: center;
|
| 396 |
-
padding:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
color: var(--ink-faint);
|
| 398 |
-
|
| 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 |
-
|
| 410 |
-
|
| 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:
|
| 423 |
}
|
| 424 |
.turn-speaker-select {
|
| 425 |
-
font-size: 0.
|
| 426 |
-
font-weight:
|
| 427 |
border: none;
|
| 428 |
-
background-color:
|
| 429 |
-
background-position: right
|
| 430 |
background-size: 8px 5px;
|
| 431 |
border-radius: 999px;
|
| 432 |
-
padding: 3px
|
| 433 |
-
|
|
|
|
| 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.
|
| 456 |
-
line-height: 1.
|
| 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:
|
| 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:
|
| 510 |
-
color:
|
| 511 |
-
font-size: 0.
|
|
|
|
| 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:
|
| 522 |
-
.status-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 537 |
.result-block.visible { display: block; }
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
/* ----------------
|
| 570 |
dialog {
|
| 571 |
border: none;
|
| 572 |
-
border-radius:
|
| 573 |
padding: 0;
|
| 574 |
max-width: 520px;
|
| 575 |
width: 92vw;
|
| 576 |
-
box-shadow: 0 24px
|
| 577 |
background: var(--paper);
|
| 578 |
color: var(--ink);
|
| 579 |
}
|
| 580 |
-
dialog::backdrop { background: rgba(
|
| 581 |
-
.dialog-body { padding:
|
| 582 |
-
.dialog-body h3 {
|
| 583 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
.dialog-body textarea {
|
| 585 |
width: 100%;
|
| 586 |
border: 1px solid var(--border-strong);
|
| 587 |
border-radius: var(--radius-sm);
|
| 588 |
-
padding:
|
| 589 |
font-size: 0.86rem;
|
| 590 |
-
background: var(--
|
| 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:
|
| 612 |
-
.app-shell { grid-template-columns:
|
| 613 |
-
}
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
.
|
| 617 |
-
.
|
|
|
|
| 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 |
}
|