ACloudCenter Claude Fable 5 commited on
Commit
9d60340
·
1 Parent(s): 2232a4b

Make polish real post-processing; fix distortion, underline and dead MP3 click

Browse files

Audit of the reported problems, all confirmed and fixed:

Polish was preview-only, which is not what post-processing means. Speed,
tone and level are now rendered into the exported file by a new
/api/audio/{id}/export endpoint — WSOLA time-stretch (pitch preserved,
duration accurate to 0.2%), RBJ shelving EQ (measured within 0.1dB of
target) and a boost-only leveller, block-processed so a 5.5-hour take
costs ~1.7 minutes and bounded memory. Download links switch to the
rendered export as soon as a setting is changed, and the panel says
which one you will get.

The distortion was real: preview applied +4.6dB of makeup gain after
compression with nothing to catch peaks, so anything above -4.6dBFS
clipped. Replaced with a limiter-terminated graph. The exported
leveller only ever boosts — it previously normalised every take to the
same loudness, making a healthy take quieter.

Stage 'Download WAV' was underlined because .btn-ink lacked
text-decoration:none while .btn-pill-outline had it; moved to .btn.
Clicking MP3 did nothing visible while the server encoded, so exports
now announce themselves.

Voice-clone previews were never affected by polish — the preview
element is separate from the result element; verified.

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

Files changed (4) hide show
  1. app.py +180 -0
  2. static/app.js +69 -15
  3. static/index.html +3 -2
  4. static/styles.css +11 -0
app.py CHANGED
@@ -852,6 +852,149 @@ async def api_last_take() -> dict:
852
 
853
  MP3_CACHE: dict[str, bytes] = {} # audio_id -> encoded mp3, built lazily on first request
854
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
 
856
  def _encode_mp3(wav_bytes: bytes) -> bytes:
857
  """Encode our PCM16 WAV to mono MP3 (96 kbps — transparent for 24kHz speech)."""
@@ -890,6 +1033,43 @@ async def api_audio_mp3(audio_id: str) -> Response:
890
  )
891
 
892
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
893
  @app.get("/api/audio/{audio_id}/peaks")
894
  async def api_audio_peaks(audio_id: str, buckets: int = 2048) -> dict:
