ACloudCenter Claude Fable 5.1 commited on
Commit
3fb3c19
·
1 Parent(s): 8c74acb

Recover last take is per visitor, not global

Browse files

/api/last-take used to return the newest take from anyone, so any visitor
could recover someone else's render. Takes are now remembered per owner: a
random id the client keeps in localStorage and sends as X-Chorus-Visitor,
falling back to the client IP. Each owner's latest take is still protected
from the 15-minute audio expiry; owner entries age out after 6 h.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bjX8vNkBk9Rjt2SToVF75

Files changed (3) hide show
  1. README.md +1 -1
  2. app.py +38 -14
  3. static/app.js +20 -2
README.md CHANGED
@@ -85,7 +85,7 @@ The lightweight FastAPI frontend (this repo, hosted as a Docker Space) is separa
85
  └────────────────────────────────┘ └──────────────────────────────────┘
86
  ```
87
 
88
- - **Frontend** (`app.py` + `static/`): script generation via the HF Inference API, script parsing, an SSE endpoint that relays progress and streamed chunk audio from Modal, post-processing (tone shelves, loudness normalization, spectral denoise, time-stretch), MP3 encoding, and byte-range serving of finished takes. Takes live in memory for 15 minutes.
89
  - **Backend** (`backend_modal/modal_runner.py`): a Modal class that loads both models at container start and exposes `generate_podcast` as a streaming generator. Deployed separately. The VibeVoice model code and reference voice WAVs alongside it are gitignored.
90
  - **Scaling profiles**: the backend deploys with `VIBEVOICE_PROFILE=launch` (one container always warm, one buffer under load) or `tail` (scale to zero). Both cap at 4 concurrent GPUs; extra requests queue. The model weights are baked into the image and the loaded process is memory-snapshotted, so a cold container in `tail` mode restores the snapshot and copies weights to the GPU instead of re-downloading ~17 GB from the Hub (which was most of the old ~3 minute cold start). Measured 2026-09-12: 29 s from request to a ready GPU. The UI still warns when the GPU is cold.
91
  - **Responsible-use gate**: Generate stays locked until the visitor accepts the responsible-use terms shown above it (no impersonation without consent, no deception, harassment, defamation, or illegal content, rights to any uploaded voice, disclosure that the audio is synthetic). The server refuses renders without it and writes one JSON line per acceptance to `data/consent_log.jsonl` (time, IP, user agent, voices used, script hash — never the script) and to the Space logs as `CONSENT {...}`.
 
85
  └────────────────────────────────┘ └──────────────────────────────────┘
86
  ```
87
 
88
+ - **Frontend** (`app.py` + `static/`): script generation via the HF Inference API, script parsing, an SSE endpoint that relays progress and streamed chunk audio from Modal, post-processing (tone shelves, loudness normalization, spectral denoise, time-stretch), MP3 encoding, and byte-range serving of finished takes. Takes live in memory for 15 minutes; audio ids are random, and "Recover last take" only offers a visitor their own most recent render (keyed by a random per-browser id, or the IP as a fallback).
89
  - **Backend** (`backend_modal/modal_runner.py`): a Modal class that loads both models at container start and exposes `generate_podcast` as a streaming generator. Deployed separately. The VibeVoice model code and reference voice WAVs alongside it are gitignored.
90
  - **Scaling profiles**: the backend deploys with `VIBEVOICE_PROFILE=launch` (one container always warm, one buffer under load) or `tail` (scale to zero). Both cap at 4 concurrent GPUs; extra requests queue. The model weights are baked into the image and the loaded process is memory-snapshotted, so a cold container in `tail` mode restores the snapshot and copies weights to the GPU instead of re-downloading ~17 GB from the Hub (which was most of the old ~3 minute cold start). Measured 2026-09-12: 29 s from request to a ready GPU. The UI still warns when the GPU is cold.
91
  - **Responsible-use gate**: Generate stays locked until the visitor accepts the responsible-use terms shown above it (no impersonation without consent, no deception, harassment, defamation, or illegal content, rights to any uploaded voice, disclosure that the audio is synthetic). The server refuses renders without it and writes one JSON line per acceptance to `data/consent_log.jsonl` (time, IP, user agent, voices used, script hash — never the script) and to the Space logs as `CONSENT {...}`.
app.py CHANGED
@@ -568,7 +568,27 @@ def _encode_wav(sample_rate: int, audio: np.ndarray) -> bytes:
568
  AUDIO_STORE: dict[str, tuple[float, bytes]] = {}
