minhahwang Copilot commited on
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>

Files changed (6) hide show
  1. app.py +27 -6
  2. inference_lfm.py +62 -9
  3. qa_flow.py +38 -2
  4. test_modules/test_qa_flow.py +23 -1
  5. test_modules/test_tts_pregen.py +202 -0
  6. 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 all audio in background for faster playback.
 
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 audio: {warmed} / {total_chunks} chunks cached — tap Play anytime"
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: #94a3b8; display: inline-block;"></span>
717
- <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">READY</span>
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
- from liquid_audio import LFM2AudioModel, LFM2AudioProcessor
27
-
28
- logger.info("Loading %s...", HF_REPO)
29
- _processor = LFM2AudioProcessor.from_pretrained(HF_REPO).eval()
30
- _model = LFM2AudioModel.from_pretrained(HF_REPO).eval()
31
- logger.info("LFM2.5-Audio loaded.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) # (1, samples)
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
- audio_codes = torch.stack(audio_out[:-1], 1).unsqueeze(0)
 
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
- audio_writer(answer_audio_path, waveform, sr)
 
 
 
 
 
 
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": None,
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: dict[str, list[tuple[int, np.ndarray]]] = {} # key -> [(sr, wav), ...]
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(chunks: list[str], voice_profile_id: str | None = None):
158
- """Pre-generate all story audio in background. Results cached for instant playback.
 
 
 
 
159
 
160
- Call this on book selection to pre-warm the cache. Non-blocking.
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(chunks):
 
 
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
- _pregen_progress[story_key]["cached"] = len(results)
 
185
  continue
186
  # Synthesize
187
  try:
188
- wav, sample_rate = _synthesize_single(chunk, voice_profile_id)
 
 
 
 
 
 
 
 
 
 
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
- _pregen_progress[story_key]["cached"] = len(results)
 
 
197
 
198
  with _pregen_lock:
199
- _pregen_cache[story_key] = results
 
 
 
 
 
 
 
200
  _pregen_progress[story_key] = {
201
  "cached": len(results),
202
  "total": len(chunks),
 
203
  "in_progress": False,
204
- "complete": True,
 
 
205
  }
206
  _pregen_in_progress.discard(story_key)
207
- logger.info("Pre-generation complete: %d chunks cached.", len(chunks))
 
 
 
 
 
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