cvpfus Codex commited on
Commit ·
4d86717
1
Parent(s): 7711224
Bound generated speech outputs
Browse filesCo-authored-by: Codex <codex@openai.com>
- FIELD_NOTES.md +2 -0
- README.md +2 -0
- app.py +18 -0
- scripts/verify.py +22 -0
FIELD_NOTES.md
CHANGED
|
@@ -57,6 +57,8 @@ The app preloads image descriptions through `/api/image-descriptions`, caches th
|
|
| 57 |
|
| 58 |
Kokoro is still the intended model-backed TTS path. For local resilience, the frontend can use the browser speech engine when Kokoro is unavailable, and the session panel labels that as a browser fallback instead of model audio.
|
| 59 |
|
|
|
|
|
|
|
| 60 |
Reader settings are manifest-backed: the app exposes known Kokoro voices and speed bounds through `/api/article-manifest`, then the custom frontend renders the controls and passes the selected voice to `/api/speak`.
|
| 61 |
|
| 62 |
Auto-advance is intentionally opt-in. This keeps the default reader mode user-controlled while still supporting a continuous-reading demo path.
|
|
|
|
| 57 |
|
| 58 |
Kokoro is still the intended model-backed TTS path. For local resilience, the frontend can use the browser speech engine when Kokoro is unavailable, and the session panel labels that as a browser fallback instead of model audio.
|
| 59 |
|
| 60 |
+
Generated speech outputs are capped by a small retention cleanup. That keeps repeated demo sessions from turning the Space output directory into hidden state.
|
| 61 |
+
|
| 62 |
Reader settings are manifest-backed: the app exposes known Kokoro voices and speed bounds through `/api/article-manifest`, then the custom frontend renders the controls and passes the selected voice to `/api/speak`.
|
| 63 |
|
| 64 |
Auto-advance is intentionally opt-in. This keeps the default reader mode user-controlled while still supporting a continuous-reading demo path.
|
README.md
CHANGED
|
@@ -100,6 +100,8 @@ Image descriptions are preloaded into a local cache and written into the page's
|
|
| 100 |
|
| 101 |
Kokoro remains the planned tiny-model TTS path. During local demos, if the server-side Kokoro call falls back, the browser speech engine can read the same transcript so screen-reader mode still produces audible feedback.
|
| 102 |
|
|
|
|
|
|
|
| 103 |
The reader bar exposes Kokoro voice selection and speaking speed controls. Defaults come from `/api/article-manifest` so the UI, docs, and backend stay aligned.
|
| 104 |
|
| 105 |
Auto-advance is available as an opt-in reader control. It stays off by default so users keep manual control unless they choose continuous reading.
|
|
|
|
| 100 |
|
| 101 |
Kokoro remains the planned tiny-model TTS path. During local demos, if the server-side Kokoro call falls back, the browser speech engine can read the same transcript so screen-reader mode still produces audible feedback.
|
| 102 |
|
| 103 |
+
Generated speech files are pruned automatically so repeated demos do not grow the Space's `outputs` directory without bound.
|
| 104 |
+
|
| 105 |
The reader bar exposes Kokoro voice selection and speaking speed controls. Defaults come from `/api/article-manifest` so the UI, docs, and backend stay aligned.
|
| 106 |
|
| 107 |
Auto-advance is available as an opt-in reader control. It stays off by default so users keep manual control unless they choose continuous reading.
|
app.py
CHANGED
|
@@ -558,6 +558,22 @@ def _silent_wav(path: Path, seconds: float = 0.35, sample_rate: int = 24000) ->
|
|
| 558 |
wav.writeframes(b"\x00\x00" * frames)
|
| 559 |
|
| 560 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
def speak_core(text: str, voice: str, speed: float) -> dict[str, Any]:
|
| 562 |
start = time.perf_counter()
|
| 563 |
try:
|
|
@@ -577,6 +593,8 @@ def speak_core(text: str, voice: str, speed: float) -> dict[str, Any]:
|
|
| 577 |
runtime = "fallback"
|
| 578 |
warning = f"Kokoro unavailable: {exc.__class__.__name__}"
|
| 579 |
|
|
|
|
|
|
|
| 580 |
return {
|
| 581 |
"ok": True,
|
| 582 |
"runtime": runtime,
|
|
|
|
| 558 |
wav.writeframes(b"\x00\x00" * frames)
|
| 559 |
|
| 560 |
|
| 561 |
+
def _prune_speech_outputs(keep_path: Path, max_files: int = 24) -> None:
|
| 562 |
+
speech_files = sorted(
|
| 563 |
+
OUTPUT_DIR.glob("speech*.wav"),
|
| 564 |
+
key=lambda path: path.stat().st_mtime,
|
| 565 |
+
reverse=True,
|
| 566 |
+
)
|
| 567 |
+
keep_resolved = keep_path.resolve()
|
| 568 |
+
for old_path in speech_files[max_files:]:
|
| 569 |
+
if old_path.resolve() == keep_resolved:
|
| 570 |
+
continue
|
| 571 |
+
try:
|
| 572 |
+
old_path.unlink()
|
| 573 |
+
except OSError:
|
| 574 |
+
pass
|
| 575 |
+
|
| 576 |
+
|
| 577 |
def speak_core(text: str, voice: str, speed: float) -> dict[str, Any]:
|
| 578 |
start = time.perf_counter()
|
| 579 |
try:
|
|
|
|
| 593 |
runtime = "fallback"
|
| 594 |
warning = f"Kokoro unavailable: {exc.__class__.__name__}"
|
| 595 |
|
| 596 |
+
_prune_speech_outputs(output_path)
|
| 597 |
+
|
| 598 |
return {
|
| 599 |
"ok": True,
|
| 600 |
"runtime": runtime,
|
scripts/verify.py
CHANGED
|
@@ -139,6 +139,27 @@ def verify_core_fallbacks() -> None:
|
|
| 139 |
assert_true(isinstance(generated["elapsed_ms"], int), "Image generation should include elapsed_ms")
|
| 140 |
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
def verify_routes() -> None:
|
| 143 |
client = TestClient(app.app)
|
| 144 |
|
|
@@ -308,6 +329,7 @@ def main() -> None:
|
|
| 308 |
verify_static_assets()
|
| 309 |
verify_space_metadata()
|
| 310 |
verify_core_fallbacks()
|
|
|
|
| 311 |
verify_routes()
|
| 312 |
print("Tiny Narrator verification passed.")
|
| 313 |
|
|
|
|
| 139 |
assert_true(isinstance(generated["elapsed_ms"], int), "Image generation should include elapsed_ms")
|
| 140 |
|
| 141 |
|
| 142 |
+
def verify_output_retention() -> None:
|
| 143 |
+
keep_path = app.OUTPUT_DIR / "speech-retention-keep.wav"
|
| 144 |
+
_ = app.speak_core("Tiny Narrator retention check.", voice="af_heart", speed=1.0)
|
| 145 |
+
keep_path.write_bytes(b"keep")
|
| 146 |
+
stale_files = []
|
| 147 |
+
for index in range(30):
|
| 148 |
+
stale_path = app.OUTPUT_DIR / f"speech-retention-stale-{index:02d}.wav"
|
| 149 |
+
stale_path.write_bytes(b"stale")
|
| 150 |
+
stale_files.append(stale_path)
|
| 151 |
+
|
| 152 |
+
app._prune_speech_outputs(keep_path, max_files=4)
|
| 153 |
+
|
| 154 |
+
remaining = list(app.OUTPUT_DIR.glob("speech*.wav"))
|
| 155 |
+
assert_true(keep_path.exists(), "Speech retention should keep the active output")
|
| 156 |
+
assert_true(len(remaining) <= 5, "Speech retention should prune stale generated audio")
|
| 157 |
+
|
| 158 |
+
keep_path.unlink(missing_ok=True)
|
| 159 |
+
for stale_path in stale_files:
|
| 160 |
+
stale_path.unlink(missing_ok=True)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
def verify_routes() -> None:
|
| 164 |
client = TestClient(app.app)
|
| 165 |
|
|
|
|
| 329 |
verify_static_assets()
|
| 330 |
verify_space_metadata()
|
| 331 |
verify_core_fallbacks()
|
| 332 |
+
verify_output_retention()
|
| 333 |
verify_routes()
|
| 334 |
print("Tiny Narrator verification passed.")
|
| 335 |
|