569
 
570
 
571
- LAST_AUDIO: dict = {} # {"audio_id", "created", "duration"} of the newest finished take
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
 
573
 
574
  # Per-generation streamed chunks, so playback can start while later waves
@@ -585,13 +605,14 @@ def _prune_stream_chunks() -> None:
585
 
586
  def _prune_audio_store() -> None:
587
  now = time.time()
588
- keep = LAST_AUDIO.get("audio_id")
589
- # Never prune the most recent take: a lost page/tab must not orphan an
590
- # hour-long render (learned the hard way, 2026-08-20). It is only evicted
591
- # when a newer take replaces it.
 
592
  stale = [
593
  k for k, (ts, _) in AUDIO_STORE.items()
594
- if now - ts > AUDIO_TTL_SECONDS and k != keep
595
  ]
596
  for k in stale:
597
  AUDIO_STORE.pop(k, None)
@@ -947,6 +968,7 @@ async def api_generate(payload: GenerateRequest, request: Request) -> StreamingR
947
  detail="Confirm you have the right to use each uploaded voice before generating.",
948
  )
949
  _record_consent(request, payload, script, custom_audio, speakers)
 
950
 
951
  # Only pass custom_audio kwargs when a clone is actually in use, so preset-voice
952
  # generations still work against a deployed backend that predates the parameters.
@@ -1023,8 +1045,7 @@ async def api_generate(payload: GenerateRequest, request: Request) -> StreamingR
1023
  AUDIO_STORE[audio_id] = (time.time(), wav_bytes)
1024
  event["audio_id"] = audio_id
1025
  event["audio_duration"] = len(audio_array) / float(sample_rate)
1026
- LAST_AUDIO.clear()
1027
- LAST_AUDIO.update(
1028
  audio_id=audio_id,
1029
  created=time.time(),
1030
  duration=event["audio_duration"],
@@ -1035,14 +1056,17 @@ async def api_generate(payload: GenerateRequest, request: Request) -> StreamingR
1035
 
1036
 
1037
  @app.get("/api/last-take")
1038
- async def api_last_take() -> dict:
1039
- """The newest finished take, so a lost page can recover its render."""
1040
- if not LAST_AUDIO or LAST_AUDIO.get("audio_id") not in AUDIO_STORE:
 
 
 
1041
  raise HTTPException(status_code=404, detail="No recent take.")
1042
  return {
1043
- "audio_id": LAST_AUDIO["audio_id"],
1044
- "age_seconds": round(time.time() - LAST_AUDIO["created"]),
1045
- "duration": LAST_AUDIO["duration"],
1046
  }
1047
 
1048
 
 
568
  AUDIO_STORE: dict[str, tuple[float, bytes]] = {}
569
 
570
 
571
+ # Newest finished take PER VISITOR, so "Recover last take" only ever offers
572
+ # someone their own render (2026-09-12: it used to be one global slot, which
573
+ # let any visitor recover whoever rendered last). The visitor key is the
574
+ # random id the client keeps in localStorage and sends as X-Chorus-Visitor;
575
+ # without one (old clients, curl) it falls back to the client IP.
576
+ LAST_TAKES: dict[str, dict] = {} # owner -> {"audio_id", "created", "duration"}
577
+ LAST_TAKE_MAX_AGE_SECONDS = 6 * 3600 # bound the map: forget owners after 6 h
578
+ _VISITOR_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
579
+
580
+
581
+ def _take_owner(request: Request) -> str:
582
+ vid = (request.headers.get("x-chorus-visitor") or "").strip()
583
+ if _VISITOR_ID_RE.match(vid):
584
+ return f"vid:{vid}"
585
+ return f"ip:{_client_ip(request)}"
586
+
587
+
588
+ def _prune_last_takes() -> None:
589
+ cutoff = time.time() - LAST_TAKE_MAX_AGE_SECONDS
590
+ for owner in [o for o, t in LAST_TAKES.items() if t["created"] < cutoff]:
591
+ LAST_TAKES.pop(owner, None)
592
 
593
 
594
  # Per-generation streamed chunks, so playback can start while later waves
 
605
 
606
  def _prune_audio_store() -> None:
607
  now = time.time()
608
+ _prune_last_takes()
609
+ keep = {t["audio_id"] for t in LAST_TAKES.values()}
610
+ # Never prune a visitor's most recent take: a lost page/tab must not orphan
611
+ # an hour-long render (learned the hard way, 2026-08-20). It is only evicted
612
+ # when a newer take replaces it or its owner entry ages out.
613
  stale = [
614
  k for k, (ts, _) in AUDIO_STORE.items()
615
+ if now - ts > AUDIO_TTL_SECONDS and k not in keep
616
  ]
617
  for k in stale:
618
  AUDIO_STORE.pop(k, None)
 
968
  detail="Confirm you have the right to use each uploaded voice before generating.",
969
  )
