From 2b99dc8cccc196934f0eaeac95903fdba6f2b599 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 08:10:27 +0200 Subject: [PATCH] feat: deduplicate voice design requests --- CHANGELOG.md | 2 ++ TODO.md | 4 ++-- routes/tts.py | 27 ++++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc93fa..55cd399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi prompt/source content and sensitive result fields are excluded from the audit record. - **Synthesized Audio Cache Eviction.** Persisted line-audio clips now use configurable global file-count and byte limits with oldest-first eviction, and oversized individual clips are rejected. +- **Voice Design Idempotency.** Repeated voice-design submissions with the same `Idempotency-Key` now reuse the + original temporary result; reusing a key for different request data is rejected. - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL rejection and oversized JSON request rejection. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. diff --git a/TODO.md b/TODO.md index bc06965..6aedccf 100644 --- a/TODO.md +++ b/TODO.md @@ -51,8 +51,8 @@ the first block before investing in architecture or polish. - [ ] **M — Make profile portraits and character sheets use one canonical identity.** Shared structured prompts now version consistent pairs as v3 and fingerprint canonical appearance fields; add cross-record validation so mismatched profile/reference files are detected even when both are current-version. -- [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference jobs now deduplicate - repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs. +- [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference and voice-design jobs now + deduplicate repeated requests with a bounded, expiring `Idempotency-Key` contract; casting jobs remain. - [ ] **M — Improve large-table performance.** Character productions and the flat voice library now paginate at 50 visible rows; updating only affected rows and disabling preloading on low-memory devices remain. - [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses and persisted synthesized diff --git a/routes/tts.py b/routes/tts.py index 4cf3aca..d936f4c 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -5,6 +5,7 @@ import asyncio import io import json import logging +import hashlib import os import re import threading @@ -55,6 +56,8 @@ logger = logging.getLogger("uvicorn.error") router = APIRouter() _SEED_FINDER_VOICE_LOCK = threading.Lock() _TTS_GENERATION_SLOTS = asyncio.Semaphore(2) +_VOICE_DESIGN_IDEMPOTENCY: dict[str, tuple[str, dict, float]] = {} +_VOICE_DESIGN_IDEMPOTENCY_LOCK = threading.Lock() async def _bounded_tts_call(fn, *args): @@ -777,6 +780,21 @@ async def tts_style_variation(request: Request): @router.post("/api/voice-design") async def voice_design(request: Request): data = await request.json() + idempotency_key = str(request.headers.get("idempotency-key") or "").strip() + if len(idempotency_key) > 200: + raise HTTPException(400, "Idempotency-Key is too long") + request_fingerprint = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest() + if idempotency_key: + now = time.time() + with _VOICE_DESIGN_IDEMPOTENCY_LOCK: + for old_key, (_, _, created) in list(_VOICE_DESIGN_IDEMPOTENCY.items()): + if now - created > 1800: + _VOICE_DESIGN_IDEMPOTENCY.pop(old_key, None) + previous = _VOICE_DESIGN_IDEMPOTENCY.get(idempotency_key) + if previous: + if previous[0] != request_fingerprint: + raise HTTPException(409, "Idempotency-Key was already used for a different request") + return {**previous[1], "deduplicated": True} instruct: str = data.get("instruct", "").strip() sample_text: str = data.get("sample_text", "Hello! This is a voice design sample.").strip() language: str = data.get("language", "Auto") @@ -807,7 +825,14 @@ async def voice_design(request: Request): tmp.write_bytes(audio) fid = uuid.uuid4().hex _registry_put(fid, tmp) - return {"id": fid, "duration": _duration(tmp)} + result = {"id": fid, "duration": _duration(tmp)} + if idempotency_key: + with _VOICE_DESIGN_IDEMPOTENCY_LOCK: + _VOICE_DESIGN_IDEMPOTENCY[idempotency_key] = (request_fingerprint, result, time.time()) + if len(_VOICE_DESIGN_IDEMPOTENCY) > 256: + oldest = min(_VOICE_DESIGN_IDEMPOTENCY, key=lambda k: _VOICE_DESIGN_IDEMPOTENCY[k][2]) + _VOICE_DESIGN_IDEMPOTENCY.pop(oldest, None) + return result @router.post("/api/tts-route-test")