Spaces:
Sleeping
Sleeping
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 +6 -6
- .env.example +3 -0
- USAGE.md +54 -2
- apps/gradio-space/pyproject.toml +1 -1
- apps/gradio-space/src/gradio_space/app.py +7 -2
- apps/gradio-space/src/gradio_space/tabs/__init__.py +2 -0
- apps/gradio-space/src/gradio_space/tabs/echo_coach.py +2 -0
- apps/gradio-space/src/gradio_space/tabs/teacher_voice.py +332 -0
- apps/gradio-space/src/gradio_space/voice_helpers.py +43 -0
- libs/echocoach/src/echocoach/config.py +31 -7
- libs/echocoach/src/echocoach/omni.py +68 -0
- libs/echocoach/src/echocoach/prompts.py +46 -0
- libs/echocoach/src/echocoach/teacher_voice.py +290 -0
- libs/echocoach/src/echocoach/tts/piper.py +34 -13
- libs/echocoach/src/echocoach/tts/vibevoice.py +147 -0
- libs/echocoach/src/echocoach/voiceout.py +109 -0
- libs/echocoach/tests/test_teacher_voice.py +203 -0
- uv.lock +2 -2
- voice_models.yaml +22 -0
.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:
|
| 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:
|
| 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:
|
| 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:
|
| 17 |
- id: phase2-chunked-tts
|
| 18 |
content: "Optional: sentence-chunked Piper + shorter turn cap for faster time-to-first-audio"
|
| 19 |
-
status:
|
| 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:
|
| 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
|
| 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 **
|
| 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
|
| 134 |
-
raise ValueError(f"TTS preset {key!r}:
|
|
|
|
| 135 |
voices = raw.get("voices") or {}
|
| 136 |
-
if not isinstance(voices, dict)
|
| 137 |
-
raise ValueError(f"TTS preset {key!r}: voices
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
return TtsPreset(
|
| 139 |
key=key,
|
| 140 |
label=str(raw.get("label", key)),
|
| 141 |
-
backend=
|
| 142 |
voices={str(k): str(v) for k, v in voices.items()},
|
| 143 |
-
fallback_voice=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 49 |
env_dir = __import__("os").environ.get("PIPER_VOICES_DIR")
|
| 50 |
-
candidates: list[Path] = []
|
| 51 |
if env_dir:
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|