Commit ·
05a8f68
1
Parent(s): 6462150
Offer an MP3 download alongside the WAV
Browse filesA 5.5-hour take is ~950MB of WAV; as 96kbps mono MP3 it's ~6x smaller
with no audible loss for 24kHz speech. The server encodes lazily on
first request (lameenc in a worker thread, cached per take, pruned with
the audio store) at /api/audio/{id}.mp3; the dock and stage grow a
Download MP3 button pointing there, shown only while the server still
holds the take.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- app.py +41 -0
- requirements.txt +1 -0
- static/app.js +13 -3
- static/index.html +4 -0
- static/styles.css +1 -0
app.py
CHANGED
|
@@ -519,6 +519,7 @@ def _prune_audio_store() -> None:
|
|
| 519 |
]
|
| 520 |
for k in stale:
|
| 521 |
AUDIO_STORE.pop(k, None)
|
|
|
|
| 522 |
|
| 523 |
|
| 524 |
# --- Abuse guardrails ---
|
|
@@ -803,6 +804,46 @@ async def api_last_take() -> dict:
|
|
| 803 |
}
|
| 804 |
|
| 805 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
@app.get("/api/audio/{audio_id}")
|
| 807 |
async def api_audio(audio_id: str) -> Response:
|
| 808 |
entry = AUDIO_STORE.get(audio_id)
|
|
|
|
| 519 |
]
|
| 520 |
for k in stale:
|
| 521 |
AUDIO_STORE.pop(k, None)
|
| 522 |
+
MP3_CACHE.pop(k, None)
|
| 523 |
|
| 524 |
|
| 525 |
# --- Abuse guardrails ---
|
|
|
|
| 804 |
}
|
| 805 |
|
| 806 |
|
| 807 |
+
MP3_CACHE: dict[str, bytes] = {} # audio_id -> encoded mp3, built lazily on first request
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
def _encode_mp3(wav_bytes: bytes) -> bytes:
|
| 811 |
+
"""Encode our PCM16 WAV to mono MP3 (96 kbps — transparent for 24kHz speech)."""
|
| 812 |
+
import lameenc
|
| 813 |
+
|
| 814 |
+
sample_rate, samples = wavfile.read(io.BytesIO(wav_bytes))
|
| 815 |
+
if samples.ndim > 1:
|
| 816 |
+
samples = samples[:, 0]
|
| 817 |
+
if samples.dtype != np.int16:
|
| 818 |
+
samples = np.asarray(samples, dtype=np.int16)
|
| 819 |
+
encoder = lameenc.Encoder()
|
| 820 |
+
encoder.set_bit_rate(96)
|
| 821 |
+
encoder.set_in_sample_rate(int(sample_rate))
|
| 822 |
+
encoder.set_channels(1)
|
| 823 |
+
encoder.set_quality(2)
|
| 824 |
+
mp3 = bytes(encoder.encode(samples.tobytes()))
|
| 825 |
+
mp3 += bytes(encoder.flush())
|
| 826 |
+
return mp3
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
@app.get("/api/audio/{audio_id}.mp3")
|
| 830 |
+
async def api_audio_mp3(audio_id: str) -> Response:
|
| 831 |
+
entry = AUDIO_STORE.get(audio_id)
|
| 832 |
+
if entry is None:
|
| 833 |
+
raise HTTPException(status_code=404, detail="Audio not found or expired.")
|
| 834 |
+
if audio_id not in MP3_CACHE:
|
| 835 |
+
_, wav_bytes = entry
|
| 836 |
+
# Encoding hours of audio takes minutes of CPU — keep the event loop free.
|
| 837 |
+
MP3_CACHE[audio_id] = await asyncio.get_event_loop().run_in_executor(
|
| 838 |
+
None, _encode_mp3, wav_bytes
|
| 839 |
+
)
|
| 840 |
+
return Response(
|
| 841 |
+
content=MP3_CACHE[audio_id],
|
| 842 |
+
media_type="audio/mpeg",
|
| 843 |
+
headers={"Content-Disposition": 'attachment; filename="conference.mp3"'},
|
| 844 |
+
)
|
| 845 |
+
|
| 846 |
+
|
| 847 |
@app.get("/api/audio/{audio_id}")
|
| 848 |
async def api_audio(audio_id: str) -> Response:
|
| 849 |
entry = AUDIO_STORE.get(audio_id)
|
requirements.txt
CHANGED
|
@@ -5,3 +5,4 @@ modal
|
|
| 5 |
huggingface_hub
|
| 6 |
numpy
|
| 7 |
scipy
|
|
|
|
|
|
| 5 |
huggingface_hub
|
| 6 |
numpy
|
| 7 |
scipy
|
| 8 |
+
lameenc
|
static/app.js
CHANGED
|
@@ -82,6 +82,7 @@ const el = {};
|
|
| 82 |
"stageDot", "stageLine", "stageSpeaker", "stageCloseBtn", "stageDownloadBtn",
|
| 83 |
"stageScriptToggle", "stageTranscript",
|
| 84 |
"generationTime", "audioDuration", "resultModel", "downloadBtn",
|
|
|
|
| 85 |
"logToggleBtn", "logBox",
|
| 86 |
"voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid", "libraryTitle",
|
| 87 |
"cloneVoiceBtn", "cloneDialog", "closeCloneBtn", "recordBtn", "cloneFileInput", "recordTimer",
|
|
@@ -1588,12 +1589,21 @@ async function downloadTakeBlob(audioId) {
|
|
| 1588 |
return new Blob(parts, { type: audioRes.headers.get("Content-Type") || "audio/wav" });
|
| 1589 |
}
|
| 1590 |
|
| 1591 |
-
async function presentTake(blob, durationSeconds, snapshot) {
|
| 1592 |
setStatus("complete");
|
| 1593 |
const url = URL.createObjectURL(blob);
|
| 1594 |
el.resultAudio.src = url;
|
| 1595 |
el.downloadBtn.href = url;
|
| 1596 |
el.stageDownloadBtn.href = url;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1597 |
el.audioDuration.textContent = formatDuration(durationSeconds);
|
| 1598 |
el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
| 1599 |
el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
|
@@ -1641,7 +1651,7 @@ async function checkLastTake() {
|
|
| 1641 |
el.generationTime.textContent = "--";
|
| 1642 |
el.resultModel.textContent = "recovered";
|
| 1643 |
state.resultTitle = "Recovered take";
|
| 1644 |
-
await presentTake(blob, info.duration, []);
|
| 1645 |
} catch (error) {
|
| 1646 |
setStatus("error", error.message);
|
| 1647 |
btn.disabled = false;
|
|
@@ -1762,7 +1772,7 @@ el.generateBtn.addEventListener("click", async () => {
|
|
| 1762 |
el.generationTime.textContent = formatDuration((performance.now() - started) / 1000);
|
| 1763 |
el.resultModel.textContent = state.model;
|
| 1764 |
state.resultTitle = el.scriptTitle.textContent;
|
| 1765 |
-
await presentTake(blob, evt.audio_duration, turnsSnapshot);
|
| 1766 |
}
|
| 1767 |
}
|
| 1768 |
}
|
|
|
|
| 82 |
"stageDot", "stageLine", "stageSpeaker", "stageCloseBtn", "stageDownloadBtn",
|
| 83 |
"stageScriptToggle", "stageTranscript",
|
| 84 |
"generationTime", "audioDuration", "resultModel", "downloadBtn",
|
| 85 |
+
"downloadMp3Btn", "stageDownloadMp3Btn",
|
| 86 |
"logToggleBtn", "logBox",
|
| 87 |
"voiceLibraryDialog", "closeLibraryBtn", "librarySearch", "libraryFilters", "libraryGrid", "libraryTitle",
|
| 88 |
"cloneVoiceBtn", "cloneDialog", "closeCloneBtn", "recordBtn", "cloneFileInput", "recordTimer",
|
|
|
|
| 1589 |
return new Blob(parts, { type: audioRes.headers.get("Content-Type") || "audio/wav" });
|
| 1590 |
}
|
| 1591 |
|
| 1592 |
+
async function presentTake(blob, durationSeconds, snapshot, audioId) {
|
| 1593 |
setStatus("complete");
|
| 1594 |
const url = URL.createObjectURL(blob);
|
| 1595 |
el.resultAudio.src = url;
|
| 1596 |
el.downloadBtn.href = url;
|
| 1597 |
el.stageDownloadBtn.href = url;
|
| 1598 |
+
// MP3 comes from the server (encoded lazily there) — only offer it while
|
| 1599 |
+
// the server still holds this take.
|
| 1600 |
+
const mp3Url = audioId ? `/api/audio/${audioId}.mp3` : null;
|
| 1601 |
+
el.downloadMp3Btn.hidden = !mp3Url;
|
| 1602 |
+
el.stageDownloadMp3Btn.hidden = !mp3Url;
|
| 1603 |
+
if (mp3Url) {
|
| 1604 |
+
el.downloadMp3Btn.href = mp3Url;
|
| 1605 |
+
el.stageDownloadMp3Btn.href = mp3Url;
|
| 1606 |
+
}
|
| 1607 |
el.audioDuration.textContent = formatDuration(durationSeconds);
|
| 1608 |
el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
| 1609 |
el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
|
|
|
| 1651 |
el.generationTime.textContent = "--";
|
| 1652 |
el.resultModel.textContent = "recovered";
|
| 1653 |
state.resultTitle = "Recovered take";
|
| 1654 |
+
await presentTake(blob, info.duration, [], info.audio_id);
|
| 1655 |
} catch (error) {
|
| 1656 |
setStatus("error", error.message);
|
| 1657 |
btn.disabled = false;
|
|
|
|
| 1772 |
el.generationTime.textContent = formatDuration((performance.now() - started) / 1000);
|
| 1773 |
el.resultModel.textContent = state.model;
|
| 1774 |
state.resultTitle = el.scriptTitle.textContent;
|
| 1775 |
+
await presentTake(blob, evt.audio_duration, turnsSnapshot, evt.audio_id);
|
| 1776 |
}
|
| 1777 |
}
|
| 1778 |
}
|
static/index.html
CHANGED
|
@@ -173,6 +173,8 @@
|
|
| 173 |
<div><span>Model</span> <strong id="resultModel">--</strong></div>
|
| 174 |
</div>
|
| 175 |
<a id="downloadBtn" class="btn btn-pill-outline" download="chorus-audio.wav">Download WAV</a>
|
|
|
|
|
|
|
| 176 |
<audio id="resultAudio" hidden></audio>
|
| 177 |
</div>
|
| 178 |
|
|
@@ -200,6 +202,8 @@
|
|
| 200 |
</div>
|
| 201 |
<div class="stage-actions">
|
| 202 |
<a class="btn btn-ink" id="stageDownloadBtn" download="chorus-audio.wav">Download WAV</a>
|
|
|
|
|
|
|
| 203 |
<button class="btn btn-pill-outline" id="stageScriptToggle" type="button">View full script</button>
|
| 204 |
</div>
|
| 205 |
<div class="synced-transcript stage-transcript" id="stageTranscript" hidden></div>
|
|
|
|
| 173 |
<div><span>Model</span> <strong id="resultModel">--</strong></div>
|
| 174 |
</div>
|
| 175 |
<a id="downloadBtn" class="btn btn-pill-outline" download="chorus-audio.wav">Download WAV</a>
|
| 176 |
+
<a id="downloadMp3Btn" class="btn btn-pill-outline" download="chorus-audio.mp3"
|
| 177 |
+
title="Much smaller file — the server encodes it on first click, which can take a minute or two for long takes">Download MP3 (smaller)</a>
|
| 178 |
<audio id="resultAudio" hidden></audio>
|
| 179 |
</div>
|
| 180 |
|
|
|
|
| 202 |
</div>
|
| 203 |
<div class="stage-actions">
|
| 204 |
<a class="btn btn-ink" id="stageDownloadBtn" download="chorus-audio.wav">Download WAV</a>
|
| 205 |
+
<a class="btn btn-pill-outline" id="stageDownloadMp3Btn" download="chorus-audio.mp3"
|
| 206 |
+
title="Much smaller file — encoded on the server on first click">MP3</a>
|
| 207 |
<button class="btn btn-pill-outline" id="stageScriptToggle" type="button">View full script</button>
|
| 208 |
</div>
|
| 209 |
<div class="synced-transcript stage-transcript" id="stageTranscript" hidden></div>
|
static/styles.css
CHANGED
|
@@ -466,6 +466,7 @@ select:focus { outline: none; border-color: #d9a98c; }
|
|
| 466 |
}
|
| 467 |
.btn-accent:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
| 468 |
.btn-accent:disabled { background: #d9a98c; border-color: #d9a98c; opacity: 1; }
|
|
|
|
| 469 |
.btn-pill-outline {
|
| 470 |
display: block;
|
| 471 |
text-align: center;
|
|
|
|
| 466 |
}
|
| 467 |
.btn-accent:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
| 468 |
.btn-accent:disabled { background: #d9a98c; border-color: #d9a98c; opacity: 1; }
|
| 469 |
+
.result-block .btn-pill-outline { margin-bottom: 8px; }
|
| 470 |
.btn-pill-outline {
|
| 471 |
display: block;
|
| 472 |
text-align: center;
|