895
  """RMS envelope for the waveform display.
 
852
 
853
  MP3_CACHE: dict[str, bytes] = {} # audio_id -> encoded mp3, built lazily on first request
854
 
855
+ # --- Post-processing (the "polish" controls act on the exported file) ---
856
+ TONE_SHELVES = { # (low-shelf dB, high-shelf dB)
857
+ "neutral": (0.0, 0.0),
858
+ "warm": (4.0, -3.0),
859
+ "bright": (-2.0, 4.5),
860
+ }
861
+ POLISH_BLOCK_SECONDS = 30 # bounds memory regardless of take length
862
+
863
+
864
+ def _shelf_sos(kind: str, f0: float, gain_db: float, fs: float) -> np.ndarray:
865
+ """RBJ cookbook shelving biquad, as a single second-order section."""
866
+ A = 10 ** (gain_db / 40.0)
867
+ w0 = 2 * np.pi * f0 / fs
868
+ cos_w0, sin_w0 = np.cos(w0), np.sin(w0)
869
+ alpha = sin_w0 / 2.0 * np.sqrt(2.0)
870
+ sqrtA2alpha = 2.0 * np.sqrt(A) * alpha
871
+ if kind == "low":
872
+ b = [A * ((A + 1) - (A - 1) * cos_w0 + sqrtA2alpha),
873
+ 2 * A * ((A - 1) - (A + 1) * cos_w0),
874
+ A * ((A + 1) - (A - 1) * cos_w0 - sqrtA2alpha)]
875
+ a = [(A + 1) + (A - 1) * cos_w0 + sqrtA2alpha,
876
+ -2 * ((A - 1) + (A + 1) * cos_w0),
877
+ (A + 1) + (A - 1) * cos_w0 - sqrtA2alpha]
878
+ else:
879
+ b = [A * ((A + 1) + (A - 1) * cos_w0 + sqrtA2alpha),
880
+ -2 * A * ((A - 1) + (A + 1) * cos_w0),
881
+ A * ((A + 1) + (A - 1) * cos_w0 - sqrtA2alpha)]
882
+ a = [(A + 1) - (A - 1) * cos_w0 + sqrtA2alpha,
883
+ 2 * ((A - 1) - (A + 1) * cos_w0),
884
+ (A + 1) - (A - 1) * cos_w0 - sqrtA2alpha]
885
+ return np.array([[b[0] / a[0], b[1] / a[0], b[2] / a[0], 1.0, a[1] / a[0], a[2] / a[0]]])
886
+
887
+
888
+ def _time_stretch(x: np.ndarray, rate: float) -> np.ndarray:
889
+ """WSOLA time-scaling: rate > 1 shortens (faster), < 1 lengthens (slower).
890
+
891
+ Pitch is preserved — the point of the exercise, since plain resampling
892
+ would turn a slowed voice into a drawl an octave down.
893
+ """
894
+ if abs(rate - 1.0) < 1e-3 or len(x) < 4096:
895
+ return x
896
+ N, search = 1024, 128
897
+ Hs = N // 2
898
+ Ha = max(1, int(round(Hs * rate)))
899
+ win = np.hanning(N).astype(np.float32)
900
+ frames = max(1, int((len(x) - N - search) / Ha))
901
+ out = np.zeros(frames * Hs + N, dtype=np.float32)
902
+ norm = np.zeros_like(out)
903
+ prev_tail = x[:N] * win
904
+ for i in range(frames):
905
+ want = i * Ha
906
+ lo = max(0, want - search)
907
+ hi = min(len(x) - N, want + search)
908
+ if hi <= lo:
909
+ seg_start = min(max(0, want), max(0, len(x) - N))
910
+ else:
911
+ cand = x[lo:hi + N]
912
+ # pick the offset whose overlap best matches the previous tail
913
+ windows = np.lib.stride_tricks.sliding_window_view(cand, N)[: hi - lo + 1]
914
+ scores = windows[:, :Hs] @ prev_tail[Hs:]
915
+ seg_start = lo + int(np.argmax(scores))
916
+ seg = x[seg_start:seg_start + N]
917
+ if len(seg) < N:
918
+ break
919
+ seg = seg * win
920
+ o = i * Hs
921
+ out[o:o + N] += seg
922
+ norm[o:o + N] += win
923
+ prev_tail = seg
924
+ np.maximum(norm, 1e-6, out=norm)
925
+ return out / norm
926
+
927
+
928
+ def _polish_wav(wav_bytes: bytes, speed: float, tone: str, level: bool) -> bytes:
929
+ """Apply the polish settings to a stored take and return a new WAV.
930
+
931
+ Processed in blocks so a five-hour render doesn't need gigabytes at once.
932
+ """
933
+ from scipy.signal import sosfilt, sosfilt_zi
934
+
935
+ sample_rate, samples = wavfile.read(io.BytesIO(wav_bytes))
936
+ if samples.ndim > 1:
937
+ samples = samples[:, 0]
938
+ low_db, high_db = TONE_SHELVES.get(tone, TONE_SHELVES["neutral"])
939
+ sos = None
940
+ if low_db or high_db:
941
+ parts = []
942
+ if low_db:
943
+ parts.append(_shelf_sos("low", 250.0, low_db, sample_rate))
944
+ if high_db:
945
+ parts.append(_shelf_sos("high", 3500.0, high_db, sample_rate))
946
+ sos = np.vstack(parts)
947
+ zi = sosfilt_zi(sos) * 0.0 if sos is not None else None
948
+
949
+ block = POLISH_BLOCK_SECONDS * sample_rate
950
+ overlap = int(0.025 * sample_rate) # crossfade between stretched blocks
951
+ fade = np.linspace(0.0, 1.0, overlap, dtype=np.float32)
952
+ pieces: list[np.ndarray] = []
953
+ peak = 1e-6
954
+ sq_sum = 0.0
955
+ sq_n = 0
956
+ for start in range(0, len(samples), block):
957
+ chunk = samples[start:start + block].astype(np.float32) / 32768.0
958
+ if sos is not None:
959
+ chunk, zi = sosfilt(sos, chunk, zi=zi)
960
+ chunk = chunk.astype(np.float32)
961
+ chunk = _time_stretch(chunk, speed)
962
+ if pieces and overlap and len(chunk) > overlap and len(pieces[-1]) > overlap:
963
+ pieces[-1][-overlap:] = pieces[-1][-overlap:] * (1 - fade) + chunk[:overlap] * fade
964
+ chunk = chunk[overlap:]
965
+ if len(chunk):
966
+ peak = max(peak, float(np.abs(chunk).max()))
967
+ sq_sum += float(np.dot(chunk, chunk))
968
+ sq_n += len(chunk)
969
+ pieces.append(chunk)
970
+
971
+ if level:
972
+ # Lift quiet speech toward a target loudness; the limiter below only
973
+ # touches peaks, so this raises level without squashing the whole take.
974
+ # Boost-only: a take that is already at a healthy level is left alone
975
+ # rather than pulled down to a target.
976
+ rms = np.sqrt(sq_sum / max(1, sq_n))
977
+ gain = float(min(6.0, max(1.0, 0.18 / max(rms, 1e-6))))
978
+ else:
979
+ gain = float(min(1.0, 0.99 / peak)) # only pull down if it would clip
980
+
981
+ KNEE = 0.8
982
+ out = bytearray()
983
+ for chunk in pieces:
984
+ y = chunk * gain
985
+ if level:
986
+ mag = np.abs(y)
987
+ over = mag > KNEE
988
+ if over.any():
989
+ excess = (mag[over] - KNEE) / (1.0 - KNEE)
990
+ y[over] = np.sign(y[over]) * (KNEE + (1.0 - KNEE) * np.tanh(excess))
991
+ np.clip(y, -1.0, 1.0, out=y)
992
+ out += (y * 32767.0).astype(np.int16).tobytes()
993
+
994
+ header = io.BytesIO()
995
+ wavfile.write(header, sample_rate, np.frombuffer(bytes(out), dtype=np.int16))
996
+ return header.getvalue()
997
+
998
 
999
  def _encode_mp3(wav_bytes: bytes) -> bytes:
1000
  """Encode our PCM16 WAV to mono MP3 (96 kbps — transparent for 24kHz speech)."""
 
1033
  )
1034
 
1035
 
1036
+ POLISH_CACHE: dict[tuple, bytes] = {} # (audio_id, speed, tone, level, fmt) -> bytes
1037
+
1038
+
1039
+ @app.get("/api/audio/{audio_id}/export")
1040
+ async def api_audio_export(
1041
+ audio_id: str,
1042
+ speed: float = 1.0,
1043
+ tone: str = "neutral",
1044
+ level: bool = False,
1045
+ fmt: str = "wav",
1046
+ ) -> Response:
1047
+ """Download the take with the polish settings baked in."""
1048
+ entry = AUDIO_STORE.get(audio_id)
1049
+ if entry is None:
1050
+ raise HTTPException(status_code=404, detail="Audio not found or expired.")
1051
+ speed = round(min(1.25, max(0.8, speed)), 2)
1052
+ tone = tone if tone in TONE_SHELVES else "neutral"
1053
+ fmt = "mp3" if fmt == "mp3" else "wav"
1054
+ key = (audio_id, speed, tone, bool(level), fmt)
1055
+ if key not in POLISH_CACHE:
1056
+ _, wav_bytes = entry
1057
+ loop = asyncio.get_event_loop()
1058
+ processed = await loop.run_in_executor(
1059
+ None, _polish_wav, wav_bytes, speed, tone, bool(level)
1060
+ )
1061
+ if fmt == "mp3":
1062
+ processed = await loop.run_in_executor(None, _encode_mp3, processed)
1063
+ POLISH_CACHE.clear() # one polished export at a time; these are large
1064
+ POLISH_CACHE[key] = processed
1065
+ data = POLISH_CACHE[key]
1066
+ return Response(
1067
+ content=data,
1068
+ media_type="audio/mpeg" if fmt == "mp3" else "audio/wav",
1069
+ headers={"Content-Disposition": f'attachment; filename="chorus-polished.{fmt}"'},
1070
+ )
1071
+
1072
+
1073
  @app.get("/api/audio/{audio_id}/peaks")
1074
  async def api_audio_peaks(audio_id: str, buckets: int = 2048) -> dict:
1075
  """RMS envelope for the waveform display.
