ACloudCenter Claude Fable 5 commited on
Commit
a4d6323
·
1 Parent(s): 211e07b

Add playback polish; stop warm-up polluting generation time

Browse files

Polish: 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>

Files changed (3) hide show
  1. static/app.js +149 -7
  2. static/index.html +26 -0
  3. static/styles.css +58 -0
static/app.js CHANGED
@@ -13,8 +13,8 @@ const SCRIPT_GEN_MESSAGES = [
13
  ];
14
 
15
  const PRIMARY_STAGE_MESSAGES = {
16
- connecting: ["Submitted", "Provisioning GPU resources... cold starts can take up to a minute."],
17
- queued: ["Queued", "Worker is spinning up. Cold starts may take 30-60 seconds."],
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 = (Date.now() - progress.startedAt) / 1000;
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"} elapsed`);
 
 
 
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((performance.now() - started) / 1000);
 
 
 
 
 
 
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);
static/index.html CHANGED
@@ -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>
static/styles.css CHANGED
@@ -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