Add playback polish; stop warm-up polluting generation time
Browse filesPolish: a panel on the player stage with speed (0.8-1.25x, pitch
preserved so no chipmunk), tone (neutral/warm/bright via shelf filters)
and a leveller for quiet speech. It runs live on the playing element,
so it costs nothing and works on a five-hour take — and it is labelled
as affecting playback only, since the stored WAV is untouched.
Warm-up: GPU cold start was folded into 'elapsed' and into the reported
Generation time, which also included the download — a 59-minute render
reported as 72 minutes. The clock now rebases when synthesis actually
starts, warm-up is reported as its own metric, and generation time is
captured before the transfer. Added a realtime-factor metric; both now
agree with the backend's own log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- static/app.js +149 -7
- static/index.html +26 -0
- static/styles.css +58 -0
|
@@ -13,8 +13,8 @@ const SCRIPT_GEN_MESSAGES = [
|
|
| 13 |
];
|
| 14 |
|
| 15 |
const PRIMARY_STAGE_MESSAGES = {
|
| 16 |
-
connecting: ["Submitted", "
|
| 17 |
-
queued: ["Queued", "
|
| 18 |
loading_model: ["Loading model", "Streaming VibeVoice weights to the GPU."],
|
| 19 |
loading_voices: ["Loading voices", null],
|
| 20 |
preparing_inputs: ["Preparing", "Formatting the conversation for the model."],
|
|
@@ -83,8 +83,10 @@ const el = {};
|
|
| 83 |
"composerCollapsedStrip", "collapsedSummary", "composerBody",
|
| 84 |
"playerStage", "stageTitle", "stagePlayBtn", "stageWaveform", "stageTime",
|
| 85 |
"stageDot", "stageLine", "stageSpeaker", "stageCloseBtn", "stageDownloadBtn",
|
| 86 |
-
"stageScriptToggle", "stageTranscript",
|
|
|
|
| 87 |
"generationTime", "audioDuration", "resultModel", "downloadBtn",
|
|
|
|
| 88 |
"downloadMp3Btn", "stageDownloadMp3Btn",
|
| 89 |
"logToggleBtn", "logBox",
|
| 90 |
"voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid", "libraryTitle",
|
|
@@ -1489,6 +1491,106 @@ el.resultAudio.addEventListener("ended", () => setPlayIcons("►"));
|
|
| 1489 |
el.resultAudio.addEventListener("timeupdate", updatePlaybackUI);
|
| 1490 |
el.resultAudio.addEventListener("loadedmetadata", updatePlaybackUI);
|
| 1491 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1492 |
/* Now Playing stage */
|
| 1493 |
function openPlayerStage() {
|
| 1494 |
showStagePane("player");
|
|
@@ -1570,8 +1672,23 @@ el.stopGenBtn.addEventListener("click", () => {
|
|
| 1570 |
const progress = {
|
| 1571 |
startedAt: 0, wave: 0, totalWaves: 0, ticker: null, label: "",
|
| 1572 |
waveStartedAt: 0, waveDurations: [],
|
|
|
|
| 1573 |
};
|
| 1574 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1575 |
function formatMinutes(seconds) {
|
| 1576 |
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
| 1577 |
if (seconds < 90) return `${Math.round(seconds)}s`;
|
|
@@ -1581,9 +1698,18 @@ function formatMinutes(seconds) {
|
|
| 1581 |
|
| 1582 |
function paintProgress() {
|
| 1583 |
if (!progress.startedAt) return;
|
| 1584 |
-
const elapsed = (
|
| 1585 |
const parts = [];
|
| 1586 |
let pctText = "Starting…";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1587 |
if (progress.totalWaves) {
|
| 1588 |
const done = Math.max(0, progress.wave - 1);
|
| 1589 |
// Waves land in steps minutes apart, so a bare completed-wave count would
|
|
@@ -1622,7 +1748,10 @@ function paintProgress() {
|
|
| 1622 |
} else if (progress.label) {
|
| 1623 |
parts.push(progress.label);
|
| 1624 |
}
|
| 1625 |
-
parts.push(`${formatMinutes(elapsed) || "0s"}
|
|
|
|
|
|
|
|
|
|
| 1626 |
const meta = parts.join(" · ");
|
| 1627 |
el.progressMeta.hidden = false;
|
| 1628 |
el.progressMeta.textContent = meta;
|
|
@@ -1657,6 +1786,8 @@ function startProgress() {
|
|
| 1657 |
progress.label = "";
|
| 1658 |
progress.waveStartedAt = 0;
|
| 1659 |
progress.waveDurations = [];
|
|
|
|
|
|
|
| 1660 |
el.progressFill.style.width = "0%";
|
| 1661 |
el.progressTrack.classList.remove("indeterminate");
|
| 1662 |
el.genStageTrack.classList.remove("indeterminate");
|
|
@@ -1757,6 +1888,7 @@ async function presentTake(blob, durationSeconds, snapshot, audioId) {
|
|
| 1757 |
el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
| 1758 |
el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
| 1759 |
setPlayIcons("►");
|
|
|
|
| 1760 |
buildSyncedTranscript(snapshot);
|
| 1761 |
el.dockEmpty.hidden = true;
|
| 1762 |
el.resultBlock.classList.add("visible");
|
|
@@ -1863,7 +1995,6 @@ el.generateBtn.addEventListener("click", async () => {
|
|
| 1863 |
el.logBox.classList.remove("visible");
|
| 1864 |
el.logToggleBtn.hidden = true;
|
| 1865 |
el.logToggleBtn.textContent = "View generation log";
|
| 1866 |
-
const started = performance.now();
|
| 1867 |
startProgress();
|
| 1868 |
// The render is already submitted — edits here can't reach it, so lock the
|
| 1869 |
// workspace rather than let controls silently no-op, and put progress
|
|
@@ -1927,6 +2058,7 @@ el.generateBtn.addEventListener("click", async () => {
|
|
| 1927 |
if (!rawEvent.startsWith("data: ")) continue;
|
| 1928 |
const evt = JSON.parse(rawEvent.slice(6));
|
| 1929 |
const isDone = evt.stage === "complete" || evt.stage === "error";
|
|
|
|
| 1930 |
// Read the real status for progress before parody flavour replaces it.
|
| 1931 |
noteProgressFromStatus(evt.status);
|
| 1932 |
const displayLine = isDone ? evt.status : nextParodyLine() || evt.status;
|
|
@@ -1946,8 +2078,18 @@ el.generateBtn.addEventListener("click", async () => {
|
|
| 1946 |
}
|
| 1947 |
|
| 1948 |
if (evt.stage === "complete" && evt.audio_id) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1949 |
const blob = await downloadTakeBlob(evt.audio_id);
|
| 1950 |
-
el.generationTime.textContent = formatDuration(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1951 |
el.resultModel.textContent = state.model;
|
| 1952 |
state.resultTitle = el.scriptTitle.textContent;
|
| 1953 |
await presentTake(blob, evt.audio_duration, turnsSnapshot, evt.audio_id);
|
|
|
|
| 13 |
];
|
| 14 |
|
| 15 |
const PRIMARY_STAGE_MESSAGES = {
|
| 16 |
+
connecting: ["Submitted", "GPU resources warming up — a cold start can take up to a minute."],
|
| 17 |
+
queued: ["Queued", "GPU resources warming up — the worker is spinning up."],
|
| 18 |
loading_model: ["Loading model", "Streaming VibeVoice weights to the GPU."],
|
| 19 |
loading_voices: ["Loading voices", null],
|
| 20 |
preparing_inputs: ["Preparing", "Formatting the conversation for the model."],
|
|
|
|
| 83 |
"composerCollapsedStrip", "collapsedSummary", "composerBody",
|
| 84 |
"playerStage", "stageTitle", "stagePlayBtn", "stageWaveform", "stageTime",
|
| 85 |
"stageDot", "stageLine", "stageSpeaker", "stageCloseBtn", "stageDownloadBtn",
|
| 86 |
+
"stageScriptToggle", "stageTranscript", "stagePolishToggle", "polishPanel",
|
| 87 |
+
"polishSpeed", "polishSpeedValue", "polishTone", "polishBoost", "polishReset",
|
| 88 |
"generationTime", "audioDuration", "resultModel", "downloadBtn",
|
| 89 |
+
"realtimeRow", "realtimeFactor", "warmupRow", "warmupTime",
|
| 90 |
"downloadMp3Btn", "stageDownloadMp3Btn",
|
| 91 |
"logToggleBtn", "logBox",
|
| 92 |
"voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid", "libraryTitle",
|
|
|
|
| 1491 |
el.resultAudio.addEventListener("timeupdate", updatePlaybackUI);
|
| 1492 |
el.resultAudio.addEventListener("loadedmetadata", updatePlaybackUI);
|
| 1493 |
|
| 1494 |
+
/* ---------------- Post-generation polish ----------------
|
| 1495 |
+
Everything here runs live on the playing element — playbackRate for pace
|
| 1496 |
+
(pitch preserved, so no chipmunk) and a small Web Audio graph for tone and
|
| 1497 |
+
levelling. That means it costs nothing and works on a five-hour take, but
|
| 1498 |
+
it shapes playback only; the stored WAV is untouched. */
|
| 1499 |
+
const TONE_CURVES = {
|
| 1500 |
+
neutral: { low: 0, high: 0 },
|
| 1501 |
+
warm: { low: 4, high: -3 },
|
| 1502 |
+
bright: { low: -2, high: 4.5 },
|
| 1503 |
+
};
|
| 1504 |
+
|
| 1505 |
+
const polish = { ctx: null, low: null, high: null, comp: null, gain: null, tone: "neutral" };
|
| 1506 |
+
|
| 1507 |
+
function ensureAudioGraph() {
|
| 1508 |
+
if (polish.ctx) return true;
|
| 1509 |
+
const Ctx = window.AudioContext || window.webkitAudioContext;
|
| 1510 |
+
if (!Ctx) return false;
|
| 1511 |
+
try {
|
| 1512 |
+
polish.ctx = new Ctx();
|
| 1513 |
+
// createMediaElementSource may only be called once per element; from here
|
| 1514 |
+
// on the element's audio reaches the speakers through this graph.
|
| 1515 |
+
const source = polish.ctx.createMediaElementSource(el.resultAudio);
|
| 1516 |
+
polish.low = polish.ctx.createBiquadFilter();
|
| 1517 |
+
polish.low.type = "lowshelf";
|
| 1518 |
+
polish.low.frequency.value = 250;
|
| 1519 |
+
polish.high = polish.ctx.createBiquadFilter();
|
| 1520 |
+
polish.high.type = "highshelf";
|
| 1521 |
+
polish.high.frequency.value = 3500;
|
| 1522 |
+
polish.comp = polish.ctx.createDynamicsCompressor();
|
| 1523 |
+
polish.gain = polish.ctx.createGain();
|
| 1524 |
+
source.connect(polish.low);
|
| 1525 |
+
polish.low.connect(polish.high);
|
| 1526 |
+
polish.high.connect(polish.comp);
|
| 1527 |
+
polish.comp.connect(polish.gain);
|
| 1528 |
+
polish.gain.connect(polish.ctx.destination);
|
| 1529 |
+
applyPolish();
|
| 1530 |
+
return true;
|
| 1531 |
+
} catch (error) {
|
| 1532 |
+
console.warn("Audio polish unavailable:", error);
|
| 1533 |
+
polish.ctx = null;
|
| 1534 |
+
return false;
|
| 1535 |
+
}
|
| 1536 |
+
}
|
| 1537 |
+
|
| 1538 |
+
function applyPolish() {
|
| 1539 |
+
const speed = Number(el.polishSpeed.value);
|
| 1540 |
+
el.resultAudio.preservesPitch = true;
|
| 1541 |
+
el.resultAudio.mozPreservesPitch = true;
|
| 1542 |
+
el.resultAudio.webkitPreservesPitch = true;
|
| 1543 |
+
el.resultAudio.playbackRate = speed;
|
| 1544 |
+
el.polishSpeedValue.textContent = `${speed.toFixed(2)}×`;
|
| 1545 |
+
|
| 1546 |
+
el.polishTone.querySelectorAll("button").forEach((btn) => {
|
| 1547 |
+
btn.classList.toggle("active", btn.dataset.tone === polish.tone);
|
| 1548 |
+
});
|
| 1549 |
+
|
| 1550 |
+
if (!polish.ctx) return;
|
| 1551 |
+
const curve = TONE_CURVES[polish.tone] || TONE_CURVES.neutral;
|
| 1552 |
+
polish.low.gain.value = curve.low;
|
| 1553 |
+
polish.high.gain.value = curve.high;
|
| 1554 |
+
const boost = el.polishBoost.checked;
|
| 1555 |
+
// ratio 1 is a straight wire, so the compressor is bypassed when off.
|
| 1556 |
+
polish.comp.threshold.value = boost ? -26 : 0;
|
| 1557 |
+
polish.comp.ratio.value = boost ? 4 : 1;
|
| 1558 |
+
polish.gain.gain.value = boost ? 1.7 : 1;
|
| 1559 |
+
}
|
| 1560 |
+
|
| 1561 |
+
function polishTouched() {
|
| 1562 |
+
ensureAudioGraph();
|
| 1563 |
+
if (polish.ctx && polish.ctx.state === "suspended") polish.ctx.resume();
|
| 1564 |
+
applyPolish();
|
| 1565 |
+
}
|
| 1566 |
+
|
| 1567 |
+
el.polishSpeed.addEventListener("input", polishTouched);
|
| 1568 |
+
el.polishBoost.addEventListener("change", polishTouched);
|
| 1569 |
+
el.polishTone.querySelectorAll("button").forEach((btn) => {
|
| 1570 |
+
btn.addEventListener("click", () => {
|
| 1571 |
+
polish.tone = btn.dataset.tone;
|
| 1572 |
+
polishTouched();
|
| 1573 |
+
});
|
| 1574 |
+
});
|
| 1575 |
+
el.polishReset.addEventListener("click", () => {
|
| 1576 |
+
el.polishSpeed.value = "1";
|
| 1577 |
+
el.polishBoost.checked = false;
|
| 1578 |
+
polish.tone = "neutral";
|
| 1579 |
+
polishTouched();
|
| 1580 |
+
});
|
| 1581 |
+
el.stagePolishToggle.addEventListener("click", () => {
|
| 1582 |
+
el.polishPanel.hidden = !el.polishPanel.hidden;
|
| 1583 |
+
el.stagePolishToggle.textContent = el.polishPanel.hidden ? "Polish" : "Hide polish";
|
| 1584 |
+
if (!el.polishPanel.hidden) {
|
| 1585 |
+
polishTouched();
|
| 1586 |
+
el.polishPanel.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
| 1587 |
+
}
|
| 1588 |
+
});
|
| 1589 |
+
// A suspended context would silence the element now that audio routes through it.
|
| 1590 |
+
el.resultAudio.addEventListener("play", () => {
|
| 1591 |
+
if (polish.ctx && polish.ctx.state === "suspended") polish.ctx.resume();
|
| 1592 |
+
});
|
| 1593 |
+
|
| 1594 |
/* Now Playing stage */
|
| 1595 |
function openPlayerStage() {
|
| 1596 |
showStagePane("player");
|
|
|
|
| 1672 |
const progress = {
|
| 1673 |
startedAt: 0, wave: 0, totalWaves: 0, ticker: null, label: "",
|
| 1674 |
waveStartedAt: 0, waveDurations: [],
|
| 1675 |
+
genStartedAt: 0, warmupSecs: 0,
|
| 1676 |
};
|
| 1677 |
|
| 1678 |
+
// GPU warm-up is real work but it isn't generation — folding it into "elapsed"
|
| 1679 |
+
// makes a render look slower than it was and skews the ETA, so the clock is
|
| 1680 |
+
// rebased the moment synthesis actually starts and warm-up is reported apart.
|
| 1681 |
+
function markGenerationStarted() {
|
| 1682 |
+
if (progress.genStartedAt || !progress.startedAt) return;
|
| 1683 |
+
progress.genStartedAt = Date.now();
|
| 1684 |
+
progress.warmupSecs = (progress.genStartedAt - progress.startedAt) / 1000;
|
| 1685 |
+
}
|
| 1686 |
+
|
| 1687 |
+
function generationSeconds() {
|
| 1688 |
+
const base = progress.genStartedAt || progress.startedAt;
|
| 1689 |
+
return base ? (Date.now() - base) / 1000 : 0;
|
| 1690 |
+
}
|
| 1691 |
+
|
| 1692 |
function formatMinutes(seconds) {
|
| 1693 |
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
| 1694 |
if (seconds < 90) return `${Math.round(seconds)}s`;
|
|
|
|
| 1698 |
|
| 1699 |
function paintProgress() {
|
| 1700 |
if (!progress.startedAt) return;
|
| 1701 |
+
const elapsed = generationSeconds();
|
| 1702 |
const parts = [];
|
| 1703 |
let pctText = "Starting…";
|
| 1704 |
+
if (!progress.genStartedAt) {
|
| 1705 |
+
// Still warming up — say so plainly instead of ticking a generation clock.
|
| 1706 |
+
el.progressMeta.hidden = false;
|
| 1707 |
+
const warm = `GPU warming up · ${formatMinutes(elapsed) || "0s"}`;
|
| 1708 |
+
el.progressMeta.textContent = warm;
|
| 1709 |
+
el.genStagePct.textContent = "Warming up…";
|
| 1710 |
+
el.genStageMeta.textContent = warm;
|
| 1711 |
+
return;
|
| 1712 |
+
}
|
| 1713 |
if (progress.totalWaves) {
|
| 1714 |
const done = Math.max(0, progress.wave - 1);
|
| 1715 |
// Waves land in steps minutes apart, so a bare completed-wave count would
|
|
|
|
| 1748 |
} else if (progress.label) {
|
| 1749 |
parts.push(progress.label);
|
| 1750 |
}
|
| 1751 |
+
parts.push(`${formatMinutes(elapsed) || "0s"} generating`);
|
| 1752 |
+
if (progress.warmupSecs >= 5) {
|
| 1753 |
+
parts.push(`${formatMinutes(progress.warmupSecs)} warm-up`);
|
| 1754 |
+
}
|
| 1755 |
const meta = parts.join(" · ");
|
| 1756 |
el.progressMeta.hidden = false;
|
| 1757 |
el.progressMeta.textContent = meta;
|
|
|
|
| 1786 |
progress.label = "";
|
| 1787 |
progress.waveStartedAt = 0;
|
| 1788 |
progress.waveDurations = [];
|
| 1789 |
+
progress.genStartedAt = 0;
|
| 1790 |
+
progress.warmupSecs = 0;
|
| 1791 |
el.progressFill.style.width = "0%";
|
| 1792 |
el.progressTrack.classList.remove("indeterminate");
|
| 1793 |
el.genStageTrack.classList.remove("indeterminate");
|
|
|
|
| 1888 |
el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
| 1889 |
el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
| 1890 |
setPlayIcons("►");
|
| 1891 |
+
applyPolish(); // carry the listener's speed/tone onto the new take
|
| 1892 |
buildSyncedTranscript(snapshot);
|
| 1893 |
el.dockEmpty.hidden = true;
|
| 1894 |
el.resultBlock.classList.add("visible");
|
|
|
|
| 1995 |
el.logBox.classList.remove("visible");
|
| 1996 |
el.logToggleBtn.hidden = true;
|
| 1997 |
el.logToggleBtn.textContent = "View generation log";
|
|
|
|
| 1998 |
startProgress();
|
| 1999 |
// The render is already submitted — edits here can't reach it, so lock the
|
| 2000 |
// workspace rather than let controls silently no-op, and put progress
|
|
|
|
| 2058 |
if (!rawEvent.startsWith("data: ")) continue;
|
| 2059 |
const evt = JSON.parse(rawEvent.slice(6));
|
| 2060 |
const isDone = evt.stage === "complete" || evt.stage === "error";
|
| 2061 |
+
if (evt.stage === "generating_audio") markGenerationStarted();
|
| 2062 |
// Read the real status for progress before parody flavour replaces it.
|
| 2063 |
noteProgressFromStatus(evt.status);
|
| 2064 |
const displayLine = isDone ? evt.status : nextParodyLine() || evt.status;
|
|
|
|
| 2078 |
}
|
| 2079 |
|
| 2080 |
if (evt.stage === "complete" && evt.audio_id) {
|
| 2081 |
+
// Measure before the download so the figure is render time, not
|
| 2082 |
+
// render + warm-up + transferring hundreds of megabytes.
|
| 2083 |
+
const renderSeconds = generationSeconds();
|
| 2084 |
+
const warmupSeconds = progress.warmupSecs;
|
| 2085 |
const blob = await downloadTakeBlob(evt.audio_id);
|
| 2086 |
+
el.generationTime.textContent = formatDuration(renderSeconds);
|
| 2087 |
+
if (renderSeconds > 0 && evt.audio_duration) {
|
| 2088 |
+
el.realtimeRow.hidden = false;
|
| 2089 |
+
el.realtimeFactor.textContent = `${(evt.audio_duration / renderSeconds).toFixed(2)}× realtime`;
|
| 2090 |
+
}
|
| 2091 |
+
el.warmupRow.hidden = warmupSeconds < 5;
|
| 2092 |
+
el.warmupTime.textContent = formatDuration(warmupSeconds);
|
| 2093 |
el.resultModel.textContent = state.model;
|
| 2094 |
state.resultTitle = el.scriptTitle.textContent;
|
| 2095 |
await presentTake(blob, evt.audio_duration, turnsSnapshot, evt.audio_id);
|
|
@@ -171,6 +171,8 @@
|
|
| 171 |
|
| 172 |
<div class="result-metrics">
|
| 173 |
<div><span>Generation time</span> <strong id="generationTime">--</strong></div>
|
|
|
|
|
|
|
| 174 |
<div><span>Audio length</span> <strong id="audioDuration">--</strong></div>
|
| 175 |
<div><span>Model</span> <strong id="resultModel">--</strong></div>
|
| 176 |
</div>
|
|
@@ -221,8 +223,32 @@
|
|
| 221 |
<a class="btn btn-ink" id="stageDownloadBtn" download="chorus-audio.wav">Download WAV</a>
|
| 222 |
<a class="btn btn-pill-outline" id="stageDownloadMp3Btn" download="chorus-audio.mp3"
|
| 223 |
title="Much smaller file — encoded on the server on first click">MP3</a>
|
|
|
|
| 224 |
<button class="btn btn-pill-outline" id="stageScriptToggle" type="button">View full script</button>
|
| 225 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
<div class="synced-transcript stage-transcript" id="stageTranscript" hidden></div>
|
| 227 |
</div>
|
| 228 |
</div>
|
|
|
|
| 171 |
|
| 172 |
<div class="result-metrics">
|
| 173 |
<div><span>Generation time</span> <strong id="generationTime">--</strong></div>
|
| 174 |
+
<div id="realtimeRow" hidden><span>Speed</span> <strong id="realtimeFactor">--</strong></div>
|
| 175 |
+
<div id="warmupRow" hidden><span>GPU warm-up</span> <strong id="warmupTime">--</strong></div>
|
| 176 |
<div><span>Audio length</span> <strong id="audioDuration">--</strong></div>
|
| 177 |
<div><span>Model</span> <strong id="resultModel">--</strong></div>
|
| 178 |
</div>
|
|
|
|
| 223 |
<a class="btn btn-ink" id="stageDownloadBtn" download="chorus-audio.wav">Download WAV</a>
|
| 224 |
<a class="btn btn-pill-outline" id="stageDownloadMp3Btn" download="chorus-audio.mp3"
|
| 225 |
title="Much smaller file — encoded on the server on first click">MP3</a>
|
| 226 |
+
<button class="btn btn-pill-outline" id="stagePolishToggle" type="button">Polish</button>
|
| 227 |
<button class="btn btn-pill-outline" id="stageScriptToggle" type="button">View full script</button>
|
| 228 |
</div>
|
| 229 |
+
|
| 230 |
+
<div class="polish-panel" id="polishPanel" hidden>
|
| 231 |
+
<div class="polish-row">
|
| 232 |
+
<span class="polish-label">Speed</span>
|
| 233 |
+
<input type="range" id="polishSpeed" min="0.8" max="1.25" step="0.05" value="1" />
|
| 234 |
+
<span class="polish-value" id="polishSpeedValue">1.00×</span>
|
| 235 |
+
</div>
|
| 236 |
+
<div class="polish-row">
|
| 237 |
+
<span class="polish-label">Tone</span>
|
| 238 |
+
<div class="seg polish-seg" id="polishTone">
|
| 239 |
+
<button type="button" class="single-line" data-tone="neutral"><span>Neutral</span></button>
|
| 240 |
+
<button type="button" class="single-line" data-tone="warm"><span>Warm</span></button>
|
| 241 |
+
<button type="button" class="single-line" data-tone="bright"><span>Bright</span></button>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
<label class="polish-check">
|
| 245 |
+
<input type="checkbox" id="polishBoost" /> Even out quiet speech
|
| 246 |
+
</label>
|
| 247 |
+
<div class="polish-note">
|
| 248 |
+
<span>Affects playback here — downloads stay the original take.</span>
|
| 249 |
+
<button type="button" class="polish-reset" id="polishReset">Reset</button>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
<div class="synced-transcript stage-transcript" id="stageTranscript" hidden></div>
|
| 253 |
</div>
|
| 254 |
</div>
|
|
@@ -919,6 +919,64 @@ body.is-generating .canvas { opacity: 0.6; transition: opacity 0.2s; }
|
|
| 919 |
}
|
| 920 |
.gen-stage-log:empty { display: none; }
|
| 921 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
.stage-transcript { margin: 20px 0 0; max-height: 260px; }
|
| 923 |
.stage-transcript .sync-text { font-size: 0.88rem; }
|
| 924 |
|
|
|
|
| 919 |
}
|
| 920 |
.gen-stage-log:empty { display: none; }
|
| 921 |
|
| 922 |
+
/* Post-generation polish */
|
| 923 |
+
.polish-panel {
|
| 924 |
+
margin-top: 18px;
|
| 925 |
+
padding: 16px 18px;
|
| 926 |
+
background: var(--paper-soft);
|
| 927 |
+
border: 1px solid var(--border);
|
| 928 |
+
border-radius: 14px;
|
| 929 |
+
}
|
| 930 |
+
.polish-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
| 931 |
+
.polish-label {
|
| 932 |
+
font-size: 0.7rem;
|
| 933 |
+
font-weight: 700;
|
| 934 |
+
letter-spacing: 0.12em;
|
| 935 |
+
text-transform: uppercase;
|
| 936 |
+
color: var(--ink-faint);
|
| 937 |
+
width: 58px;
|
| 938 |
+
flex-shrink: 0;
|
| 939 |
+
}
|
| 940 |
+
.polish-row input[type="range"] { flex: 1; margin: 0; }
|
| 941 |
+
.polish-value {
|
| 942 |
+
font-size: 0.82rem;
|
| 943 |
+
font-weight: 700;
|
| 944 |
+
color: var(--accent);
|
| 945 |
+
width: 48px;
|
| 946 |
+
text-align: right;
|
| 947 |
+
font-variant-numeric: tabular-nums;
|
| 948 |
+
}
|
| 949 |
+
.polish-seg { flex: 1; margin-bottom: 0; }
|
| 950 |
+
.polish-check {
|
| 951 |
+
display: flex;
|
| 952 |
+
align-items: center;
|
| 953 |
+
gap: 8px;
|
| 954 |
+
font-size: 0.8rem;
|
| 955 |
+
color: var(--ink-dim);
|
| 956 |
+
cursor: pointer;
|
| 957 |
+
}
|
| 958 |
+
.polish-note {
|
| 959 |
+
display: flex;
|
| 960 |
+
align-items: center;
|
| 961 |
+
justify-content: space-between;
|
| 962 |
+
gap: 10px;
|
| 963 |
+
margin-top: 12px;
|
| 964 |
+
padding-top: 10px;
|
| 965 |
+
border-top: 1px solid var(--border);
|
| 966 |
+
font-size: 0.72rem;
|
| 967 |
+
color: var(--ink-ghost);
|
| 968 |
+
}
|
| 969 |
+
.polish-reset {
|
| 970 |
+
background: none;
|
| 971 |
+
border: none;
|
| 972 |
+
padding: 0;
|
| 973 |
+
font-size: 0.74rem;
|
| 974 |
+
font-weight: 700;
|
| 975 |
+
color: var(--accent);
|
| 976 |
+
cursor: pointer;
|
| 977 |
+
flex-shrink: 0;
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
.stage-transcript { margin: 20px 0 0; max-height: 260px; }
|
| 981 |
.stage-transcript .sync-text { font-size: 0.88rem; }
|
| 982 |
|