minhahwang Copilot commited on
Commit
397b4ac
·
1 Parent(s): 68f1a16

refactor: runtime config, Q&A flow module, storage paths, and cleanup

Browse files

- runtime_config.py: centralized storage paths (supports HF persistent storage)
- qa_flow.py: testable Q&A logic with dedup (claim_audio_submission)
- tts.py: pregeneration progress tracking (get_pregeneration_status)
- app.py: use runtime_config paths, show pregen status on book select,
remove flash-attn runtime install, fix profile stories count
- README.md: updated for LFM Q&A architecture
- .gitignore: add audio_cache/
- requirements.txt: updated deps

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Files changed (11) hide show
  1. .gitignore +1 -0
  2. README.md +15 -12
  3. app.py +50 -97
  4. inference_lfm.py +15 -11
  5. mission.md +11 -11
  6. qa_flow.py +119 -0
  7. requirements.txt +5 -3
  8. runtime_config.py +66 -0
  9. test_modules/test_qa_flow.py +85 -0
  10. tts.py +60 -3
  11. voice_clone.py +31 -26
.gitignore CHANGED
@@ -1,3 +1,4 @@
1
  __pycache__/
2
  sample_sounds/
3
  Voice_Profile/
 
 
1
  __pycache__/
2
  sample_sounds/
3
  Voice_Profile/
4
+ audio_cache/
README.md CHANGED
@@ -11,7 +11,7 @@ pinned: false
11
 
12
  # MomsVoice
13
 
14
- **A parent records their voice. A bedtime story plays in that voice. The child can ask questions and hear answers — all in mom or dad's voice.**
15
 
16
  Built for the Hugging Face Hackathon. Runs entirely on local models inside a Gradio app on Hugging Face Spaces — no external APIs, no data leaves the server.
17
 
@@ -24,7 +24,7 @@ Built for the Hugging Face Hackathon. Runs entirely on local models inside a Gra
24
  ↓
25
  ❓ Child taps Ask → Story pauses, mic auto-starts
26
  ↓
27
- Child asks a question → Answer auto-plays in narrator's voice
28
  ↓
29
  ↩️ Resume → Story continues from where it paused
30
  ```
@@ -35,8 +35,8 @@ Built for the Hugging Face Hackathon. Runs entirely on local models inside a Gra
35
  |---|---|---|
36
  | **Clone** | Parent records or uploads 15–30s of audio. Speaker embedding is extracted and cached. | Qwen3-TTS-1.7B Base |
37
  | **Listen** | Story plays in the cloned voice as interruptible sentence chunks. Falls back to CustomVoice 0.6B stock voice (speaker "vivian") if no clone. | Qwen3-TTS-1.7B / CustomVoice 0.6B |
38
- | **Ask** | Child taps Ask → narration pauses, microphone auto-starts. When recording stops, question is transcribed and answer auto-generates. | Whisper-small (ASR) |
39
- | **Answer** | A short grounded answer is generated from the full story context and spoken in the narrator's voice. Auto-plays immediately. | Qwen2.5-3B-Instruct + Qwen3-TTS |
40
  | **Resume** | Story continues from where it left off. Play also pauses on tab switch. | Cached chunks |
41
 
42
  ## Models
@@ -45,10 +45,9 @@ Built for the Hugging Face Hackathon. Runs entirely on local models inside a Gra
45
  |---|---|---|
46
  | [Qwen3-TTS-1.7B-Base](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base) | Zero-shot voice cloning + TTS | 1.7B params |
47
  | [Qwen3-TTS-0.6B-CustomVoice](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice) | Fast stock-voice TTS (predefined speakers) | 0.6B params |
48
- | [Qwen2.5-3B-Instruct](https://huggingface.co/Qwen/Qwen2.5-3B-Instruct) | Story Q&A (context-grounded answers) | 3B params |
49
- | [Whisper-small](https://huggingface.co/openai/whisper-small) | Child speech-to-text | 244M params |
50
 
51
- All models are preloaded at startup for fast response times.
52
 
53
  ## Tech Stack
54
 
@@ -57,6 +56,7 @@ All models are preloaded at startup for fast response times.
57
  - **Stories:** 10 public domain `.txt` files (downloaded and cleaned from Project Gutenberg)
58
  - **Storage:** In-memory session cache + disk-persisted voice profiles (`Voice_Profile/`)
59
  - **TTS Backends:** Qwen3-TTS Base (cloned voice) + CustomVoice 0.6B (stock fallback)
 
60
 
61
  ## Features
62
 
@@ -84,8 +84,10 @@ python app.py
84
  ├── app.py # Main Gradio app (UI + all event wiring)
85
  ├── voice_clone.py # Qwen3-TTS voice cloning + profile persistence
86
  ├── tts.py # Unified TTS interface (Base cloned + CustomVoice stock)
87
- ├── inference.py # ASR (Whisper-small) + Q&A (Qwen2.5-3B-Instruct)
88
- ├── requirements.txt # Python deps (gradio, qwen-tts, transformers, torch)
 
 
89
  ├── stories/ # 10 cleaned public domain story texts (TTS-ready)
90
  ├── story_downloader/ # Story acquisition & cleaning pipeline
91
  │ ├── gutenberg_downloader.py # Reusable Project Gutenberg downloader/parser
@@ -120,11 +122,12 @@ All stories are public domain from [Project Gutenberg](https://www.gutenberg.org
120
 
121
  ## Key Design Decisions
122
 
123
- - **All local inference** — voice, Q&A, and ASR run on the Space GPU. No external APIs.
124
  - **Interruptible chunked streaming** — sentences synthesized and played one at a time for fast start and clean pause/resume.
125
- - **Voice-first Q&A** — microphone auto-starts on Ask; answer auto-plays when ready. No manual button clicks needed.
126
- - **Full story context** — Q&A uses the entire story (or top-5 relevant paragraphs for long stories) plus current position for grounded answers.
127
  - **Smart pause** — playback pauses on Ask, tab switch, or Pause button. Resume continues from exact position.
 
128
  - **Privacy-first** — no audio leaves the server, no user accounts, no external database.
129
 
130
  ## Privacy
 
11
 
12
  # MomsVoice
13
 
14
+ **A parent records their voice. A bedtime story plays in that voice. The child can ask questions and hear low-latency spoken answers from a local audio model.**
15
 
16
  Built for the Hugging Face Hackathon. Runs entirely on local models inside a Gradio app on Hugging Face Spaces — no external APIs, no data leaves the server.
17
 
 
24
  ↓
25
  ❓ Child taps Ask → Story pauses, mic auto-starts
26
  ↓
27
+ Child asks a question → Answer auto-plays from the Q&A audio model
28
  ↓
29
  ↩️ Resume → Story continues from where it paused
30
  ```
 
35
  |---|---|---|
36
  | **Clone** | Parent records or uploads 15–30s of audio. Speaker embedding is extracted and cached. | Qwen3-TTS-1.7B Base |
37
  | **Listen** | Story plays in the cloned voice as interruptible sentence chunks. Falls back to CustomVoice 0.6B stock voice (speaker "vivian") if no clone. | Qwen3-TTS-1.7B / CustomVoice 0.6B |
