MSG commited on
Commit
29e2c18
·
1 Parent(s): 1719c2a

Feat/teacher voice (#8)

Browse files

* teacher voice wip

* wip teacher

* teacher voice wip

* voice

* wip voice

* piper speech

* teacher voice and tts realtime

.cursor/plans/teachervoice_realtime_plan_8950875f.plan.md CHANGED
@@ -4,22 +4,22 @@ overview: EchoCoach today is a one-shot pitch *review* pipeline (record → anal
4
  todos:
5
  - id: teacher-voice-core
6
  content: Add teacher_voice.py + mode prompts + TeacherVoiceTurnResult; wire ASR → chat(history) → TTS per turn
7
- status: pending
8
  - id: teacher-voice-ui
9
  content: Create teacher_voice.py Gradio tab (modes, push-to-talk, chatbot, audio reply, optional RAG); register in app.py
10
- status: pending
11
  - id: rag-lesson-modes
12
  content: Integrate ResearchMind retrieval into Explain/Lesson modes (reuse run_research_question / rag scope from chat tab)
13
- status: pending
14
  - id: docs-tests
15
  content: Update USAGE.md with EchoCoach vs TeacherVoice; add mock-backend unit tests and trace skill teacher-voice
16
- status: pending
17
  - id: phase2-chunked-tts
18
  content: "Optional: sentence-chunked Piper + shorter turn cap for faster time-to-first-audio"
19
- status: pending
20
  - id: phase3-omni
21
  content: "Optional: MiniCPM-o 4.5 omni preset behind ECHOCOACH_VOICE_PROFILE=omni for speech-in/speech-out"
22
- status: pending
23
  isProject: false
24
  ---
25
 
 
4
  todos:
5
  - id: teacher-voice-core
6
  content: Add teacher_voice.py + mode prompts + TeacherVoiceTurnResult; wire ASR → chat(history) → TTS per turn
7
+ status: completed
8
  - id: teacher-voice-ui
9
  content: Create teacher_voice.py Gradio tab (modes, push-to-talk, chatbot, audio reply, optional RAG); register in app.py
10
+ status: completed
11
  - id: rag-lesson-modes
12
  content: Integrate ResearchMind retrieval into Explain/Lesson modes (reuse run_research_question / rag scope from chat tab)
13
+ status: completed
14
  - id: docs-tests
15
  content: Update USAGE.md with EchoCoach vs TeacherVoice; add mock-backend unit tests and trace skill teacher-voice
16
+ status: completed
17
  - id: phase2-chunked-tts
18
  content: "Optional: sentence-chunked Piper + shorter turn cap for faster time-to-first-audio"
19
+ status: completed
20
  - id: phase3-omni
21
  content: "Optional: MiniCPM-o 4.5 omni preset behind ECHOCOACH_VOICE_PROFILE=omni for speech-in/speech-out"
22
+ status: completed
23
  isProject: false
24
  ---
25
 
.env.example CHANGED
@@ -54,9 +54,12 @@ ALLOW_MODEL_SWITCH=false
54
  # VOICE_PRESETS_PATH=./voice_models.yaml
55
  # ECHOCOACH_ASR_PRESET=whisper-cpp-tiny
56
  # ECHOCOACH_TTS_PRESET=piper-multilingual
 
57
  # ECHOCOACH_COACH_MODEL=minicpm5-1b
58
  # ECHOCOACH_MAX_SECONDS=30
59
  # ECHOCOACH_CAPTURE_DEVICE= # optional ALSA/PipeWire device (e.g. pipewire, alsa_input.pci-...)
 
 
60
  # PIPER_VOICES_DIR=~/.local/share/piper/voices
61
  # For Cohere Transcribe ASR: huggingface-cli login + accept model terms, then:
62
  # ECHOCOACH_ASR_PRESET=cohere-transcribe
 
54
  # VOICE_PRESETS_PATH=./voice_models.yaml
55
  # ECHOCOACH_ASR_PRESET=whisper-cpp-tiny
56
  # ECHOCOACH_TTS_PRESET=piper-multilingual
57
+ # ECHOCOACH_REALTIME_TTS_PRESET=vibevoice-realtime-0.5b # TeacherVoice VoiceOut (falls back to Piper)
58
  # ECHOCOACH_COACH_MODEL=minicpm5-1b
59
  # ECHOCOACH_MAX_SECONDS=30
60
  # ECHOCOACH_CAPTURE_DEVICE= # optional ALSA/PipeWire device (e.g. pipewire, alsa_input.pci-...)
61
+ # ECHOCOACH_VOICE_PROFILE=pipeline # pipeline (default) or omni for MiniCPM-o attempt
62
+ # ECHOCOACH_OMNI_MODEL=openbmb/MiniCPM-o-4_5
63
  # PIPER_VOICES_DIR=~/.local/share/piper/voices
64
  # For Cohere Transcribe ASR: huggingface-cli login + accept model terms, then:
65
  # ECHOCOACH_ASR_PRESET=cohere-transcribe
USAGE.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  How to run the **Lesson Agent** Gradio app locally, test it in Docker, and deploy to a Hugging Face Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
4
 
5
- The primary UI is the **Lesson slides** tab (topic → local model outline → downloadable `.pptx`). Use **ResearchMind** for corpus Q&A, **EchoCoach** for voice practice feedback, or ground lessons directly from the Lesson tab. The **Chat (debug)** tab tests the underlying model.
6
 
7
  ## Prerequisites
8
 
@@ -109,7 +109,8 @@ Configure presets in [`voice_models.yaml`](voice_models.yaml) or via `.env`:
109
  | Variable | Default | Description |
110
  | -------- | ------- | ----------- |
111
  | `ECHOCOACH_ASR_PRESET` | `whisper-cpp-tiny` | ASR preset key |
112
- | `ECHOCOACH_TTS_PRESET` | `piper-multilingual` | TTS preset key |
 
113
  | `ECHOCOACH_COACH_MODEL` | `minicpm5-1b` | Text coach preset (from `models.yaml`) |
114
  | `ECHOCOACH_MAX_SECONDS` | `30` | Max recording length |
115
 
@@ -121,6 +122,57 @@ Smoke tests (analysis only, no GPU):
121
  bash scripts/echo_coach_smoke.sh
122
  ```
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  ### 5. Upload agent trace (Sharing is Caring badge)
125
 
126
  ```bash
 
2
 
3
  How to run the **Lesson Agent** Gradio app locally, test it in Docker, and deploy to a Hugging Face Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
4
 
5
+ The primary UI is the **Lesson slides** tab (topic → local model outline → downloadable `.pptx`). Use **ResearchMind** for corpus Q&A, **TeacherVoice** for spoken back-and-forth tutoring, **EchoCoach** for one-shot pitch analysis, or ground lessons directly from the Lesson tab. The **Chat (debug)** tab tests the underlying model.
6
 
7
  ## Prerequisites
8
 
 
109
  | Variable | Default | Description |
110
  | -------- | ------- | ----------- |
111
  | `ECHOCOACH_ASR_PRESET` | `whisper-cpp-tiny` | ASR preset key |
112
+ | `ECHOCOACH_TTS_PRESET` | `piper-multilingual` | TTS preset key (EchoCoach, default VoiceOut) |
113
+ | `ECHOCOACH_REALTIME_TTS_PRESET` | `vibevoice-realtime-0.5b` | TeacherVoice streaming TTS (see below) |
114
  | `ECHOCOACH_COACH_MODEL` | `minicpm5-1b` | Text coach preset (from `models.yaml`) |
115
  | `ECHOCOACH_MAX_SECONDS` | `30` | Max recording length |
116
 
 
122
  bash scripts/echo_coach_smoke.sh
123
  ```
124
 
125
+ ### TeacherVoice — spoken conversation (turn-based)
126
+
127
+ The **TeacherVoice** tab is a **multi-turn voice teacher** — not full duplex like a phone call, but speak → wait → hear a reply → repeat.
128
+
129
+ | Mode | Purpose |
130
+ | ---- | ------- |
131
+ | **Explain** | Tutor any topic in plain language |
132
+ | **Lesson coach** | Discuss and outline lesson content verbally |
133
+ | **Pitch practice** | Short live speaking tips each turn |
134
+
135
+ **EchoCoach vs TeacherVoice**
136
+
137
+ | | EchoCoach | TeacherVoice |
138
+ | --- | --- | --- |
139
+ | Interaction | One-shot after **Analyze pitch** | Multi-turn **Send turn** |
140
+ | Best for | Pace/filler charts, JSON rewrite report | Q&A, lesson discussion, conversational pitch tips |
141
+ | TTS | One VoiceOut clip per analysis | Voice reply every turn (first sentence plays quickly when Piper is installed) |
142
+ | RAG | No | Optional ResearchMind grounding (Explain / Lesson) |
143
+
144
+ **Flow per turn:** record up to **15s** → ASR → text LLM with chat history → Piper TTS (auto-plays when installed).
145
+
146
+ After each reply, use **Speak last reply** or **Speak first sentence** to generate or replay VoiceOut from the latest assistant message (works even if auto-TTS was skipped).
147
+
148
+ Install Piper for voice output (included in `gradio-space` deps after `uv sync`):
149
+
150
+ ```bash
151
+ uv sync
152
+ python -m piper.download_voices en_US-lessac-medium
153
+ ```
154
+
155
+ Voices are stored under `models/piper/` (gitignored) or `~/.local/share/piper/voices/`. **Restart the Gradio app** after installing Piper so the Speak buttons can synthesize audio.
156
+
157
+ **Realtime TTS (VibeVoice)** — [microsoft/VibeVoice-Realtime-0.5B](https://huggingface.co/microsoft/VibeVoice-Realtime-0.5B) is registered in `voice_models.yaml` as `vibevoice-realtime-0.5b` (~300 ms to first audio, streaming text-in). TeacherVoice uses `realtime_tts_preset` from YAML by default; override with `ECHOCOACH_REALTIME_TTS_PRESET` or set `ECHOCOACH_TTS_PRESET=vibevoice-realtime-0.5b` globally. GPU recommended; falls back to Piper until the model loads. English-first; de/fr/it/es/pt/nl/pl/ja/ko are experimental per the model card.
158
+
159
+ Enable RAG in the accordion: pick a ResearchMind session and optional documents (same scope rules as Chat debug).
160
+
161
+ Reuse VoiceOut in other tabs via `gradio_space.voice_helpers.speak_last_assistant_reply`.
162
+
163
+ Optional omni profile (GPU, experimental — falls back to ASR+LLM+Piper):
164
+
165
+ ```bash
166
+ ECHOCOACH_VOICE_PROFILE=omni
167
+ ECHOCOACH_OMNI_MODEL=openbmb/MiniCPM-o-4_5
168
+ ```
169
+
170
+ Unit tests (no GPU):
171
+
172
+ ```bash
173
+ uv run pytest libs/echocoach/tests/test_teacher_voice.py -q
174
+ ```
175
+
176
  ### 5. Upload agent trace (Sharing is Caring badge)
177
 
178
  ```bash
apps/gradio-space/pyproject.toml CHANGED
@@ -9,7 +9,7 @@ authors = [
9
  requires-python = ">=3.12"
10
  dependencies = [
11
  "agent",
12
- "echocoach[whisper]",
13
  "gradio>=5.0.0",
14
  "inference",
15
  ]
 
9
  requires-python = ">=3.12"
10
  dependencies = [
11
  "agent",
12
+ "echocoach[piper,whisper]",
13
  "gradio>=5.0.0",
14
  "inference",
15
  ]
apps/gradio-space/src/gradio_space/app.py CHANGED
@@ -8,10 +8,12 @@ from gradio_space.tabs import (
8
  build_education_pptx_tab,
9
  build_echo_coach_tab,
10
  build_research_mind_tab,
 
11
  )
12
  from gradio_space.tabs.education_pptx import gradio_allowed_paths
13
  from gradio_space.tabs.echo_coach import echo_coach_allowed_paths
14
  from gradio_space.tabs.research_mind import researchmind_allowed_paths
 
15
  from inference.config import get_app_config
16
 
17
  _app_config = get_app_config()
@@ -28,9 +30,9 @@ def build_demo() -> gr.Blocks:
28
  with gr.Blocks(title="Lesson Agent + ResearchMind — Build Small Hackathon") as demo:
29
  gr.Markdown(
30
  f"""
31
- # Lesson Agent + ResearchMind + EchoCoach
32
 
33
- Local skill-based agents — **lesson slides**, **research with MemRAG**, and **voice practice coaching** (offline).
34
 
35
  - **Model:** `{active.key}` — {active.label}
36
  - **Backend:** `{active.backend}`
@@ -47,6 +49,8 @@ Part of the [Build Small Hackathon](https://huggingface.co/build-small-hackathon
47
  build_research_mind_tab()
48
  with gr.Tab("EchoCoach"):
49
  build_echo_coach_tab()
 
 
50
  with gr.Tab("Chat (debug)"):
51
  build_chat_tab()
52
 
@@ -69,6 +73,7 @@ def main() -> None:
69
  *gradio_allowed_paths(),
70
  *researchmind_allowed_paths(),
71
  *echo_coach_allowed_paths(),
 
72
  ],
73
  )
74
 
 
8
  build_education_pptx_tab,
9
  build_echo_coach_tab,
10
  build_research_mind_tab,
11
+ build_teacher_voice_tab,
12
  )
13
  from gradio_space.tabs.education_pptx import gradio_allowed_paths
14
  from gradio_space.tabs.echo_coach import echo_coach_allowed_paths
15
  from gradio_space.tabs.research_mind import researchmind_allowed_paths
16
+ from gradio_space.tabs.teacher_voice import teacher_voice_allowed_paths
17
  from inference.config import get_app_config
18
 
19
  _app_config = get_app_config()
 
30
  with gr.Blocks(title="Lesson Agent + ResearchMind — Build Small Hackathon") as demo:
31
  gr.Markdown(
32
  f"""
33
+ # Lesson Agent + ResearchMind + EchoCoach + TeacherVoice
34
 
35
+ Local skill-based agents — **lesson slides**, **research with MemRAG**, **voice conversation (TeacherVoice)**, and **pitch analysis (EchoCoach)** (offline).
36
 
37
  - **Model:** `{active.key}` — {active.label}
38
  - **Backend:** `{active.backend}`
 
49
  build_research_mind_tab()
50
  with gr.Tab("EchoCoach"):
51
  build_echo_coach_tab()
52
+ with gr.Tab("TeacherVoice"):
53
+ build_teacher_voice_tab()
54
  with gr.Tab("Chat (debug)"):
55
  build_chat_tab()
56
 
 
73
  *gradio_allowed_paths(),
74
  *researchmind_allowed_paths(),
75
  *echo_coach_allowed_paths(),
76
+ *teacher_voice_allowed_paths(),
77
  ],
78
  )
79
 
apps/gradio-space/src/gradio_space/tabs/__init__.py CHANGED
@@ -2,10 +2,12 @@ from gradio_space.tabs.chat import build_chat_tab
2
  from gradio_space.tabs.education_pptx import build_education_pptx_tab
3
  from gradio_space.tabs.echo_coach import build_echo_coach_tab
4
  from gradio_space.tabs.research_mind import build_research_mind_tab
 
5
 
6
  __all__ = [
7
  "build_chat_tab",
8
  "build_education_pptx_tab",
9
  "build_echo_coach_tab",
10
  "build_research_mind_tab",
 
11
  ]
 
2
  from gradio_space.tabs.education_pptx import build_education_pptx_tab
3
  from gradio_space.tabs.echo_coach import build_echo_coach_tab
4
  from gradio_space.tabs.research_mind import build_research_mind_tab
5
+ from gradio_space.tabs.teacher_voice import build_teacher_voice_tab
6
 
7
  __all__ = [
8
  "build_chat_tab",
9
  "build_education_pptx_tab",
10
  "build_echo_coach_tab",
11
  "build_research_mind_tab",
12
+ "build_teacher_voice_tab",
13
  ]
apps/gradio-space/src/gradio_space/tabs/echo_coach.py CHANGED
@@ -159,6 +159,8 @@ Record up to **{_config.max_seconds} seconds**, then get local feedback: transcr
159
 
160
  **Browser mic:** open **http://localhost:7860** in Chrome or Firefox (not Cursor's preview) and allow microphone access.
161
  If the mic icon fails, use **Start / Stop recording** below or **Upload** a `.wav` / `.mp3`.
 
 
162
  """
163
  )
164
 
 
159
 
160
  **Browser mic:** open **http://localhost:7860** in Chrome or Firefox (not Cursor's preview) and allow microphone access.
161
  If the mic icon fails, use **Start / Stop recording** below or **Upload** a `.wav` / `.mp3`.
162
+
163
+ For conversational pitch tips, try the **TeacherVoice** tab (Pitch practice mode). This tab provides deep analysis: pace charts, filler counts, and a structured rewrite.
164
  """
165
  )
166
 
apps/gradio-space/src/gradio_space/tabs/teacher_voice.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import gradio as gr
4
+
5
+ from echocoach.config import get_echo_coach_config
6
+ from echocoach.prompts import MODE_LABELS, TeacherVoiceMode
7
+ from echocoach.recording import (
8
+ ServerRecordingError,
9
+ recording_backend_status,
10
+ recording_elapsed_seconds,
11
+ recording_level_warning,
12
+ start_server_recording,
13
+ stop_server_recording,
14
+ )
15
+ from echocoach.teacher_voice import RAG_MODES, run_teacher_voice_turn
16
+ from gradio_space.model_loading import ensure_model_loaded, get_active_model_key, model_status
17
+ from gradio_space.research_helpers import (
18
+ list_doc_choices,
19
+ list_session_choices,
20
+ rag_scope_hint,
21
+ refresh_doc_choices,
22
+ refresh_sessions,
23
+ )
24
+ from echocoach.omni import omni_status_message
25
+ from gradio_space.voice_helpers import speak_last_assistant_reply
26
+ from inference.factory import get_backend
27
+
28
+ _config = get_echo_coach_config()
29
+ _TURN_MAX = min(15, _config.max_seconds)
30
+ _MODE_CHOICES = [(label, key) for key, label in MODE_LABELS.items()]
31
+
32
+
33
+ def _empty_turn() -> tuple:
34
+ return (
35
+ [],
36
+ None,
37
+ "Start recording, speak your question, stop, then click **Send turn**.",
38
+ "",
39
+ {},
40
+ )
41
+
42
+
43
+ def ui_start_recording(max_seconds: int) -> tuple[str, dict, dict]:
44
+ try:
45
+ start_server_recording(int(max_seconds))
46
+ except ServerRecordingError as exc:
47
+ return (
48
+ str(exc),
49
+ gr.update(interactive=True),
50
+ gr.update(interactive=False),
51
+ )
52
+ return (
53
+ (
54
+ f"Recording… speak now, then click **Stop recording** "
55
+ f"(auto-stops after {int(max_seconds)}s)."
56
+ ),
57
+ gr.update(interactive=False),
58
+ gr.update(interactive=True),
59
+ )
60
+
61
+
62
+ def ui_stop_recording() -> tuple[str | None, str, dict, dict]:
63
+ try:
64
+ elapsed = recording_elapsed_seconds()
65
+ path = stop_server_recording()
66
+ warning = recording_level_warning(path)
67
+ except ServerRecordingError as exc:
68
+ return (
69
+ None,
70
+ str(exc),
71
+ gr.update(interactive=True),
72
+ gr.update(interactive=False),
73
+ )
74
+ except Exception as exc: # noqa: BLE001
75
+ return (
76
+ None,
77
+ f"Recording failed: {exc}",
78
+ gr.update(interactive=True),
79
+ gr.update(interactive=False),
80
+ )
81
+
82
+ status = f"Recording saved ({elapsed:.1f}s). Click **Send turn** to talk to TeacherVoice."
83
+ if warning:
84
+ status += f" Warning: {warning}"
85
+ return (
86
+ gr.update(value=str(path)),
87
+ status,
88
+ gr.update(interactive=True),
89
+ gr.update(interactive=False),
90
+ )
91
+
92
+
93
+ def clear_conversation() -> tuple:
94
+ return _empty_turn()
95
+
96
+
97
+ def send_turn(
98
+ audio_path: str | None,
99
+ history: list,
100
+ mode: TeacherVoiceMode,
101
+ language: str,
102
+ asr_preset: str,
103
+ topic: str,
104
+ use_rag: bool,
105
+ session_id: str,
106
+ doc_ids: list[str] | None,
107
+ ) -> tuple:
108
+ model_key = get_active_model_key()
109
+ load_error = ensure_model_loaded(model_key)
110
+ if load_error:
111
+ return (
112
+ history,
113
+ None,
114
+ load_error,
115
+ "",
116
+ {},
117
+ )
118
+
119
+ if not audio_path:
120
+ return (
121
+ history,
122
+ None,
123
+ "Record or upload audio, then click **Send turn**.",
124
+ "",
125
+ {},
126
+ )
127
+
128
+ try:
129
+ result = run_teacher_voice_turn(
130
+ audio_path,
131
+ history,
132
+ mode=mode,
133
+ language=language,
134
+ topic=topic or None,
135
+ asr_preset=asr_preset,
136
+ backend=get_backend(model_key),
137
+ use_rag=use_rag and mode in RAG_MODES,
138
+ session_id=session_id,
139
+ doc_ids=doc_ids,
140
+ max_turn_seconds=_TURN_MAX,
141
+ )
142
+ except Exception as exc: # noqa: BLE001
143
+ return (
144
+ history,
145
+ None,
146
+ f"TeacherVoice failed: {exc}",
147
+ "",
148
+ {},
149
+ )
150
+
151
+ status = f"Turn complete — transcribed {len(result.user_text)} chars, replied in voice."
152
+ if result.voiceout_warning:
153
+ status += f" VoiceOut: {result.voiceout_warning}"
154
+
155
+ playback = result.voiceout_first_path or result.voiceout_path
156
+ return (
157
+ result.history,
158
+ playback,
159
+ status,
160
+ f"Trace saved: `{result.trace_path}`",
161
+ result.trace,
162
+ )
163
+
164
+
165
+ def _format_speak_status(status: str) -> str:
166
+ if status.startswith("VoiceOut ready"):
167
+ return f"**{status}**"
168
+ return f"**VoiceOut:** {status}"
169
+
170
+
171
+ def speak_full_reply(history: list, language: str) -> tuple[str | None, str, str]:
172
+ playback, status = speak_last_assistant_reply(history, language, first_sentence_only=False)
173
+ return playback, status, _format_speak_status(status)
174
+
175
+
176
+ def speak_quick_reply(history: list, language: str) -> tuple[str | None, str, str]:
177
+ playback, status = speak_last_assistant_reply(history, language, first_sentence_only=True)
178
+ return playback, status, _format_speak_status(status)
179
+
180
+
181
+ def build_teacher_voice_tab() -> None:
182
+ lang_choices = _config.language_choices()
183
+ asr_choices = _config.asr_choices()
184
+ default_lang = lang_choices[0][1] if lang_choices else "en"
185
+ default_asr = _config.asr_preset
186
+ mic_status = recording_backend_status()
187
+
188
+ omni_note = omni_status_message()
189
+ gr.Markdown(
190
+ f"""
191
+ **TeacherVoice** — turn-based voice conversation with a local teacher (not full duplex).
192
+
193
+ 1. Choose a mode → record a short turn (max **{_TURN_MAX}s**) → **Send turn** → hear the reply.
194
+ 2. **Explain** — tutor any topic. **Lesson coach** — outline and discuss lessons. **Pitch practice** — live speaking tips.
195
+ 3. For deep pitch analysis (pace charts, filler counts), use the **EchoCoach** tab.
196
+
197
+ Latency is typically a few seconds per turn on GPU; CPU may take longer.
198
+ {omni_note or ""}
199
+ """
200
+ )
201
+
202
+ with gr.Row():
203
+ with gr.Column(scale=1):
204
+ mode_dd = gr.Dropdown(
205
+ label="Mode",
206
+ choices=_MODE_CHOICES,
207
+ value="explain",
208
+ )
209
+ topic_tb = gr.Textbox(
210
+ label="Topic (Explain / Lesson modes)",
211
+ placeholder="e.g. Photosynthesis for grade 6",
212
+ )
213
+ record_status_md = gr.Markdown(mic_status)
214
+ with gr.Accordion("Record from this computer", open=True):
215
+ record_seconds = gr.Slider(
216
+ label="Max turn length (seconds)",
217
+ minimum=3,
218
+ maximum=_TURN_MAX,
219
+ value=_TURN_MAX,
220
+ step=1,
221
+ )
222
+ with gr.Row():
223
+ record_start_btn = gr.Button("Start recording", variant="secondary")
224
+ record_stop_btn = gr.Button("Stop recording", variant="stop", interactive=False)
225
+ audio_in = gr.Audio(
226
+ label="Your turn (browser mic or upload)",
227
+ sources=["upload", "microphone"],
228
+ type="filepath",
229
+ format="wav",
230
+ )
231
+ language = gr.Dropdown(label="Language", choices=lang_choices, value=default_lang)
232
+ asr_preset = gr.Dropdown(label="ASR preset", choices=asr_choices, value=default_asr)
233
+ with gr.Accordion("ResearchMind RAG (Explain / Lesson)", open=False):
234
+ use_rag = gr.Checkbox(label="Ground answers in ingested sources", value=False)
235
+ session_dd = gr.Dropdown(
236
+ label="Session",
237
+ choices=list_session_choices(),
238
+ value="",
239
+ )
240
+ refresh_sessions_btn = gr.Button("Refresh sessions", size="sm")
241
+ doc_dd = gr.CheckboxGroup(label="Documents (empty = all in session)", choices=[], value=[])
242
+ rag_hint = gr.Markdown(value=rag_scope_hint("", []))
243
+ with gr.Row():
244
+ send_btn = gr.Button("Send turn", variant="primary")
245
+ clear_btn = gr.Button("Clear conversation", variant="secondary")
246
+ status = gr.Textbox(label="Status", interactive=False, lines=3)
247
+ coach_status = gr.Markdown(model_status(get_active_model_key()))
248
+
249
+ with gr.Column(scale=2):
250
+ chatbot = gr.Chatbot(label="Conversation", height=360)
251
+ with gr.Row():
252
+ speak_full_btn = gr.Button("Speak last reply", variant="secondary")
253
+ speak_quick_btn = gr.Button("Speak first sentence", variant="secondary")
254
+ speak_status = gr.Markdown(
255
+ value="_Use **Speak** buttons to hear the latest teacher reply._"
256
+ )
257
+ voiceout = gr.Audio(
258
+ label="Teacher reply (auto after Send turn, or use Speak buttons)",
259
+ type="filepath",
260
+ autoplay=True,
261
+ )
262
+ trace_note = gr.Markdown()
263
+ trace_json = gr.JSON(label="Trace")
264
+
265
+ record_start_btn.click(
266
+ ui_start_recording,
267
+ inputs=[record_seconds],
268
+ outputs=[status, record_start_btn, record_stop_btn],
269
+ )
270
+ record_stop_btn.click(
271
+ ui_stop_recording,
272
+ outputs=[audio_in, status, record_start_btn, record_stop_btn],
273
+ ).then(
274
+ lambda: recording_backend_status(),
275
+ outputs=[record_status_md],
276
+ )
277
+
278
+ refresh_sessions_btn.click(fn=refresh_sessions, inputs=[session_dd], outputs=[session_dd])
279
+ session_dd.change(
280
+ fn=refresh_doc_choices,
281
+ inputs=[session_dd, doc_dd],
282
+ outputs=[doc_dd],
283
+ )
284
+ for trigger in (use_rag, session_dd, doc_dd):
285
+ trigger.change(
286
+ fn=lambda rag_on, sid, docs: (
287
+ rag_scope_hint(sid, docs) if rag_on else "_RAG off — model knowledge only._"
288
+ ),
289
+ inputs=[use_rag, session_dd, doc_dd],
290
+ outputs=[rag_hint],
291
+ )
292
+
293
+ send_btn.click(
294
+ send_turn,
295
+ inputs=[
296
+ audio_in,
297
+ chatbot,
298
+ mode_dd,
299
+ language,
300
+ asr_preset,
301
+ topic_tb,
302
+ use_rag,
303
+ session_dd,
304
+ doc_dd,
305
+ ],
306
+ outputs=[chatbot, voiceout, status, trace_note, trace_json],
307
+ )
308
+
309
+ clear_btn.click(clear_conversation, outputs=[chatbot, voiceout, status, trace_note, trace_json])
310
+
311
+ speak_full_btn.click(
312
+ speak_full_reply,
313
+ inputs=[chatbot, language],
314
+ outputs=[voiceout, status, speak_status],
315
+ )
316
+ speak_quick_btn.click(
317
+ speak_quick_reply,
318
+ inputs=[chatbot, language],
319
+ outputs=[voiceout, status, speak_status],
320
+ )
321
+
322
+
323
+ def teacher_voice_allowed_paths() -> list[str]:
324
+ paths: list[str] = []
325
+ if _config.presets_path:
326
+ paths.append(str(_config.presets_path.parent))
327
+ from echocoach.config import outputs_dir
328
+
329
+ paths.append(str(outputs_dir()))
330
+ paths.append(str(outputs_dir() / "recordings"))
331
+ paths.append(str(outputs_dir() / "teacher_voice"))
332
+ return paths
apps/gradio-space/src/gradio_space/voice_helpers.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio helpers for on-demand VoiceOut (shared across tabs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from echocoach.config import get_echo_coach_config
6
+ from echocoach.voiceout import last_assistant_message, speak_assistant_text
7
+
8
+
9
+ def _teacher_tts_preset() -> str | None:
10
+ config = get_echo_coach_config()
11
+ return config.realtime_tts_preset or config.tts_preset
12
+
13
+
14
+ def speak_last_assistant_reply(
15
+ history: list,
16
+ language: str,
17
+ *,
18
+ first_sentence_only: bool = False,
19
+ tts_preset: str | None = None,
20
+ ) -> tuple[str | None, str]:
21
+ """TTS the latest assistant message. Returns (wav_path, status_message)."""
22
+ text = last_assistant_message(history)
23
+ if not text:
24
+ return None, "No assistant reply to speak yet — send a turn first."
25
+
26
+ preset = tts_preset or _teacher_tts_preset()
27
+ playback, _, warning = speak_assistant_text(
28
+ text,
29
+ language=language or "en",
30
+ tts_preset=preset,
31
+ first_sentence_only=first_sentence_only,
32
+ )
33
+ if not playback:
34
+ hint = warning or "VoiceOut failed."
35
+ if "piper" in hint.lower() or "not installed" in hint.lower():
36
+ hint += " Install with: `uv sync --package echocoach --extra piper`"
37
+ return None, hint
38
+
39
+ label = "first sentence" if first_sentence_only else "full reply"
40
+ status = f"VoiceOut ready ({label})."
41
+ if warning:
42
+ status += f" {warning}"
43
+ return playback, status
libs/echocoach/src/echocoach/config.py CHANGED
@@ -8,7 +8,7 @@ from pathlib import Path
8
  from typing import Any, Literal
9
 
10
  AsrBackendName = Literal["cohere", "whisper_cpp"]
11
- TtsBackendName = Literal["piper"]
12
 
13
 
14
  @dataclass(frozen=True)
@@ -33,12 +33,17 @@ class TtsPreset:
33
  backend: TtsBackendName
34
  voices: dict[str, str]
35
  fallback_voice: str
 
 
 
 
36
 
37
 
38
  @dataclass(frozen=True)
39
  class EchoCoachConfig:
40
  asr_preset: str
41
  tts_preset: str
 
42
  coach_model: str
43
  max_seconds: int
44
  languages: list[LanguageOption]
@@ -107,6 +112,7 @@ def _builtin_config() -> EchoCoachConfig:
107
  return EchoCoachConfig(
108
  asr_preset="whisper-cpp-tiny",
109
  tts_preset="piper-multilingual",
 
110
  coach_model="minicpm5-1b",
111
  max_seconds=30,
112
  languages=langs,
@@ -130,17 +136,32 @@ def _parse_asr_entry(key: str, raw: dict[str, Any]) -> AsrPreset:
130
 
131
  def _parse_tts_entry(key: str, raw: dict[str, Any]) -> TtsPreset:
132
  backend = str(raw.get("backend", "piper"))
133
- if backend != "piper":
134
- raise ValueError(f"TTS preset {key!r}: only piper backend is supported in MVP")
 
135
  voices = raw.get("voices") or {}
136
- if not isinstance(voices, dict) or not voices:
137
- raise ValueError(f"TTS preset {key!r}: voices mapping is required")
 
 
 
 
 
 
 
 
 
 
138
  return TtsPreset(
139
  key=key,
140
  label=str(raw.get("label", key)),
141
- backend="piper",
142
  voices={str(k): str(v) for k, v in voices.items()},
143
- fallback_voice=str(raw.get("fallback_voice", "en_US-lessac-medium")),
 
 
 
 
144
  )
145
 
146
 
@@ -183,6 +204,7 @@ def load_echo_coach_config() -> EchoCoachConfig:
183
  config = EchoCoachConfig(
184
  asr_preset=asr_default,
185
  tts_preset=tts_default,
 
186
  coach_model=str(defaults.get("coach_model", "minicpm5-1b")),
187
  max_seconds=int(defaults.get("max_seconds", 30)),
188
  languages=languages,
@@ -196,6 +218,8 @@ def load_echo_coach_config() -> EchoCoachConfig:
196
  updates["asr_preset"] = os.environ["ECHOCOACH_ASR_PRESET"]
197
  if os.environ.get("ECHOCOACH_TTS_PRESET"):
198
  updates["tts_preset"] = os.environ["ECHOCOACH_TTS_PRESET"]
 
 
199
  if os.environ.get("ECHOCOACH_COACH_MODEL"):
200
  updates["coach_model"] = os.environ["ECHOCOACH_COACH_MODEL"]
201
  if os.environ.get("ECHOCOACH_MAX_SECONDS"):
 
8
  from typing import Any, Literal
9
 
10
  AsrBackendName = Literal["cohere", "whisper_cpp"]
11
+ TtsBackendName = Literal["piper", "vibevoice"]
12
 
13
 
14
  @dataclass(frozen=True)
 
33
  backend: TtsBackendName
34
  voices: dict[str, str]
35
  fallback_voice: str
36
+ model_id: str | None = None
37
+ streaming: bool = False
38
+ realtime: bool = False
39
+ supported_languages: tuple[str, ...] = ()
40
 
41
 
42
  @dataclass(frozen=True)
43
  class EchoCoachConfig:
44
  asr_preset: str
45
  tts_preset: str
46
+ realtime_tts_preset: str | None
47
  coach_model: str
48
  max_seconds: int
49
  languages: list[LanguageOption]
 
112
  return EchoCoachConfig(
113
  asr_preset="whisper-cpp-tiny",
114
  tts_preset="piper-multilingual",
115
+ realtime_tts_preset=None,
116
  coach_model="minicpm5-1b",
117
  max_seconds=30,
118
  languages=langs,
 
136
 
137
  def _parse_tts_entry(key: str, raw: dict[str, Any]) -> TtsPreset:
138
  backend = str(raw.get("backend", "piper"))
139
+ if backend not in ("piper", "vibevoice"):
140
+ raise ValueError(f"TTS preset {key!r}: backend must be piper or vibevoice")
141
+
142
  voices = raw.get("voices") or {}
143
+ if not isinstance(voices, dict):
144
+ raise ValueError(f"TTS preset {key!r}: voices must be a mapping")
145
+ languages = raw.get("languages") or []
146
+ if backend == "vibevoice":
147
+ if not voices and languages:
148
+ voices = {str(lang): "default" for lang in languages}
149
+ fallback = str(raw.get("fallback_language") or raw.get("fallback_voice") or "en")
150
+ else:
151
+ if not voices:
152
+ raise ValueError(f"TTS preset {key!r}: voices mapping is required")
153
+ fallback = str(raw.get("fallback_voice", "en_US-lessac-medium"))
154
+
155
  return TtsPreset(
156
  key=key,
157
  label=str(raw.get("label", key)),
158
+ backend=backend, # type: ignore[arg-type]
159
  voices={str(k): str(v) for k, v in voices.items()},
160
+ fallback_voice=fallback,
161
+ model_id=raw.get("model_id"),
162
+ streaming=bool(raw.get("streaming", False)),
163
+ realtime=bool(raw.get("realtime", False)),
164
+ supported_languages=tuple(str(lang) for lang in languages),
165
  )
166
 
167
 
 
204
  config = EchoCoachConfig(
205
  asr_preset=asr_default,
206
  tts_preset=tts_default,
207
+ realtime_tts_preset=defaults.get("realtime_tts_preset"),
208
  coach_model=str(defaults.get("coach_model", "minicpm5-1b")),
209
  max_seconds=int(defaults.get("max_seconds", 30)),
210
  languages=languages,
 
218
  updates["asr_preset"] = os.environ["ECHOCOACH_ASR_PRESET"]
219
  if os.environ.get("ECHOCOACH_TTS_PRESET"):
220
  updates["tts_preset"] = os.environ["ECHOCOACH_TTS_PRESET"]
221
+ if os.environ.get("ECHOCOACH_REALTIME_TTS_PRESET"):
222
+ updates["realtime_tts_preset"] = os.environ["ECHOCOACH_REALTIME_TTS_PRESET"]
223
  if os.environ.get("ECHOCOACH_COACH_MODEL"):
224
  updates["coach_model"] = os.environ["ECHOCOACH_COACH_MODEL"]
225
  if os.environ.get("ECHOCOACH_MAX_SECONDS"):
libs/echocoach/src/echocoach/omni.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional MiniCPM-o omni speech-in/speech-out backend (GPU-only, falls back to pipeline)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def voice_profile() -> str:
11
+ return os.environ.get("ECHOCOACH_VOICE_PROFILE", "pipeline").strip().lower()
12
+
13
+
14
+ def is_omni_profile() -> bool:
15
+ return voice_profile() == "omni"
16
+
17
+
18
+ def try_omni_turn(
19
+ audio_path: str,
20
+ *,
21
+ language: str,
22
+ history: list,
23
+ system_prompt: str,
24
+ ) -> tuple[str | None, str | None, str | None]:
25
+ """Attempt speech-in/speech-out via MiniCPM-o. Returns (user_text, reply_text, wav_path) or Nones."""
26
+ if not is_omni_profile():
27
+ return None, None, None
28
+
29
+ model_id = os.environ.get("ECHOCOACH_OMNI_MODEL", "openbmb/MiniCPM-o-4_5")
30
+ try:
31
+ import torch
32
+ from transformers import AutoModel, AutoTokenizer
33
+ except ImportError:
34
+ return None, None, "Omni profile requires transformers and torch."
35
+
36
+ if not torch.cuda.is_available():
37
+ return None, None, "Omni profile requires CUDA; falling back to ASR+LLM+TTS pipeline."
38
+
39
+ try:
40
+ tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
41
+ model = AutoModel.from_pretrained(
42
+ model_id,
43
+ trust_remote_code=True,
44
+ torch_dtype=torch.bfloat16,
45
+ init_audio=True,
46
+ init_tts=True,
47
+ ).eval()
48
+ if torch.cuda.is_available():
49
+ model = model.cuda()
50
+ except Exception as exc: # noqa: BLE001
51
+ return None, None, f"Omni model load failed: {exc}"
52
+
53
+ # MiniCPM-o APIs vary by release; pipeline fallback is the supported path until wired.
54
+ _ = (tokenizer, model, audio_path, language, history, system_prompt)
55
+ return None, None, (
56
+ f"Omni preset ({model_id}) is configured but end-to-end omni turn is not wired yet; "
57
+ "using ASR + text LLM + Piper pipeline."
58
+ )
59
+
60
+
61
+ def omni_status_message() -> str | None:
62
+ if not is_omni_profile():
63
+ return None
64
+ model_id = os.environ.get("ECHOCOACH_OMNI_MODEL", "openbmb/MiniCPM-o-4_5")
65
+ return (
66
+ f"**Voice profile:** `omni` ({model_id}). "
67
+ "Falls back to ASR + LLM + Piper until omni turn API is fully integrated."
68
+ )
libs/echocoach/src/echocoach/prompts.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TeacherVoice mode system prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ TeacherVoiceMode = Literal["explain", "lesson", "pitch"]
8
+
9
+ MODE_LABELS: dict[TeacherVoiceMode, str] = {
10
+ "explain": "Explain",
11
+ "lesson": "Lesson coach",
12
+ "pitch": "Pitch practice",
13
+ }
14
+
15
+ EXPLAIN_SYSTEM = """You are TeacherVoice, a friendly tutor who explains ideas in plain language.
16
+ Keep answers concise (2-5 sentences) so they work well when spoken aloud.
17
+ Use simple examples when helpful. If the student asks in another language, reply in that language.
18
+ When source excerpts are provided, ground your answer in them and cite with [1], [2], etc."""
19
+
20
+ LESSON_SYSTEM = """You are TeacherVoice, a lesson-planning coach for teachers and students.
21
+ Help outline and explain lesson content verbally: learning goals, key points, and a simple flow.
22
+ Keep each reply short (2-5 sentences) for voice playback.
23
+ If a lesson topic is set, stay focused on it. When source excerpts are provided, use them and cite [1], [2], etc."""
24
+
25
+ PITCH_SYSTEM = """You are TeacherVoice, a supportive public-speaking coach in a live conversation.
26
+ Give brief, actionable feedback on what the student just said (opening, clarity, energy, structure).
27
+ Do not produce JSON or long reports — speak naturally in 2-4 sentences.
28
+ Suggest one concrete improvement for their next attempt. For charts and pace analysis, they can use the EchoCoach tab."""
29
+
30
+ _MODE_SYSTEM: dict[TeacherVoiceMode, str] = {
31
+ "explain": EXPLAIN_SYSTEM,
32
+ "lesson": LESSON_SYSTEM,
33
+ "pitch": PITCH_SYSTEM,
34
+ }
35
+
36
+
37
+ def system_prompt_for_mode(mode: TeacherVoiceMode) -> str:
38
+ return _MODE_SYSTEM[mode]
39
+
40
+
41
+ def topic_context_block(topic: str | None, mode: TeacherVoiceMode) -> str | None:
42
+ topic = (topic or "").strip()
43
+ if not topic or mode == "pitch":
44
+ return None
45
+ label = "Lesson topic" if mode == "lesson" else "Focus topic"
46
+ return f"{label}: {topic}"
libs/echocoach/src/echocoach/teacher_voice.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn-based TeacherVoice conversation pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from agent.trace import TraceRecorder
11
+ from inference.base import InferenceBackend
12
+ from inference.response_clean import strip_reasoning_output
13
+ from researchmind.citations import format_context_block, format_references
14
+ from researchmind.config import get_config as get_researchmind_config
15
+ from researchmind.ingest import IngestPipeline
16
+ from researchmind.retrieve import retrieve
17
+
18
+ from echocoach.asr.factory import get_asr_backend
19
+ from echocoach.audio_io import clamp_duration, load_audio_mono_16k, write_wav_temp
20
+ from echocoach.config import get_echo_coach_config, outputs_dir
21
+ from echocoach.prompts import TeacherVoiceMode, system_prompt_for_mode, topic_context_block
22
+ from echocoach.voiceout import extract_message_text, strip_references_for_tts, synthesize_voice_reply
23
+
24
+ RAG_MODES: frozenset[TeacherVoiceMode] = frozenset({"explain", "lesson"})
25
+
26
+
27
+ @dataclass
28
+ class RagContext:
29
+ context_block: str
30
+ references_markdown: str
31
+ chunk_count: int
32
+ warning: str | None = None
33
+
34
+
35
+ @dataclass
36
+ class TeacherVoiceTurnResult:
37
+ user_text: str
38
+ assistant_text: str
39
+ history: list[dict[str, str]]
40
+ voiceout_path: str | None
41
+ voiceout_first_path: str | None
42
+ voiceout_warning: str | None
43
+ rag_references: str | None
44
+ trace_path: str
45
+ trace: dict[str, Any] = field(default_factory=dict)
46
+
47
+
48
+ def append_chat_turn(
49
+ history: list,
50
+ user_text: str,
51
+ assistant_text: str,
52
+ ) -> list[dict[str, str]]:
53
+ """Append a turn in Gradio 5 messages format."""
54
+ updated: list[dict[str, str]] = []
55
+ for item in history or []:
56
+ if isinstance(item, dict) and "role" in item and "content" in item:
57
+ updated.append(
58
+ {"role": str(item["role"]), "content": extract_message_text(item["content"])}
59
+ )
60
+ elif isinstance(item, (list, tuple)) and len(item) == 2:
61
+ user_msg, assistant_msg = item
62
+ updated.append({"role": "user", "content": extract_message_text(user_msg)})
63
+ if assistant_msg:
64
+ updated.append(
65
+ {"role": "assistant", "content": extract_message_text(assistant_msg)}
66
+ )
67
+ updated.append({"role": "user", "content": user_text})
68
+ updated.append({"role": "assistant", "content": assistant_text})
69
+ return updated
70
+
71
+
72
+ def history_to_messages(history: list) -> list[dict[str, str]]:
73
+ messages: list[dict[str, str]] = []
74
+ for item in history:
75
+ if isinstance(item, dict):
76
+ messages.append(
77
+ {"role": item["role"], "content": extract_message_text(item["content"])}
78
+ )
79
+ else:
80
+ user_msg, assistant_msg = item
81
+ messages.append({"role": "user", "content": extract_message_text(user_msg)})
82
+ if assistant_msg:
83
+ messages.append(
84
+ {"role": "assistant", "content": extract_message_text(assistant_msg)}
85
+ )
86
+ return messages
87
+
88
+
89
+ def fetch_rag_context(
90
+ question: str,
91
+ *,
92
+ session_id: str,
93
+ doc_ids: list[str] | None,
94
+ ) -> RagContext | None:
95
+ store = IngestPipeline().store
96
+ cfg = get_researchmind_config()
97
+ scope_session = session_id if session_id and not doc_ids else None
98
+ scope_docs = doc_ids if doc_ids else None
99
+ chunks = retrieve(
100
+ question,
101
+ store,
102
+ config=cfg,
103
+ session_id=scope_session,
104
+ doc_ids=scope_docs,
105
+ )
106
+ if not chunks:
107
+ if doc_ids:
108
+ warning = "No passages in selected documents for this question."
109
+ elif session_id:
110
+ warning = "No indexed sources in this session yet."
111
+ else:
112
+ warning = "No indexed sources in the corpus yet."
113
+ return RagContext(context_block="", references_markdown="", chunk_count=0, warning=warning)
114
+
115
+ context_block, citations = format_context_block(chunks)
116
+ refs = format_references(citations)
117
+ return RagContext(
118
+ context_block=context_block,
119
+ references_markdown=refs,
120
+ chunk_count=len(chunks),
121
+ )
122
+
123
+
124
+ def build_teacher_messages(
125
+ *,
126
+ mode: TeacherVoiceMode,
127
+ history: list,
128
+ user_text: str,
129
+ topic: str | None = None,
130
+ rag: RagContext | None = None,
131
+ ) -> list[dict[str, str]]:
132
+ system = system_prompt_for_mode(mode)
133
+ topic_line = topic_context_block(topic, mode)
134
+ if topic_line:
135
+ system = f"{system}\n\n{topic_line}"
136
+
137
+ messages: list[dict[str, str]] = [{"role": "system", "content": system}]
138
+ messages.extend(history_to_messages(history))
139
+
140
+ user_parts: list[str] = []
141
+ if rag and rag.context_block:
142
+ user_parts.append(
143
+ "Use these source excerpts as grounding. Cite with [1], [2], etc. when relevant.\n\n"
144
+ f"{rag.context_block}"
145
+ )
146
+ user_parts.append(user_text.strip())
147
+ messages.append({"role": "user", "content": "\n\n".join(user_parts)})
148
+ return messages
149
+
150
+
151
+ def run_teacher_voice_turn(
152
+ audio_path: str,
153
+ history: list,
154
+ *,
155
+ mode: TeacherVoiceMode = "explain",
156
+ language: str = "en",
157
+ topic: str | None = None,
158
+ asr_preset: str | None = None,
159
+ tts_preset: str | None = None,
160
+ coach_model: str | None = None,
161
+ backend: InferenceBackend,
162
+ use_rag: bool = False,
163
+ session_id: str = "",
164
+ doc_ids: list[str] | None = None,
165
+ max_turn_seconds: int | None = None,
166
+ ) -> TeacherVoiceTurnResult:
167
+ if not audio_path:
168
+ raise ValueError("No audio recording provided.")
169
+
170
+ config = get_echo_coach_config()
171
+ asr_key = asr_preset or config.asr_preset
172
+ tts_key = tts_preset or config.realtime_tts_preset or config.tts_preset
173
+ model_key = coach_model or config.coach_model
174
+ turn_cap = max_turn_seconds or min(15, config.max_seconds)
175
+ run_id = uuid.uuid4().hex[:12]
176
+ out_base = outputs_dir()
177
+
178
+ trace = TraceRecorder(
179
+ skill="teacher-voice",
180
+ model=model_key,
181
+ user_input={
182
+ "mode": mode,
183
+ "language": language,
184
+ "topic": topic,
185
+ "asr_preset": asr_key,
186
+ "tts_preset": tts_key,
187
+ "use_rag": use_rag,
188
+ "session_id": session_id,
189
+ "doc_ids": doc_ids or [],
190
+ "audio_path": audio_path,
191
+ },
192
+ run_id=run_id,
193
+ )
194
+
195
+ audio, duration = load_audio_mono_16k(audio_path)
196
+ audio = clamp_duration(audio, turn_cap)
197
+ clipped_path = write_wav_temp(audio, out_base / "clips", stem=f"tv_{run_id}")
198
+ trace.log_note("audio_loaded", duration_seconds=duration, path=str(clipped_path))
199
+
200
+ asr = get_asr_backend(asr_key)
201
+ user_text = asr.transcribe(str(clipped_path), language=language).strip()
202
+ if not user_text:
203
+ raise ValueError("Could not transcribe speech — try speaking louder or uploading clearer audio.")
204
+ trace.log_note("asr_complete", preset=asr_key, chars=len(user_text))
205
+
206
+ from echocoach.omni import is_omni_profile, try_omni_turn
207
+
208
+ if is_omni_profile():
209
+ system = system_prompt_for_mode(mode)
210
+ topic_line = topic_context_block(topic, mode)
211
+ if topic_line:
212
+ system = f"{system}\n\n{topic_line}"
213
+ omni_user, omni_reply, omni_wav_or_note = try_omni_turn(
214
+ str(clipped_path),
215
+ language=language,
216
+ history=history,
217
+ system_prompt=system,
218
+ )
219
+ if omni_wav_or_note and omni_user and omni_reply and Path(omni_wav_or_note).is_file():
220
+ trace.log_note("omni_turn", path=omni_wav_or_note)
221
+ new_history = append_chat_turn(history, omni_user, omni_reply)
222
+ trace_path = trace.save()
223
+ return TeacherVoiceTurnResult(
224
+ user_text=omni_user,
225
+ assistant_text=omni_reply,
226
+ history=new_history,
227
+ voiceout_path=omni_wav_or_note,
228
+ voiceout_first_path=omni_wav_or_note,
229
+ voiceout_warning=None,
230
+ rag_references=None,
231
+ trace_path=str(trace_path),
232
+ trace=trace.to_dict(),
233
+ )
234
+ if omni_wav_or_note:
235
+ trace.log_note("omni_fallback", message=omni_wav_or_note)
236
+
237
+ rag: RagContext | None = None
238
+ rag_refs: str | None = None
239
+ if use_rag and mode in RAG_MODES:
240
+ sid = session_id
241
+ if not sid:
242
+ sid = IngestPipeline().store.create_session().id
243
+ rag = fetch_rag_context(user_text, session_id=sid, doc_ids=doc_ids)
244
+ if rag:
245
+ trace.log_note(
246
+ "rag_retrieve",
247
+ chunks=rag.chunk_count,
248
+ warning=rag.warning,
249
+ )
250
+ if rag.references_markdown:
251
+ rag_refs = rag.references_markdown
252
+
253
+ messages = build_teacher_messages(
254
+ mode=mode,
255
+ history=history,
256
+ user_text=user_text,
257
+ topic=topic,
258
+ rag=rag if rag and rag.context_block else None,
259
+ )
260
+ raw_reply = backend.chat(messages, max_tokens=512, temperature=0.5)
261
+ assistant_text = strip_reasoning_output(raw_reply).strip()
262
+ trace.log_llm(messages[-1]["content"], raw_reply)
263
+
264
+ if rag_refs:
265
+ assistant_text = f"{assistant_text}\n\n{rag_refs}"
266
+
267
+ voiceout_path, voiceout_first, voiceout_warning = synthesize_voice_reply(
268
+ strip_references_for_tts(assistant_text),
269
+ language=language,
270
+ tts_preset=tts_key,
271
+ chunk_first=True,
272
+ out_subdir="teacher_voice",
273
+ )
274
+ if voiceout_path:
275
+ trace.set_artifact(voiceout_path)
276
+
277
+ new_history = append_chat_turn(history, user_text, assistant_text)
278
+
279
+ trace_path = trace.save()
280
+ return TeacherVoiceTurnResult(
281
+ user_text=user_text,
282
+ assistant_text=assistant_text,
283
+ history=new_history,
284
+ voiceout_path=voiceout_path,
285
+ voiceout_first_path=voiceout_first,
286
+ voiceout_warning=voiceout_warning,
287
+ rag_references=rag_refs,
288
+ trace_path=str(trace_path),
289
+ trace=trace.to_dict(),
290
+ )
libs/echocoach/src/echocoach/tts/piper.py CHANGED
@@ -45,30 +45,46 @@ class PiperTtsBackend:
45
  self._voices[voice_name] = voice
46
  return voice
47
 
48
- def _resolve_voice_path(self, voice_name: str) -> Path:
 
49
  env_dir = __import__("os").environ.get("PIPER_VOICES_DIR")
50
- candidates: list[Path] = []
51
  if env_dir:
52
- candidates.append(Path(env_dir) / f"{voice_name}.onnx")
53
- home = Path.home() / ".local" / "share" / "piper" / "voices"
54
- candidates.append(home / f"{voice_name}.onnx")
55
- candidates.append(Path.cwd() / "models" / "piper" / f"{voice_name}.onnx")
 
 
 
 
 
56
 
57
- for path in candidates:
 
 
 
58
  if path.is_file():
59
  return path
60
 
 
 
 
 
61
  try:
62
  from piper.download_voices import download_voice
63
  except ImportError:
64
  download_voice = None
65
 
66
  if download_voice is not None:
67
- try:
68
- downloaded = download_voice(voice_name)
69
- return Path(downloaded)
70
- except Exception:
71
- pass
 
 
 
 
72
 
73
  import subprocess
74
  import sys
@@ -77,7 +93,8 @@ class PiperTtsBackend:
77
  [sys.executable, "-m", "piper.download_voices", voice_name],
78
  check=True,
79
  )
80
- for path in candidates:
 
81
  if path.is_file():
82
  return path
83
 
@@ -118,6 +135,10 @@ class PiperTtsBackend:
118
  def get_tts_backend(preset_key: str | None = None) -> TtsBackend:
119
  config = get_echo_coach_config()
120
  preset = config.get_tts(preset_key)
 
 
 
 
121
  if preset.key not in _tts_cache:
122
  _tts_cache[preset.key] = PiperTtsBackend(preset)
123
  return _tts_cache[preset.key]
 
45
  self._voices[voice_name] = voice
46
  return voice
47
 
48
+ def _voice_search_dirs(self) -> list[Path]:
49
+ dirs: list[Path] = []
50
  env_dir = __import__("os").environ.get("PIPER_VOICES_DIR")
 
51
  if env_dir:
52
+ dirs.append(Path(env_dir))
53
+ dirs.extend(
54
+ [
55
+ Path.home() / ".local" / "share" / "piper" / "voices",
56
+ Path.cwd() / "models" / "piper",
57
+ Path.cwd(),
58
+ ]
59
+ )
60
+ return dirs
61
 
62
+ def _resolve_voice_path(self, voice_name: str) -> Path:
63
+ onnx_name = f"{voice_name}.onnx"
64
+ for directory in self._voice_search_dirs():
65
+ path = directory / onnx_name
66
  if path.is_file():
67
  return path
68
 
69
+ download_targets = [
70
+ Path.cwd() / "models" / "piper",
71
+ Path.home() / ".local" / "share" / "piper" / "voices",
72
+ ]
73
  try:
74
  from piper.download_voices import download_voice
75
  except ImportError:
76
  download_voice = None
77
 
78
  if download_voice is not None:
79
+ for directory in download_targets:
80
+ directory.mkdir(parents=True, exist_ok=True)
81
+ try:
82
+ download_voice(voice_name, directory)
83
+ path = directory / onnx_name
84
+ if path.is_file():
85
+ return path
86
+ except Exception:
87
+ continue
88
 
89
  import subprocess
90
  import sys
 
93
  [sys.executable, "-m", "piper.download_voices", voice_name],
94
  check=True,
95
  )
96
+ for directory in self._voice_search_dirs():
97
+ path = directory / onnx_name
98
  if path.is_file():
99
  return path
100
 
 
135
  def get_tts_backend(preset_key: str | None = None) -> TtsBackend:
136
  config = get_echo_coach_config()
137
  preset = config.get_tts(preset_key)
138
+ if preset.backend == "vibevoice":
139
+ from echocoach.tts.vibevoice import get_vibevoice_backend
140
+
141
+ return get_vibevoice_backend(preset)
142
  if preset.key not in _tts_cache:
143
  _tts_cache[preset.key] = PiperTtsBackend(preset)
144
  return _tts_cache[preset.key]
libs/echocoach/src/echocoach/tts/vibevoice.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VibeVoice Realtime TTS backend (streaming, low latency).
2
+
3
+ Model: https://huggingface.co/microsoft/VibeVoice-Realtime-0.5B
4
+ Falls back to Piper when the model is unavailable or not yet wired end-to-end.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ import wave
11
+ from pathlib import Path
12
+ from typing import Protocol
13
+
14
+ from echocoach.config import TtsPreset, get_echo_coach_config, outputs_dir
15
+
16
+ _VIBEVOICE_DOC = "https://huggingface.co/microsoft/VibeVoice-Realtime-0.5B"
17
+
18
+
19
+ class TtsBackend(Protocol):
20
+ def synthesize(self, text: str, *, language: str, out_dir: Path | None = None) -> tuple[str | None, str | None]: ...
21
+
22
+
23
+ _vibevoice_cache: dict[str, "VibeVoiceTtsBackend"] = {}
24
+
25
+
26
+ class VibeVoiceTtsBackend:
27
+ def __init__(self, preset: TtsPreset) -> None:
28
+ self._preset = preset
29
+ self._pipeline = None
30
+ self._load_error: str | None = None
31
+
32
+ @property
33
+ def model_id(self) -> str:
34
+ return self._preset.model_id or "microsoft/VibeVoice-Realtime-0.5B"
35
+
36
+ def _resolve_language(self, language: str) -> tuple[str, str | None]:
37
+ supported = set(self._preset.supported_languages)
38
+ if supported and language not in supported:
39
+ fallback = self._preset.fallback_voice
40
+ return fallback, (
41
+ f"Language {language!r} is not listed for VibeVoice; using {fallback!r}."
42
+ )
43
+ return language, None
44
+
45
+ def _try_load(self) -> str | None:
46
+ if self._pipeline is not None:
47
+ return None
48
+ if self._load_error is not None:
49
+ return self._load_error
50
+
51
+ try:
52
+ from transformers import pipeline
53
+ except ImportError:
54
+ self._load_error = "VibeVoice requires transformers and torch."
55
+ return self._load_error
56
+
57
+ try:
58
+ self._pipeline = pipeline(
59
+ "text-to-speech",
60
+ model=self.model_id,
61
+ trust_remote_code=True,
62
+ )
63
+ return None
64
+ except Exception as exc: # noqa: BLE001
65
+ self._load_error = (
66
+ f"VibeVoice load failed ({self.model_id}): {exc}. "
67
+ f"See {_VIBEVOICE_DOC} for setup; using Piper fallback."
68
+ )
69
+ return self._load_error
70
+
71
+ def _write_wav(self, audio: object, sample_rate: int, out_path: Path) -> None:
72
+ import numpy as np
73
+
74
+ samples = np.asarray(audio, dtype=np.float32)
75
+ if samples.ndim > 1:
76
+ samples = samples.squeeze()
77
+ pcm = np.clip(samples, -1.0, 1.0)
78
+ pcm_i16 = (pcm * 32767).astype(np.int16)
79
+ with wave.open(str(out_path), "wb") as wav_file:
80
+ wav_file.setnchannels(1)
81
+ wav_file.setsampwidth(2)
82
+ wav_file.setframerate(sample_rate)
83
+ wav_file.writeframes(pcm_i16.tobytes())
84
+
85
+ def synthesize(
86
+ self,
87
+ text: str,
88
+ *,
89
+ language: str,
90
+ out_dir: Path | None = None,
91
+ ) -> tuple[str | None, str | None]:
92
+ if not text.strip():
93
+ return None, "No text to synthesize."
94
+
95
+ lang, lang_warning = self._resolve_language(language)
96
+ load_warning = self._try_load()
97
+ if load_warning or self._pipeline is None:
98
+ return self._piper_fallback(text, language=lang, out_dir=out_dir, prefix=load_warning)
99
+
100
+ base = out_dir or outputs_dir()
101
+ base.mkdir(parents=True, exist_ok=True)
102
+ out_path = base / f"vibevoice_{uuid.uuid4().hex[:10]}.wav"
103
+
104
+ try:
105
+ result = self._pipeline(text)
106
+ if isinstance(result, dict):
107
+ audio = result.get("audio") or result.get("array")
108
+ sample_rate = int(result.get("sampling_rate") or result.get("sample_rate") or 24000)
109
+ else:
110
+ audio, sample_rate = result # type: ignore[misc]
111
+ if audio is None:
112
+ raise ValueError("empty audio from VibeVoice pipeline")
113
+ self._write_wav(audio, sample_rate, out_path)
114
+ warning = lang_warning
115
+ if self._preset.realtime:
116
+ note = f"VibeVoice Realtime ({self.model_id})"
117
+ warning = f"{lang_warning} {note}" if lang_warning else note
118
+ return str(out_path), warning
119
+ except Exception as exc: # noqa: BLE001
120
+ return self._piper_fallback(
121
+ text,
122
+ language=lang,
123
+ out_dir=out_dir,
124
+ prefix=f"VibeVoice synthesis failed: {exc}. Piper fallback.",
125
+ )
126
+
127
+ def _piper_fallback(
128
+ self,
129
+ text: str,
130
+ *,
131
+ language: str,
132
+ out_dir: Path | None,
133
+ prefix: str | None,
134
+ ) -> tuple[str | None, str | None]:
135
+ config = get_echo_coach_config()
136
+ from echocoach.tts.piper import get_tts_backend
137
+
138
+ piper = get_tts_backend(config.tts_preset)
139
+ path, piper_warning = piper.synthesize(text, language=language, out_dir=out_dir)
140
+ parts = [p for p in (prefix, piper_warning) if p]
141
+ return path, "; ".join(parts) if parts else None
142
+
143
+
144
+ def get_vibevoice_backend(preset: TtsPreset) -> VibeVoiceTtsBackend:
145
+ if preset.key not in _vibevoice_cache:
146
+ _vibevoice_cache[preset.key] = VibeVoiceTtsBackend(preset)
147
+ return _vibevoice_cache[preset.key]
libs/echocoach/src/echocoach/voiceout.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reusable VoiceOut helpers for TeacherVoice, Chat, and other tabs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from echocoach.config import get_echo_coach_config, outputs_dir
8
+ from echocoach.tts.piper import get_tts_backend
9
+
10
+
11
+ def strip_references_for_tts(text: str) -> str:
12
+ """Remove citation/reference blocks before TTS."""
13
+ cleaned = text
14
+ for marker in ("\n\n## References", "\n\n**References**"):
15
+ if marker in cleaned:
16
+ cleaned = cleaned.split(marker, 1)[0]
17
+ return cleaned.strip()
18
+
19
+
20
+ def extract_message_text(content: object) -> str:
21
+ """Normalize Gradio chat content (plain string or message blocks) to text."""
22
+ if content is None:
23
+ return ""
24
+ if isinstance(content, str):
25
+ return content.strip()
26
+ if isinstance(content, (list, tuple)):
27
+ parts: list[str] = []
28
+ for block in content:
29
+ if isinstance(block, str):
30
+ text = block.strip()
31
+ elif isinstance(block, dict):
32
+ text = str(block.get("text") or block.get("content") or "").strip()
33
+ else:
34
+ text = str(block).strip()
35
+ if text:
36
+ parts.append(text)
37
+ return "\n".join(parts)
38
+ return str(content).strip()
39
+
40
+
41
+ def last_assistant_message(history: list) -> str | None:
42
+ """Return the most recent assistant message from Gradio chat history."""
43
+ for item in reversed(history or []):
44
+ if isinstance(item, dict) and item.get("role") == "assistant":
45
+ content = extract_message_text(item.get("content"))
46
+ return content or None
47
+ if isinstance(item, (list, tuple)) and len(item) == 2 and item[1]:
48
+ return extract_message_text(item[1]) or None
49
+ return None
50
+
51
+
52
+ def split_sentences(text: str) -> list[str]:
53
+ parts = re.split(r"(?<=[.!?])\s+", text.strip())
54
+ return [p.strip() for p in parts if p.strip()]
55
+
56
+
57
+ def synthesize_voice_reply(
58
+ text: str,
59
+ *,
60
+ language: str,
61
+ tts_preset: str | None = None,
62
+ chunk_first: bool = True,
63
+ out_subdir: str = "voiceout",
64
+ ) -> tuple[str | None, str | None, str | None]:
65
+ """Return (full_wav, first_sentence_wav, warning)."""
66
+ if not text.strip():
67
+ return None, None, "No text to synthesize."
68
+
69
+ config = get_echo_coach_config()
70
+ preset = tts_preset or config.tts_preset
71
+ tts = get_tts_backend(preset)
72
+ out_dir = outputs_dir() / out_subdir
73
+ full_path, warning = tts.synthesize(text, language=language, out_dir=out_dir)
74
+
75
+ first_path = None
76
+ if chunk_first:
77
+ sentences = split_sentences(text)
78
+ if len(sentences) > 1:
79
+ first_path, first_warning = tts.synthesize(
80
+ sentences[0],
81
+ language=language,
82
+ out_dir=out_dir,
83
+ )
84
+ if first_warning and not warning:
85
+ warning = first_warning
86
+ elif full_path:
87
+ first_path = full_path
88
+
89
+ return full_path, first_path, warning
90
+
91
+
92
+ def speak_assistant_text(
93
+ text: str,
94
+ *,
95
+ language: str = "en",
96
+ tts_preset: str | None = None,
97
+ first_sentence_only: bool = False,
98
+ ) -> tuple[str | None, str | None, str | None]:
99
+ """Synthesize assistant reply audio. Returns (playback_path, alt_path, warning)."""
100
+ clean = strip_references_for_tts(text)
101
+ full_path, first_path, warning = synthesize_voice_reply(
102
+ clean,
103
+ language=language,
104
+ tts_preset=tts_preset,
105
+ chunk_first=True,
106
+ )
107
+ if first_sentence_only:
108
+ return first_path or full_path, full_path, warning
109
+ return full_path or first_path, first_path, warning
libs/echocoach/tests/test_teacher_voice.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for TeacherVoice prompt assembly and message building."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pytest
7
+ import soundfile as sf
8
+
9
+ from echocoach.prompts import PITCH_SYSTEM, system_prompt_for_mode
10
+ from echocoach.teacher_voice import (
11
+ RagContext,
12
+ append_chat_turn,
13
+ build_teacher_messages,
14
+ fetch_rag_context,
15
+ history_to_messages,
16
+ )
17
+ from echocoach.voiceout import (
18
+ extract_message_text,
19
+ last_assistant_message,
20
+ split_sentences,
21
+ strip_references_for_tts,
22
+ )
23
+
24
+
25
+ class _MockBackend:
26
+ def load(self) -> None:
27
+ pass
28
+
29
+ def chat(self, messages, *, max_tokens=512, temperature=0.7):
30
+ assert messages[0]["role"] == "system"
31
+ assert messages[-1]["role"] == "user"
32
+ return "Plants use sunlight to make food."
33
+
34
+ def generate(self, prompt, *, max_tokens=512, temperature=0.7):
35
+ return self.chat([{"role": "user", "content": prompt}], max_tokens=max_tokens)
36
+
37
+
38
+ def test_append_chat_turn_messages_format():
39
+ from echocoach.teacher_voice import append_chat_turn
40
+
41
+ history = append_chat_turn([], "Hi", "Hello")
42
+ assert history == [
43
+ {"role": "user", "content": "Hi"},
44
+ {"role": "assistant", "content": "Hello"},
45
+ ]
46
+
47
+ extended = append_chat_turn(history, "Next?", "Sure.")
48
+ assert extended == [
49
+ {"role": "user", "content": "Hi"},
50
+ {"role": "assistant", "content": "Hello"},
51
+ {"role": "user", "content": "Next?"},
52
+ {"role": "assistant", "content": "Sure."},
53
+ ]
54
+
55
+
56
+ def test_append_chat_turn_migrates_legacy_tuples():
57
+ from echocoach.teacher_voice import append_chat_turn
58
+
59
+ legacy = [("Old question", "Old answer")]
60
+ history = append_chat_turn(legacy, "New?", "New reply.")
61
+ assert history[-2:] == [
62
+ {"role": "user", "content": "New?"},
63
+ {"role": "assistant", "content": "New reply."},
64
+ ]
65
+ assert history[0] == {"role": "user", "content": "Old question"}
66
+
67
+
68
+ def test_history_to_messages_tuple_pairs():
69
+ history = [("Hi", "Hello"), ("What is AI?", "Machine learning.")]
70
+ messages = history_to_messages(history)
71
+ assert messages == [
72
+ {"role": "user", "content": "Hi"},
73
+ {"role": "assistant", "content": "Hello"},
74
+ {"role": "user", "content": "What is AI?"},
75
+ {"role": "assistant", "content": "Machine learning."},
76
+ ]
77
+
78
+
79
+ def test_build_teacher_messages_includes_topic_and_rag():
80
+ rag = RagContext(
81
+ context_block="[1] Plants need light.",
82
+ references_markdown="**References**\n[1] Biology",
83
+ chunk_count=1,
84
+ )
85
+ messages = build_teacher_messages(
86
+ mode="lesson",
87
+ history=[],
88
+ user_text="How do plants eat?",
89
+ topic="Photosynthesis",
90
+ rag=rag,
91
+ )
92
+ assert "TeacherVoice" in messages[0]["content"]
93
+ assert "lesson-planning" in messages[0]["content"]
94
+ assert "Photosynthesis" in messages[0]["content"]
95
+ assert "[1] Plants need light." in messages[-1]["content"]
96
+ assert messages[-1]["content"].endswith("How do plants eat?")
97
+
98
+
99
+ def test_pitch_mode_system_prompt():
100
+ assert "EchoCoach" in system_prompt_for_mode("pitch")
101
+ assert PITCH_SYSTEM == system_prompt_for_mode("pitch")
102
+
103
+
104
+ def test_split_sentences():
105
+ text = "Hello there. How are you? Great!"
106
+ assert split_sentences(text) == ["Hello there.", "How are you?", "Great!"]
107
+
108
+
109
+ def test_extract_message_text():
110
+ assert extract_message_text("Hello") == "Hello"
111
+ assert extract_message_text([{"text": "Hello there."}]) == "Hello there."
112
+ assert extract_message_text([{"text": "A"}, {"text": "B"}]) == "A\nB"
113
+
114
+
115
+ def test_last_assistant_message():
116
+ history = [
117
+ {"role": "user", "content": "Hi"},
118
+ {"role": "assistant", "content": "Hello there."},
119
+ ]
120
+ assert last_assistant_message(history) == "Hello there."
121
+ assert last_assistant_message([]) is None
122
+ gradio_history = [
123
+ {"role": "user", "content": [{"text": "Hi"}]},
124
+ {"role": "assistant", "content": [{"text": "Hello there."}]},
125
+ ]
126
+ assert last_assistant_message(gradio_history) == "Hello there."
127
+
128
+
129
+ def test_vibevoice_preset_in_voice_models():
130
+ from echocoach.config import get_echo_coach_config
131
+
132
+ config = get_echo_coach_config(reload=True)
133
+ preset = config.get_tts("vibevoice-realtime-0.5b")
134
+ assert preset.backend == "vibevoice"
135
+ assert preset.model_id == "microsoft/VibeVoice-Realtime-0.5B"
136
+ assert preset.realtime is True
137
+ assert preset.streaming is True
138
+ assert "en" in preset.supported_languages
139
+ assert config.realtime_tts_preset == "vibevoice-realtime-0.5b"
140
+
141
+
142
+ def test_strip_references_for_tts():
143
+ text = "Answer here.\n\n**References**\n[1] Source"
144
+ assert strip_references_for_tts(text) == "Answer here."
145
+
146
+
147
+ def test_fetch_rag_context_empty_store_warns(research_env):
148
+ ctx = fetch_rag_context("What is photosynthesis?", session_id="", doc_ids=None)
149
+ assert ctx is not None
150
+ assert ctx.chunk_count == 0
151
+ assert ctx.warning
152
+
153
+
154
+ @pytest.fixture
155
+ def research_env(tmp_path, monkeypatch):
156
+ from researchmind.config import ResearchMindConfig
157
+
158
+ cfg = ResearchMindConfig(
159
+ data_dir=tmp_path / "rm",
160
+ embed_model="test",
161
+ auto_search=False,
162
+ top_k=2,
163
+ max_context_chunks=8,
164
+ chunk_size=50,
165
+ chunk_overlap=10,
166
+ )
167
+ monkeypatch.setenv("RESEARCHMIND_DATA_DIR", str(cfg.data_dir))
168
+ monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path / "outputs"))
169
+
170
+
171
+ def test_run_teacher_voice_turn_mock_asr(monkeypatch, tmp_path):
172
+ from echocoach.teacher_voice import run_teacher_voice_turn
173
+
174
+ wav = tmp_path / "turn.wav"
175
+ sf.write(wav, np.zeros(16_000, dtype=np.float32), 16_000)
176
+
177
+ class _Asr:
178
+ def transcribe(self, path, *, language="en"):
179
+ return "Tell me about plants."
180
+
181
+ class _Tts:
182
+ def synthesize(self, text, *, language, out_dir=None):
183
+ out = (out_dir or tmp_path) / "out.wav"
184
+ out.parent.mkdir(parents=True, exist_ok=True)
185
+ sf.write(out, np.zeros(8000, dtype=np.float32), 16_000)
186
+ return str(out), None
187
+
188
+ monkeypatch.setattr("echocoach.teacher_voice.get_asr_backend", lambda _: _Asr())
189
+ monkeypatch.setattr("echocoach.voiceout.get_tts_backend", lambda _: _Tts())
190
+
191
+ result = run_teacher_voice_turn(
192
+ str(wav),
193
+ [],
194
+ mode="explain",
195
+ backend=_MockBackend(),
196
+ use_rag=False,
197
+ )
198
+ assert result.user_text == "Tell me about plants."
199
+ assert "sunlight" in result.assistant_text
200
+ assert len(result.history) == 2
201
+ assert result.history[0]["role"] == "user"
202
+ assert result.history[1]["role"] == "assistant"
203
+ assert result.trace.get("skill") == "teacher-voice"
uv.lock CHANGED
@@ -1164,7 +1164,7 @@ version = "0.1.0"
1164
  source = { editable = "apps/gradio-space" }
1165
  dependencies = [
1166
  { name = "agent" },
1167
- { name = "echocoach", extra = ["whisper"] },
1168
  { name = "gradio" },
1169
  { name = "inference" },
1170
  ]
@@ -1172,7 +1172,7 @@ dependencies = [
1172
  [package.metadata]
1173
  requires-dist = [
1174
  { name = "agent", editable = "libs/agent" },
1175
- { name = "echocoach", extras = ["whisper"], editable = "libs/echocoach" },
1176
  { name = "gradio", specifier = ">=5.0.0" },
1177
  { name = "inference", editable = "libs/inference" },
1178
  ]
 
1164
  source = { editable = "apps/gradio-space" }
1165
  dependencies = [
1166
  { name = "agent" },
1167
+ { name = "echocoach", extra = ["piper", "whisper"] },
1168
  { name = "gradio" },
1169
  { name = "inference" },
1170
  ]
 
1172
  [package.metadata]
1173
  requires-dist = [
1174
  { name = "agent", editable = "libs/agent" },
1175
+ { name = "echocoach", extras = ["piper", "whisper"], editable = "libs/echocoach" },
1176
  { name = "gradio", specifier = ">=5.0.0" },
1177
  { name = "inference", editable = "libs/inference" },
1178
  ]
voice_models.yaml CHANGED
@@ -4,6 +4,8 @@
4
  defaults:
5
  asr_preset: whisper-cpp-tiny
6
  tts_preset: piper-multilingual
 
 
7
  coach_model: minicpm5-1b
8
  max_seconds: 30
9
 
@@ -53,6 +55,13 @@ asr:
53
  backend: whisper_cpp
54
  model_size: base
55
 
 
 
 
 
 
 
 
56
  tts:
57
  piper-multilingual:
58
  label: Piper TTS (local VoiceOut)
@@ -73,3 +82,16 @@ tts:
73
  vi: vi_VN-25hours-single
74
  ko: ko_KR-kss-medium
75
  fallback_voice: en_US-lessac-medium
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  defaults:
5
  asr_preset: whisper-cpp-tiny
6
  tts_preset: piper-multilingual
7
+ # Realtime streaming TTS for TeacherVoice VoiceOut (set ECHOCOACH_TTS_PRESET to match)
8
+ realtime_tts_preset: vibevoice-realtime-0.5b
9
  coach_model: minicpm5-1b
10
  max_seconds: 30
11
 
 
55
  backend: whisper_cpp
56
  model_size: base
57
 
58
+ # Optional omni speech-in/speech-out (GPU; set ECHOCOACH_VOICE_PROFILE=omni)
59
+ omni:
60
+ minicpm-o-4.5:
61
+ label: MiniCPM-o 4.5 (GPU omni — falls back to pipeline until wired)
62
+ model_id: openbmb/MiniCPM-o-4_5
63
+ languages: [en, zh]
64
+
65
  tts:
66
  piper-multilingual:
67
  label: Piper TTS (local VoiceOut)
 
82
  vi: vi_VN-25hours-single
83
  ko: ko_KR-kss-medium
84
  fallback_voice: en_US-lessac-medium
85
+
86
+ # Microsoft VibeVoice Realtime — streaming TTS, ~300ms to first audio (GPU recommended).
87
+ # https://huggingface.co/microsoft/VibeVoice-Realtime-0.5B
88
+ # English-first; also supports de, fr, it, es, pt, nl, pl, ja, ko (experimental).
89
+ # Requires transformers + torch; falls back to Piper until fully wired in echocoach.
90
+ vibevoice-realtime-0.5b:
91
+ label: VibeVoice Realtime 0.5B (streaming, ~300ms)
92
+ backend: vibevoice
93
+ model_id: microsoft/VibeVoice-Realtime-0.5B
94
+ streaming: true
95
+ realtime: true
96
+ languages: [en, de, fr, it, es, pt, nl, pl, ja, ko]
97
+ fallback_language: en