static/app.js CHANGED
@@ -84,6 +84,7 @@ const el = {};
84
  "stageDot", "stageLine", "stageSpeaker", "stageCloseBtn", "stageDownloadBtn",
85
  "stageScriptToggle", "stageTranscript", "stagePolishToggle", "polishPanel",
86
  "polishSpeed", "polishSpeedValue", "polishTone", "polishBoost", "polishReset",
 
87
  "generationTime", "audioDuration", "resultModel", "downloadBtn",
88
  "realtimeRow", "realtimeFactor", "warmupRow", "warmupTime",
89
  "downloadMp3Btn", "stageDownloadMp3Btn",
@@ -1429,7 +1430,20 @@ const TONE_CURVES = {
1429
  bright: { low: -2, high: 4.5 },
1430
  };
1431
 
1432
- const polish = { ctx: null, low: null, high: null, comp: null, gain: null, tone: "neutral" };
 
 
 
 
 
 
 
 
 
 
 
 
 
1433
 
1434
  function ensureAudioGraph() {
1435
  if (polish.ctx) return true;
@@ -1446,13 +1460,21 @@ function ensureAudioGraph() {
1446
  polish.high = polish.ctx.createBiquadFilter();
1447
  polish.high.type = "highshelf";
1448
  polish.high.frequency.value = 3500;
1449
- polish.comp = polish.ctx.createDynamicsCompressor();
1450
  polish.gain = polish.ctx.createGain();
 
 
 
 
 
 
 
 
 
1451
  source.connect(polish.low);
1452
  polish.low.connect(polish.high);
1453
- polish.high.connect(polish.comp);
1454
- polish.comp.connect(polish.gain);
1455
- polish.gain.connect(polish.ctx.destination);
1456
  applyPolish();
1457
  return true;
1458
  } catch (error) {
@@ -1474,17 +1496,51 @@ function applyPolish() {
1474
  btn.classList.toggle("active", btn.dataset.tone === polish.tone);
1475
  });
1476
 
 
 
1477
  if (!polish.ctx) return;
1478
  const curve = TONE_CURVES[polish.tone] || TONE_CURVES.neutral;
1479
  polish.low.gain.value = curve.low;
1480
  polish.high.gain.value = curve.high;
1481
- const boost = el.polishBoost.checked;
1482
- // ratio 1 is a straight wire, so the compressor is bypassed when off.
1483
- polish.comp.threshold.value = boost ? -26 : 0;
1484
- polish.comp.ratio.value = boost ? 4 : 1;
1485
- polish.gain.gain.value = boost ? 1.7 : 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1486
  }
