Spaces:
Paused
Paused
Commit ·
994d3d9
1
Parent(s): 397b4ac
feat: bounded pre-gen, cancellation, disk cache pruning, GPU lock
Browse files- tts.py: limit pre-gen to first 3 chunks (configurable via env vars),
cancel_pregeneration() for Ask/book-switch, LRU eviction of story cache,
disk cache pruning (max 400 files / 512MB), GPU_INFERENCE_LOCK integration
- app.py: cancel pregen on Ask and book select, show target/error status
- inference_lfm.py: GPU lock around generate, improved error handling
- qa_flow.py: release_audio_submission for retry on error
- test_modules/test_tts_pregen.py: new test for pregen flow
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- app.py +27 -6
- inference_lfm.py +62 -9
- qa_flow.py +38 -2
- test_modules/test_qa_flow.py +23 -1
- test_modules/test_tts_pregen.py +202 -0
- tts.py +133 -14
app.py
CHANGED
|
@@ -9,6 +9,7 @@ import time
|
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
from tts import (
|
|
|
|
| 12 |
generate_audio_stream,
|
| 13 |
get_pregeneration_status,
|
| 14 |
pregenerate_story_audio,
|
|
@@ -16,7 +17,7 @@ from tts import (
|
|
| 16 |
)
|
| 17 |
from inference import transcribe_audio, answer_story_question
|
| 18 |
from inference_lfm import answer_question_audio
|
| 19 |
-
from qa_flow import build_qa_response, claim_audio_submission
|
| 20 |
from runtime_config import SAMPLE_SOUNDS_DIR, gradio_allowed_paths
|
| 21 |
from voice_clone import load_default_profile, list_saved_profiles
|
| 22 |
|
|
@@ -698,23 +699,40 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
|
|
| 698 |
total = max(len(paras) - 1, 0)
|
| 699 |
tts_chunks = split_into_chunks("\n\n".join(paras))
|
| 700 |
|
| 701 |
-
# Pre-generate
|
|
|
|
| 702 |
pregenerate_story_audio(tts_chunks, voice_profile_id=profile_id)
|
| 703 |
|
| 704 |
pregen = get_pregeneration_status(tts_chunks, voice_profile_id=profile_id)
|
| 705 |
warmed = int(pregen["cached"])
|
| 706 |
total_chunks = int(pregen["total"])
|
|
|
|
|
|
|
| 707 |
if pregen["complete"]:
|
| 708 |
chunk_text = f"{warmed} / {total_chunks} chunks cached — ready"
|
| 709 |
chunk_color = "#4ade80"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 710 |
else:
|
| 711 |
-
chunk_text = f"Warming
|
| 712 |
chunk_color = "#f59e0b"
|
|
|
|
|
|
|
| 713 |
chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: {chunk_color}; font-family: monospace; text-align: center;">{chunk_text}</div>"""
|
| 714 |
-
status_html = """
|
| 715 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 716 |
-
<span style="width: 8px; height: 8px; border-radius: 50%; background:
|
| 717 |
-
<span style="font-size: 11px; color:
|
| 718 |
</div>
|
| 719 |
"""
|
| 720 |
story_html = render_story_text(paras, 0)
|
|
@@ -990,6 +1008,7 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
|
|
| 990 |
|
| 991 |
# 8. Ask button — PAUSED-ASKING state
|
| 992 |
def enter_asking_state():
|
|
|
|
| 993 |
status_html = """
|
| 994 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 995 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block; animation: pulse 1s infinite;"></span>
|
|
@@ -1031,6 +1050,8 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
|
|
| 1031 |
answer_fn=answer_question_audio,
|
| 1032 |
max_new_tokens=100,
|
| 1033 |
)
|
|
|
|
|
|
|
| 1034 |
|
| 1035 |
if not result["ok"]:
|
| 1036 |
answer_html = """
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
from tts import (
|
| 12 |
+
cancel_pregeneration,
|
| 13 |
generate_audio_stream,
|
| 14 |
get_pregeneration_status,
|
| 15 |
pregenerate_story_audio,
|
|
|
|
| 17 |
)
|
| 18 |
from inference import transcribe_audio, answer_story_question
|
| 19 |
from inference_lfm import answer_question_audio
|
| 20 |
+
from qa_flow import build_qa_response, claim_audio_submission, release_audio_submission
|
| 21 |
from runtime_config import SAMPLE_SOUNDS_DIR, gradio_allowed_paths
|
| 22 |
from voice_clone import load_default_profile, list_saved_profiles
|
| 23 |
|
|
|
|
| 699 |
total = max(len(paras) - 1, 0)
|
| 700 |
tts_chunks = split_into_chunks("\n\n".join(paras))
|
| 701 |
|
| 702 |
+
# Pre-generate only the first few chunks in background for fast start.
|
| 703 |
+
cancel_pregeneration()
|
| 704 |
pregenerate_story_audio(tts_chunks, voice_profile_id=profile_id)
|
| 705 |
|
| 706 |
pregen = get_pregeneration_status(tts_chunks, voice_profile_id=profile_id)
|
| 707 |
warmed = int(pregen["cached"])
|
| 708 |
total_chunks = int(pregen["total"])
|
| 709 |
+
target_chunks = int(pregen.get("target", min(total_chunks, 3)))
|
| 710 |
+
errors = int(pregen.get("errors", 0))
|
| 711 |
if pregen["complete"]:
|
| 712 |
chunk_text = f"{warmed} / {total_chunks} chunks cached — ready"
|
| 713 |
chunk_color = "#4ade80"
|
| 714 |
+
status_label = "READY"
|
| 715 |
+
status_color = "#94a3b8"
|
| 716 |
+
elif warmed >= target_chunks and errors == 0:
|
| 717 |
+
chunk_text = f"First {target_chunks} chunks cached — tap Play"
|
| 718 |
+
chunk_color = "#4ade80"
|
| 719 |
+
status_label = "READY — FIRST CHUNKS CACHED"
|
| 720 |
+
status_color = "#4ade80"
|
| 721 |
+
elif errors:
|
| 722 |
+
chunk_text = f"Warmup hit {errors} error(s); Play will generate on demand"
|
| 723 |
+
chunk_color = "#ef4444"
|
| 724 |
+
status_label = "READY — ON-DEMAND AUDIO"
|
| 725 |
+
status_color = "#f59e0b"
|
| 726 |
else:
|
| 727 |
+
chunk_text = f"Warming first {target_chunks} chunks: {warmed} / {target_chunks} cached — tap Play anytime"
|
| 728 |
chunk_color = "#f59e0b"
|
| 729 |
+
status_label = "WARMING AUDIO"
|
| 730 |
+
status_color = "#f59e0b"
|
| 731 |
chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: {chunk_color}; font-family: monospace; text-align: center;">{chunk_text}</div>"""
|
| 732 |
+
status_html = f"""
|
| 733 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 734 |
+
<span style="width: 8px; height: 8px; border-radius: 50%; background: {status_color}; display: inline-block;"></span>
|
| 735 |
+
<span style="font-size: 11px; color: {status_color}; font-family: monospace;">{status_label}</span>
|
| 736 |
</div>
|
| 737 |
"""
|
| 738 |
story_html = render_story_text(paras, 0)
|
|
|
|
| 1008 |
|
| 1009 |
# 8. Ask button — PAUSED-ASKING state
|
| 1010 |
def enter_asking_state():
|
| 1011 |
+
cancel_pregeneration()
|
| 1012 |
status_html = """
|
| 1013 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 1014 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block; animation: pulse 1s infinite;"></span>
|
|
|
|
| 1050 |
answer_fn=answer_question_audio,
|
| 1051 |
max_new_tokens=100,
|
| 1052 |
)
|
| 1053 |
+
if has_audio and result.get("error"):
|
| 1054 |
+
release_audio_submission(question_audio_path)
|
| 1055 |
|
| 1056 |
if not result["ok"]:
|
| 1057 |
answer_html = """
|
inference_lfm.py
CHANGED
|
@@ -5,6 +5,7 @@ Replaces the 3-model pipeline (Whisper ASR + Qwen Q&A + Qwen TTS) with a single
|
|
| 5 |
multimodal model that accepts audio/text input and produces audio+text output.
|
| 6 |
"""
|
| 7 |
import logging
|
|
|
|
| 8 |
import torch
|
| 9 |
import numpy as np
|
| 10 |
|
|
@@ -14,21 +15,72 @@ logger = logging.getLogger(__name__)
|
|
| 14 |
|
| 15 |
_processor = None
|
| 16 |
_model = None
|
|
|
|
| 17 |
|
| 18 |
HF_REPO = "LiquidAI/LFM2.5-Audio-1.5B"
|
| 19 |
SAMPLE_RATE = 24000
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def get_lfm_model():
|
| 23 |
"""Load LFM2.5-Audio-1.5B model. Cached after first call."""
|
| 24 |
global _processor, _model
|
| 25 |
if _model is None:
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
return _processor, _model
|
| 33 |
|
| 34 |
|
|
@@ -66,7 +118,7 @@ def answer_question_audio(
|
|
| 66 |
if question_audio_path:
|
| 67 |
import librosa
|
| 68 |
wav_np, sr = librosa.load(question_audio_path, sr=16000, mono=True)
|
| 69 |
-
wav = torch.from_numpy(wav_np).unsqueeze(0)
|
| 70 |
sr = 16000
|
| 71 |
chat.add_audio(wav, sr)
|
| 72 |
elif question_text:
|
|
@@ -95,12 +147,13 @@ def answer_question_audio(
|
|
| 95 |
# Decode text
|
| 96 |
answer_text = ""
|
| 97 |
if text_out:
|
| 98 |
-
answer_text = "".join(processor.text.decode(t) for t in text_out).strip()
|
| 99 |
|
| 100 |
# Decode audio
|
| 101 |
waveform = None
|
| 102 |
if audio_out and len(audio_out) > 1:
|
| 103 |
-
|
|
|
|
| 104 |
with GPU_INFERENCE_LOCK, torch.inference_mode():
|
| 105 |
waveform_tensor = processor.decode(audio_codes)
|
| 106 |
waveform = waveform_tensor.cpu().numpy().squeeze()
|
|
|
|
| 5 |
multimodal model that accepts audio/text input and produces audio+text output.
|
| 6 |
"""
|
| 7 |
import logging
|
| 8 |
+
import threading
|
| 9 |
import torch
|
| 10 |
import numpy as np
|
| 11 |
|
|
|
|
| 15 |
|
| 16 |
_processor = None
|
| 17 |
_model = None
|
| 18 |
+
_model_lock = threading.Lock()
|
| 19 |
|
| 20 |
HF_REPO = "LiquidAI/LFM2.5-Audio-1.5B"
|
| 21 |
SAMPLE_RATE = 24000
|
| 22 |
|
| 23 |
|
| 24 |
+
def _select_device() -> torch.device:
|
| 25 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _select_dtype() -> torch.dtype:
|
| 29 |
+
if not torch.cuda.is_available():
|
| 30 |
+
return torch.float32
|
| 31 |
+
cap = torch.cuda.get_device_capability()
|
| 32 |
+
return torch.bfloat16 if cap[0] >= 8 else torch.float16
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _move_module(module, device: torch.device, dtype: torch.dtype):
|
| 36 |
+
if not hasattr(module, "to"):
|
| 37 |
+
return module
|
| 38 |
+
try:
|
| 39 |
+
return module.to(device=device, dtype=dtype)
|
| 40 |
+
except TypeError:
|
| 41 |
+
try:
|
| 42 |
+
return module.to(device)
|
| 43 |
+
except Exception:
|
| 44 |
+
return module
|
| 45 |
+
except Exception:
|
| 46 |
+
return module
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _module_device(module, fallback: torch.device) -> torch.device:
|
| 50 |
+
try:
|
| 51 |
+
return next(module.parameters()).device
|
| 52 |
+
except Exception:
|
| 53 |
+
return getattr(module, "device", fallback)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
def get_lfm_model():
|
| 57 |
"""Load LFM2.5-Audio-1.5B model. Cached after first call."""
|
| 58 |
global _processor, _model
|
| 59 |
if _model is None:
|
| 60 |
+
with _model_lock:
|
| 61 |
+
if _model is None:
|
| 62 |
+
from liquid_audio import LFM2AudioModel, LFM2AudioProcessor
|
| 63 |
+
|
| 64 |
+
device = _select_device()
|
| 65 |
+
dtype = _select_dtype()
|
| 66 |
+
logger.info("Loading %s on %s (%s)...", HF_REPO, device, dtype)
|
| 67 |
+
_processor = LFM2AudioProcessor.from_pretrained(HF_REPO).eval()
|
| 68 |
+
try:
|
| 69 |
+
_model = LFM2AudioModel.from_pretrained(
|
| 70 |
+
HF_REPO,
|
| 71 |
+
torch_dtype=dtype if device.type == "cuda" else torch.float32,
|
| 72 |
+
).eval()
|
| 73 |
+
except TypeError:
|
| 74 |
+
_model = LFM2AudioModel.from_pretrained(HF_REPO).eval()
|
| 75 |
+
|
| 76 |
+
moved_processor = _move_module(_processor, device, dtype)
|
| 77 |
+
if moved_processor is not None:
|
| 78 |
+
_processor = moved_processor
|
| 79 |
+
moved_model = _move_module(_model, device, dtype)
|
| 80 |
+
if moved_model is not None:
|
| 81 |
+
_model = moved_model
|
| 82 |
+
_model = _model.eval()
|
| 83 |
+
logger.info("LFM2.5-Audio loaded on %s.", _module_device(_model, device))
|
| 84 |
return _processor, _model
|
| 85 |
|
| 86 |
|
|
|
|
| 118 |
if question_audio_path:
|
| 119 |
import librosa
|
| 120 |
wav_np, sr = librosa.load(question_audio_path, sr=16000, mono=True)
|
| 121 |
+
wav = torch.from_numpy(wav_np).unsqueeze(0).to(_module_device(model, _select_device()))
|
| 122 |
sr = 16000
|
| 123 |
chat.add_audio(wav, sr)
|
| 124 |
elif question_text:
|
|
|
|
| 147 |
# Decode text
|
| 148 |
answer_text = ""
|
| 149 |
if text_out:
|
| 150 |
+
answer_text = "".join(processor.text.decode(t.detach().cpu()) for t in text_out).strip()
|
| 151 |
|
| 152 |
# Decode audio
|
| 153 |
waveform = None
|
| 154 |
if audio_out and len(audio_out) > 1:
|
| 155 |
+
decode_device = _module_device(processor, _module_device(model, _select_device()))
|
| 156 |
+
audio_codes = torch.stack(audio_out[:-1], 1).unsqueeze(0).to(decode_device)
|
| 157 |
with GPU_INFERENCE_LOCK, torch.inference_mode():
|
| 158 |
waveform_tensor = processor.decode(audio_codes)
|
| 159 |
waveform = waveform_tensor.cpu().numpy().squeeze()
|
qa_flow.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import logging
|
|
|
|
| 5 |
import threading
|
| 6 |
import time
|
| 7 |
import uuid
|
|
@@ -14,6 +15,7 @@ DEFAULT_SAMPLE_RATE = 24000
|
|
| 14 |
_RECENT_AUDIO_LOCK = threading.Lock()
|
| 15 |
_RECENT_AUDIO_SUBMISSIONS: dict[str, float] = {}
|
| 16 |
_DUPLICATE_WINDOW_SEC = 30.0
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class AudioWriter(Protocol):
|
|
@@ -55,12 +57,38 @@ def claim_audio_submission(audio_path: str | Path | None) -> bool:
|
|
| 55 |
return True
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def _default_audio_writer(path: Path, waveform, sample_rate: int) -> None:
|
| 59 |
import soundfile as sf
|
| 60 |
|
| 61 |
sf.write(str(path), waveform, sample_rate)
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
def build_qa_response(
|
| 65 |
*,
|
| 66 |
question_text: str | None,
|
|
@@ -86,6 +114,7 @@ def build_qa_response(
|
|
| 86 |
}
|
| 87 |
|
| 88 |
story_context = "\n\n".join(paragraphs or [])
|
|
|
|
| 89 |
try:
|
| 90 |
answer_text, waveform, sr = answer_fn(
|
| 91 |
question_audio_path=audio_path if has_audio else None,
|
|
@@ -97,6 +126,7 @@ def build_qa_response(
|
|
| 97 |
answer_text = "Hmm, I'm not sure about that! Let's keep listening to find out."
|
| 98 |
except Exception as exc:
|
| 99 |
logger.exception("LFM Q&A failed: %s", exc)
|
|
|
|
| 100 |
answer_text = (
|
| 101 |
"Oops, I couldn't think of an answer right now. Let's keep reading! "
|
| 102 |
f"({type(exc).__name__})"
|
|
@@ -108,12 +138,18 @@ def build_qa_response(
|
|
| 108 |
if waveform is not None:
|
| 109 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 110 |
answer_audio_path = output_dir / f"qa_answer_{uuid.uuid4().hex[:8]}.wav"
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
return {
|
| 114 |
"ok": True,
|
| 115 |
"answer_text": answer_text,
|
| 116 |
"display_question": q_txt or "(audio question)",
|
| 117 |
"audio_path": answer_audio_path,
|
| 118 |
-
"error":
|
| 119 |
}
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import logging
|
| 5 |
+
import os
|
| 6 |
import threading
|
| 7 |
import time
|
| 8 |
import uuid
|
|
|
|
| 15 |
_RECENT_AUDIO_LOCK = threading.Lock()
|
| 16 |
_RECENT_AUDIO_SUBMISSIONS: dict[str, float] = {}
|
| 17 |
_DUPLICATE_WINDOW_SEC = 30.0
|
| 18 |
+
_GENERATED_QA_MAX_FILES = int(os.environ.get("MOMSVOICE_QA_AUDIO_MAX_FILES", "30"))
|
| 19 |
|
| 20 |
|
| 21 |
class AudioWriter(Protocol):
|
|
|
|
| 57 |
return True
|
| 58 |
|
| 59 |
|
| 60 |
+
def release_audio_submission(audio_path: str | Path | None) -> None:
|
| 61 |
+
"""Allow a recording to be submitted again, used after failed generation."""
|
| 62 |
+
normalized = _normalize_audio_path(audio_path)
|
| 63 |
+
if not normalized:
|
| 64 |
+
return
|
| 65 |
+
with _RECENT_AUDIO_LOCK:
|
| 66 |
+
_RECENT_AUDIO_SUBMISSIONS.pop(normalized, None)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
def _default_audio_writer(path: Path, waveform, sample_rate: int) -> None:
|
| 70 |
import soundfile as sf
|
| 71 |
|
| 72 |
sf.write(str(path), waveform, sample_rate)
|
| 73 |
|
| 74 |
|
| 75 |
+
def _prune_generated_answers(output_dir: Path) -> None:
|
| 76 |
+
try:
|
| 77 |
+
files = sorted(
|
| 78 |
+
output_dir.glob("qa_answer_*.wav"),
|
| 79 |
+
key=lambda path: path.stat().st_mtime,
|
| 80 |
+
reverse=True,
|
| 81 |
+
)
|
| 82 |
+
except OSError:
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
for stale in files[_GENERATED_QA_MAX_FILES:]:
|
| 86 |
+
try:
|
| 87 |
+
stale.unlink()
|
| 88 |
+
except OSError:
|
| 89 |
+
continue
|
| 90 |
+
|
| 91 |
+
|
| 92 |
def build_qa_response(
|
| 93 |
*,
|
| 94 |
question_text: str | None,
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
story_context = "\n\n".join(paragraphs or [])
|
| 117 |
+
error = None
|
| 118 |
try:
|
| 119 |
answer_text, waveform, sr = answer_fn(
|
| 120 |
question_audio_path=audio_path if has_audio else None,
|
|
|
|
| 126 |
answer_text = "Hmm, I'm not sure about that! Let's keep listening to find out."
|
| 127 |
except Exception as exc:
|
| 128 |
logger.exception("LFM Q&A failed: %s", exc)
|
| 129 |
+
error = type(exc).__name__
|
| 130 |
answer_text = (
|
| 131 |
"Oops, I couldn't think of an answer right now. Let's keep reading! "
|
| 132 |
f"({type(exc).__name__})"
|
|
|
|
| 138 |
if waveform is not None:
|
| 139 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 140 |
answer_audio_path = output_dir / f"qa_answer_{uuid.uuid4().hex[:8]}.wav"
|
| 141 |
+
try:
|
| 142 |
+
audio_writer(answer_audio_path, waveform, sr)
|
| 143 |
+
_prune_generated_answers(output_dir)
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
logger.exception("Failed to write answer audio: %s", exc)
|
| 146 |
+
error = error or type(exc).__name__
|
| 147 |
+
answer_audio_path = None
|
| 148 |
|
| 149 |
return {
|
| 150 |
"ok": True,
|
| 151 |
"answer_text": answer_text,
|
| 152 |
"display_question": q_txt or "(audio question)",
|
| 153 |
"audio_path": answer_audio_path,
|
| 154 |
+
"error": error,
|
| 155 |
}
|
test_modules/test_qa_flow.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
"""Dependency-light tests for the Ask/Q&A server flow."""
|
|
|
|
| 2 |
import tempfile
|
| 3 |
import sys
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
| 7 |
|
| 8 |
-
from qa_flow import build_qa_response, claim_audio_submission
|
| 9 |
|
| 10 |
|
| 11 |
def check(label, condition):
|
|
@@ -27,6 +29,10 @@ def fake_writer(path: Path, waveform, sample_rate: int):
|
|
| 27 |
path.write_bytes(b"RIFF fake wav")
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def test_empty_question():
|
| 31 |
result = build_qa_response(
|
| 32 |
question_text="",
|
|
@@ -75,6 +81,21 @@ def test_duplicate_audio_claim():
|
|
| 75 |
audio = Path(tmp) / "same-question.wav"
|
| 76 |
check("first audio claim accepted", claim_audio_submission(audio) is True)
|
| 77 |
check("duplicate audio claim rejected", claim_audio_submission(audio) is False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
if __name__ == "__main__":
|
|
@@ -82,4 +103,5 @@ if __name__ == "__main__":
|
|
| 82 |
test_text_question()
|
| 83 |
test_audio_question_writes_answer()
|
| 84 |
test_duplicate_audio_claim()
|
|
|
|
| 85 |
print("Q&A flow tests passed")
|
|
|
|
| 1 |
"""Dependency-light tests for the Ask/Q&A server flow."""
|
| 2 |
+
import logging
|
| 3 |
import tempfile
|
| 4 |
import sys
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 8 |
+
logging.disable(logging.CRITICAL)
|
| 9 |
|
| 10 |
+
from qa_flow import build_qa_response, claim_audio_submission, release_audio_submission
|
| 11 |
|
| 12 |
|
| 13 |
def check(label, condition):
|
|
|
|
| 29 |
path.write_bytes(b"RIFF fake wav")
|
| 30 |
|
| 31 |
|
| 32 |
+
def failing_answer_fn(**kwargs):
|
| 33 |
+
raise RuntimeError("model unavailable")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
def test_empty_question():
|
| 37 |
result = build_qa_response(
|
| 38 |
question_text="",
|
|
|
|
| 81 |
audio = Path(tmp) / "same-question.wav"
|
| 82 |
check("first audio claim accepted", claim_audio_submission(audio) is True)
|
| 83 |
check("duplicate audio claim rejected", claim_audio_submission(audio) is False)
|
| 84 |
+
release_audio_submission(audio)
|
| 85 |
+
check("released audio claim can retry", claim_audio_submission(audio) is True)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_failed_answer_marks_error():
|
| 89 |
+
result = build_qa_response(
|
| 90 |
+
question_text="",
|
| 91 |
+
question_audio_path="question.wav",
|
| 92 |
+
paragraphs=["The Three Little Pigs met a wolf."],
|
| 93 |
+
output_dir=Path(tempfile.gettempdir()),
|
| 94 |
+
answer_fn=failing_answer_fn,
|
| 95 |
+
)
|
| 96 |
+
check("failed answer still returns display result", result["ok"] is True)
|
| 97 |
+
check("failed answer records error", result["error"] == "RuntimeError")
|
| 98 |
+
check("failed answer has no audio", result["audio_path"] is None)
|
| 99 |
|
| 100 |
|
| 101 |
if __name__ == "__main__":
|
|
|
|
| 103 |
test_text_question()
|
| 104 |
test_audio_question_writes_answer()
|
| 105 |
test_duplicate_audio_claim()
|
| 106 |
+
test_failed_answer_marks_error()
|
| 107 |
print("Q&A flow tests passed")
|
test_modules/test_tts_pregen.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dependency-light tests for TTS pregeneration behavior."""
|
| 2 |
+
import os
|
| 3 |
+
import logging
|
| 4 |
+
import sys
|
| 5 |
+
import tempfile
|
| 6 |
+
import time
|
| 7 |
+
import types
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 11 |
+
logging.disable(logging.CRITICAL)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FakeArray(list):
|
| 15 |
+
def __mul__(self, value):
|
| 16 |
+
if isinstance(value, list):
|
| 17 |
+
return FakeArray(a * b for a, b in zip(self, value))
|
| 18 |
+
return FakeArray(item * value for item in self)
|
| 19 |
+
|
| 20 |
+
__rmul__ = __mul__
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def install_dependency_stubs():
|
| 24 |
+
try:
|
| 25 |
+
import numpy as real_np # noqa: F401
|
| 26 |
+
except ModuleNotFoundError:
|
| 27 |
+
fake_np = types.ModuleType("numpy")
|
| 28 |
+
fake_np.ndarray = FakeArray
|
| 29 |
+
fake_np.float32 = float
|
| 30 |
+
fake_np.pi = 3.141592653589793
|
| 31 |
+
fake_np.ones = lambda n, dtype=None: FakeArray([1.0] * n)
|
| 32 |
+
fake_np.zeros = lambda n, dtype=None: FakeArray([0.0] * n)
|
| 33 |
+
fake_np.linspace = lambda start, stop, num, dtype=None: FakeArray(
|
| 34 |
+
[start + (stop - start) * i / max(num - 1, 1) for i in range(num)]
|
| 35 |
+
)
|
| 36 |
+
fake_np.sin = lambda values: FakeArray(
|
| 37 |
+
__import__("math").sin(value) for value in values
|
| 38 |
+
)
|
| 39 |
+
fake_np.concatenate = lambda arrays: FakeArray(
|
| 40 |
+
item for array in arrays for item in array
|
| 41 |
+
)
|
| 42 |
+
sys.modules["numpy"] = fake_np
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
import soundfile as real_sf # noqa: F401
|
| 46 |
+
except ModuleNotFoundError:
|
| 47 |
+
fake_sf = types.ModuleType("soundfile")
|
| 48 |
+
audio_store = {}
|
| 49 |
+
|
| 50 |
+
def write(path, waveform, sample_rate):
|
| 51 |
+
audio_store[str(path)] = (waveform, sample_rate)
|
| 52 |
+
Path(path).write_bytes(b"RIFF fake wav")
|
| 53 |
+
|
| 54 |
+
def read(path, dtype="float32"):
|
| 55 |
+
waveform, sample_rate = audio_store[str(path)]
|
| 56 |
+
return waveform, sample_rate
|
| 57 |
+
|
| 58 |
+
fake_sf.write = write
|
| 59 |
+
fake_sf.read = read
|
| 60 |
+
sys.modules["soundfile"] = fake_sf
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
install_dependency_stubs()
|
| 64 |
+
|
| 65 |
+
import numpy as np # noqa: E402
|
| 66 |
+
import tts # noqa: E402
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
PASS = 0
|
| 70 |
+
FAIL = 0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def check(label, condition, detail=""):
|
| 74 |
+
global PASS, FAIL
|
| 75 |
+
if condition:
|
| 76 |
+
PASS += 1
|
| 77 |
+
print(f"[OK] {label}")
|
| 78 |
+
else:
|
| 79 |
+
FAIL += 1
|
| 80 |
+
print(f"[FAIL] {label} -- {detail}")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def wait_for_pregen(chunks, profile_id=None, timeout=2.0):
|
| 84 |
+
deadline = time.time() + timeout
|
| 85 |
+
status = tts.get_pregeneration_status(chunks, profile_id)
|
| 86 |
+
while status.get("in_progress") and time.time() < deadline:
|
| 87 |
+
time.sleep(0.02)
|
| 88 |
+
status = tts.get_pregeneration_status(chunks, profile_id)
|
| 89 |
+
return status
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def reset_pregen_state():
|
| 93 |
+
tts.cancel_pregeneration()
|
| 94 |
+
with tts._pregen_lock:
|
| 95 |
+
tts._pregen_cache.clear()
|
| 96 |
+
tts._pregen_in_progress.clear()
|
| 97 |
+
tts._pregen_progress.clear()
|
| 98 |
+
tts._pregen_cancel_events.clear()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def run_with_temp_cache(test_fn):
|
| 102 |
+
original_cache_dir = tts._CACHE_DIR
|
| 103 |
+
original_synth = tts._synthesize_single
|
| 104 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 105 |
+
tts._CACHE_DIR = tmp
|
| 106 |
+
os.makedirs(tts._CACHE_DIR, exist_ok=True)
|
| 107 |
+
reset_pregen_state()
|
| 108 |
+
try:
|
| 109 |
+
test_fn()
|
| 110 |
+
finally:
|
| 111 |
+
reset_pregen_state()
|
| 112 |
+
tts._CACHE_DIR = original_cache_dir
|
| 113 |
+
tts._synthesize_single = original_synth
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_pregen_limits_initial_chunks():
|
| 117 |
+
calls = []
|
| 118 |
+
|
| 119 |
+
def fake_synth(chunk, profile_id):
|
| 120 |
+
calls.append(chunk)
|
| 121 |
+
return np.ones(8, dtype=np.float32), 24000
|
| 122 |
+
|
| 123 |
+
tts._synthesize_single = fake_synth
|
| 124 |
+
chunks = ["one", "two", "three", "four", "five"]
|
| 125 |
+
tts.pregenerate_story_audio(chunks, voice_profile_id="voice-a", max_chunks=2)
|
| 126 |
+
status = wait_for_pregen(chunks, "voice-a")
|
| 127 |
+
|
| 128 |
+
check("pregen targets requested initial chunks", status.get("target") == 2, status)
|
| 129 |
+
check("pregen synthesized only initial chunks", calls == chunks[:2], calls)
|
| 130 |
+
check("partial pregen is not marked complete", status.get("complete") is False, status)
|
| 131 |
+
check("partial pregen records cached count", status.get("cached") == 2, status)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_pregen_failure_records_error():
|
| 135 |
+
def fake_synth(chunk, profile_id):
|
| 136 |
+
if chunk == "bad":
|
| 137 |
+
raise RuntimeError("boom")
|
| 138 |
+
return np.ones(8, dtype=np.float32), 24000
|
| 139 |
+
|
| 140 |
+
tts._synthesize_single = fake_synth
|
| 141 |
+
chunks = ["ok", "bad", "later"]
|
| 142 |
+
tts.pregenerate_story_audio(chunks, voice_profile_id="voice-b", max_chunks=3)
|
| 143 |
+
status = wait_for_pregen(chunks, "voice-b")
|
| 144 |
+
|
| 145 |
+
check("failed pregen records error", status.get("errors") == 1, status)
|
| 146 |
+
check("failed pregen is not complete", status.get("complete") is False, status)
|
| 147 |
+
check("failed chunk is not cached", tts._get_cached_audio("bad", "voice-b") is None)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_cancel_stops_waiting_pregen():
|
| 151 |
+
calls = []
|
| 152 |
+
|
| 153 |
+
def fake_synth(chunk, profile_id):
|
| 154 |
+
calls.append(chunk)
|
| 155 |
+
return np.ones(8, dtype=np.float32), 24000
|
| 156 |
+
|
| 157 |
+
tts._synthesize_single = fake_synth
|
| 158 |
+
chunks = ["slow-one", "slow-two"]
|
| 159 |
+
tts.GPU_INFERENCE_LOCK.acquire()
|
| 160 |
+
try:
|
| 161 |
+
tts.pregenerate_story_audio(chunks, voice_profile_id="voice-c", max_chunks=2)
|
| 162 |
+
time.sleep(0.05)
|
| 163 |
+
tts.cancel_pregeneration()
|
| 164 |
+
finally:
|
| 165 |
+
tts.GPU_INFERENCE_LOCK.release()
|
| 166 |
+
|
| 167 |
+
status = wait_for_pregen(chunks, "voice-c")
|
| 168 |
+
time.sleep(0.05)
|
| 169 |
+
check("cancel marks pregen cancelled", status.get("cancelled") is True, status)
|
| 170 |
+
check("cancelled pregen does not synthesize queued chunks", calls == [], calls)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_progress_metadata_is_bounded():
|
| 174 |
+
original_limit = tts._PREGEN_PROGRESS_MAX_STORIES
|
| 175 |
+
tts._PREGEN_PROGRESS_MAX_STORIES = 2
|
| 176 |
+
try:
|
| 177 |
+
with tts._pregen_lock:
|
| 178 |
+
for idx in range(5):
|
| 179 |
+
tts._pregen_progress[f"story-{idx}"] = {
|
| 180 |
+
"cached": 0,
|
| 181 |
+
"total": 1,
|
| 182 |
+
"target": 1,
|
| 183 |
+
"in_progress": False,
|
| 184 |
+
"complete": False,
|
| 185 |
+
"errors": 0,
|
| 186 |
+
"cancelled": False,
|
| 187 |
+
}
|
| 188 |
+
tts._prune_pregen_progress_locked()
|
| 189 |
+
keys = list(tts._pregen_progress)
|
| 190 |
+
check("pregen progress metadata is bounded", len(keys) == 2, keys)
|
| 191 |
+
check("pregen progress keeps newest records", keys == ["story-3", "story-4"], keys)
|
| 192 |
+
finally:
|
| 193 |
+
tts._PREGEN_PROGRESS_MAX_STORIES = original_limit
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
run_with_temp_cache(test_pregen_limits_initial_chunks)
|
| 198 |
+
run_with_temp_cache(test_pregen_failure_records_error)
|
| 199 |
+
run_with_temp_cache(test_cancel_stops_waiting_pregen)
|
| 200 |
+
run_with_temp_cache(test_progress_metadata_is_bounded)
|
| 201 |
+
print(f"TTS pregen tests: {PASS} passed, {FAIL} failed")
|
| 202 |
+
sys.exit(1 if FAIL else 0)
|
tts.py
CHANGED
|
@@ -22,11 +22,12 @@ import os
|
|
| 22 |
import queue
|
| 23 |
import re
|
| 24 |
import threading
|
|
|
|
| 25 |
|
| 26 |
import numpy as np
|
| 27 |
import soundfile as sf
|
| 28 |
|
| 29 |
-
from runtime_config import AUDIO_CACHE_DIR
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
|
|
@@ -49,9 +50,17 @@ _TRANSITION_CHIME = 0.08 * np.sin(2 * np.pi * 440 * _chime_t) * np.linspace(1, 0
|
|
| 49 |
|
| 50 |
# Background pre-generation state
|
| 51 |
_pregen_lock = threading.Lock()
|
| 52 |
-
_pregen_cache:
|
| 53 |
_pregen_in_progress: set[str] = set()
|
| 54 |
_pregen_progress: dict[str, dict[str, int | bool]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def _cache_key(text: str, voice_profile_id: str | None) -> str:
|
|
@@ -64,6 +73,34 @@ def _story_key(chunks: list[str], voice_profile_id: str | None) -> str:
|
|
| 64 |
return _cache_key("\n".join(chunks), voice_profile_id)
|
| 65 |
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
def get_pregeneration_status(
|
| 68 |
chunks: list[str],
|
| 69 |
voice_profile_id: str | None = None,
|
|
@@ -76,25 +113,33 @@ def get_pregeneration_status(
|
|
| 76 |
story_key = _story_key(chunks, voice_profile_id)
|
| 77 |
with _pregen_lock:
|
| 78 |
if story_key in _pregen_cache:
|
|
|
|
| 79 |
return {
|
| 80 |
"cached": len(_pregen_cache[story_key]),
|
| 81 |
"total": total,
|
|
|
|
| 82 |
"in_progress": False,
|
| 83 |
"complete": True,
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
progress = _pregen_progress.get(story_key)
|
| 86 |
if progress is not None:
|
| 87 |
return dict(progress)
|
| 88 |
|
|
|
|
| 89 |
cached = sum(
|
| 90 |
-
1 for chunk in chunks
|
| 91 |
if _get_cached_audio(chunk, voice_profile_id) is not None
|
| 92 |
)
|
| 93 |
return {
|
| 94 |
"cached": cached,
|
| 95 |
"total": total,
|
|
|
|
| 96 |
"in_progress": False,
|
| 97 |
"complete": cached == total,
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
|
| 100 |
|
|
@@ -117,10 +162,40 @@ def _save_cached_audio(chunk_text: str, voice_profile_id: str | None, wav: np.nd
|
|
| 117 |
path = os.path.join(_CACHE_DIR, f"{key}.wav")
|
| 118 |
try:
|
| 119 |
sf.write(path, wav, sr)
|
|
|
|
| 120 |
except Exception:
|
| 121 |
pass
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
def split_into_chunks(text: str) -> list[str]:
|
| 125 |
"""Split text into short chunks suitable for low-latency TTS streaming.
|
| 126 |
|
|
@@ -154,57 +229,101 @@ def split_into_chunks(text: str) -> list[str]:
|
|
| 154 |
return [c for c in chunks if c]
|
| 155 |
|
| 156 |
|
| 157 |
-
def pregenerate_story_audio(
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
-
Call this on book selection to pre-warm
|
| 161 |
"""
|
| 162 |
story_key = _story_key(chunks, voice_profile_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
with _pregen_lock:
|
| 165 |
if story_key in _pregen_in_progress or story_key in _pregen_cache:
|
| 166 |
return # Already running or done
|
|
|
|
| 167 |
_pregen_in_progress.add(story_key)
|
|
|
|
| 168 |
_pregen_progress[story_key] = {
|
| 169 |
"cached": 0,
|
| 170 |
"total": len(chunks),
|
|
|
|
| 171 |
"in_progress": True,
|
| 172 |
"complete": False,
|
|
|
|
|
|
|
| 173 |
}
|
| 174 |
|
| 175 |
def _worker():
|
| 176 |
results = []
|
|
|
|
| 177 |
sr = 24000
|
| 178 |
-
for i, chunk in enumerate(
|
|
|
|
|
|
|
| 179 |
# Check disk cache first
|
| 180 |
cached = _get_cached_audio(chunk, voice_profile_id)
|
| 181 |
if cached is not None:
|
| 182 |
results.append((sr, cached))
|
| 183 |
with _pregen_lock:
|
| 184 |
-
|
|
|
|
| 185 |
continue
|
| 186 |
# Synthesize
|
| 187 |
try:
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
sr = sample_rate
|
| 190 |
results.append((sr, wav))
|
| 191 |
_save_cached_audio(chunk, voice_profile_id, wav, sr)
|
| 192 |
except Exception as e:
|
|
|
|
| 193 |
logger.warning("Pre-gen failed on chunk %d: %s", i, e)
|
| 194 |
-
results.append((sr, np.zeros(0, dtype=np.float32)))
|
| 195 |
with _pregen_lock:
|
| 196 |
-
|
|
|
|
|
|
|
| 197 |
|
| 198 |
with _pregen_lock:
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
_pregen_progress[story_key] = {
|
| 201 |
"cached": len(results),
|
| 202 |
"total": len(chunks),
|
|
|
|
| 203 |
"in_progress": False,
|
| 204 |
-
"complete":
|
|
|
|
|
|
|
| 205 |
}
|
| 206 |
_pregen_in_progress.discard(story_key)
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
threading.Thread(target=_worker, daemon=True).start()
|
| 210 |
|
|
|
|
| 22 |
import queue
|
| 23 |
import re
|
| 24 |
import threading
|
| 25 |
+
from collections import OrderedDict
|
| 26 |
|
| 27 |
import numpy as np
|
| 28 |
import soundfile as sf
|
| 29 |
|
| 30 |
+
from runtime_config import AUDIO_CACHE_DIR, GPU_INFERENCE_LOCK
|
| 31 |
|
| 32 |
logger = logging.getLogger(__name__)
|
| 33 |
|
|
|
|
| 50 |
|
| 51 |
# Background pre-generation state
|
| 52 |
_pregen_lock = threading.Lock()
|
| 53 |
+
_pregen_cache: OrderedDict[str, list[tuple[int, np.ndarray]]] = OrderedDict()
|
| 54 |
_pregen_in_progress: set[str] = set()
|
| 55 |
_pregen_progress: dict[str, dict[str, int | bool]] = {}
|
| 56 |
+
_pregen_cancel_events: dict[str, threading.Event] = {}
|
| 57 |
+
|
| 58 |
+
# Keep background work small so live Ask/playback can take the GPU quickly.
|
| 59 |
+
_PREGEN_CHUNK_LIMIT = int(os.environ.get("MOMSVOICE_PREGEN_CHUNKS", "3"))
|
| 60 |
+
_PREGEN_CACHE_MAX_STORIES = int(os.environ.get("MOMSVOICE_PREGEN_CACHE_STORIES", "4"))
|
| 61 |
+
_PREGEN_PROGRESS_MAX_STORIES = int(os.environ.get("MOMSVOICE_PREGEN_PROGRESS_STORIES", "16"))
|
| 62 |
+
_AUDIO_CACHE_MAX_FILES = int(os.environ.get("MOMSVOICE_AUDIO_CACHE_MAX_FILES", "400"))
|
| 63 |
+
_AUDIO_CACHE_MAX_BYTES = int(os.environ.get("MOMSVOICE_AUDIO_CACHE_MAX_BYTES", str(512 * 1024 * 1024)))
|
| 64 |
|
| 65 |
|
| 66 |
def _cache_key(text: str, voice_profile_id: str | None) -> str:
|
|
|
|
| 73 |
return _cache_key("\n".join(chunks), voice_profile_id)
|
| 74 |
|
| 75 |
|
| 76 |
+
def _prune_pregen_progress_locked() -> None:
|
| 77 |
+
removable = [
|
| 78 |
+
story_key for story_key, progress in _pregen_progress.items()
|
| 79 |
+
if story_key not in _pregen_in_progress and not progress.get("in_progress")
|
| 80 |
+
]
|
| 81 |
+
while len(_pregen_progress) > _PREGEN_PROGRESS_MAX_STORIES and removable:
|
| 82 |
+
_pregen_progress.pop(removable.pop(0), None)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def cancel_pregeneration() -> None:
|
| 86 |
+
"""Ask all background pre-generation workers to stop after their current chunk."""
|
| 87 |
+
with _pregen_lock:
|
| 88 |
+
for event in _pregen_cancel_events.values():
|
| 89 |
+
event.set()
|
| 90 |
+
for story_key in list(_pregen_in_progress):
|
| 91 |
+
progress = _pregen_progress.get(story_key)
|
| 92 |
+
if progress is not None:
|
| 93 |
+
progress["in_progress"] = False
|
| 94 |
+
progress["cancelled"] = True
|
| 95 |
+
_pregen_in_progress.clear()
|
| 96 |
+
_prune_pregen_progress_locked()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _prune_pregen_cache_locked() -> None:
|
| 100 |
+
while len(_pregen_cache) > _PREGEN_CACHE_MAX_STORIES:
|
| 101 |
+
_pregen_cache.popitem(last=False)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
def get_pregeneration_status(
|
| 105 |
chunks: list[str],
|
| 106 |
voice_profile_id: str | None = None,
|
|
|
|
| 113 |
story_key = _story_key(chunks, voice_profile_id)
|
| 114 |
with _pregen_lock:
|
| 115 |
if story_key in _pregen_cache:
|
| 116 |
+
_pregen_cache.move_to_end(story_key)
|
| 117 |
return {
|
| 118 |
"cached": len(_pregen_cache[story_key]),
|
| 119 |
"total": total,
|
| 120 |
+
"target": total,
|
| 121 |
"in_progress": False,
|
| 122 |
"complete": True,
|
| 123 |
+
"errors": 0,
|
| 124 |
+
"cancelled": False,
|
| 125 |
}
|
| 126 |
progress = _pregen_progress.get(story_key)
|
| 127 |
if progress is not None:
|
| 128 |
return dict(progress)
|
| 129 |
|
| 130 |
+
target = min(total, _PREGEN_CHUNK_LIMIT)
|
| 131 |
cached = sum(
|
| 132 |
+
1 for chunk in chunks[:target]
|
| 133 |
if _get_cached_audio(chunk, voice_profile_id) is not None
|
| 134 |
)
|
| 135 |
return {
|
| 136 |
"cached": cached,
|
| 137 |
"total": total,
|
| 138 |
+
"target": target,
|
| 139 |
"in_progress": False,
|
| 140 |
"complete": cached == total,
|
| 141 |
+
"errors": 0,
|
| 142 |
+
"cancelled": False,
|
| 143 |
}
|
| 144 |
|
| 145 |
|
|
|
|
| 162 |
path = os.path.join(_CACHE_DIR, f"{key}.wav")
|
| 163 |
try:
|
| 164 |
sf.write(path, wav, sr)
|
| 165 |
+
_prune_audio_cache()
|
| 166 |
except Exception:
|
| 167 |
pass
|
| 168 |
|
| 169 |
|
| 170 |
+
def _prune_audio_cache():
|
| 171 |
+
"""Bound disk cache by deleting oldest cached audio files."""
|
| 172 |
+
try:
|
| 173 |
+
entries = [
|
| 174 |
+
entry for entry in os.scandir(_CACHE_DIR)
|
| 175 |
+
if entry.is_file() and entry.name.endswith(".wav")
|
| 176 |
+
]
|
| 177 |
+
total_bytes = sum(entry.stat().st_size for entry in entries)
|
| 178 |
+
if len(entries) <= _AUDIO_CACHE_MAX_FILES and total_bytes <= _AUDIO_CACHE_MAX_BYTES:
|
| 179 |
+
return
|
| 180 |
+
|
| 181 |
+
entries.sort(key=lambda entry: entry.stat().st_mtime)
|
| 182 |
+
idx = 0
|
| 183 |
+
while idx < len(entries):
|
| 184 |
+
if len(entries) <= _AUDIO_CACHE_MAX_FILES and total_bytes <= _AUDIO_CACHE_MAX_BYTES:
|
| 185 |
+
break
|
| 186 |
+
entry = entries[idx]
|
| 187 |
+
try:
|
| 188 |
+
size = entry.stat().st_size
|
| 189 |
+
os.remove(entry.path)
|
| 190 |
+
total_bytes -= size
|
| 191 |
+
entries.pop(idx)
|
| 192 |
+
except OSError:
|
| 193 |
+
idx += 1
|
| 194 |
+
continue
|
| 195 |
+
except OSError:
|
| 196 |
+
return
|
| 197 |
+
|
| 198 |
+
|
| 199 |
def split_into_chunks(text: str) -> list[str]:
|
| 200 |
"""Split text into short chunks suitable for low-latency TTS streaming.
|
| 201 |
|
|
|
|
| 229 |
return [c for c in chunks if c]
|
| 230 |
|
| 231 |
|
| 232 |
+
def pregenerate_story_audio(
|
| 233 |
+
chunks: list[str],
|
| 234 |
+
voice_profile_id: str | None = None,
|
| 235 |
+
max_chunks: int = _PREGEN_CHUNK_LIMIT,
|
| 236 |
+
):
|
| 237 |
+
"""Pre-generate the first few story chunks in background.
|
| 238 |
|
| 239 |
+
Call this on book selection to pre-warm initial playback. Non-blocking.
|
| 240 |
"""
|
| 241 |
story_key = _story_key(chunks, voice_profile_id)
|
| 242 |
+
target_chunks = chunks[:max(0, min(len(chunks), max_chunks))]
|
| 243 |
+
target = len(target_chunks)
|
| 244 |
+
if target == 0:
|
| 245 |
+
return
|
| 246 |
|
| 247 |
with _pregen_lock:
|
| 248 |
if story_key in _pregen_in_progress or story_key in _pregen_cache:
|
| 249 |
return # Already running or done
|
| 250 |
+
cancel_event = threading.Event()
|
| 251 |
_pregen_in_progress.add(story_key)
|
| 252 |
+
_pregen_cancel_events[story_key] = cancel_event
|
| 253 |
_pregen_progress[story_key] = {
|
| 254 |
"cached": 0,
|
| 255 |
"total": len(chunks),
|
| 256 |
+
"target": target,
|
| 257 |
"in_progress": True,
|
| 258 |
"complete": False,
|
| 259 |
+
"errors": 0,
|
| 260 |
+
"cancelled": False,
|
| 261 |
}
|
| 262 |
|
| 263 |
def _worker():
|
| 264 |
results = []
|
| 265 |
+
errors = 0
|
| 266 |
sr = 24000
|
| 267 |
+
for i, chunk in enumerate(target_chunks):
|
| 268 |
+
if cancel_event.is_set():
|
| 269 |
+
break
|
| 270 |
# Check disk cache first
|
| 271 |
cached = _get_cached_audio(chunk, voice_profile_id)
|
| 272 |
if cached is not None:
|
| 273 |
results.append((sr, cached))
|
| 274 |
with _pregen_lock:
|
| 275 |
+
if _pregen_cancel_events.get(story_key) is cancel_event:
|
| 276 |
+
_pregen_progress[story_key]["cached"] = len(results)
|
| 277 |
continue
|
| 278 |
# Synthesize
|
| 279 |
try:
|
| 280 |
+
acquired = False
|
| 281 |
+
while not cancel_event.is_set():
|
| 282 |
+
acquired = GPU_INFERENCE_LOCK.acquire(timeout=0.1)
|
| 283 |
+
if acquired:
|
| 284 |
+
break
|
| 285 |
+
if not acquired or cancel_event.is_set():
|
| 286 |
+
break
|
| 287 |
+
try:
|
| 288 |
+
wav, sample_rate = _synthesize_single(chunk, voice_profile_id)
|
| 289 |
+
finally:
|
| 290 |
+
GPU_INFERENCE_LOCK.release()
|
| 291 |
sr = sample_rate
|
| 292 |
results.append((sr, wav))
|
| 293 |
_save_cached_audio(chunk, voice_profile_id, wav, sr)
|
| 294 |
except Exception as e:
|
| 295 |
+
errors += 1
|
| 296 |
logger.warning("Pre-gen failed on chunk %d: %s", i, e)
|
|
|
|
| 297 |
with _pregen_lock:
|
| 298 |
+
if _pregen_cancel_events.get(story_key) is cancel_event:
|
| 299 |
+
_pregen_progress[story_key]["cached"] = len(results)
|
| 300 |
+
_pregen_progress[story_key]["errors"] = errors
|
| 301 |
|
| 302 |
with _pregen_lock:
|
| 303 |
+
if _pregen_cancel_events.get(story_key) is not cancel_event:
|
| 304 |
+
return
|
| 305 |
+
cancelled = cancel_event.is_set()
|
| 306 |
+
fully_cached = len(results) == len(chunks) and errors == 0 and not cancelled
|
| 307 |
+
if fully_cached:
|
| 308 |
+
_pregen_cache[story_key] = results
|
| 309 |
+
_pregen_cache.move_to_end(story_key)
|
| 310 |
+
_prune_pregen_cache_locked()
|
| 311 |
_pregen_progress[story_key] = {
|
| 312 |
"cached": len(results),
|
| 313 |
"total": len(chunks),
|
| 314 |
+
"target": target,
|
| 315 |
"in_progress": False,
|
| 316 |
+
"complete": fully_cached,
|
| 317 |
+
"errors": errors,
|
| 318 |
+
"cancelled": cancelled,
|
| 319 |
}
|
| 320 |
_pregen_in_progress.discard(story_key)
|
| 321 |
+
_pregen_cancel_events.pop(story_key, None)
|
| 322 |
+
_prune_pregen_progress_locked()
|
| 323 |
+
logger.info(
|
| 324 |
+
"Pre-generation finished: %d/%d initial chunks cached (errors=%d, cancelled=%s).",
|
| 325 |
+
len(results), target, errors, cancelled,
|
| 326 |
+
)
|
| 327 |
|
| 328 |
threading.Thread(target=_worker, daemon=True).start()
|
| 329 |
|