38
+ | **Ask** | Child taps Ask → narration pauses, microphone auto-starts. When recording stops, the question is sent directly to the Q&A audio model. | LFM2.5-Audio-1.5B |
39
+ | **Answer** | A short grounded answer is generated from the story context and spoken by the low-latency Q&A audio model. It does not use the cloned narrator voice. | LFM2.5-Audio-1.5B |
40
  | **Resume** | Story continues from where it left off. Play also pauses on tab switch. | Cached chunks |
41
 
42
  ## Models
 
45
  |---|---|---|
46
  | [Qwen3-TTS-1.7B-Base](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base) | Zero-shot voice cloning + TTS | 1.7B params |
47
  | [Qwen3-TTS-0.6B-CustomVoice](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice) | Fast stock-voice TTS (predefined speakers) | 0.6B params |
48
+ | [LiquidAI/LFM2.5-Audio-1.5B](https://huggingface.co/LiquidAI/LFM2.5-Audio-1.5B) | End-to-end story Q&A with audio input and spoken audio output | 1.5B params |
 
49
 
50
+ The stock narration model and LFM Q&A model are preloaded at startup for fast response times. The 1.7B cloning model is loaded when cloning or cloned narration is needed.
51
 
52
  ## Tech Stack
53
 
 
56
  - **Stories:** 10 public domain `.txt` files (downloaded and cleaned from Project Gutenberg)
57
  - **Storage:** In-memory session cache + disk-persisted voice profiles (`Voice_Profile/`)
58
  - **TTS Backends:** Qwen3-TTS Base (cloned voice) + CustomVoice 0.6B (stock fallback)
59
+ - **Q&A Backend:** LFM2.5-Audio for direct audio-in/audio-out answers with lower interaction latency
60
 
61
  ## Features
62
 
 
84
  ├── app.py # Main Gradio app (UI + all event wiring)
85
  ├── voice_clone.py # Qwen3-TTS voice cloning + profile persistence
86
  ├── tts.py # Unified TTS interface (Base cloned + CustomVoice stock)
87
+ ├── inference_lfm.py # LFM2.5-Audio Q&A (audio/text input → text/audio output)
88
+ ├── inference.py # Legacy Whisper + Qwen text Q&A path
89
+ ├── requirements.txt # Python deps for the active LFM + Qwen app
90
+ ├── requirements_lfm.txt # Legacy copy of the LFM requirements
91
  ├── stories/ # 10 cleaned public domain story texts (TTS-ready)
92
  ├── story_downloader/ # Story acquisition & cleaning pipeline
93
  │ ├── gutenberg_downloader.py # Reusable Project Gutenberg downloader/parser
 
122
 
123
  ## Key Design Decisions
124
 
125
+ - **All local inference** — narration, voice cloning, and Q&A run on the Space GPU. No external APIs.
126
  - **Interruptible chunked streaming** — sentences synthesized and played one at a time for fast start and clean pause/resume.
127
+ - **Voice-first Q&A** — microphone auto-starts on Ask; LFM generates text and spoken audio in one low-latency path. No manual button clicks needed.
128
+ - **Grounded Q&A** — LFM receives the selected story context so answers stay tied to the current story.
129
  - **Smart pause** — playback pauses on Ask, tab switch, or Pause button. Resume continues from exact position.
130
+ - **Mounted runtime storage** — generated audio, narration cache, and voice profiles are written under `MOMSVOICE_STORAGE_DIR` / `SPACE_STORAGE_DIR`, or `/data/momsvoice` when a Hugging Face bucket is mounted at `/data`.
131
  - **Privacy-first** — no audio leaves the server, no user accounts, no external database.
132
 
133
  ## Privacy
app.py CHANGED
@@ -1,11 +1,3 @@
1
- import subprocess, sys
2
-
3
- # Install FlashAttention-2 at runtime (needs CUDA, can't compile during Docker build)
4
- subprocess.run(
5
- [sys.executable, "-m", "pip", "install", "flash-attn", "--no-build-isolation", "-q"],
6
- capture_output=True,
7
- )
8
-
9
  import os
10
  import html
11
  import math
@@ -16,14 +8,18 @@ import gradio as gr
16
  import time
17
  from pathlib import Path
18
 
19
- from tts import split_into_chunks, generate_audio_stream, pregenerate_story_audio
 
 
 
 
 
20
  from inference import transcribe_audio, answer_story_question
21
- from inference_lfm import answer_question_audio, SAMPLE_RATE as LFM_SR
 
 
22
  from voice_clone import load_default_profile, list_saved_profiles
23
 
24
- # Create directories for sample audio files
25
- os.makedirs("sample_sounds", exist_ok=True)
26
-
27
  # Generate relaxing audio chime waveforms procedurally
28
  # This runs fully client/server-side with standard Python, eliminating mock limits or Torch wait times
29
  def generate_chimes_wav(filename, duration=120, melody_type="lullaby"):
@@ -68,8 +64,8 @@ def generate_chimes_wav(filename, duration=120, melody_type="lullaby"):
68
  wav_file.writeframes(packed_val)
69
 
70
  # Generate a short placeholder chime for the preview widget's initial value
71
- _preview_chime_path = "sample_sounds/cloned_preview.wav"
72
- if not os.path.exists(_preview_chime_path):
73
  generate_chimes_wav(_preview_chime_path, duration=15, melody_type="lullaby")
74
 
75
  COVERS_DIR = Path(__file__).parent / "assets" / "covers"
@@ -554,10 +550,10 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
554
  elem_id="qa_audio_input",
555
  )
556
  question_text = gr.Textbox(visible=False)
557
- submit_question_btn = gr.Button("🔍 Get Answer in Narrator's Voice", variant="primary", elem_id="qa_submit_btn")
558
 
559
  answer_display = gr.HTML(visible=False)
560
- answer_audio = gr.Audio(label="Answer in Narrator's Voice", interactive=False, visible=False, autoplay=True)
561
 
562
  # TAB 3: Clone Voice Studio
563
  with gr.TabItem("🎙️ Clone Voice Studio") as clone_tab:
@@ -597,7 +593,7 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
597
  """)
598
 
599
  voice_cloning_success_panel = gr.HTML(visible=False)
600
- voice_sample_preview_widget = gr.Audio(value="sample_sounds/cloned_preview.wav", visible=False, label="Pre-listening synthesized vocal pitch weights match preview", interactive=False)
601
  add_to_library_btn = gr.Button("✨ Synced successfully! Bind Companion Voice to Inventory", visible=False)
602
 
603
  gr.HTML("""
@@ -627,7 +623,7 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
627
  <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; text-align: center;">
628
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
629
  <span style="font-size: 18px; display: block;">📚</span>
630
- <strong style="font-size: 15px; display: block; color: #1c1c19;">5</strong>
631
  <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Stories</span>
632
  </div>
633
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
@@ -702,10 +698,19 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
702
  total = max(len(paras) - 1, 0)
703
  tts_chunks = split_into_chunks("\n\n".join(paras))
704
 