1487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1488
  function polishTouched() {
1489
  ensureAudioGraph();
1490
  if (polish.ctx && polish.ctx.state === "suspended") polish.ctx.resume();
@@ -1782,13 +1838,11 @@ async function presentTake(audioId, durationSeconds, snapshot) {
1782
  setStatus("complete");
1783
  const url = `/api/audio/${audioId}`;
1784
  el.resultAudio.src = url;
1785
- el.downloadBtn.href = url;
1786
- el.stageDownloadBtn.href = url;
1787
- const mp3Url = `/api/audio/${audioId}.mp3`;
1788
  el.downloadMp3Btn.hidden = false;
1789
  el.stageDownloadMp3Btn.hidden = false;
1790
- el.downloadMp3Btn.href = mp3Url;
1791
- el.stageDownloadMp3Btn.href = mp3Url;
1792
  el.audioDuration.textContent = formatDuration(durationSeconds);
1793
  el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
1794
  el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
 
84
  "stageDot", "stageLine", "stageSpeaker", "stageCloseBtn", "stageDownloadBtn",
85
  "stageScriptToggle", "stageTranscript", "stagePolishToggle", "polishPanel",
86
  "polishSpeed", "polishSpeedValue", "polishTone", "polishBoost", "polishReset",
87
+ "polishNote", "polishStatus",
88
  "generationTime", "audioDuration", "resultModel", "downloadBtn",
89
  "realtimeRow", "realtimeFactor", "warmupRow", "warmupTime",
90
  "downloadMp3Btn", "stageDownloadMp3Btn",
 
1430
  bright: { low: -2, high: 4.5 },
1431
  };
1432
 
1433
+ const polish = { ctx: null, low: null, high: null, gain: null, limiter: null, tone: "neutral", audioId: null };
1434
+
1435
+ function polishSettings() {
1436
+ return {
1437
+ speed: Number(el.polishSpeed.value),
1438
+ tone: polish.tone,
1439
+ level: el.polishBoost.checked,
1440
+ };
1441
+ }
1442
+
1443
+ function polishIsDefault() {
1444
+ const s = polishSettings();
1445
+ return Math.abs(s.speed - 1) < 0.001 && s.tone === "neutral" && !s.level;
1446
+ }
1447
 
1448
  function ensureAudioGraph() {
1449
  if (polish.ctx) return true;
 
1460
  polish.high = polish.ctx.createBiquadFilter();
1461
  polish.high.type = "highshelf";
1462
  polish.high.frequency.value = 3500;
 
1463
  polish.gain = polish.ctx.createGain();
1464
+ // A brick-wall-ish limiter always sits last: the old graph applied makeup
1465
+ // gain after compression with nothing to catch peaks, which clipped and
1466
+ // was heard as distortion.
1467
+ polish.limiter = polish.ctx.createDynamicsCompressor();
1468
+ polish.limiter.threshold.value = -1.5;
1469
+ polish.limiter.knee.value = 0;
1470
+ polish.limiter.ratio.value = 20;
1471
+ polish.limiter.attack.value = 0.003;
1472
+ polish.limiter.release.value = 0.12;
1473
  source.connect(polish.low);
1474
  polish.low.connect(polish.high);
1475
+ polish.high.connect(polish.gain);
1476
+ polish.gain.connect(polish.limiter);
1477
+ polish.limiter.connect(polish.ctx.destination);
1478
  applyPolish();
1479
  return true;
1480
  } catch (error) {
 
1496
  btn.classList.toggle("active", btn.dataset.tone === polish.tone);
1497
  });