970
  _record_consent(request, payload, script, custom_audio, speakers)
971
+ take_owner = _take_owner(request)
972
 
973
  # Only pass custom_audio kwargs when a clone is actually in use, so preset-voice
974
  # generations still work against a deployed backend that predates the parameters.
 
1045
  AUDIO_STORE[audio_id] = (time.time(), wav_bytes)
1046
  event["audio_id"] = audio_id
1047
  event["audio_duration"] = len(audio_array) / float(sample_rate)
1048
+ LAST_TAKES[take_owner] = dict(
 
1049
  audio_id=audio_id,
1050
  created=time.time(),
1051
  duration=event["audio_duration"],
 
1056
 
1057
 
1058
  @app.get("/api/last-take")
1059
+ async def api_last_take(request: Request) -> dict:
1060
+ """This visitor's newest finished take, so a lost page can recover its render.
1061
+ Never anyone else's: the lookup is keyed by the caller's visitor id / IP."""
1062
+ _prune_last_takes()
1063
+ take = LAST_TAKES.get(_take_owner(request))
1064
+ if not take or take["audio_id"] not in AUDIO_STORE:
1065
  raise HTTPException(status_code=404, detail="No recent take.")
1066
  return {
1067
+ "audio_id": take["audio_id"],
1068
+ "age_seconds": round(time.time() - take["created"]),
1069
+ "duration": take["duration"],
1070
  }
1071
 
1072
 
static/app.js CHANGED
@@ -2297,9 +2297,27 @@ async function presentTake(audioId, durationSeconds, snapshot, anchors = null) {
2297
 
2298
  /* On load: if the server still holds a finished take this page doesn't know
2299
  about (lost tab, accidental navigation), offer to recover it. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2300
  async function checkLastTake() {
2301
  try {
2302
- const res = await fetch("/api/last-take", { cache: "no-store" });
2303
  if (!res.ok) return;
2304
  const info = await res.json();
2305
  const age = info.age_seconds < 120
@@ -2618,7 +2636,7 @@ el.generateBtn.addEventListener("click", async () => {
2618
  try {
2619
  const response = await fetch("/api/generate", {
2620
  method: "POST",
2621
- headers: { "Content-Type": "application/json" },
2622
  body: JSON.stringify(payload),
2623
  signal: generateAbort.signal,
2624
  });
 
2297
 
2298
  /* On load: if the server still holds a finished take this page doesn't know
2299
  about (lost tab, accidental navigation), offer to recover it. */
2300
+ /* A random per-browser id so the server can hand "Recover last take" only to
2301
+ the visitor who rendered it. Lives in localStorage (per origin, and per
2302
+ embed when the app is framed), never identifies a person. */
2303
+ function visitorId() {
2304
+ try {
2305
+ let id = localStorage.getItem("chorus-visitor-id");
2306
+ if (!id || !/^[A-Za-z0-9_-]{8,64}$/.test(id)) {
2307
+ const bytes = new Uint8Array(16);
2308
+ crypto.getRandomValues(bytes);
2309
+ id = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
2310
+ localStorage.setItem("chorus-visitor-id", id);
2311
+ }
2312
+ return id;
2313
+ } catch (_) {
2314
+ return ""; // storage blocked: the server falls back to the IP address
2315
+ }
2316
+ }
2317
+
2318
  async function checkLastTake() {
2319
  try {
2320
+ const res = await fetch("/api/last-take", { cache: "no-store", headers: { "X-Chorus-Visitor": visitorId() } });
2321
  if (!res.ok) return;
2322
  const info = await res.json();
2323
  const age = info.age_seconds < 120
 
2636
  try {
2637
  const response = await fetch("/api/generate", {
2638
  method: "POST",
2639
+ headers: { "Content-Type": "application/json", "X-Chorus-Visitor": visitorId() },
2640
  body: JSON.stringify(payload),
2641
  signal: generateAbort.signal,
2642
  });