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
- README.md +1 -1
- app.py +38 -14
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 589 |
-
|
| 590 |
-
#
|
| 591 |
-
#
|
|
|
|
| 592 |
stale = [
|
| 593 |
k for k, (ts, _) in AUDIO_STORE.items()
|
| 594 |
-
if now - ts > AUDIO_TTL_SECONDS and k
|
| 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 |
-
|
| 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 |
-
"""
|
| 1040 |
-
|
|
|
|
|
|
|
|
|
|
| 1041 |
raise HTTPException(status_code=404, detail="No recent take.")
|
| 1042 |
return {
|
| 1043 |
-
"audio_id":
|
| 1044 |
-
"age_seconds": round(time.time() -
|
| 1045 |
-
"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 |
});
|