705
- # Pre-generate all audio in background for instant playback (#1 + #11)
706
  pregenerate_story_audio(tts_chunks, voice_profile_id=profile_id)
707
-
708
- chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #64748b; font-family: monospace; text-align: center;">{len(tts_chunks)} chunks ready — tap Play</div>"""
 
 
 
 
 
 
 
 
 
709
  status_html = """
710
  <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
711
  <span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span>
@@ -781,8 +786,8 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
781
  progress(0.7, desc="Generating voice preview...")
782
  try:
783
  preview_wav, preview_sr = synthesize_cloned_preview(profile_id)
784
- preview_path = f"sample_sounds/clone_preview_{profile_id}.wav"
785
- sf.write(preview_path, preview_wav, preview_sr)
786
  except Exception as e:
787
  import logging
788
  logging.getLogger(__name__).exception("Preview synthesis failed")
@@ -813,7 +818,7 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
813
  return (
814
  gr.HTML(visible=False),
815
  gr.HTML(value=cloned_card_html, visible=True),
816
- gr.Audio(value=preview_path, visible=preview_path is not None),
817
  gr.Button(visible=True),
818
  profile_id,
819
  )
@@ -1008,22 +1013,6 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
1008
  inputs=[],
1009
  outputs=[player_status_bar, play_btn, pause_btn, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio],
1010
  cancels=[play_event],
1011
- js="""() => {
1012
- // Auto-start microphone recording after a brief delay for UI to render
1013
- setTimeout(() => {
1014
- const recordBtn = document.querySelector('#qa_audio_input button[aria-label="Record"], #qa_audio_input button.record-button, .qa-audio-group button[aria-label="Record"]');
1015
- if (recordBtn) { recordBtn.click(); }
1016
- else {
1017
- // Fallback: find any record button inside the audio component
1018
- const btns = document.querySelectorAll('button');
1019
- for (const b of btns) {
1020
- if (b.getAttribute('aria-label') === 'Record' || b.textContent.includes('Record')) {
1021
- b.click(); break;
1022
- }
1023
- }
1024
- }
1025
- }, 500);
1026
- }"""
1027
  )
1028
 
1029
  # 9. Submit question — LFM2.5-Audio end-to-end (single model: audio-in → audio+text-out)
@@ -1031,7 +1020,19 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
1031
  q_txt = (question_txt or "").strip()
1032
  has_audio = question_audio_path is not None and question_audio_path != ""
1033
 
1034
- if not q_txt and not has_audio:
 
 
 
 
 
 
 
 
 
 
 
 
1035
  answer_html = """
1036
  <div style="padding: 10px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 10px; font-size: 12px; color: #991b1b;">
1037
  Please type a question or record one with the microphone.
@@ -1039,42 +1040,9 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
1039
  """
1040
  return gr.HTML(value=answer_html, visible=True), gr.Audio(visible=False)
1041
 
1042
- # Build story context from paragraphs
1043
- story_context = "\n\n".join(paragraphs) if paragraphs else ""
1044
-
1045
- # Use LFM2.5-Audio end-to-end: accepts audio/text → produces text+audio answer
1046
- import uuid
1047
- import soundfile as sf
1048
- import logging as _log
1049
-
1050
- try:
1051
- answer_text, waveform, sr = answer_question_audio(
1052
- question_audio_path=question_audio_path if has_audio else None,
1053
- question_text=q_txt if q_txt and not has_audio else None,
1054
- story_context=story_context,
1055
- max_new_tokens=100,
1056
- )
1057
- if not answer_text:
1058
- answer_text = "Hmm, I'm not sure about that! Let's keep listening to find out."
1059
- except Exception as e:
1060
- answer_text = f"Oops, I couldn't think of an answer right now. Let's keep reading! ({type(e).__name__})"
1061
- waveform = None
1062
- sr = LFM_SR
1063
- _log.getLogger(__name__).exception("LFM Q&A failed: %s", e)
1064
-
1065
- # Save audio if LFM produced it
1066
- answer_audio_path_out = None
1067
- if waveform is not None:
1068
- answer_audio_path_out = f"sample_sounds/qa_answer_{uuid.uuid4().hex[:8]}.wav"
1069
- sf.write(answer_audio_path_out, waveform, sr)
1070
-
1071
- # Display question text (transcribe if needed for display only)
1072
- display_question = q_txt
1073
- if not display_question and has_audio:
1074
- display_question = "(audio question)"
1075
-
1076
- safe_answer = html.escape(answer_text)
1077
- safe_question = html.escape(display_question)
1078
  answer_html = f"""
1079
  <div style="margin-top: 12px; padding: 16px; background: rgba(240,253,244,0.1); border: 1px solid rgba(187,247,208,0.3); border-radius: 14px;">
1080
  <div style="font-size: 10px; font-weight: 700; text-transform: uppercase; color: #4ade80; margin-bottom: 6px;">Answer (LFM2.5-Audio)</div>
@@ -1087,7 +1055,7 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
1087
  </div>
1088
  """
1089
  if answer_audio_path_out:
1090
- return gr.HTML(value=answer_html, visible=True), gr.Audio(value=answer_audio_path_out, visible=True)
1091
  return gr.HTML(value=answer_html, visible=True), gr.Audio(visible=False)
1092
 
1093
  submit_question_btn.click(
@@ -1096,23 +1064,8 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
1096
  outputs=[answer_display, answer_audio]
1097
  )
1098
 
1099
- # Auto-submit when voice recording completes
1100
- # In Gradio 5.35, .stop_recording may not pass audio value reliably.
1101
- # Use .stop_recording with a small delay via JS to click the submit button.
1102
- question_audio.stop_recording(
1103
- fn=None,
1104
- inputs=None,
1105
- outputs=None,
1106
- js="""() => {
1107
- // Wait for audio value to be committed, then click submit
1108
- setTimeout(() => {
1109
- const btn = document.querySelector('#qa_submit_btn button, #qa_submit_btn');
1110
- if (btn) btn.click();
1111
- }, 500);
1112
- }"""
1113
- )
1114
-
1115
- # .change() as primary Python handler — fires after audio value is committed
1116
  def auto_submit_on_audio(audio_path, question_txt, paragraphs, slider_val, profile_id):
1117
  """Auto-submit when a recording is available."""
1118
  if not audio_path:
@@ -1177,4 +1130,4 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
1177
  )
1178
 
1179
  if __name__ == "__main__":
1180
- demo.queue().launch(allowed_paths=[str(Path(__file__).parent / "assets")])
 
 
 
 
 
 
 
 
 
1
  import os
2
  import html
3
  import math
 
8
  import time
9
  from pathlib import Path
10
 
11
+ from tts import (
12
+ generate_audio_stream,
13
+ get_pregeneration_status,
14
+ pregenerate_story_audio,
15
+ split_into_chunks,
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
 
 
 
 
23
  # Generate relaxing audio chime waveforms procedurally
24
  # This runs fully client/server-side with standard Python, eliminating mock limits or Torch wait times
25
  def generate_chimes_wav(filename, duration=120, melody_type="lullaby"):
 
64
  wav_file.writeframes(packed_val)
65
 
66
  # Generate a short placeholder chime for the preview widget's initial value
67
+ _preview_chime_path = SAMPLE_SOUNDS_DIR / "cloned_preview.wav"
68
+ if not _preview_chime_path.exists():
69
  generate_chimes_wav(_preview_chime_path, duration=15, melody_type="lullaby")
70
 
71
  COVERS_DIR = Path(__file__).parent / "assets" / "covers"
 
550
  elem_id="qa_audio_input",
551
  )
552
  question_text = gr.Textbox(visible=False)
553
+ submit_question_btn = gr.Button("🔍 Get Answer", variant="primary", elem_id="qa_submit_btn")
554
 
555
  answer_display = gr.HTML(visible=False)
556
+ answer_audio = gr.Audio(label="Answer Audio", interactive=False, visible=False, autoplay=True)
557
 
558
  # TAB 3: Clone Voice Studio
559
  with gr.TabItem("🎙️ Clone Voice Studio") as clone_tab:
 
593
  """)
