Make polish real post-processing; fix distortion, underline and dead MP3 click
Browse filesAudit 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>
- app.py +180 -0
- static/app.js +69 -15
- static/index.html +3 -2
- static/styles.css +11 -0
|
@@ -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.
|
|
@@ -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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 1454 |
-
polish.
|
| 1455 |
-
polish.
|
| 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 |
-
|
| 1482 |
-
|
| 1483 |
-
|
| 1484 |
-
|
| 1485 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1786 |
-
el.stageDownloadBtn.href = url;
|
| 1787 |
-
const mp3Url = `/api/audio/${audioId}.mp3`;
|
| 1788 |
el.downloadMp3Btn.hidden = false;
|
| 1789 |
el.stageDownloadMp3Btn.hidden = false;
|
| 1790 |
-
el.
|
| 1791 |
-
|
| 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)}`;
|
|
@@ -242,10 +242,11 @@
|
|
| 242 |
</div>
|
| 243 |
</div>
|
| 244 |
<label class="polish-check">
|
| 245 |
-
<input type="checkbox" id="polishBoost" />
|
| 246 |
</label>
|
|
|
|
| 247 |
<div class="polish-note">
|
| 248 |
-
<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>
|
|
@@ -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;
|