1498
 
1499
+ updateExportLinks();
1500
+
1501
  if (!polish.ctx) return;
1502
  const curve = TONE_CURVES[polish.tone] || TONE_CURVES.neutral;
1503
  polish.low.gain.value = curve.low;
1504
  polish.high.gain.value = curve.high;
1505
+ polish.gain.gain.value = el.polishBoost.checked ? 1.6 : 1;
1506
+ }
1507
+
1508
+ /* Downloads carry the polish settings: default settings stream the stored take
1509
+ untouched, anything else is rendered by the server. */
1510
+ function updateExportLinks() {
1511
+ if (!polish.audioId) return;
1512
+ const plain = `/api/audio/${polish.audioId}`;
1513
+ const s = polishSettings();
1514
+ const query = `speed=${s.speed}&tone=${s.tone}&level=${s.level}`;
1515
+ const isDefault = polishIsDefault();
1516
+ const wavUrl = isDefault ? plain : `${plain}/export?${query}&fmt=wav`;
1517
+ const mp3Url = isDefault ? `${plain}.mp3` : `${plain}/export?${query}&fmt=mp3`;
1518
+ el.downloadBtn.href = wavUrl;
1519
+ el.stageDownloadBtn.href = wavUrl;
1520
+ el.downloadMp3Btn.href = mp3Url;
1521
+ el.stageDownloadMp3Btn.href = mp3Url;
1522
+ el.polishNote.textContent = isDefault
1523
+ ? "Settings are live here; downloads give you the original take."
1524
+ : "Downloads are rendered with these settings — that can take a moment.";
1525
  }
