ACloudCenter Claude Fable 5 commited on
Commit
a5c94a5
·
1 Parent(s): 07e4766

Never orphan a finished take: server keeps it, UI offers recovery

Browse files

Losing 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>

Files changed (2) hide show
  1. app.py +29 -1
  2. 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
- stale = [k for k, (ts, _) in AUDIO_STORE.items() if now - ts > AUDIO_TTL_SECONDS]
 
 
 
 
 
 
 
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
- // Long takes are hundreds of MB — stream the download with progress
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
- buildSyncedTranscript(turnsSnapshot);
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