594
 
595
  voice_cloning_success_panel = gr.HTML(visible=False)
596
+ voice_sample_preview_widget = gr.Audio(value=str(_preview_chime_path), visible=False, label="Pre-listening synthesized vocal pitch weights match preview", interactive=False)
597
  add_to_library_btn = gr.Button("✨ Synced successfully! Bind Companion Voice to Inventory", visible=False)
598
 
599
  gr.HTML("""
 
623
  <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; text-align: center;">
624
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
625
  <span style="font-size: 18px; display: block;">📚</span>
626
+ <strong style="font-size: 15px; display: block; color: #1c1c19;">10</strong>
627
  <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Stories</span>
628
  </div>
629
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
 
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>
 
786
  progress(0.7, desc="Generating voice preview...")
787
  try:
788
  preview_wav, preview_sr = synthesize_cloned_preview(profile_id)
789
+ preview_path = SAMPLE_SOUNDS_DIR / f"clone_preview_{profile_id}.wav"
790
+ sf.write(str(preview_path), preview_wav, preview_sr)
791
  except Exception as e:
792
  import logging
793
  logging.getLogger(__name__).exception("Preview synthesis failed")
 
818
  return (
819
  gr.HTML(visible=False),
820
  gr.HTML(value=cloned_card_html, visible=True),
821
+ gr.Audio(value=str(preview_path) if preview_path else None, visible=preview_path is not None),
822
  gr.Button(visible=True),
823
  profile_id,
824
  )
 
1013
  inputs=[],
1014
  outputs=[player_status_bar, play_btn, pause_btn, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio],
1015
  cancels=[play_event],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1016
  )
1017
 
1018
  # 9. Submit question — LFM2.5-Audio end-to-end (single model: audio-in → audio+text-out)
 
1020
  q_txt = (question_txt or "").strip()
1021
  has_audio = question_audio_path is not None and question_audio_path != ""
1022
 
1023
+ if has_audio and not claim_audio_submission(question_audio_path):
1024
+ return gr.update(), gr.update()
1025
+
1026
+ result = build_qa_response(
1027
+ question_text=q_txt,
1028
+ question_audio_path=question_audio_path if has_audio else None,
1029
+ paragraphs=paragraphs,
1030
+ output_dir=SAMPLE_SOUNDS_DIR,
1031
+ answer_fn=answer_question_audio,
1032
+ max_new_tokens=100,
1033
+ )
1034
+
1035
+ if not result["ok"]:
1036
  answer_html = """
1037
  <div style="padding: 10px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 10px; font-size: 12px; color: #991b1b;">
1038
  Please type a question or record one with the microphone.
 
1040
  """
1041
  return gr.HTML(value=answer_html, visible=True), gr.Audio(visible=False)
1042
 
1043
+ answer_audio_path_out = result["audio_path"]
1044
+ safe_answer = html.escape(str(result["answer_text"]))
1045
+ safe_question = html.escape(str(result["display_question"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1046
  answer_html = f"""
1047
  <div style="margin-top: 12px; padding: 16px; background: rgba(240,253,244,0.1); border: 1px solid rgba(187,247,208,0.3); border-radius: 14px;">
1048
  <div style="font-size: 10px; font-weight: 700; text-transform: uppercase; color: #4ade80; margin-bottom: 6px;">Answer (LFM2.5-Audio)</div>
 
1055
  </div>
1056
  """
1057
  if answer_audio_path_out:
1058
+ return gr.HTML(value=answer_html, visible=True), gr.Audio(value=str(answer_audio_path_out), visible=True)
1059
  return gr.HTML(value=answer_html, visible=True), gr.Audio(visible=False)
1060
 
1061
  submit_question_btn.click(
 
1064
  outputs=[answer_display, answer_audio]
1065
  )
1066
 
1067
+ # Auto-submit when voice recording completes. .change() fires after the
1068
+ # recorded audio file path is committed, avoiding duplicate submit events.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1069
  def auto_submit_on_audio(audio_path, question_txt, paragraphs, slider_val, profile_id):
1070
  """Auto-submit when a recording is available."""
1071
  if not audio_path:
 
1130
  )
1131
 
1132
  if __name__ == "__main__":
1133
+ demo.queue(max_size=8).launch(allowed_paths=gradio_allowed_paths())
inference_lfm.py CHANGED
@@ -8,6 +8,8 @@ import logging
8
  import torch
9
  import numpy as np
10
 
 
 
11
  logger = logging.getLogger(__name__)
12
 
13
  _processor = None
@@ -78,16 +80,17 @@ def answer_question_audio(
78
  text_out = []
79
  audio_out = []
80
 
81
- for t in model.generate_interleaved(
82
- **chat,
83
- max_new_tokens=max_new_tokens,
84
- audio_temperature=1.0,
85
- audio_top_k=4,
86
- ):
87
- if t.numel() == 1:
88
- text_out.append(t)
89
- else:
90
- audio_out.append(t)
 
91
 
92
  # Decode text
93
  answer_text = ""
@@ -98,7 +101,8 @@ def answer_question_audio(
98
  waveform = None
99
  if audio_out and len(audio_out) > 1:
100
  audio_codes = torch.stack(audio_out[:-1], 1).unsqueeze(0)
101
- waveform_tensor = processor.decode(audio_codes)
 
102
  waveform = waveform_tensor.cpu().numpy().squeeze()
103
 
104
  return answer_text, waveform, SAMPLE_RATE
 
8
  import torch
9
  import numpy as np
10
 
11
+ from runtime_config import GPU_INFERENCE_LOCK
12
+
13
  logger = logging.getLogger(__name__)
14
 
15
  _processor = None
 
80
  text_out = []
81
  audio_out = []
82
 
83
+ with GPU_INFERENCE_LOCK, torch.inference_mode():
84
+ for t in model.generate_interleaved(
85
+ **chat,
86
+ max_new_tokens=max_new_tokens,
87
+ audio_temperature=1.0,
88
+ audio_top_k=4,
89
+ ):
90
+ if t.numel() == 1:
91
+ text_out.append(t)
92
+ else:
93
+ audio_out.append(t)
94
 
95
  # Decode text
96
  answer_text = ""
 
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()
107
 
108
  return answer_text, waveform, SAMPLE_RATE
mission.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  ## Product Vision
4
 
5
- **ReadBookMom** — a parent records a short voice sample, then a bedtime story and story Q&A are spoken back in that parent's cloned voice. The demo runs on local models inside a Gradio app on Hugging Face Spaces.
6
 
7
  ## Problem
8
 
@@ -15,14 +15,14 @@ Parent records 15s of voice → Pick a story → Story starts in parent's voice
15
  ↓
16
  Child taps Ask mid-story → Narration pauses
17
  ↓
18
- Child asks a question → Hears answer in parent's voice
19
  ↓
20
  Resume story from the same position (or ask another question)
21
  ↓
22
  Story finishes → Show "Pick another story" prompt
23
  ```
24
 
25
- Latency and natural interruption are part of the demo promise: the story should start quickly, pause cleanly when the child wants to ask something, answer in the cloned voice, then resume without losing the story position. The child may ask multiple questions in a row before resuming. When the last chunk finishes playing, the app returns to the story selection screen.
26
 
27
  **Demo time budget:** A 3-minute live walkthrough allows ~2 Q&A rounds (each round ≈ 15s of dead air: pause + ask + wait + answer + resume). Plan the demo script around 1–2 questions placed at natural story moments.
28
 
@@ -31,7 +31,7 @@ Latency and natural interruption are part of the demo promise: the story should
31
  Ship a working Hugging Face Space that demonstrates:
32
  1. **Voice cloning** — parent uploads/records a short audio clip, Qwen3-TTS extracts speaker embedding
33
  2. **Interruptible story narration** — Qwen3-TTS streams sentence-sized chunks in the cloned voice (Supertonic fallback for stock voice)
34
- 3. **Voice Q&A during narration** — child taps Ask, narration pauses, Qwen2.5-3B-Instruct answers from the story context, then Qwen3-TTS speaks the answer in the cloned voice
35
 
36
  ## Target Demo User
37
 
@@ -60,7 +60,7 @@ A parent at a laptop who records their voice, picks a short story, and shows the
60
  - Play story narrated in cloned voice with interruptible chunked streaming
61
  - Pause narration through an Ask button, preserve the current story chunk, answer, then resume
62
  - Cache generated narration per voice session and story
63
- - Ask a question about the story, receive a grounded 1–2 sentence Qwen2.5-3B-Instruct answer, and hear it in the cloned voice
64
  - Clean, child-friendly UI (Google Stitch-inspired via gr.Server)
65
 
66
  **Out of scope:**
@@ -74,7 +74,7 @@ A parent at a laptop who records their voice, picks a short story, and shows the
74
 
75
  | Constraint | Implementation |
76
  |---|---|
77
- | Content grounding | Qwen2.5-3B-Instruct receives the selected story text plus a strict answer-from-story instruction |
78
  | Voice privacy | All inference local on HF Space GPU — no audio leaves the server |
79
  | Child safety | Pre-curated stories only; no user-uploaded content |
80
 
@@ -86,17 +86,17 @@ A parent at a laptop who records their voice, picks a short story, and shows the
86
  | Story narration | Use interruptible chunked streaming: generate the first paragraph chunk, play it immediately, then continue generating queued chunks. |
87
  | Interruption | Add an Ask state that pauses playback, cancels or deprioritizes queued narration generation, and stores the current chunk index. |
88
  | Story replay | Cache full generated narration by voice session and story ID. |
89
- | Q&A context | Send the current story position plus the most relevant story passages to Qwen2.5-3B-Instruct instead of the full story when possible. |
90
- | Q&A length | Cap answers to 1–2 short child-friendly sentences before sending to TTS. |
91
  | Pre-generated Q&A | While each chunk plays, generate 2–3 anticipated Q&A pairs with audio in the background. Match incoming questions against the cache for sub-1s responses; fall back to live generation on miss. |
92
  | Story resume | Resume from the paused chunk after the spoken answer; use cached next chunks when available. |
93
- | Voice question input | Load ASR only when audio questions are used; text questions bypass ASR. |
94
 
95
  ## Design Review Notes
96
 
97
  | Topic | Critique | Upgrade |
98
  |---|---|---|
99
- | Privacy | An external LLM API would weaken the privacy story even if audio stayed local. | Use Qwen2.5-3B-Instruct for local story Q&A so the demo has one clear privacy narrative. |
100
- | Latency | A 3B-class Q&A model is a better fit for the demo loop than an 8B-class model, but TTS and interruption responsiveness remain the user-visible bottlenecks. | Cache voice setup, stream chunked narration, make playback cancellable, cap Q&A output, and use 4-bit or 8-bit loading on T4. |
101
  | Demo clarity | Voice cloning, narration, ASR, and Q&A can feel like too many moving parts. | Present the loop as three simple actions: record, listen, ask. |
102
 
 
2
 
3
  ## Product Vision
4
 
5
+ **ReadBookMom** — a parent records a short voice sample, then a bedtime story is narrated in that parent's cloned voice. Story Q&A uses a local audio model for fast spoken answers. The demo runs on local models inside a Gradio app on Hugging Face Spaces.
6
 
7
  ## Problem
8
 
 
15
  ↓
16
  Child taps Ask mid-story → Narration pauses
17
  ↓
18
+ Child asks a question → Hears a fast spoken answer
19
  ↓
20
  Resume story from the same position (or ask another question)
21
  ↓
22
  Story finishes → Show "Pick another story" prompt
23
  ```
24
 
25
+ Latency and natural interruption are part of the demo promise: the story should start quickly, pause cleanly when the child wants to ask something, answer with low delay, then resume without losing the story position. The Q&A answer voice is intentionally generated by the local audio Q&A model rather than the cloned narrator voice. The child may ask multiple questions in a row before resuming. When the last chunk finishes playing, the app returns to the story selection screen.
26
 
27
  **Demo time budget:** A 3-minute live walkthrough allows ~2 Q&A rounds (each round ≈ 15s of dead air: pause + ask + wait + answer + resume). Plan the demo script around 1–2 questions placed at natural story moments.
28
 
 
31
  Ship a working Hugging Face Space that demonstrates:
32
  1. **Voice cloning** — parent uploads/records a short audio clip, Qwen3-TTS extracts speaker embedding
33
  2. **Interruptible story narration** — Qwen3-TTS streams sentence-sized chunks in the cloned voice (Supertonic fallback for stock voice)
34
+ 3. **Voice Q&A during narration** — child taps Ask, narration pauses, LFM2.5-Audio answers from the story context, then speaks the answer directly for lower latency
35
 
36
  ## Target Demo User
37
 
 
60
  - Play story narrated in cloned voice with interruptible chunked streaming
61
  - Pause narration through an Ask button, preserve the current story chunk, answer, then resume
62
  - Cache generated narration per voice session and story
63
+ - Ask a question about the story, receive a grounded 1–2 sentence LFM2.5-Audio answer, and hear it as low-latency generated speech
64
  - Clean, child-friendly UI (Google Stitch-inspired via gr.Server)
65
 
66
  **Out of scope:**
 
74
 
75
  | Constraint | Implementation |
76
  |---|---|
77
+ | Content grounding | LFM2.5-Audio receives the selected story text plus a strict answer-from-story instruction |
78
  | Voice privacy | All inference local on HF Space GPU — no audio leaves the server |
79
  | Child safety | Pre-curated stories only; no user-uploaded content |
80
 
 
86
  | Story narration | Use interruptible chunked streaming: generate the first paragraph chunk, play it immediately, then continue generating queued chunks. |
87
  | Interruption | Add an Ask state that pauses playback, cancels or deprioritizes queued narration generation, and stores the current chunk index. |
88
  | Story replay | Cache full generated narration by voice session and story ID. |
89
+ | Q&A context | Send the current story position plus the most relevant story passages to LFM2.5-Audio instead of the full story when possible. |
90
+ | Q&A length | Cap answers to 1–2 short child-friendly sentences to reduce spoken-answer latency. |
91
  | Pre-generated Q&A | While each chunk plays, generate 2–3 anticipated Q&A pairs with audio in the background. Match incoming questions against the cache for sub-1s responses; fall back to live generation on miss. |
92
  | Story resume | Resume from the paused chunk after the spoken answer; use cached next chunks when available. |
93
+ | Voice question input | Use LFM2.5-Audio directly for audio questions; text questions bypass audio input. |
94
 
95
  ## Design Review Notes
96
 
97
  | Topic | Critique | Upgrade |
98
  |---|---|---|
99
+ | Privacy | An external LLM API would weaken the privacy story even if audio stayed local. | Use local LFM2.5-Audio for story Q&A so the demo has one clear privacy narrative. |
100
+ | Latency | The cloned narrator voice is valuable for story playback, but forcing every Q&A answer through cloned TTS adds delay to the live interaction loop. | Use LFM2.5-Audio for direct audio-in/audio-out answers, cache voice setup, stream chunked narration, make playback cancellable, and cap Q&A output. |
101
  | Demo clarity | Voice cloning, narration, ASR, and Q&A can feel like too many moving parts. | Present the loop as three simple actions: record, listen, ask. |
102
 
qa_flow.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Server-side Q&A flow helpers for the Gradio Ask interaction."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Callable, Iterable, Protocol
10
+
11
+ logger = logging.getLogger(__name__)
12
+ DEFAULT_SAMPLE_RATE = 24000
13
+
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):
20
+ def __call__(self, path: Path, waveform, sample_rate: int) -> None:
21
+ ...
22
+
23
+
24
+ def _normalize_audio_path(audio_path: str | Path | None) -> str:
25
+ if not audio_path:
26
+ return ""
27
+ try:
28
+ path = Path(audio_path).resolve()
29
+ stat = path.stat()
30
+ return f"{path}:{stat.st_size}:{stat.st_mtime_ns}"
31
+ except OSError:
32
+ return str(audio_path)
33
+
34
+
35
+ def claim_audio_submission(audio_path: str | Path | None) -> bool:
36
+ """Return False when the same recorded audio was already claimed recently."""
37
+ normalized = _normalize_audio_path(audio_path)
38
+ if not normalized:
39
+ return True
40
+
41
+ now = time.monotonic()
42
+ cutoff = now - _DUPLICATE_WINDOW_SEC
43
+ with _RECENT_AUDIO_LOCK:
44
+ stale = [
45
+ key for key, seen_at in _RECENT_AUDIO_SUBMISSIONS.items()
46
+ if seen_at < cutoff
47
+ ]
48
+ for key in stale:
49
+ _RECENT_AUDIO_SUBMISSIONS.pop(key, None)
50
+
51
+ if normalized in _RECENT_AUDIO_SUBMISSIONS:
52
+ return False
53
+
54
+ _RECENT_AUDIO_SUBMISSIONS[normalized] = now
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,
67
+ question_audio_path: str | Path | None,
68
+ paragraphs: Iterable[str] | None,
69
+ output_dir: Path,
70
+ answer_fn: Callable[..., tuple[str, object | None, int]],
71
+ audio_writer: AudioWriter = _default_audio_writer,
72
+ max_new_tokens: int = 100,
73
+ ) -> dict[str, object]:
74
+ """Run Q&A and return display-ready data without importing Gradio."""
75
+ q_txt = (question_text or "").strip()
76
+ audio_path = str(question_audio_path) if question_audio_path else None
77
+ has_audio = bool(audio_path)
78
+
79
+ if not q_txt and not has_audio:
80
+ return {
81
+ "ok": False,
82
+ "answer_text": "Please type a question or record one with the microphone.",
83
+ "display_question": "",
84
+ "audio_path": None,
85
+ "error": None,
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,
92
+ question_text=q_txt if q_txt and not has_audio else None,
93
+ story_context=story_context,
94
+ max_new_tokens=max_new_tokens,
95
+ )
96
+ if not answer_text:
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__})"
103
+ )
104
+ waveform = None
105
+ sr = DEFAULT_SAMPLE_RATE
106
+
107
+ answer_audio_path = None
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
+ }
requirements.txt CHANGED
@@ -7,10 +7,12 @@ soundfile
7
  numpy
8
  librosa
9
 
 
 
 
10
  # Qwen3-TTS (voice cloning + stock voice)
11
  qwen-tts>=0.1.1
12
  huggingface-hub>=0.23.0
13
 
14
- # FlashAttention-2 — install separately on GPU machines:
15
- # pip install flash-attn --no-build-isolation
16
- # Falls back to SDPA (PyTorch native) if not available.
 
7
  numpy
8
  librosa
9
 
10
+ # LFM2.5-Audio end-to-end Q&A model
11
+ liquid-audio
12
+
13
  # Qwen3-TTS (voice cloning + stock voice)
14
  qwen-tts>=0.1.1
15
  huggingface-hub>=0.23.0
16
 
17
+ # FlashAttention-2 is intentionally not installed at runtime on Spaces.
18
+ # The app falls back to SDPA unless a Docker/prebuilt image provides flash-attn.
 
runtime_config.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime paths and shared execution controls for the Space."""
2
+ import os
3
+ import tempfile
4
+ import threading
5
+ import warnings
6
+ from pathlib import Path
7
+
8
+
9
+ APP_ROOT = Path(__file__).resolve().parent
10
+
11
+
12
+ def _usable_root(path: Path, *, warn: bool = False) -> Path | None:
13
+ try:
14
+ path.mkdir(parents=True, exist_ok=True)
15
+ probe = path / ".write_probe"
16
+ probe.write_text("ok", encoding="utf-8")
17
+ probe.unlink(missing_ok=True)
18
+ return path
19
+ except OSError as exc:
20
+ if warn:
21
+ warnings.warn(f"Runtime storage path is not writable: {path} ({exc})")
22
+ return None
23
+
24
+
25
+ def _select_storage_root() -> Path:
26
+ configured = (
27
+ os.environ.get("MOMSVOICE_STORAGE_DIR")
28
+ or os.environ.get("SPACE_STORAGE_DIR")
29
+ )
30
+ candidates: list[tuple[Path, bool]] = []
31
+ if configured:
32
+ candidates.append((Path(configured).expanduser(), True))
33
+ candidates.extend([
34
+ (Path("/data") / "momsvoice", False),
35
+ (APP_ROOT, True),
36
+ (Path(tempfile.gettempdir()) / "momsvoice", True),
37
+ ])
38
+
39
+ for candidate, warn in candidates:
40
+ usable = _usable_root(candidate, warn=warn)
41
+ if usable is not None:
42
+ return usable
43
+
44
+ raise RuntimeError("No writable runtime storage path is available.")
45
+
46
+
47
+ STORAGE_ROOT = _select_storage_root()
48
+
49
+ VOICE_PROFILE_DIR = STORAGE_ROOT / "Voice_Profile"
50
+ AUDIO_CACHE_DIR = STORAGE_ROOT / "audio_cache"
51
+ SAMPLE_SOUNDS_DIR = STORAGE_ROOT / "sample_sounds"
52
+
53
+ for _path in (VOICE_PROFILE_DIR, AUDIO_CACHE_DIR, SAMPLE_SOUNDS_DIR):
54
+ _path.mkdir(parents=True, exist_ok=True)
55
+
56
+
57
+ # Serializes GPU-heavy model calls across Gradio events and background pregen
58
+ # threads. This keeps the Space responsive under hackathon demo traffic.
59
+ GPU_INFERENCE_LOCK = threading.RLock()
60
+
61
+
62
+ def gradio_allowed_paths() -> list[str]:
63
+ return [
64
+ str(APP_ROOT / "assets"),
65
+ str(SAMPLE_SOUNDS_DIR),
66
+ ]
test_modules/test_qa_flow.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
12
+ if not condition:
13
+ raise AssertionError(label)
14
+ print(f"[OK] {label}")
15
+
16
+
17
+ def fake_answer_fn(**kwargs):
18
+ check("story context forwarded", "Three Little Pigs" in kwargs["story_context"])
19
+ check("max_new_tokens forwarded", kwargs["max_new_tokens"] == 12)
20
+ if kwargs["question_audio_path"]:
21
+ return "The wolf huffed and puffed.", object(), 24000
22
+ return "The pig built a house.", None, 24000
23
+
24
+
25
+ def fake_writer(path: Path, waveform, sample_rate: int):
26
+ check("audio writer receives sample rate", sample_rate == 24000)
27
+ path.write_bytes(b"RIFF fake wav")
28
+
29
+
30
+ def test_empty_question():
31
+ result = build_qa_response(
32
+ question_text="",
33
+ question_audio_path=None,
34
+ paragraphs=[],
35
+ output_dir=Path(tempfile.gettempdir()),
36
+ answer_fn=fake_answer_fn,
37
+ )
38
+ check("empty question is rejected", result["ok"] is False)
39
+ check("empty question has no audio", result["audio_path"] is None)
40
+
41
+
42
+ def test_text_question():
43
+ result = build_qa_response(
44
+ question_text="What did the pig build?",
45
+ question_audio_path=None,
46
+ paragraphs=["The Three Little Pigs built houses."],
47
+ output_dir=Path(tempfile.gettempdir()),
48
+ answer_fn=fake_answer_fn,
49
+ max_new_tokens=12,
50
+ )
51
+ check("text question succeeds", result["ok"] is True)
52
+ check("text answer returned", result["answer_text"] == "The pig built a house.")
53
+ check("text question does not write audio when model returns none", result["audio_path"] is None)
54
+
55
+
56
+ def test_audio_question_writes_answer():
57
+ with tempfile.TemporaryDirectory() as tmp:
58
+ result = build_qa_response(
59
+ question_text="",
60
+ question_audio_path=Path(tmp) / "question.wav",
61
+ paragraphs=["The Three Little Pigs met a wolf."],
62
+ output_dir=Path(tmp),
63
+ answer_fn=fake_answer_fn,
64
+ audio_writer=fake_writer,
65
+ max_new_tokens=12,
66
+ )
67
+ check("audio question succeeds", result["ok"] is True)
68
+ check("audio question display fallback", result["display_question"] == "(audio question)")
69
+ check("audio answer path created", result["audio_path"] is not None)
70
+ check("audio answer file exists", Path(result["audio_path"]).exists())
71
+
72
+
73
+ def test_duplicate_audio_claim():
74
+ with tempfile.TemporaryDirectory() as tmp:
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__":
81
+ test_empty_question()
82
+ test_text_question()
83
+ test_audio_question_writes_answer()
84
+ test_duplicate_audio_claim()
85
+ print("Q&A flow tests passed")
tts.py CHANGED
@@ -26,6 +26,8 @@ import threading
26
  import numpy as np
27
  import soundfile as sf
28
 
 
 
29
  logger = logging.getLogger(__name__)
30
 
31
  _SENTENCE_RE = re.compile(r'(?<=[.!?;])\s+')
@@ -36,7 +38,7 @@ _SENTINEL = object()
36
  _MAX_CHUNK_CHARS = 120
37
 
38
  # Audio cache directory
39
- _CACHE_DIR = "audio_cache"
40
  os.makedirs(_CACHE_DIR, exist_ok=True)
41
 
42
  # Transition chime (soft sine fade, 0.3s at 24kHz)
@@ -49,6 +51,7 @@ _TRANSITION_CHIME = 0.08 * np.sin(2 * np.pi * 440 * _chime_t) * np.linspace(1, 0
49
  _pregen_lock = threading.Lock()
50
  _pregen_cache: dict[str, list[tuple[int, np.ndarray]]] = {} # key -> [(sr, wav), ...]
51
  _pregen_in_progress: set[str] = set()
 
52
 
53
 
54
  def _cache_key(text: str, voice_profile_id: str | None) -> str:
@@ -57,6 +60,44 @@ def _cache_key(text: str, voice_profile_id: str | None) -> str:
57
  return hashlib.md5(raw.encode()).hexdigest()
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def _get_cached_audio(chunk_text: str, voice_profile_id: str | None) -> np.ndarray | None:
61
  """Check if audio for this chunk is already cached on disk."""
62
  key = _cache_key(chunk_text, voice_profile_id)
@@ -118,12 +159,18 @@ def pregenerate_story_audio(chunks: list[str], voice_profile_id: str | None = No
118
 
119
  Call this on book selection to pre-warm the cache. Non-blocking.
120
  """
121
- story_key = _cache_key("\n".join(chunks), voice_profile_id)
122
 
123
  with _pregen_lock:
124
  if story_key in _pregen_in_progress or story_key in _pregen_cache:
125
  return # Already running or done
126
  _pregen_in_progress.add(story_key)
 
 
 
 
 
 
127
 
128
  def _worker():
129
  results = []
@@ -133,6 +180,8 @@ def pregenerate_story_audio(chunks: list[str], voice_profile_id: str | None = No
133
  cached = _get_cached_audio(chunk, voice_profile_id)
134
  if cached is not None:
135
  results.append((sr, cached))
 
 
136
  continue
137
  # Synthesize
138
  try:
@@ -143,9 +192,17 @@ def pregenerate_story_audio(chunks: list[str], voice_profile_id: str | None = No
143
  except Exception as e:
144
  logger.warning("Pre-gen failed on chunk %d: %s", i, e)
145
  results.append((sr, np.zeros(0, dtype=np.float32)))
 
 
146
 
147
  with _pregen_lock:
148
  _pregen_cache[story_key] = results
 
 
 
 
 
 
149
  _pregen_in_progress.discard(story_key)
150
  logger.info("Pre-generation complete: %d chunks cached.", len(chunks))
151
 
@@ -186,7 +243,7 @@ def generate_audio_stream(
186
  sample_rate = 24000
187
 
188
  # Check if pre-generated cache is available
189
- story_key = _cache_key("\n".join(chunks), voice_profile_id)
190
  pregen_results = None
191
  with _pregen_lock:
192
  if story_key in _pregen_cache:
 
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
 
33
  _SENTENCE_RE = re.compile(r'(?<=[.!?;])\s+')
 
38
  _MAX_CHUNK_CHARS = 120
39
 
40
  # Audio cache directory
41
+ _CACHE_DIR = str(AUDIO_CACHE_DIR)
42
  os.makedirs(_CACHE_DIR, exist_ok=True)
43
 
44
  # Transition chime (soft sine fade, 0.3s at 24kHz)
 
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:
 
60
  return hashlib.md5(raw.encode()).hexdigest()
61
 
62
 
63
+ 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,
70
+ ) -> dict[str, int | bool]:
71
+ """Return how much of a story is actually cached or warmed."""
72
+ total = len(chunks)
73
+ if total == 0:
74
+ return {"cached": 0, "total": 0, "in_progress": False, "complete": False}
75
+
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
+
101
  def _get_cached_audio(chunk_text: str, voice_profile_id: str | None) -> np.ndarray | None:
102
  """Check if audio for this chunk is already cached on disk."""
103
  key = _cache_key(chunk_text, voice_profile_id)
 
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 = []
 
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:
 
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
 
 
243
  sample_rate = 24000
244
 
245
  # Check if pre-generated cache is available
246
+ story_key = _story_key(chunks, voice_profile_id)
247
  pregen_results = None
248
  with _pregen_lock:
249
  if story_key in _pregen_cache:
voice_clone.py CHANGED
@@ -34,6 +34,8 @@ import numpy as np
34
  import soundfile as sf
35
  import torch
36
 
 
 
37
  logger = logging.getLogger(__name__)
38
 
39
  # ---------------------------------------------------------------------------
@@ -69,7 +71,6 @@ REF_AUDIO_TARGET_SR = 24000
69
  # ---------------------------------------------------------------------------
70
  # Voice profile persistence
71
  # ---------------------------------------------------------------------------
72
- VOICE_PROFILE_DIR = Path(__file__).parent / "Voice_Profile"
73
  VOICE_PROFILE_DIR.mkdir(exist_ok=True)
74
 
75
  # Default profile ID — used when no cloned voice exists
@@ -346,10 +347,11 @@ def create_voice_profile(ref_audio_path: str, voice_name: str = "Cloned Voice")
346
  model = get_qwen_tts()
347
 
348
  logger.info("Creating voice profile from %s...", ref_audio_path)
349
- prompt_items = model.create_voice_clone_prompt(
350
- ref_audio=trimmed_path,
351
- x_vector_only_mode=True,
352
- )
 
353
 
354
  profile_id = uuid.uuid4().hex[:12]
355
  with _cache_lock:
@@ -382,13 +384,14 @@ def synthesize_cloned(text: str, profile_id: str) -> tuple[np.ndarray, int]:
382
 
383
  model = get_qwen_tts()
384
 
385
- audio_list, sample_rate = model.generate_voice_clone(
386
- text=text,
387
- language="english",
388
- voice_clone_prompt=prompt_items,
389
- non_streaming_mode=False,
390
- **GENERATION_PARAMS,
391
- )
 
392
 
393
  wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
394
  return wav, sample_rate
@@ -404,13 +407,14 @@ def synthesize_custom_voice_streaming(
404
  """
405
  model = get_custom_voice_model()
406
 
407
- audio_list, sample_rate = model.generate_custom_voice(
408
- text=text,
409
- speaker=speaker,
410
- language=language,
411
- non_streaming_mode=False,
412
- **GENERATION_PARAMS,
413
- )
 
414
 
415
  # Yield each segment as it was generated
416
  for segment in audio_list:
@@ -428,13 +432,14 @@ def synthesize_custom_voice(
428
  """
429
  model = get_custom_voice_model()
430
 
431
- audio_list, sample_rate = model.generate_custom_voice(
432
- text=text,
433
- speaker=speaker,
434
- language=language,
435
- non_streaming_mode=False,
436
- **GENERATION_PARAMS,
437
- )
 
438
 
439
  wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
440
  return wav, sample_rate
 
34
  import soundfile as sf
35
  import torch
36
 
37
+ from runtime_config import GPU_INFERENCE_LOCK, VOICE_PROFILE_DIR
38
+
39
  logger = logging.getLogger(__name__)
40
 
41
  # ---------------------------------------------------------------------------
 
71
  # ---------------------------------------------------------------------------
72
  # Voice profile persistence
73
  # ---------------------------------------------------------------------------
 
74
  VOICE_PROFILE_DIR.mkdir(exist_ok=True)
75
 
76
  # Default profile ID — used when no cloned voice exists
 
347
  model = get_qwen_tts()
348
 
349
  logger.info("Creating voice profile from %s...", ref_audio_path)
350
+ with GPU_INFERENCE_LOCK:
351
+ prompt_items = model.create_voice_clone_prompt(
352
+ ref_audio=trimmed_path,
353
+ x_vector_only_mode=True,
354
+ )
355
 
356
  profile_id = uuid.uuid4().hex[:12]
357
  with _cache_lock:
 
384
 
385
  model = get_qwen_tts()
386
 
387
+ with GPU_INFERENCE_LOCK:
388
+ audio_list, sample_rate = model.generate_voice_clone(
389
+ text=text,
390
+ language="english",
391
+ voice_clone_prompt=prompt_items,
392
+ non_streaming_mode=False,
393
+ **GENERATION_PARAMS,
394
+ )
395
 
396
  wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
397
  return wav, sample_rate
 
407
  """
408
  model = get_custom_voice_model()
409
 
410
+ with GPU_INFERENCE_LOCK:
411
+ audio_list, sample_rate = model.generate_custom_voice(
412
+ text=text,
413
+ speaker=speaker,
414
+ language=language,
415
+ non_streaming_mode=False,
416
+ **GENERATION_PARAMS,
417
+ )
418
 
419
  # Yield each segment as it was generated
420
  for segment in audio_list:
 
432
  """
433
  model = get_custom_voice_model()
434
 
435
+ with GPU_INFERENCE_LOCK:
436
+ audio_list, sample_rate = model.generate_custom_voice(
437
+ text=text,
438
+ speaker=speaker,
439
+ language=language,
440
+ non_streaming_mode=False,
441
+ **GENERATION_PARAMS,
442
+ )
443
 
444
  wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
445
  return wav, sample_rate