Commit ·
a5c94a5
1
Parent(s): 07e4766
Never orphan a finished take: server keeps it, UI offers recovery
Browse filesLosing the page (accidental navigation, tab close, HF wrapper clicks)
orphaned a finished render permanently — the audio_id lived only in
page state, and a 60-minute take died that way today. Now the server
remembers the newest take (exempt from TTL pruning until a newer one
replaces it) and exposes it at /api/last-take; on load, if the server
holds a take the page doesn't know about, the output dock offers a
one-click 'Recover last take' that streams it into the player.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- app.py +29 -1
- static/app.js +89 -45
app.py
CHANGED
|
@@ -504,9 +504,19 @@ def _encode_wav(sample_rate: int, audio: np.ndarray) -> bytes:
|
|
| 504 |
AUDIO_STORE: dict[str, tuple[float, bytes]] = {}
|
| 505 |
|
| 506 |
|
|
|
|
|
|
|
|
|
|
| 507 |
def _prune_audio_store() -> None:
|
| 508 |
now = time.time()
|
| 509 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
for k in stale:
|
| 511 |
AUDIO_STORE.pop(k, None)
|
| 512 |
|
|
@@ -770,11 +780,29 @@ async def api_generate(payload: GenerateRequest, request: Request) -> StreamingR
|
|
| 770 |
AUDIO_STORE[audio_id] = (time.time(), wav_bytes)
|
| 771 |
event["audio_id"] = audio_id
|
| 772 |
event["audio_duration"] = len(audio_array) / float(sample_rate)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 773 |
yield f"data: {json.dumps(event)}\n\n"
|
| 774 |
|
| 775 |
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 776 |
|
| 777 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
@app.get("/api/audio/{audio_id}")
|
| 779 |
async def api_audio(audio_id: str) -> Response:
|
| 780 |
entry = AUDIO_STORE.get(audio_id)
|
|
|
|
| 504 |
AUDIO_STORE: dict[str, tuple[float, bytes]] = {}
|
| 505 |
|
| 506 |
|
| 507 |
+
LAST_AUDIO: dict = {} # {"audio_id", "created", "duration"} of the newest finished take
|
| 508 |
+
|
| 509 |
+
|
| 510 |
def _prune_audio_store() -> None:
|
| 511 |
now = time.time()
|
| 512 |
+
keep = LAST_AUDIO.get("audio_id")
|
| 513 |
+
# Never prune the most recent take: a lost page/tab must not orphan an
|
| 514 |
+
# hour-long render (learned the hard way, 2026-08-20). It is only evicted
|
| 515 |
+
# when a newer take replaces it.
|
| 516 |
+
stale = [
|
| 517 |
+
k for k, (ts, _) in AUDIO_STORE.items()
|
| 518 |
+
if now - ts > AUDIO_TTL_SECONDS and k != keep
|
| 519 |
+
]
|
| 520 |
for k in stale:
|
| 521 |
AUDIO_STORE.pop(k, None)
|
| 522 |
|
|
|
|
| 780 |
AUDIO_STORE[audio_id] = (time.time(), wav_bytes)
|
| 781 |
event["audio_id"] = audio_id
|
| 782 |
event["audio_duration"] = len(audio_array) / float(sample_rate)
|
| 783 |
+
LAST_AUDIO.clear()
|
| 784 |
+
LAST_AUDIO.update(
|
| 785 |
+
audio_id=audio_id,
|
| 786 |
+
created=time.time(),
|
| 787 |
+
duration=event["audio_duration"],
|
| 788 |
+
)
|
| 789 |
yield f"data: {json.dumps(event)}\n\n"
|
| 790 |
|
| 791 |
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 792 |
|
| 793 |
|
| 794 |
+
@app.get("/api/last-take")
|
| 795 |
+
async def api_last_take() -> dict:
|
| 796 |
+
"""The newest finished take, so a lost page can recover its render."""
|
| 797 |
+
if not LAST_AUDIO or LAST_AUDIO.get("audio_id") not in AUDIO_STORE:
|
| 798 |
+
raise HTTPException(status_code=404, detail="No recent take.")
|
| 799 |
+
return {
|
| 800 |
+
"audio_id": LAST_AUDIO["audio_id"],
|
| 801 |
+
"age_seconds": round(time.time() - LAST_AUDIO["created"]),
|
| 802 |
+
"duration": LAST_AUDIO["duration"],
|
| 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)
|
static/app.js
CHANGED
|
@@ -1492,6 +1492,92 @@ el.logToggleBtn.addEventListener("click", () => {
|
|
| 1492 |
el.logToggleBtn.textContent = visible ? "Hide generation log" : "View generation log";
|
| 1493 |
});
|
| 1494 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1495 |
/* ---------------- Generate ---------------- */
|
| 1496 |
function fileToBase64(file) {
|
| 1497 |
return new Promise((resolve, reject) => {
|
|
@@ -1599,54 +1685,11 @@ el.generateBtn.addEventListener("click", async () => {
|
|
| 1599 |
}
|
| 1600 |
|
| 1601 |
if (evt.stage === "complete" && evt.audio_id) {
|
| 1602 |
-
|
| 1603 |
-
// so "Complete" never looks like a hang while the WAV transfers.
|
| 1604 |
-
setStatus("downloading");
|
| 1605 |
-
const audioRes = await fetch(`/api/audio/${evt.audio_id}`);
|
| 1606 |
-
if (!audioRes.ok) throw new Error("The finished audio could not be fetched from the server.");
|
| 1607 |
-
const totalBytes = Number(audioRes.headers.get("Content-Length")) || 0;
|
| 1608 |
-
const audioReader = audioRes.body.getReader();
|
| 1609 |
-
const parts = [];
|
| 1610 |
-
let received = 0;
|
| 1611 |
-
let lastShown = -1;
|
| 1612 |
-
while (true) {
|
| 1613 |
-
const part = await audioReader.read();
|
| 1614 |
-
if (part.done) break;
|
| 1615 |
-
parts.push(part.value);
|
| 1616 |
-
received += part.value.length;
|
| 1617 |
-
const mb = Math.floor(received / 1048576);
|
| 1618 |
-
if (mb !== lastShown) {
|
| 1619 |
-
lastShown = mb;
|
| 1620 |
-
setStatus("downloading", totalBytes
|
| 1621 |
-
? `Downloading your take… ${mb} / ${Math.ceil(totalBytes / 1048576)} MB`
|
| 1622 |
-
: `Downloading your take… ${mb} MB`);
|
| 1623 |
-
}
|
| 1624 |
-
}
|
| 1625 |
-
const blob = new Blob(parts, { type: audioRes.headers.get("Content-Type") || "audio/wav" });
|
| 1626 |
-
setStatus("complete");
|
| 1627 |
-
const url = URL.createObjectURL(blob);
|
| 1628 |
-
el.resultAudio.src = url;
|
| 1629 |
-
el.downloadBtn.href = url;
|
| 1630 |
-
el.stageDownloadBtn.href = url;
|
| 1631 |
el.generationTime.textContent = formatDuration((performance.now() - started) / 1000);
|
| 1632 |
-
el.audioDuration.textContent = formatDuration(evt.audio_duration);
|
| 1633 |
el.resultModel.textContent = state.model;
|
| 1634 |
-
el.playerTime.textContent = `0:00 / ${formatClock(evt.audio_duration)}`;
|
| 1635 |
-
el.stageTime.textContent = `0:00 / ${formatClock(evt.audio_duration)}`;
|
| 1636 |
-
setPlayIcons("►");
|
| 1637 |
state.resultTitle = el.scriptTitle.textContent;
|
| 1638 |
-
|
| 1639 |
-
el.dockEmpty.hidden = true;
|
| 1640 |
-
el.resultBlock.classList.add("visible");
|
| 1641 |
-
try {
|
| 1642 |
-
// Very long takes (hours of WAV) can exceed the browser's decode
|
| 1643 |
-
// memory — the placeholder waveform is fine, never fail the take.
|
| 1644 |
-
state.wavePeaks = await decodeWavePeaks(url, 48);
|
| 1645 |
-
} catch {
|
| 1646 |
-
state.wavePeaks = null;
|
| 1647 |
-
}
|
| 1648 |
-
renderWave(0);
|
| 1649 |
-
openPlayerStage();
|
| 1650 |
}
|
| 1651 |
}
|
| 1652 |
}
|
|
@@ -1691,6 +1734,7 @@ async function init() {
|
|
| 1691 |
renderTurns();
|
| 1692 |
renderExamplePills();
|
| 1693 |
updateStatus();
|
|
|
|
| 1694 |
window.setInterval(updateStatus, 8000);
|
| 1695 |
}
|
| 1696 |
|
|
|
|
| 1492 |
el.logToggleBtn.textContent = visible ? "Hide generation log" : "View generation log";
|
| 1493 |
});
|
| 1494 |
|
| 1495 |
+
/* ---------------- Take download & presentation ---------------- */
|
| 1496 |
+
// Long takes are hundreds of MB — stream the download with progress so
|
| 1497 |
+
// "Complete" never looks like a hang while the WAV transfers.
|
| 1498 |
+
async function downloadTakeBlob(audioId) {
|
| 1499 |
+
setStatus("downloading");
|
| 1500 |
+
const audioRes = await fetch(`/api/audio/${audioId}`);
|
| 1501 |
+
if (!audioRes.ok) throw new Error("The finished audio could not be fetched from the server.");
|
| 1502 |
+
const totalBytes = Number(audioRes.headers.get("Content-Length")) || 0;
|
| 1503 |
+
const audioReader = audioRes.body.getReader();
|
| 1504 |
+
const parts = [];
|
| 1505 |
+
let received = 0;
|
| 1506 |
+
let lastShown = -1;
|
| 1507 |
+
while (true) {
|
| 1508 |
+
const part = await audioReader.read();
|
| 1509 |
+
if (part.done) break;
|
| 1510 |
+
parts.push(part.value);
|
| 1511 |
+
received += part.value.length;
|
| 1512 |
+
const mb = Math.floor(received / 1048576);
|
| 1513 |
+
if (mb !== lastShown) {
|
| 1514 |
+
lastShown = mb;
|
| 1515 |
+
setStatus("downloading", totalBytes
|
| 1516 |
+
? `Downloading your take… ${mb} / ${Math.ceil(totalBytes / 1048576)} MB`
|
| 1517 |
+
: `Downloading your take… ${mb} MB`);
|
| 1518 |
+
}
|
| 1519 |
+
}
|
| 1520 |
+
return new Blob(parts, { type: audioRes.headers.get("Content-Type") || "audio/wav" });
|
| 1521 |
+
}
|
| 1522 |
+
|
| 1523 |
+
async function presentTake(blob, durationSeconds, snapshot) {
|
| 1524 |
+
setStatus("complete");
|
| 1525 |
+
const url = URL.createObjectURL(blob);
|
| 1526 |
+
el.resultAudio.src = url;
|
| 1527 |
+
el.downloadBtn.href = url;
|
| 1528 |
+
el.stageDownloadBtn.href = url;
|
| 1529 |
+
el.audioDuration.textContent = formatDuration(durationSeconds);
|
| 1530 |
+
el.playerTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
| 1531 |
+
el.stageTime.textContent = `0:00 / ${formatClock(durationSeconds)}`;
|
| 1532 |
+
setPlayIcons("►");
|
| 1533 |
+
buildSyncedTranscript(snapshot);
|
| 1534 |
+
el.dockEmpty.hidden = true;
|
| 1535 |
+
el.resultBlock.classList.add("visible");
|
| 1536 |
+
try {
|
| 1537 |
+
// Very long takes (hours of WAV) can exceed the browser's decode
|
| 1538 |
+
// memory — the placeholder waveform is fine, never fail the take.
|
| 1539 |
+
state.wavePeaks = await decodeWavePeaks(url, 48);
|
| 1540 |
+
} catch {
|
| 1541 |
+
state.wavePeaks = null;
|
| 1542 |
+
}
|
| 1543 |
+
renderWave(0);
|
| 1544 |
+
openPlayerStage();
|
| 1545 |
+
}
|
| 1546 |
+
|
| 1547 |
+
/* On load: if the server still holds a finished take this page doesn't know
|
| 1548 |
+
about (lost tab, accidental navigation), offer to recover it. */
|
| 1549 |
+
async function checkLastTake() {
|
| 1550 |
+
try {
|
| 1551 |
+
const res = await fetch("/api/last-take", { cache: "no-store" });
|
| 1552 |
+
if (!res.ok) return;
|
| 1553 |
+
const info = await res.json();
|
| 1554 |
+
const age = info.age_seconds < 120
|
| 1555 |
+
? "moments ago"
|
| 1556 |
+
: `${Math.round(info.age_seconds / 60)} min ago`;
|
| 1557 |
+
el.dockEmpty.textContent =
|
| 1558 |
+
`A finished take (${formatDuration(info.duration)}, generated ${age}) is still on the server.`;
|
| 1559 |
+
const btn = document.createElement("button");
|
| 1560 |
+
btn.type = "button";
|
| 1561 |
+
btn.className = "btn btn-accent";
|
| 1562 |
+
btn.style.cssText = "display:block; margin:12px auto 0;";
|
| 1563 |
+
btn.textContent = "Recover last take";
|
| 1564 |
+
btn.addEventListener("click", async () => {
|
| 1565 |
+
btn.disabled = true;
|
| 1566 |
+
try {
|
| 1567 |
+
const blob = await downloadTakeBlob(info.audio_id);
|
| 1568 |
+
el.generationTime.textContent = "--";
|
| 1569 |
+
el.resultModel.textContent = "recovered";
|
| 1570 |
+
state.resultTitle = "Recovered take";
|
| 1571 |
+
await presentTake(blob, info.duration, []);
|
| 1572 |
+
} catch (error) {
|
| 1573 |
+
setStatus("error", error.message);
|
| 1574 |
+
btn.disabled = false;
|
| 1575 |
+
}
|
| 1576 |
+
});
|
| 1577 |
+
el.dockEmpty.append(btn);
|
| 1578 |
+
} catch { /* nothing to recover */ }
|
| 1579 |
+
}
|
| 1580 |
+
|
| 1581 |
/* ---------------- Generate ---------------- */
|
| 1582 |
function fileToBase64(file) {
|
| 1583 |
return new Promise((resolve, reject) => {
|
|
|
|
| 1685 |
}
|
| 1686 |
|
| 1687 |
if (evt.stage === "complete" && evt.audio_id) {
|
| 1688 |
+
const blob = await downloadTakeBlob(evt.audio_id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1689 |
el.generationTime.textContent = formatDuration((performance.now() - started) / 1000);
|
|
|
|
| 1690 |
el.resultModel.textContent = state.model;
|
|
|
|
|
|
|
|
|
|
| 1691 |
state.resultTitle = el.scriptTitle.textContent;
|
| 1692 |
+
await presentTake(blob, evt.audio_duration, turnsSnapshot);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1693 |
}
|
| 1694 |
}
|
| 1695 |
}
|
|
|
|
| 1734 |
renderTurns();
|
| 1735 |
renderExamplePills();
|
| 1736 |
updateStatus();
|
| 1737 |
+
checkLastTake();
|
| 1738 |
window.setInterval(updateStatus, 8000);
|
| 1739 |
}
|
| 1740 |
|