1526
 
1527
+ // Encoding/rendering happens on the server when the link is clicked, and a
1528
+ // long take takes real time, so say so instead of appearing to do nothing.
1529
+ function noteExportStarted(kind) {
1530
+ el.polishStatus.hidden = false;
1531
+ el.polishStatus.textContent = `Preparing your ${kind}… the download starts when it's ready (long takes can take a minute or two).`;
1532
+ clearTimeout(noteExportStarted.timer);
1533
+ noteExportStarted.timer = setTimeout(() => { el.polishStatus.hidden = true; }, 20000);
1534
+ }
1535
+
1536
+ [["downloadBtn", "WAV"], ["stageDownloadBtn", "WAV"],
1537
+ ["downloadMp3Btn", "MP3"], ["stageDownloadMp3Btn", "MP3"]].forEach(([id, kind]) => {
1538
+ el[id].addEventListener("click", () => {
1539
+ // The stored WAV is served instantly; everything else is rendered on demand.
1540
+ if (kind === "MP3" || !polishIsDefault()) noteExportStarted(kind);
1541
+ });
1542
+ });
1543
+
1544
  function polishTouched() {
1545
  ensureAudioGraph();
1546
  if (polish.ctx && polish.ctx.state === "suspended") polish.ctx.resume();
 
1838
  setStatus("complete");
1839
  const url = `/api/audio/${audioId}`;
1840
  el.resultAudio.src = url;
1841
+ polish.audioId = audioId;
 
 
1842
  el.downloadMp3Btn.hidden = false;
1843
  el.stageDownloadMp3Btn.hidden = false;
1844
+ el.polishStatus.hidden = true;
1845
+ updateExportLinks();
1846
  el.audioDuration.textContent = formatDuration(durationSeconds);
1847
  el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
1848
  el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
static/index.html CHANGED
@@ -242,10 +242,11 @@
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>
 
242
  </div>
243
  </div>
244
  <label class="polish-check">
245
+ <input type="checkbox" id="polishBoost" /> Lift quiet speech
246
  </label>
247
+ <div class="polish-status" id="polishStatus" hidden></div>
248
  <div class="polish-note">
249
+ <span id="polishNote">Preview only until you change something then downloads are rendered with these settings.</span>
250
  <button type="button" class="polish-reset" id="polishReset">Reset</button>
251
  </div>
252
  </div>
static/styles.css CHANGED
@@ -437,6 +437,7 @@ select:focus { outline: none; border-color: #d9a98c; }
437
  font-family: var(--font-ui);
438
  font-size: 0.84rem;
439
  font-weight: 700;
 
440
  border-radius: var(--radius-sm);
441
  border: 1px solid var(--border-strong);
442
  background: var(--paper);
@@ -974,6 +975,16 @@ body.is-generating .canvas { opacity: 0.6; transition: opacity 0.2s; }
974
  font-size: 0.72rem;
975
  color: var(--ink-ghost);
976
  }
 
 
 
 
 
 
 
 
 
 
977
  .polish-reset {
978
  background: none;
979
  border: none;
 
437
  font-family: var(--font-ui);
438
  font-size: 0.84rem;
439
  font-weight: 700;
440
+ text-decoration: none; /* anchors styled as buttons must not underline */
441
  border-radius: var(--radius-sm);
442
  border: 1px solid var(--border-strong);
443
  background: var(--paper);
 
975
  font-size: 0.72rem;
976
  color: var(--ink-ghost);
977
  }
978
+ .polish-status {
979
+ margin-top: 10px;
980
+ padding: 9px 12px;
981
+ border-radius: 10px;
982
+ background: var(--accent-soft);
983
+ color: var(--accent-hover);
984
+ font-size: 0.76rem;
985
+ font-weight: 600;
986
+ line-height: 1.45;
987
+ }
988
  .polish-reset {
989
  background: none;
990
  border: none;