Spaces:
Running
v1.3 — fast natural voice + audio-reactive motion
Browse files- Switch primary TTS to edge-tts (Microsoft neural voices: Ava, Andrew,
Sonia, Ryan, …). Sub-second per paragraph, much more natural than
pyttsx3/gTTS.
- Stream speech paragraph-by-paragraph: synthesise + push chunk N
while chunk N-1 is already playing. First words now reach the
speaker ~1-2s after the LLM finishes instead of 25-30s.
- Voice picker in the in-app UI (US/UK/IE/AU, male/female), backed by a
curated catalogue exposed via /api/state and /api/voices.
- New /api/speech/voice endpoint to change voice live.
- Audio-reactive motion: speech.compute_envelope() emits a 50 ms RMS
loudness signal, _AppState rolls it forward in real time, and
motion._speaking_frame adds head dips, antenna lifts, body sway in
proportion to the actual audio amplitude. Robot moves with the voice
rather than on a fixed loop.
- Bigger amplitudes across all motion states (idle, thinking,
responding, speaking, acknowledge_toggle) for a more embodied feel.
- pyproject: add edge-tts>=6.1.10.
- README + landing page updated with new voice/motion behaviour and
env vars (MARGINALIA_VOICE, MARGINALIA_EDGE_RATE, MARGINALIA_EDGE_PITCH).
- README.md +52 -25
- index.html +34 -20
- marginalia/main.py +186 -33
- marginalia/motion.py +98 -59
- marginalia/speech.py +154 -56
- marginalia/static/index.html +4 -0
- marginalia/static/main.js +51 -0
- marginalia/static/style.css +64 -0
- pyproject.toml +1 -0
|
@@ -37,12 +37,17 @@ Below the answer, an "In the margin" panel shows the Wikipedia hits the
|
|
| 37 |
system used to ground its reading — title, intro extract, and a thumbnail
|
| 38 |
portrait or cover, when one is available.
|
| 39 |
|
| 40 |
-
**Reachy reads each answer aloud.**
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
text only, no voice. Twist again to bring the voice back. The same
|
| 45 |
-
toggle is mirrored as a small pill in the in-app UI
|
|
|
|
| 46 |
|
| 47 |
This is a Reachy Mini **Python app**. It runs on the robot via the
|
| 48 |
[Reachy Mini Control App](https://huggingface.co/docs/reachy_mini), serves
|
|
@@ -81,8 +86,11 @@ to the robot (or simulator) currently selected in the Control App.
|
|
| 81 |
| `MARGINALIA_LLM_MODEL` | `meta-llama/Llama-3.3-70B-Instruct` | Any chat-capable model on the HF inference API. |
|
| 82 |
| `MARGINALIA_LLM_MAX_TOKENS` | `1100` | Cap per response. |
|
| 83 |
| `MARGINALIA_LLM_TEMPERATURE` | `0.45` | Lower = more careful, higher = more associative. |
|
| 84 |
-
| `MARGINALIA_TTS_MODE` | `auto` | `auto` / `hf` / `offline` / `gtts`. `auto` tries HF → pyttsx3 → gTTS. |
|
| 85 |
-
| `
|
|
|
|
|
|
|
|
|
|
| 86 |
| `MARGINALIA_TTS_RATE` | `175` | pyttsx3 speech rate (words per minute). |
|
| 87 |
| `MARGINALIA_TTS_VOICE` | unset | Optional pyttsx3 voice id (system-dependent). |
|
| 88 |
| `MARGINALIA_TTS_GTTS_LANG` | `en` | gTTS language code. |
|
|
@@ -109,17 +117,24 @@ to the robot (or simulator) currently selected in the Control App.
|
|
| 109 |
│ answer / how to read / │
|
| 110 |
│ further reading │
|
| 111 |
├───────────────────────────┤
|
| 112 |
-
│ speech.
|
| 113 |
-
│
|
| 114 |
-
│
|
|
|
|
|
|
|
| 115 |
│ 16 kHz mono float32 │
|
|
|
|
|
|
|
| 116 |
└──────┬────────────────────┘
|
| 117 |
▼
|
| 118 |
┌──────────────────────────┐
|
| 119 |
-
│ motion state machine
|
| 120 |
-
|
| 121 |
-
│
|
| 122 |
-
│
|
|
|
|
|
|
|
|
|
|
| 123 |
└──────────────────────────┘
|
| 124 |
▲
|
| 125 |
│ antenna twist polled
|
|
@@ -139,19 +154,28 @@ to the robot (or simulator) currently selected in the Control App.
|
|
| 139 |
sections in markdown: answer, *How to read this*, *Further reading*.
|
| 140 |
* **`marginalia/speech.py`** — `clean_for_speech()` strips citations,
|
| 141 |
URLs, and the trailing sections, leaving just speakable prose;
|
| 142 |
-
`
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
* **`marginalia/main.py`** — `Marginalia(ReachyMiniApp)` registers
|
| 146 |
-
`/api/health`, `/api/state`, `/api/speech/toggle`, `/api/
|
| 147 |
-
(ambient SSE), and `/api/ask`
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
| 150 |
* **`marginalia/motion.py`** — a 50 Hz state machine returning
|
| 151 |
`MotionFrame(head, antennas, body_yaw)`:
|
| 152 |
*idle → thinking → nodding → responding → speaking → idle*, plus a
|
| 153 |
short *acknowledge_toggle* burst when the user mutes/unmutes by
|
| 154 |
-
twisting an antenna.
|
|
|
|
|
|
|
| 155 |
|
| 156 |
## Tweaking
|
| 157 |
|
|
@@ -165,9 +189,12 @@ to the robot (or simulator) currently selected in the Control App.
|
|
| 165 |
`marginalia/motion.py` with named constants for amplitude, period,
|
| 166 |
and tilt for each state. Try a bigger `THINKING_BODY_AMP_DEG` or a
|
| 167 |
slower `SPEAKING_NOD_PERIOD_S`.
|
| 168 |
-
* **Different voice.** Pick any
|
| 169 |
-
|
| 170 |
-
`
|
|
|
|
|
|
|
|
|
|
| 171 |
* **Different mute gesture sensitivity.** Adjust `TWIST_THRESHOLD_RAD`
|
| 172 |
(default `0.4` ≈ 23°) and `TWIST_COOLDOWN_S` in `marginalia/main.py`
|
| 173 |
if the robot mishears your twists.
|
|
|
|
| 37 |
system used to ground its reading — title, intro extract, and a thumbnail
|
| 38 |
portrait or cover, when one is available.
|
| 39 |
|
| 40 |
+
**Reachy reads each answer aloud, paragraph by paragraph.** As soon as
|
| 41 |
+
the first paragraph is synthesised it starts playing through the robot's
|
| 42 |
+
speaker — Reachy doesn't wait for the whole answer before opening its
|
| 43 |
+
mouth. The remaining paragraphs synthesise in the background and queue
|
| 44 |
+
up seamlessly. While speaking, the head, antennas, and body move *with
|
| 45 |
+
the audio*: the head dips on emphasised words, the antennas perk up at
|
| 46 |
+
loud syllables, the torso sways through long sentences. Prefer to read
|
| 47 |
+
in silence? Twist *either* antenna and Reachy switches to **read mode** —
|
| 48 |
text only, no voice. Twist again to bring the voice back. The same
|
| 49 |
+
toggle is mirrored as a small pill in the in-app UI, alongside a voice
|
| 50 |
+
picker (US/UK/IE/AU, male/female).
|
| 51 |
|
| 52 |
This is a Reachy Mini **Python app**. It runs on the robot via the
|
| 53 |
[Reachy Mini Control App](https://huggingface.co/docs/reachy_mini), serves
|
|
|
|
| 86 |
| `MARGINALIA_LLM_MODEL` | `meta-llama/Llama-3.3-70B-Instruct` | Any chat-capable model on the HF inference API. |
|
| 87 |
| `MARGINALIA_LLM_MAX_TOKENS` | `1100` | Cap per response. |
|
| 88 |
| `MARGINALIA_LLM_TEMPERATURE` | `0.45` | Lower = more careful, higher = more associative. |
|
| 89 |
+
| `MARGINALIA_TTS_MODE` | `auto` | `auto` / `edge` / `hf` / `offline` / `gtts`. `auto` tries edge-tts → HF → pyttsx3 → gTTS. |
|
| 90 |
+
| `MARGINALIA_VOICE` | `en-US-AvaNeural` | Default edge-tts voice. The in-app UI exposes a curated picker; this is the fallback when no voice is selected yet. |
|
| 91 |
+
| `MARGINALIA_EDGE_RATE` | `+0%` | Edge-tts speaking rate, e.g. `+10%`, `-5%`. |
|
| 92 |
+
| `MARGINALIA_EDGE_PITCH` | `+0Hz` | Edge-tts pitch shift, e.g. `+30Hz`, `-20Hz`. |
|
| 93 |
+
| `MARGINALIA_HF_TTS_MODEL` | `facebook/mms-tts-eng` | HF Inference text-to-speech model used as a fallback if edge-tts is unreachable. |
|
| 94 |
| `MARGINALIA_TTS_RATE` | `175` | pyttsx3 speech rate (words per minute). |
|
| 95 |
| `MARGINALIA_TTS_VOICE` | unset | Optional pyttsx3 voice id (system-dependent). |
|
| 96 |
| `MARGINALIA_TTS_GTTS_LANG` | `en` | gTTS language code. |
|
|
|
|
| 117 |
│ answer / how to read / │
|
| 118 |
│ further reading │
|
| 119 |
├───────────────────────────┤
|
| 120 |
+
│ speech.split_for_speech() │
|
| 121 |
+
│ → paragraph chunks │
|
| 122 |
+
│ speech.synthesize() per │
|
| 123 |
+
│ chunk — edge-tts → HF → │
|
| 124 |
+
│ pyttsx3 → gTTS, │
|
| 125 |
│ 16 kHz mono float32 │
|
| 126 |
+
│ compute_envelope() per │
|
| 127 |
+
│ chunk → audio loudness │
|
| 128 |
└──────┬────────────────────┘
|
| 129 |
▼
|
| 130 |
┌──────────────────────────┐
|
| 131 |
+
│ motion state machine, │
|
| 132 |
+
��� audio-reactive overlay │
|
| 133 |
+
│ → head, antennas, body │
|
| 134 |
+
│ move *with* the audio │
|
| 135 |
+
│ + media.push_audio_sample│
|
| 136 |
+
│ (chunk N+1 synthesises │
|
| 137 |
+
│ while N is playing) │
|
| 138 |
└──────────────────────────┘
|
| 139 |
▲
|
| 140 |
│ antenna twist polled
|
|
|
|
| 154 |
sections in markdown: answer, *How to read this*, *Further reading*.
|
| 155 |
* **`marginalia/speech.py`** — `clean_for_speech()` strips citations,
|
| 156 |
URLs, and the trailing sections, leaving just speakable prose;
|
| 157 |
+
`split_for_speech()` cuts the prose into paragraph-sized chunks for
|
| 158 |
+
low-latency streaming TTS; `synthesize()` tries edge-tts (Microsoft
|
| 159 |
+
neural voices) first, then HF Inference TTS, then `pyttsx3`, then
|
| 160 |
+
gTTS — always returning 16 kHz mono float32 PCM ready for the robot
|
| 161 |
+
speaker. `compute_envelope()` produces a per-frame loudness signal
|
| 162 |
+
that drives audio-reactive head and antenna motion. `VOICES` is the
|
| 163 |
+
curated voice catalogue exposed to the UI dropdown.
|
| 164 |
* **`marginalia/main.py`** — `Marginalia(ReachyMiniApp)` registers
|
| 165 |
+
`/api/health`, `/api/state`, `/api/speech/toggle`, `/api/speech/voice`,
|
| 166 |
+
`/api/voices`, `/api/events` (ambient SSE), and `/api/ask`
|
| 167 |
+
(per-question SSE) on the SDK's bundled FastAPI app, serves the static
|
| 168 |
+
UI from `static/`, and polls antenna positions in the run loop to
|
| 169 |
+
toggle speech on a user twist. The run loop reads the live audio
|
| 170 |
+
loudness from `_AppState` and feeds it to `motion.pose_for_state` so
|
| 171 |
+
the robot moves with the voice.
|
| 172 |
* **`marginalia/motion.py`** — a 50 Hz state machine returning
|
| 173 |
`MotionFrame(head, antennas, body_yaw)`:
|
| 174 |
*idle → thinking → nodding → responding → speaking → idle*, plus a
|
| 175 |
short *acknowledge_toggle* burst when the user mutes/unmutes by
|
| 176 |
+
twisting an antenna. The `speaking` state takes an extra `audio_env`
|
| 177 |
+
argument: a 0–1 instantaneous loudness that adds head dips, antenna
|
| 178 |
+
lifts, and body sway in time with the actual audio.
|
| 179 |
|
| 180 |
## Tweaking
|
| 181 |
|
|
|
|
| 189 |
`marginalia/motion.py` with named constants for amplitude, period,
|
| 190 |
and tilt for each state. Try a bigger `THINKING_BODY_AMP_DEG` or a
|
| 191 |
slower `SPEAKING_NOD_PERIOD_S`.
|
| 192 |
+
* **Different voice.** Pick any voice from the in-app dropdown — Ava,
|
| 193 |
+
Andrew, Sonia, Ryan, Emily, Natasha, etc. — or pin a default via
|
| 194 |
+
`MARGINALIA_VOICE=en-GB-RyanNeural`. Speed it up with
|
| 195 |
+
`MARGINALIA_EDGE_RATE=+15%` or pitch it down with
|
| 196 |
+
`MARGINALIA_EDGE_PITCH=-25Hz`. Force a specific backend with
|
| 197 |
+
`MARGINALIA_TTS_MODE=edge|hf|offline|gtts`.
|
| 198 |
* **Different mute gesture sensitivity.** Adjust `TWIST_THRESHOLD_RAD`
|
| 199 |
(default `0.4` ≈ 23°) and `TWIST_COOLDOWN_S` in `marginalia/main.py`
|
| 200 |
if the robot mishears your twists.
|
|
@@ -50,7 +50,8 @@
|
|
| 50 |
<div class="preview-meta">
|
| 51 |
<span class="preview-pill">§ How to read this</span>
|
| 52 |
<span class="preview-pill alt">¶ Further reading</span>
|
| 53 |
-
<span class="preview-pill voice">♪
|
|
|
|
| 54 |
</div>
|
| 55 |
|
| 56 |
<p class="preview-note">
|
|
@@ -80,13 +81,17 @@
|
|
| 80 |
<code>http://0.0.0.0:8042</code> automatically.
|
| 81 |
</li>
|
| 82 |
<li>
|
| 83 |
-
<strong>Ask a question.</strong> Reachy
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
</li>
|
| 91 |
<li>
|
| 92 |
<strong>Prefer to read in silence?</strong> Twist either
|
|
@@ -126,10 +131,14 @@
|
|
| 126 |
glance at the source material before clicking through.
|
| 127 |
</li>
|
| 128 |
<li>
|
| 129 |
-
<strong>An embodied reading.</strong> The robot speaks
|
| 130 |
-
answer
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
</li>
|
| 134 |
</ul>
|
| 135 |
</section>
|
|
@@ -162,16 +171,21 @@
|
|
| 162 |
machine drives the head, antennas, and body together:
|
| 163 |
idle sway → thinking sway with curling antennas → a
|
| 164 |
single nod the moment the first token arrives → an
|
| 165 |
-
attentive bob while the rest streams in →
|
| 166 |
-
head
|
|
|
|
|
|
|
| 167 |
</li>
|
| 168 |
<li>
|
| 169 |
-
<strong>Voice.</strong>
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
| 175 |
acknowledged with a small head nod.
|
| 176 |
</li>
|
| 177 |
</ol>
|
|
|
|
| 50 |
<div class="preview-meta">
|
| 51 |
<span class="preview-pill">§ How to read this</span>
|
| 52 |
<span class="preview-pill alt">¶ Further reading</span>
|
| 53 |
+
<span class="preview-pill voice">♪ Ava — neural voice</span>
|
| 54 |
+
<span class="preview-pill voice">♪ Andrew — companion</span>
|
| 55 |
</div>
|
| 56 |
|
| 57 |
<p class="preview-note">
|
|
|
|
| 81 |
<code>http://0.0.0.0:8042</code> automatically.
|
| 82 |
</li>
|
| 83 |
<li>
|
| 84 |
+
<strong>Ask a question.</strong> Reachy sways its head
|
| 85 |
+
while it thinks, nods the moment the first token lands,
|
| 86 |
+
then starts speaking the answer paragraph by paragraph
|
| 87 |
+
— the first words come through the speaker about a
|
| 88 |
+
second after the LLM finishes, while the rest of the
|
| 89 |
+
answer is still being synthesised in the background.
|
| 90 |
+
The head dips, antennas perk up, and the body sways
|
| 91 |
+
<em>with</em> the audio. Each answer comes with a
|
| 92 |
+
<em>How to read this</em> panel of tips
|
| 93 |
+
tailored to the work, plus <em>Further reading</em>
|
| 94 |
+
for amplification.
|
| 95 |
</li>
|
| 96 |
<li>
|
| 97 |
<strong>Prefer to read in silence?</strong> Twist either
|
|
|
|
| 131 |
glance at the source material before clicking through.
|
| 132 |
</li>
|
| 133 |
<li>
|
| 134 |
+
<strong>An embodied reading.</strong> The robot speaks
|
| 135 |
+
the answer paragraph by paragraph in a natural neural
|
| 136 |
+
voice (Microsoft Edge TTS — pick from US, UK, IE,
|
| 137 |
+
AU, male or female from a small dropdown), and the
|
| 138 |
+
head, antennas, and body move <em>in time with the
|
| 139 |
+
audio waveform</em>: dips on emphasised words, antenna
|
| 140 |
+
lifts on loud syllables. Twist an antenna any time to
|
| 141 |
+
switch to silent <em>read mode</em>.
|
| 142 |
</li>
|
| 143 |
</ul>
|
| 144 |
</section>
|
|
|
|
| 171 |
machine drives the head, antennas, and body together:
|
| 172 |
idle sway → thinking sway with curling antennas → a
|
| 173 |
single nod the moment the first token arrives → an
|
| 174 |
+
attentive bob while the rest streams in → audio-reactive
|
| 175 |
+
head and antenna motion while the answer is read aloud,
|
| 176 |
+
so the robot moves <em>with</em> the voice rather than
|
| 177 |
+
on a fixed loop.
|
| 178 |
</li>
|
| 179 |
<li>
|
| 180 |
+
<strong>Voice.</strong> The cleaned answer prose is split
|
| 181 |
+
into paragraph chunks. Each chunk is synthesised through
|
| 182 |
+
<strong>edge-tts</strong> (Microsoft's free neural
|
| 183 |
+
voices — Ava, Andrew, Sonia, Ryan, …) and
|
| 184 |
+
pushed to the robot speaker as soon as it's ready, so
|
| 185 |
+
the next chunk renders <em>while the previous one is
|
| 186 |
+
already playing</em>. Fallbacks: HF Inference TTS,
|
| 187 |
+
<code>pyttsx3</code>, gTTS. Twisting either antenna above
|
| 188 |
+
~23° toggles spoken vs silent mode and is
|
| 189 |
acknowledged with a small head nod.
|
| 190 |
</li>
|
| 191 |
</ol>
|
|
@@ -10,8 +10,13 @@ antennas for user-induced twists (which mute or un-mute the spoken
|
|
| 10 |
answer), and shares state with the FastAPI handlers via `_AppState`.
|
| 11 |
|
| 12 |
A `POST /api/ask` request streams the LLM answer to the browser via
|
| 13 |
-
Server-Sent Events. When speech is enabled, the
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
@@ -43,10 +48,11 @@ TWIST_COOLDOWN_S = 1.5
|
|
| 43 |
class _AppState:
|
| 44 |
"""Thread-safe shared state.
|
| 45 |
|
| 46 |
-
Tracks the motion state (drives the run loop)
|
| 47 |
-
flag
|
| 48 |
-
|
| 49 |
-
|
|
|
|
| 50 |
"""
|
| 51 |
|
| 52 |
def __init__(self) -> None:
|
|
@@ -54,9 +60,15 @@ class _AppState:
|
|
| 54 |
self._state: motion.State = "idle"
|
| 55 |
self._t_change: float = time.time()
|
| 56 |
self._speech_enabled: bool = True
|
|
|
|
| 57 |
self._listeners: list[asyncio.Queue] = []
|
| 58 |
self._listeners_lock = threading.Lock()
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
def get(self) -> tuple[motion.State, float]:
|
| 61 |
with self._lock:
|
| 62 |
return self._state, time.time() - self._t_change
|
|
@@ -97,9 +109,30 @@ class _AppState:
|
|
| 97 |
self._broadcast({"speech_enabled": value})
|
| 98 |
return value
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def snapshot(self) -> dict[str, Any]:
|
| 101 |
with self._lock:
|
| 102 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
def subscribe(self) -> asyncio.Queue:
|
| 105 |
q: asyncio.Queue = asyncio.Queue(maxsize=32)
|
|
@@ -121,11 +154,46 @@ class _AppState:
|
|
| 121 |
except asyncio.QueueFull:
|
| 122 |
pass
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
class _AskRequest(BaseModel):
|
| 126 |
question: str
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
def _sse(event: str, data: Any) -> str:
|
| 130 |
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
| 131 |
|
|
@@ -155,11 +223,24 @@ class Marginalia(ReachyMiniApp):
|
|
| 155 |
def get_state() -> JSONResponse:
|
| 156 |
return JSONResponse(self._app_state.snapshot())
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
@self.settings_app.post("/api/speech/toggle")
|
| 159 |
def toggle_speech() -> JSONResponse:
|
| 160 |
value = self._app_state.toggle_speech()
|
| 161 |
return JSONResponse({"speech_enabled": value})
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
@self.settings_app.get("/api/events")
|
| 164 |
async def events(request: Request) -> StreamingResponse:
|
| 165 |
return StreamingResponse(
|
|
@@ -248,26 +329,42 @@ class Marginalia(ReachyMiniApp):
|
|
| 248 |
yield frame
|
| 249 |
finally:
|
| 250 |
self._app_state.set("idle")
|
|
|
|
| 251 |
|
| 252 |
async def _speak(self, full_text: str, request: Request):
|
| 253 |
-
"""Synthesise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
loop = asyncio.get_event_loop()
|
| 255 |
|
| 256 |
spoken_text = speech.clean_for_speech(full_text)
|
| 257 |
if not spoken_text:
|
| 258 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
try:
|
| 261 |
-
|
| 262 |
except Exception as e:
|
| 263 |
-
logger.warning("synthesize failed: %
|
| 264 |
yield _sse("speech_error", {"message": str(e)})
|
| 265 |
return
|
| 266 |
|
| 267 |
-
if
|
| 268 |
-
logger.warning(
|
| 269 |
-
"synthesize returned no audio for %d chars of prose", len(spoken_text)
|
| 270 |
-
)
|
| 271 |
yield _sse(
|
| 272 |
"speech_error",
|
| 273 |
{"message": "Reachy couldn't speak this one — see app logs for the TTS backend error."},
|
|
@@ -276,37 +373,88 @@ class Marginalia(ReachyMiniApp):
|
|
| 276 |
if not self._app_state.speech_enabled:
|
| 277 |
return
|
| 278 |
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
-
played = False
|
| 284 |
-
if self._reachy is not None:
|
| 285 |
try:
|
| 286 |
with self._media_lock:
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
except Exception as e:
|
| 292 |
-
logger.warning("audio push failed: %
|
|
|
|
|
|
|
| 293 |
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
await asyncio.sleep(tick)
|
| 299 |
-
sleep_s -= tick
|
| 300 |
if await request.is_disconnected():
|
| 301 |
break
|
|
|
|
|
|
|
| 302 |
try:
|
| 303 |
with self._media_lock:
|
| 304 |
if self._reachy is not None:
|
| 305 |
self._reachy.media.stop_playing()
|
| 306 |
except Exception as e:
|
| 307 |
-
logger.warning("audio stop failed: %
|
| 308 |
-
|
| 309 |
-
await asyncio.sleep(0.05)
|
| 310 |
|
| 311 |
yield _sse("spoken_done", {})
|
| 312 |
|
|
@@ -329,7 +477,12 @@ class Marginalia(ReachyMiniApp):
|
|
| 329 |
self._app_state.set("idle")
|
| 330 |
state, t_state = self._app_state.get()
|
| 331 |
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
|
| 334 |
try:
|
| 335 |
reachy_mini.set_target(
|
|
|
|
| 10 |
answer), and shares state with the FastAPI handlers via `_AppState`.
|
| 11 |
|
| 12 |
A `POST /api/ask` request streams the LLM answer to the browser via
|
| 13 |
+
Server-Sent Events. When speech is enabled, the answer is split into
|
| 14 |
+
paragraph-sized chunks and synthesised + pushed to the robot speaker
|
| 15 |
+
one chunk at a time, so the first words start playing within ~1-2 s
|
| 16 |
+
instead of waiting for the whole answer to render. While the robot
|
| 17 |
+
speaks, the run loop reads the per-frame audio loudness from
|
| 18 |
+
`_AppState` and feeds it to `motion.pose_for_state` for audio-reactive
|
| 19 |
+
head and antenna movement.
|
| 20 |
"""
|
| 21 |
|
| 22 |
from __future__ import annotations
|
|
|
|
| 48 |
class _AppState:
|
| 49 |
"""Thread-safe shared state.
|
| 50 |
|
| 51 |
+
Tracks the motion state (drives the run loop), a ``speech_enabled``
|
| 52 |
+
flag, the active TTS voice, and a rolling audio-loudness envelope
|
| 53 |
+
used to drive audio-reactive head and antenna motion while the robot
|
| 54 |
+
speaks. State changes notify any subscribed asyncio queue so the
|
| 55 |
+
FastAPI handlers can broadcast `state_changed` SSE frames.
|
| 56 |
"""
|
| 57 |
|
| 58 |
def __init__(self) -> None:
|
|
|
|
| 60 |
self._state: motion.State = "idle"
|
| 61 |
self._t_change: float = time.time()
|
| 62 |
self._speech_enabled: bool = True
|
| 63 |
+
self._voice: str = speech.DEFAULT_VOICE
|
| 64 |
self._listeners: list[asyncio.Queue] = []
|
| 65 |
self._listeners_lock = threading.Lock()
|
| 66 |
|
| 67 |
+
self._envelope: np.ndarray = np.zeros(0, dtype=np.float32)
|
| 68 |
+
self._envelope_dt: float = speech.ENVELOPE_FRAME_MS / 1000.0
|
| 69 |
+
self._envelope_start_mono: float = 0.0
|
| 70 |
+
self._envelope_active: bool = False
|
| 71 |
+
|
| 72 |
def get(self) -> tuple[motion.State, float]:
|
| 73 |
with self._lock:
|
| 74 |
return self._state, time.time() - self._t_change
|
|
|
|
| 109 |
self._broadcast({"speech_enabled": value})
|
| 110 |
return value
|
| 111 |
|
| 112 |
+
@property
|
| 113 |
+
def voice(self) -> str:
|
| 114 |
+
with self._lock:
|
| 115 |
+
return self._voice
|
| 116 |
+
|
| 117 |
+
def set_voice(self, voice_id: str) -> bool:
|
| 118 |
+
if voice_id not in speech.VOICE_IDS:
|
| 119 |
+
return False
|
| 120 |
+
with self._lock:
|
| 121 |
+
changed = self._voice != voice_id
|
| 122 |
+
self._voice = voice_id
|
| 123 |
+
if changed:
|
| 124 |
+
logger.info("voice -> %s", voice_id)
|
| 125 |
+
self._broadcast({"voice": voice_id})
|
| 126 |
+
return changed
|
| 127 |
+
|
| 128 |
def snapshot(self) -> dict[str, Any]:
|
| 129 |
with self._lock:
|
| 130 |
+
return {
|
| 131 |
+
"state": self._state,
|
| 132 |
+
"speech_enabled": self._speech_enabled,
|
| 133 |
+
"voice": self._voice,
|
| 134 |
+
"voices": speech.VOICES,
|
| 135 |
+
}
|
| 136 |
|
| 137 |
def subscribe(self) -> asyncio.Queue:
|
| 138 |
q: asyncio.Queue = asyncio.Queue(maxsize=32)
|
|
|
|
| 154 |
except asyncio.QueueFull:
|
| 155 |
pass
|
| 156 |
|
| 157 |
+
def begin_envelope(self, start_mono: float) -> None:
|
| 158 |
+
with self._lock:
|
| 159 |
+
self._envelope = np.zeros(0, dtype=np.float32)
|
| 160 |
+
self._envelope_dt = speech.ENVELOPE_FRAME_MS / 1000.0
|
| 161 |
+
self._envelope_start_mono = start_mono
|
| 162 |
+
self._envelope_active = True
|
| 163 |
+
|
| 164 |
+
def append_envelope(self, env: np.ndarray, dt_s: float) -> None:
|
| 165 |
+
if env is None or len(env) == 0:
|
| 166 |
+
return
|
| 167 |
+
with self._lock:
|
| 168 |
+
if not self._envelope_active:
|
| 169 |
+
return
|
| 170 |
+
self._envelope_dt = float(dt_s)
|
| 171 |
+
self._envelope = np.concatenate([self._envelope, env.astype(np.float32, copy=False)])
|
| 172 |
+
|
| 173 |
+
def end_envelope(self) -> None:
|
| 174 |
+
with self._lock:
|
| 175 |
+
self._envelope_active = False
|
| 176 |
+
self._envelope = np.zeros(0, dtype=np.float32)
|
| 177 |
+
|
| 178 |
+
def current_audio_level(self) -> float:
|
| 179 |
+
with self._lock:
|
| 180 |
+
if not self._envelope_active or len(self._envelope) == 0:
|
| 181 |
+
return 0.0
|
| 182 |
+
elapsed = time.monotonic() - self._envelope_start_mono
|
| 183 |
+
idx = int(elapsed / self._envelope_dt)
|
| 184 |
+
if idx < 0 or idx >= len(self._envelope):
|
| 185 |
+
return 0.0
|
| 186 |
+
return float(self._envelope[idx])
|
| 187 |
+
|
| 188 |
|
| 189 |
class _AskRequest(BaseModel):
|
| 190 |
question: str
|
| 191 |
|
| 192 |
|
| 193 |
+
class _VoiceRequest(BaseModel):
|
| 194 |
+
voice: str
|
| 195 |
+
|
| 196 |
+
|
| 197 |
def _sse(event: str, data: Any) -> str:
|
| 198 |
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
| 199 |
|
|
|
|
| 223 |
def get_state() -> JSONResponse:
|
| 224 |
return JSONResponse(self._app_state.snapshot())
|
| 225 |
|
| 226 |
+
@self.settings_app.get("/api/voices")
|
| 227 |
+
def list_voices() -> JSONResponse:
|
| 228 |
+
return JSONResponse(
|
| 229 |
+
{"voice": self._app_state.voice, "voices": speech.VOICES}
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
@self.settings_app.post("/api/speech/toggle")
|
| 233 |
def toggle_speech() -> JSONResponse:
|
| 234 |
value = self._app_state.toggle_speech()
|
| 235 |
return JSONResponse({"speech_enabled": value})
|
| 236 |
|
| 237 |
+
@self.settings_app.post("/api/speech/voice")
|
| 238 |
+
def set_voice(req: _VoiceRequest) -> JSONResponse:
|
| 239 |
+
ok = self._app_state.set_voice(req.voice)
|
| 240 |
+
return JSONResponse(
|
| 241 |
+
{"ok": ok, "voice": self._app_state.voice}, status_code=200 if ok else 400
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
@self.settings_app.get("/api/events")
|
| 245 |
async def events(request: Request) -> StreamingResponse:
|
| 246 |
return StreamingResponse(
|
|
|
|
| 329 |
yield frame
|
| 330 |
finally:
|
| 331 |
self._app_state.set("idle")
|
| 332 |
+
self._app_state.end_envelope()
|
| 333 |
|
| 334 |
async def _speak(self, full_text: str, request: Request):
|
| 335 |
+
"""Synthesise + play the answer prose paragraph-by-paragraph.
|
| 336 |
+
|
| 337 |
+
For low latency we don't synthesise the whole answer up front.
|
| 338 |
+
We split the cleaned text into paragraph chunks, synthesise the
|
| 339 |
+
first one, push it to the speaker, and then synthesise + push
|
| 340 |
+
the rest while the first is already playing. The audio engine
|
| 341 |
+
queues samples, so multiple chunks play seamlessly.
|
| 342 |
+
"""
|
| 343 |
loop = asyncio.get_event_loop()
|
| 344 |
|
| 345 |
spoken_text = speech.clean_for_speech(full_text)
|
| 346 |
if not spoken_text:
|
| 347 |
return
|
| 348 |
+
chunks = speech.split_for_speech(spoken_text)
|
| 349 |
+
if not chunks:
|
| 350 |
+
return
|
| 351 |
+
|
| 352 |
+
voice = self._app_state.voice
|
| 353 |
+
|
| 354 |
+
def synth(text: str) -> np.ndarray:
|
| 355 |
+
return speech.synthesize(text, voice=voice)
|
| 356 |
+
|
| 357 |
+
total_chunks = len(chunks)
|
| 358 |
|
| 359 |
try:
|
| 360 |
+
first_pcm = await loop.run_in_executor(None, synth, chunks[0])
|
| 361 |
except Exception as e:
|
| 362 |
+
logger.warning("synthesize failed: %r", e)
|
| 363 |
yield _sse("speech_error", {"message": str(e)})
|
| 364 |
return
|
| 365 |
|
| 366 |
+
if first_pcm is None or len(first_pcm) == 0:
|
| 367 |
+
logger.warning("first chunk produced no audio (%d chars)", len(chunks[0]))
|
|
|
|
|
|
|
| 368 |
yield _sse(
|
| 369 |
"speech_error",
|
| 370 |
{"message": "Reachy couldn't speak this one — see app logs for the TTS backend error."},
|
|
|
|
| 373 |
if not self._app_state.speech_enabled:
|
| 374 |
return
|
| 375 |
|
| 376 |
+
if self._reachy is None:
|
| 377 |
+
yield _sse("speech_error", {"message": "Robot media not available."})
|
| 378 |
+
return
|
| 379 |
+
|
| 380 |
+
sample_rate = float(speech.ROBOT_SAMPLE_RATE)
|
| 381 |
+
playback_deadline = time.monotonic()
|
| 382 |
+
|
| 383 |
+
try:
|
| 384 |
+
with self._media_lock:
|
| 385 |
+
media = self._reachy.media
|
| 386 |
+
media.start_playing()
|
| 387 |
+
|
| 388 |
+
self._app_state.begin_envelope(time.monotonic())
|
| 389 |
+
self._app_state.set("speaking")
|
| 390 |
+
yield _sse(
|
| 391 |
+
"speaking",
|
| 392 |
+
{"chunks": total_chunks, "voice": voice},
|
| 393 |
+
)
|
| 394 |
|
|
|
|
|
|
|
| 395 |
try:
|
| 396 |
with self._media_lock:
|
| 397 |
+
self._reachy.media.push_audio_sample(
|
| 398 |
+
np.asarray(first_pcm, dtype=np.float32)
|
| 399 |
+
)
|
| 400 |
+
env, dt = speech.compute_envelope(first_pcm)
|
| 401 |
+
self._app_state.append_envelope(env, dt)
|
| 402 |
+
playback_deadline = max(playback_deadline, time.monotonic()) + (
|
| 403 |
+
len(first_pcm) / sample_rate
|
| 404 |
+
)
|
| 405 |
+
yield _sse("chunk_played", {"index": 0, "of": total_chunks})
|
| 406 |
except Exception as e:
|
| 407 |
+
logger.warning("audio push failed: %r", e)
|
| 408 |
+
yield _sse("speech_error", {"message": str(e)})
|
| 409 |
+
return
|
| 410 |
|
| 411 |
+
for i, chunk in enumerate(chunks[1:], start=1):
|
| 412 |
+
if not self._app_state.speech_enabled:
|
| 413 |
+
break
|
| 414 |
+
if await request.is_disconnected():
|
| 415 |
+
break
|
| 416 |
+
|
| 417 |
+
try:
|
| 418 |
+
pcm = await loop.run_in_executor(None, synth, chunk)
|
| 419 |
+
except Exception as e:
|
| 420 |
+
logger.warning("synthesize failed on chunk %d: %r", i, e)
|
| 421 |
+
continue
|
| 422 |
+
|
| 423 |
+
if pcm is None or len(pcm) == 0:
|
| 424 |
+
continue
|
| 425 |
+
|
| 426 |
+
try:
|
| 427 |
+
with self._media_lock:
|
| 428 |
+
if self._reachy is None:
|
| 429 |
+
break
|
| 430 |
+
self._reachy.media.push_audio_sample(
|
| 431 |
+
np.asarray(pcm, dtype=np.float32)
|
| 432 |
+
)
|
| 433 |
+
env, dt = speech.compute_envelope(pcm)
|
| 434 |
+
self._app_state.append_envelope(env, dt)
|
| 435 |
+
playback_deadline += len(pcm) / sample_rate
|
| 436 |
+
yield _sse("chunk_played", {"index": i, "of": total_chunks})
|
| 437 |
+
except Exception as e:
|
| 438 |
+
logger.warning("audio push failed on chunk %d: %r", i, e)
|
| 439 |
+
break
|
| 440 |
+
|
| 441 |
+
while self._app_state.speech_enabled:
|
| 442 |
+
remaining = playback_deadline - time.monotonic()
|
| 443 |
+
if remaining <= 0.0:
|
| 444 |
+
break
|
| 445 |
+
tick = min(0.25, remaining)
|
| 446 |
await asyncio.sleep(tick)
|
|
|
|
| 447 |
if await request.is_disconnected():
|
| 448 |
break
|
| 449 |
+
|
| 450 |
+
finally:
|
| 451 |
try:
|
| 452 |
with self._media_lock:
|
| 453 |
if self._reachy is not None:
|
| 454 |
self._reachy.media.stop_playing()
|
| 455 |
except Exception as e:
|
| 456 |
+
logger.warning("audio stop failed: %r", e)
|
| 457 |
+
self._app_state.end_envelope()
|
|
|
|
| 458 |
|
| 459 |
yield _sse("spoken_done", {})
|
| 460 |
|
|
|
|
| 477 |
self._app_state.set("idle")
|
| 478 |
state, t_state = self._app_state.get()
|
| 479 |
|
| 480 |
+
audio_env = (
|
| 481 |
+
self._app_state.current_audio_level()
|
| 482 |
+
if state == "speaking"
|
| 483 |
+
else 0.0
|
| 484 |
+
)
|
| 485 |
+
frame = motion.pose_for_state(state, t_state, audio_env=audio_env)
|
| 486 |
|
| 487 |
try:
|
| 488 |
reachy_mini.set_target(
|
|
@@ -55,46 +55,61 @@ class MotionFrame:
|
|
| 55 |
body_yaw: float
|
| 56 |
|
| 57 |
|
| 58 |
-
IDLE_YAW_AMP_DEG =
|
| 59 |
-
IDLE_PITCH_AMP_DEG =
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
def _deg(angle_deg: float) -> float:
|
|
@@ -105,12 +120,13 @@ def _idle_frame(t: float) -> MotionFrame:
|
|
| 105 |
head_phase = 2.0 * math.pi * t / IDLE_HEAD_PERIOD_S
|
| 106 |
yaw = IDLE_YAW_AMP_DEG * math.sin(head_phase)
|
| 107 |
pitch = IDLE_PITCH_AMP_DEG * math.sin(head_phase * 0.5)
|
|
|
|
| 108 |
body_yaw = IDLE_BODY_AMP_DEG * math.sin(2.0 * math.pi * t / IDLE_BODY_PERIOD_S)
|
| 109 |
-
breath = IDLE_ANTENNA_BREATH_DEG * math.sin(2.0 * math.pi * t /
|
| 110 |
ant_deg = IDLE_ANTENNA_REST_DEG + breath
|
| 111 |
return MotionFrame(
|
| 112 |
-
head=create_head_pose(yaw=yaw, pitch=pitch, degrees=True),
|
| 113 |
-
antennas=[_deg(ant_deg), _deg(ant_deg)],
|
| 114 |
body_yaw=_deg(body_yaw),
|
| 115 |
)
|
| 116 |
|
|
@@ -118,8 +134,8 @@ def _idle_frame(t: float) -> MotionFrame:
|
|
| 118 |
def _thinking_frame(t: float) -> MotionFrame:
|
| 119 |
head_phase = 2.0 * math.pi * t / THINKING_HEAD_PERIOD_S
|
| 120 |
yaw = THINKING_YAW_AMP_DEG * math.sin(head_phase)
|
| 121 |
-
pitch = -THINKING_PITCH_AMP_DEG * (0.5 - 0.5 * math.cos(head_phase * 1.
|
| 122 |
-
roll =
|
| 123 |
body_yaw = THINKING_BODY_AMP_DEG * math.sin(2.0 * math.pi * t / THINKING_BODY_PERIOD_S)
|
| 124 |
|
| 125 |
ant_phase = 2.0 * math.pi * t / THINKING_ANTENNA_PERIOD_S
|
|
@@ -149,9 +165,13 @@ def _nodding_frame(t: float) -> MotionFrame:
|
|
| 149 |
|
| 150 |
def _responding_frame(t: float) -> MotionFrame:
|
| 151 |
phase = 2.0 * math.pi * t / RESPONDING_PERIOD_S
|
| 152 |
-
yaw =
|
| 153 |
pitch = RESPONDING_PITCH_BIAS_DEG + RESPONDING_BOB_AMP_DEG * math.sin(phase)
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
ant_phase = 2.0 * math.pi * t / RESPONDING_ANTENNA_PERIOD_S
|
| 156 |
right = RESPONDING_ANTENNA_AMP_DEG * math.sin(ant_phase)
|
| 157 |
left = RESPONDING_ANTENNA_AMP_DEG * math.sin(ant_phase + math.pi)
|
|
@@ -159,28 +179,43 @@ def _responding_frame(t: float) -> MotionFrame:
|
|
| 159 |
return MotionFrame(
|
| 160 |
head=create_head_pose(yaw=yaw, pitch=pitch, degrees=True),
|
| 161 |
antennas=[_deg(right), _deg(left)],
|
| 162 |
-
body_yaw=
|
| 163 |
)
|
| 164 |
|
| 165 |
|
| 166 |
-
def _speaking_frame(t: float) -> MotionFrame:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
nod_phase = 2.0 * math.pi * t / SPEAKING_NOD_PERIOD_S
|
| 168 |
-
|
| 169 |
0.5 - 0.5 * math.cos(nod_phase)
|
| 170 |
)
|
|
|
|
|
|
|
| 171 |
yaw_phase = 2.0 * math.pi * t / SPEAKING_YAW_PERIOD_S
|
| 172 |
-
yaw = SPEAKING_YAW_AMP_DEG * math.sin(yaw_phase)
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
body_yaw = SPEAKING_BODY_AMP_DEG * math.sin(
|
| 175 |
2.0 * math.pi * t / SPEAKING_BODY_PERIOD_S
|
| 176 |
-
)
|
| 177 |
|
| 178 |
ant_phase = 2.0 * math.pi * t / SPEAKING_ANTENNA_PERIOD_S
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
| 181 |
|
| 182 |
return MotionFrame(
|
| 183 |
-
head=create_head_pose(yaw=yaw, pitch=pitch, degrees=True),
|
| 184 |
antennas=[_deg(right), _deg(left)],
|
| 185 |
body_yaw=_deg(body_yaw),
|
| 186 |
)
|
|
@@ -189,20 +224,24 @@ def _speaking_frame(t: float) -> MotionFrame:
|
|
| 189 |
def _acknowledge_frame(t: float) -> MotionFrame:
|
| 190 |
p = max(0.0, min(1.0, t / ACK_DURATION_S))
|
| 191 |
burst = math.sin(math.pi * p)
|
| 192 |
-
pitch = ACK_PITCH_DOWN_DEG * burst * 0.
|
| 193 |
ant_deg = ACK_ANTENNA_DEG * burst
|
|
|
|
| 194 |
return MotionFrame(
|
| 195 |
head=create_head_pose(pitch=pitch, degrees=True),
|
| 196 |
antennas=[_deg(ant_deg), _deg(ant_deg)],
|
| 197 |
-
body_yaw=
|
| 198 |
)
|
| 199 |
|
| 200 |
|
| 201 |
-
def pose_for_state(
|
|
|
|
|
|
|
| 202 |
"""Return the motion frame for `state` at local time `t_state` (seconds).
|
| 203 |
|
| 204 |
`t_state` resets to 0 every time the state changes, so each pattern
|
| 205 |
-
starts from a clean phase.
|
|
|
|
| 206 |
"""
|
| 207 |
if state == "thinking":
|
| 208 |
return _thinking_frame(t_state)
|
|
@@ -211,7 +250,7 @@ def pose_for_state(state: State, t_state: float) -> MotionFrame:
|
|
| 211 |
if state == "responding":
|
| 212 |
return _responding_frame(t_state)
|
| 213 |
if state == "speaking":
|
| 214 |
-
return _speaking_frame(t_state)
|
| 215 |
if state == "acknowledge_toggle":
|
| 216 |
return _acknowledge_frame(t_state)
|
| 217 |
return _idle_frame(t_state)
|
|
|
|
| 55 |
body_yaw: float
|
| 56 |
|
| 57 |
|
| 58 |
+
IDLE_YAW_AMP_DEG = 6.0
|
| 59 |
+
IDLE_PITCH_AMP_DEG = 2.5
|
| 60 |
+
IDLE_ROLL_AMP_DEG = 2.0
|
| 61 |
+
IDLE_HEAD_PERIOD_S = 5.5
|
| 62 |
+
IDLE_BODY_AMP_DEG = 9.0
|
| 63 |
+
IDLE_BODY_PERIOD_S = 14.0
|
| 64 |
+
IDLE_ANTENNA_REST_DEG = -8.0
|
| 65 |
+
IDLE_ANTENNA_BREATH_DEG = 5.0
|
| 66 |
+
|
| 67 |
+
THINKING_YAW_AMP_DEG = 22.0
|
| 68 |
+
THINKING_PITCH_AMP_DEG = 10.0
|
| 69 |
+
THINKING_ROLL_AMP_DEG = 7.0
|
| 70 |
+
THINKING_HEAD_PERIOD_S = 2.6
|
| 71 |
+
THINKING_BODY_AMP_DEG = 22.0
|
| 72 |
+
THINKING_BODY_PERIOD_S = 4.0
|
| 73 |
+
THINKING_ANTENNA_FORWARD_DEG = 32.0
|
| 74 |
+
THINKING_ANTENNA_PERIOD_S = 1.9
|
| 75 |
+
|
| 76 |
+
NODDING_DURATION_S = 0.8
|
| 77 |
+
NODDING_PITCH_DOWN_DEG = 18.0
|
| 78 |
+
NODDING_PITCH_UP_DEG = -8.0
|
| 79 |
+
NODDING_ANTENNA_DEG = 22.0
|
| 80 |
+
|
| 81 |
+
RESPONDING_BOB_AMP_DEG = 4.5
|
| 82 |
+
RESPONDING_PITCH_BIAS_DEG = 8.0
|
| 83 |
+
RESPONDING_YAW_AMP_DEG = 6.0
|
| 84 |
+
RESPONDING_PERIOD_S = 3.4
|
| 85 |
+
RESPONDING_BODY_AMP_DEG = 6.0
|
| 86 |
+
RESPONDING_BODY_PERIOD_S = 9.0
|
| 87 |
+
RESPONDING_ANTENNA_AMP_DEG = 14.0
|
| 88 |
+
RESPONDING_ANTENNA_PERIOD_S = 3.0
|
| 89 |
+
|
| 90 |
+
SPEAKING_NOD_PERIOD_S = 1.6
|
| 91 |
+
SPEAKING_NOD_AMP_DEG = 9.0
|
| 92 |
+
SPEAKING_PITCH_BIAS_DEG = 8.0
|
| 93 |
+
SPEAKING_YAW_AMP_DEG = 10.0
|
| 94 |
+
SPEAKING_YAW_PERIOD_S = 5.5
|
| 95 |
+
SPEAKING_ROLL_AMP_DEG = 4.0
|
| 96 |
+
SPEAKING_ROLL_PERIOD_S = 8.0
|
| 97 |
+
SPEAKING_BODY_AMP_DEG = 14.0
|
| 98 |
+
SPEAKING_BODY_PERIOD_S = 9.0
|
| 99 |
+
SPEAKING_ANTENNA_AMP_DEG = 22.0
|
| 100 |
+
SPEAKING_ANTENNA_PERIOD_S = 2.3
|
| 101 |
+
|
| 102 |
+
# Audio-reactive overlay applied during the ``speaking`` state. The
|
| 103 |
+
# ``audio_env`` arg is an instantaneous loudness in [0, 1].
|
| 104 |
+
AUDIO_REACTIVE_PITCH_DEG = 8.0
|
| 105 |
+
AUDIO_REACTIVE_YAW_DEG = 4.0
|
| 106 |
+
AUDIO_REACTIVE_ANTENNA_DEG = 18.0
|
| 107 |
+
AUDIO_REACTIVE_BODY_DEG = 6.0
|
| 108 |
+
|
| 109 |
+
ACK_DURATION_S = 0.55
|
| 110 |
+
ACK_ANTENNA_DEG = 38.0
|
| 111 |
+
ACK_PITCH_DOWN_DEG = 14.0
|
| 112 |
+
ACK_BODY_DEG = 12.0
|
| 113 |
|
| 114 |
|
| 115 |
def _deg(angle_deg: float) -> float:
|
|
|
|
| 120 |
head_phase = 2.0 * math.pi * t / IDLE_HEAD_PERIOD_S
|
| 121 |
yaw = IDLE_YAW_AMP_DEG * math.sin(head_phase)
|
| 122 |
pitch = IDLE_PITCH_AMP_DEG * math.sin(head_phase * 0.5)
|
| 123 |
+
roll = IDLE_ROLL_AMP_DEG * math.sin(head_phase * 0.7 + 0.4)
|
| 124 |
body_yaw = IDLE_BODY_AMP_DEG * math.sin(2.0 * math.pi * t / IDLE_BODY_PERIOD_S)
|
| 125 |
+
breath = IDLE_ANTENNA_BREATH_DEG * math.sin(2.0 * math.pi * t / 7.0)
|
| 126 |
ant_deg = IDLE_ANTENNA_REST_DEG + breath
|
| 127 |
return MotionFrame(
|
| 128 |
+
head=create_head_pose(yaw=yaw, pitch=pitch, roll=roll, degrees=True),
|
| 129 |
+
antennas=[_deg(ant_deg), _deg(ant_deg + breath * 0.4)],
|
| 130 |
body_yaw=_deg(body_yaw),
|
| 131 |
)
|
| 132 |
|
|
|
|
| 134 |
def _thinking_frame(t: float) -> MotionFrame:
|
| 135 |
head_phase = 2.0 * math.pi * t / THINKING_HEAD_PERIOD_S
|
| 136 |
yaw = THINKING_YAW_AMP_DEG * math.sin(head_phase)
|
| 137 |
+
pitch = -THINKING_PITCH_AMP_DEG * (0.5 - 0.5 * math.cos(head_phase * 1.7))
|
| 138 |
+
roll = THINKING_ROLL_AMP_DEG * math.sin(head_phase * 0.5)
|
| 139 |
body_yaw = THINKING_BODY_AMP_DEG * math.sin(2.0 * math.pi * t / THINKING_BODY_PERIOD_S)
|
| 140 |
|
| 141 |
ant_phase = 2.0 * math.pi * t / THINKING_ANTENNA_PERIOD_S
|
|
|
|
| 165 |
|
| 166 |
def _responding_frame(t: float) -> MotionFrame:
|
| 167 |
phase = 2.0 * math.pi * t / RESPONDING_PERIOD_S
|
| 168 |
+
yaw = RESPONDING_YAW_AMP_DEG * math.sin(phase * 0.7)
|
| 169 |
pitch = RESPONDING_PITCH_BIAS_DEG + RESPONDING_BOB_AMP_DEG * math.sin(phase)
|
| 170 |
|
| 171 |
+
body_yaw = RESPONDING_BODY_AMP_DEG * math.sin(
|
| 172 |
+
2.0 * math.pi * t / RESPONDING_BODY_PERIOD_S
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
ant_phase = 2.0 * math.pi * t / RESPONDING_ANTENNA_PERIOD_S
|
| 176 |
right = RESPONDING_ANTENNA_AMP_DEG * math.sin(ant_phase)
|
| 177 |
left = RESPONDING_ANTENNA_AMP_DEG * math.sin(ant_phase + math.pi)
|
|
|
|
| 179 |
return MotionFrame(
|
| 180 |
head=create_head_pose(yaw=yaw, pitch=pitch, degrees=True),
|
| 181 |
antennas=[_deg(right), _deg(left)],
|
| 182 |
+
body_yaw=_deg(body_yaw),
|
| 183 |
)
|
| 184 |
|
| 185 |
|
| 186 |
+
def _speaking_frame(t: float, audio_env: float) -> MotionFrame:
|
| 187 |
+
"""Speaking state with audio-reactive overlay.
|
| 188 |
+
|
| 189 |
+
``audio_env`` ∈ [0, ~1.5] is the current loudness; we add a head dip,
|
| 190 |
+
a small yaw flick, and an antenna lift proportional to it on top of
|
| 191 |
+
the steady speaking sway.
|
| 192 |
+
"""
|
| 193 |
+
env = max(0.0, float(audio_env))
|
| 194 |
+
|
| 195 |
nod_phase = 2.0 * math.pi * t / SPEAKING_NOD_PERIOD_S
|
| 196 |
+
base_pitch = SPEAKING_PITCH_BIAS_DEG + SPEAKING_NOD_AMP_DEG * (
|
| 197 |
0.5 - 0.5 * math.cos(nod_phase)
|
| 198 |
)
|
| 199 |
+
pitch = base_pitch + AUDIO_REACTIVE_PITCH_DEG * env
|
| 200 |
+
|
| 201 |
yaw_phase = 2.0 * math.pi * t / SPEAKING_YAW_PERIOD_S
|
| 202 |
+
yaw = SPEAKING_YAW_AMP_DEG * math.sin(yaw_phase) + AUDIO_REACTIVE_YAW_DEG * (
|
| 203 |
+
env * math.sin(2.0 * math.pi * t / 0.5)
|
| 204 |
+
)
|
| 205 |
+
roll = SPEAKING_ROLL_AMP_DEG * math.sin(2.0 * math.pi * t / SPEAKING_ROLL_PERIOD_S)
|
| 206 |
|
| 207 |
body_yaw = SPEAKING_BODY_AMP_DEG * math.sin(
|
| 208 |
2.0 * math.pi * t / SPEAKING_BODY_PERIOD_S
|
| 209 |
+
) + AUDIO_REACTIVE_BODY_DEG * env * 0.5
|
| 210 |
|
| 211 |
ant_phase = 2.0 * math.pi * t / SPEAKING_ANTENNA_PERIOD_S
|
| 212 |
+
base_right = SPEAKING_ANTENNA_AMP_DEG * math.sin(ant_phase)
|
| 213 |
+
base_left = SPEAKING_ANTENNA_AMP_DEG * math.sin(ant_phase + math.pi * 0.7)
|
| 214 |
+
right = base_right + AUDIO_REACTIVE_ANTENNA_DEG * env
|
| 215 |
+
left = base_left + AUDIO_REACTIVE_ANTENNA_DEG * env
|
| 216 |
|
| 217 |
return MotionFrame(
|
| 218 |
+
head=create_head_pose(yaw=yaw, pitch=pitch, roll=roll, degrees=True),
|
| 219 |
antennas=[_deg(right), _deg(left)],
|
| 220 |
body_yaw=_deg(body_yaw),
|
| 221 |
)
|
|
|
|
| 224 |
def _acknowledge_frame(t: float) -> MotionFrame:
|
| 225 |
p = max(0.0, min(1.0, t / ACK_DURATION_S))
|
| 226 |
burst = math.sin(math.pi * p)
|
| 227 |
+
pitch = ACK_PITCH_DOWN_DEG * burst * 0.6
|
| 228 |
ant_deg = ACK_ANTENNA_DEG * burst
|
| 229 |
+
body_yaw = ACK_BODY_DEG * burst * 0.5
|
| 230 |
return MotionFrame(
|
| 231 |
head=create_head_pose(pitch=pitch, degrees=True),
|
| 232 |
antennas=[_deg(ant_deg), _deg(ant_deg)],
|
| 233 |
+
body_yaw=_deg(body_yaw),
|
| 234 |
)
|
| 235 |
|
| 236 |
|
| 237 |
+
def pose_for_state(
|
| 238 |
+
state: State, t_state: float, audio_env: float = 0.0
|
| 239 |
+
) -> MotionFrame:
|
| 240 |
"""Return the motion frame for `state` at local time `t_state` (seconds).
|
| 241 |
|
| 242 |
`t_state` resets to 0 every time the state changes, so each pattern
|
| 243 |
+
starts from a clean phase. ``audio_env`` is only consulted by the
|
| 244 |
+
``speaking`` state — for everything else it is ignored.
|
| 245 |
"""
|
| 246 |
if state == "thinking":
|
| 247 |
return _thinking_frame(t_state)
|
|
|
|
| 250 |
if state == "responding":
|
| 251 |
return _responding_frame(t_state)
|
| 252 |
if state == "speaking":
|
| 253 |
+
return _speaking_frame(t_state, audio_env)
|
| 254 |
if state == "acknowledge_toggle":
|
| 255 |
return _acknowledge_frame(t_state)
|
| 256 |
return _idle_frame(t_state)
|
|
@@ -1,20 +1,20 @@
|
|
| 1 |
"""Text-to-speech for Marginalia.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
* **
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
empty
|
| 12 |
-
|
| 13 |
-
* **gTTS** — Google TTS over HTTP
|
| 14 |
-
almost always works as a last resort.
|
| 15 |
|
| 16 |
``MARGINALIA_TTS_MODE``:
|
| 17 |
-
``auto`` (default) —
|
|
|
|
| 18 |
``hf`` — HF only.
|
| 19 |
``offline`` — pyttsx3 only.
|
| 20 |
``gtts`` — gTTS only.
|
|
@@ -22,10 +22,18 @@ Three synthesis backends, tried in order until one returns real audio:
|
|
| 22 |
``synthesize()`` always returns mono ``float32`` PCM at **16 kHz**, ready
|
| 23 |
for ``reachy_mini.media.push_audio_sample()``. Empty input or total
|
| 24 |
failure returns an empty array (caller treats it as silence).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
"""
|
| 26 |
|
| 27 |
from __future__ import annotations
|
| 28 |
|
|
|
|
| 29 |
import io
|
| 30 |
import logging
|
| 31 |
import os
|
|
@@ -43,6 +51,7 @@ logger = logging.getLogger("marginalia.speech")
|
|
| 43 |
ROBOT_SAMPLE_RATE: Final[int] = 16000
|
| 44 |
MIN_PCM_SAMPLES: Final[int] = 800
|
| 45 |
MIN_WAV_BYTES: Final[int] = 1024
|
|
|
|
| 46 |
|
| 47 |
TTS_MODE = os.environ.get("MARGINALIA_TTS_MODE", "auto").lower().strip()
|
| 48 |
HF_TTS_MODEL = os.environ.get(
|
|
@@ -53,6 +62,29 @@ PYTTSX3_VOICE = os.environ.get("MARGINALIA_TTS_VOICE", "")
|
|
| 53 |
GTTS_LANG = os.environ.get("MARGINALIA_TTS_GTTS_LANG", "en")
|
| 54 |
TTS_DEBUG = os.environ.get("MARGINALIA_TTS_DEBUG", "").lower() in {"1", "true", "yes"}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
_CITATION_RE = re.compile(r"\[\d+\]")
|
| 57 |
_URL_RE = re.compile(r"https?://\S+")
|
| 58 |
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)")
|
|
@@ -65,14 +97,14 @@ _HR_RE = re.compile(r"^---+$", flags=re.MULTILINE)
|
|
| 65 |
|
| 66 |
|
| 67 |
def clean_for_speech(markdown: str) -> str:
|
| 68 |
-
"""Reduce a Marginalia markdown answer to a speakable string.
|
| 69 |
-
|
| 70 |
-
Drops the "How to read this" + "Further reading" sections (those stay
|
| 71 |
-
on the page only), then strips citations, URLs, code spans, and
|
| 72 |
-
markdown emphasis.
|
| 73 |
-
"""
|
| 74 |
text = markdown or ""
|
| 75 |
-
for cut in (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
idx = text.find(cut)
|
| 77 |
if idx != -1:
|
| 78 |
text = text[:idx]
|
|
@@ -92,8 +124,63 @@ def clean_for_speech(markdown: str) -> str:
|
|
| 92 |
return text.strip()
|
| 93 |
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
def _ensure_mono_float32(samples: np.ndarray) -> np.ndarray:
|
| 96 |
-
"""Convert any sample buffer into mono float32 in [-1, 1]."""
|
| 97 |
a = np.asarray(samples)
|
| 98 |
if a.ndim == 2:
|
| 99 |
a = a.mean(axis=1)
|
|
@@ -126,8 +213,30 @@ def _decode_audio_bytes(audio_bytes: bytes) -> np.ndarray:
|
|
| 126 |
return _resample(samples, src_sr=int(sr), dst_sr=ROBOT_SAMPLE_RATE)
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
def _synthesize_hf(text: str) -> np.ndarray:
|
| 130 |
-
"""HF Inference TTS. Raises on any failure so caller can fall back."""
|
| 131 |
from huggingface_hub import InferenceClient
|
| 132 |
|
| 133 |
client = InferenceClient(model=HF_TTS_MODEL)
|
|
@@ -143,12 +252,6 @@ def _synthesize_hf(text: str) -> np.ndarray:
|
|
| 143 |
|
| 144 |
|
| 145 |
def _synthesize_pyttsx3(text: str) -> np.ndarray:
|
| 146 |
-
"""Offline TTS via pyttsx3 → WAV → resample to 16 kHz.
|
| 147 |
-
|
| 148 |
-
On Linux + espeak, ``save_to_file`` sometimes writes a header-only WAV
|
| 149 |
-
with no audio data. We validate the file size after `runAndWait()` and
|
| 150 |
-
raise so the next backend can take over.
|
| 151 |
-
"""
|
| 152 |
import pyttsx3
|
| 153 |
|
| 154 |
engine = pyttsx3.init()
|
|
@@ -187,7 +290,6 @@ def _synthesize_pyttsx3(text: str) -> np.ndarray:
|
|
| 187 |
|
| 188 |
|
| 189 |
def _synthesize_gtts(text: str) -> np.ndarray:
|
| 190 |
-
"""Google TTS over HTTP. MP3 output decoded by libsndfile."""
|
| 191 |
from gtts import gTTS
|
| 192 |
|
| 193 |
buf = io.BytesIO()
|
|
@@ -203,49 +305,45 @@ def _synthesize_gtts(text: str) -> np.ndarray:
|
|
| 203 |
return samples
|
| 204 |
|
| 205 |
|
| 206 |
-
|
| 207 |
-
"hf": _synthesize_hf,
|
| 208 |
-
"offline": _synthesize_pyttsx3,
|
| 209 |
-
"gtts": _synthesize_gtts,
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
def synthesize(text: str) -> np.ndarray:
|
| 214 |
"""Return mono float32 PCM at 16 kHz speaking ``text``.
|
| 215 |
|
| 216 |
-
Honours ``MARGINALIA_TTS_MODE``.
|
| 217 |
-
|
| 218 |
-
blocks the answer pipeline on a
|
|
|
|
| 219 |
"""
|
| 220 |
text = (text or "").strip()
|
| 221 |
if not text:
|
| 222 |
return np.zeros(0, dtype=np.float32)
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
-
mode = TTS_MODE if TTS_MODE in {"auto", "hf", "offline", "gtts"} else "auto"
|
| 225 |
-
tries: list[str]
|
| 226 |
if mode == "auto":
|
| 227 |
-
tries = ["hf", "offline", "gtts"]
|
| 228 |
else:
|
| 229 |
tries = [mode]
|
| 230 |
|
| 231 |
last_err: Exception | None = None
|
| 232 |
for backend in tries:
|
| 233 |
-
fn = _BACKENDS.get(backend)
|
| 234 |
-
if fn is None:
|
| 235 |
-
continue
|
| 236 |
try:
|
| 237 |
-
|
| 238 |
-
"synthesize via %s
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
except Exception as e:
|
| 244 |
last_err = e
|
| 245 |
-
logger.warning(
|
| 246 |
-
"TTS backend %s failed: %r",
|
| 247 |
-
backend, e,
|
| 248 |
-
)
|
| 249 |
if TTS_DEBUG:
|
| 250 |
logger.warning("traceback:\n%s", traceback.format_exc())
|
| 251 |
|
|
|
|
| 1 |
"""Text-to-speech for Marginalia.
|
| 2 |
|
| 3 |
+
Backends, in order of preference:
|
| 4 |
+
|
| 5 |
+
* **edge-tts** — Microsoft Edge's neural voices over HTTP. Fast (sub-second
|
| 6 |
+
per paragraph), natural-sounding, free, no API key. The primary backend
|
| 7 |
+
and the only one with a curated voice picker.
|
| 8 |
+
* **Hugging Face Inference TTS** — falls back to ``InferenceClient.text_to_speech``
|
| 9 |
+
(default ``facebook/mms-tts-eng``) if edge-tts can't reach Microsoft.
|
| 10 |
+
* **pyttsx3** — offline. Often produces an empty WAV on Linux/espeak; we
|
| 11 |
+
validate the file and treat empty output as a failure so the next
|
| 12 |
+
backend takes over.
|
| 13 |
+
* **gTTS** — Google TTS over HTTP. Last-resort cloud fallback.
|
|
|
|
| 14 |
|
| 15 |
``MARGINALIA_TTS_MODE``:
|
| 16 |
+
``auto`` (default) — edge → hf → pyttsx3 → gtts.
|
| 17 |
+
``edge`` — edge-tts only.
|
| 18 |
``hf`` — HF only.
|
| 19 |
``offline`` — pyttsx3 only.
|
| 20 |
``gtts`` — gTTS only.
|
|
|
|
| 22 |
``synthesize()`` always returns mono ``float32`` PCM at **16 kHz**, ready
|
| 23 |
for ``reachy_mini.media.push_audio_sample()``. Empty input or total
|
| 24 |
failure returns an empty array (caller treats it as silence).
|
| 25 |
+
|
| 26 |
+
This module also exposes:
|
| 27 |
+
* ``split_for_speech(text)`` — break a clean answer into paragraph chunks
|
| 28 |
+
for low-latency streaming TTS.
|
| 29 |
+
* ``compute_envelope(pcm)`` — RMS amplitude per ~50 ms frame, used to
|
| 30 |
+
drive audio-reactive head and antenna motion while the robot speaks.
|
| 31 |
+
* ``VOICES`` and ``DEFAULT_VOICE`` — curated edge-tts voice catalogue.
|
| 32 |
"""
|
| 33 |
|
| 34 |
from __future__ import annotations
|
| 35 |
|
| 36 |
+
import asyncio
|
| 37 |
import io
|
| 38 |
import logging
|
| 39 |
import os
|
|
|
|
| 51 |
ROBOT_SAMPLE_RATE: Final[int] = 16000
|
| 52 |
MIN_PCM_SAMPLES: Final[int] = 800
|
| 53 |
MIN_WAV_BYTES: Final[int] = 1024
|
| 54 |
+
ENVELOPE_FRAME_MS: Final[int] = 50
|
| 55 |
|
| 56 |
TTS_MODE = os.environ.get("MARGINALIA_TTS_MODE", "auto").lower().strip()
|
| 57 |
HF_TTS_MODEL = os.environ.get(
|
|
|
|
| 62 |
GTTS_LANG = os.environ.get("MARGINALIA_TTS_GTTS_LANG", "en")
|
| 63 |
TTS_DEBUG = os.environ.get("MARGINALIA_TTS_DEBUG", "").lower() in {"1", "true", "yes"}
|
| 64 |
|
| 65 |
+
EDGE_RATE = os.environ.get("MARGINALIA_EDGE_RATE", "+0%")
|
| 66 |
+
EDGE_PITCH = os.environ.get("MARGINALIA_EDGE_PITCH", "+0Hz")
|
| 67 |
+
|
| 68 |
+
# Curated voice catalogue. `id` is the edge-tts voice short-name, `label`
|
| 69 |
+
# is what the UI shows. The first entry is the default if no voice is
|
| 70 |
+
# pinned via env var or per-call.
|
| 71 |
+
VOICES: list[dict[str, str]] = [
|
| 72 |
+
{"id": "en-US-AvaNeural", "label": "Ava — calm reader (US, female)"},
|
| 73 |
+
{"id": "en-US-AndrewNeural", "label": "Andrew — companion (US, male)"},
|
| 74 |
+
{"id": "en-US-EmmaNeural", "label": "Emma — bright reader (US, female)"},
|
| 75 |
+
{"id": "en-US-GuyNeural", "label": "Guy — warm narrator (US, male)"},
|
| 76 |
+
{"id": "en-US-DavisNeural", "label": "Davis — soothsayer (US, male, deep)"},
|
| 77 |
+
{"id": "en-GB-SoniaNeural", "label": "Sonia — librarian (UK, female)"},
|
| 78 |
+
{"id": "en-GB-RyanNeural", "label": "Ryan — bookish (UK, male)"},
|
| 79 |
+
{"id": "en-IE-EmilyNeural", "label": "Emily — Joycean (IE, female)"},
|
| 80 |
+
{"id": "en-AU-NatashaNeural", "label": "Natasha — antipodean (AU, female)"},
|
| 81 |
+
]
|
| 82 |
+
VOICE_IDS = {v["id"] for v in VOICES}
|
| 83 |
+
DEFAULT_VOICE = os.environ.get("MARGINALIA_VOICE", VOICES[0]["id"]).strip()
|
| 84 |
+
if DEFAULT_VOICE not in VOICE_IDS:
|
| 85 |
+
DEFAULT_VOICE = VOICES[0]["id"]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
_CITATION_RE = re.compile(r"\[\d+\]")
|
| 89 |
_URL_RE = re.compile(r"https?://\S+")
|
| 90 |
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)")
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def clean_for_speech(markdown: str) -> str:
|
| 100 |
+
"""Reduce a Marginalia markdown answer to a speakable string."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
text = markdown or ""
|
| 102 |
+
for cut in (
|
| 103 |
+
"### How to read this",
|
| 104 |
+
"### Further reading",
|
| 105 |
+
"## How to read",
|
| 106 |
+
"## Further reading",
|
| 107 |
+
):
|
| 108 |
idx = text.find(cut)
|
| 109 |
if idx != -1:
|
| 110 |
text = text[:idx]
|
|
|
|
| 124 |
return text.strip()
|
| 125 |
|
| 126 |
|
| 127 |
+
def split_for_speech(text: str, target_chars: int = 280) -> list[str]:
|
| 128 |
+
"""Break clean prose into roughly paragraph-sized speakable chunks.
|
| 129 |
+
|
| 130 |
+
Splits on blank lines first; very small pieces are merged into the
|
| 131 |
+
next chunk so we don't waste a TTS round-trip on a sentence fragment.
|
| 132 |
+
"""
|
| 133 |
+
text = (text or "").strip()
|
| 134 |
+
if not text:
|
| 135 |
+
return []
|
| 136 |
+
|
| 137 |
+
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
| 138 |
+
if not paragraphs:
|
| 139 |
+
return []
|
| 140 |
+
|
| 141 |
+
chunks: list[str] = []
|
| 142 |
+
buf = ""
|
| 143 |
+
for p in paragraphs:
|
| 144 |
+
if not buf:
|
| 145 |
+
buf = p
|
| 146 |
+
elif len(buf) < target_chars and len(p) < target_chars:
|
| 147 |
+
buf = buf + "\n\n" + p
|
| 148 |
+
if len(buf) >= target_chars:
|
| 149 |
+
chunks.append(buf)
|
| 150 |
+
buf = ""
|
| 151 |
+
else:
|
| 152 |
+
chunks.append(buf)
|
| 153 |
+
buf = p
|
| 154 |
+
if buf:
|
| 155 |
+
chunks.append(buf)
|
| 156 |
+
return chunks
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def compute_envelope(
|
| 160 |
+
pcm: np.ndarray, frame_ms: int = ENVELOPE_FRAME_MS
|
| 161 |
+
) -> tuple[np.ndarray, float]:
|
| 162 |
+
"""Return (envelope, frame_dt_seconds).
|
| 163 |
+
|
| 164 |
+
Envelope is RMS amplitude per ``frame_ms`` window, normalised so the
|
| 165 |
+
99th percentile maps to roughly 1.0. Used by the run loop to drive
|
| 166 |
+
audio-reactive head/antenna motion while the robot speaks.
|
| 167 |
+
"""
|
| 168 |
+
if pcm is None or len(pcm) == 0:
|
| 169 |
+
return np.zeros(0, dtype=np.float32), frame_ms / 1000.0
|
| 170 |
+
samples_per_frame = max(1, int(ROBOT_SAMPLE_RATE * frame_ms / 1000))
|
| 171 |
+
n_frames = len(pcm) // samples_per_frame
|
| 172 |
+
if n_frames <= 0:
|
| 173 |
+
return np.zeros(0, dtype=np.float32), frame_ms / 1000.0
|
| 174 |
+
trimmed = pcm[: n_frames * samples_per_frame]
|
| 175 |
+
framed = trimmed.reshape(n_frames, samples_per_frame).astype(np.float32, copy=False)
|
| 176 |
+
rms = np.sqrt(np.mean(framed * framed, axis=1) + 1e-9)
|
| 177 |
+
p99 = float(np.quantile(rms, 0.99))
|
| 178 |
+
if p99 > 0:
|
| 179 |
+
rms = np.clip(rms / p99, 0.0, 1.5).astype(np.float32)
|
| 180 |
+
return rms, frame_ms / 1000.0
|
| 181 |
+
|
| 182 |
+
|
| 183 |
def _ensure_mono_float32(samples: np.ndarray) -> np.ndarray:
|
|
|
|
| 184 |
a = np.asarray(samples)
|
| 185 |
if a.ndim == 2:
|
| 186 |
a = a.mean(axis=1)
|
|
|
|
| 213 |
return _resample(samples, src_sr=int(sr), dst_sr=ROBOT_SAMPLE_RATE)
|
| 214 |
|
| 215 |
|
| 216 |
+
def _synthesize_edge(text: str, voice: str) -> np.ndarray:
|
| 217 |
+
"""Microsoft Edge neural TTS over HTTP. Fast, natural, no key."""
|
| 218 |
+
import edge_tts
|
| 219 |
+
|
| 220 |
+
async def _gather() -> bytes:
|
| 221 |
+
communicate = edge_tts.Communicate(
|
| 222 |
+
text, voice, rate=EDGE_RATE, pitch=EDGE_PITCH
|
| 223 |
+
)
|
| 224 |
+
chunks = bytearray()
|
| 225 |
+
async for chunk in communicate.stream():
|
| 226 |
+
if chunk.get("type") == "audio":
|
| 227 |
+
chunks.extend(chunk["data"])
|
| 228 |
+
return bytes(chunks)
|
| 229 |
+
|
| 230 |
+
audio_bytes = asyncio.run(_gather())
|
| 231 |
+
if not audio_bytes:
|
| 232 |
+
raise RuntimeError(f"edge-tts ({voice}) returned empty audio")
|
| 233 |
+
samples = _decode_audio_bytes(audio_bytes)
|
| 234 |
+
if len(samples) < MIN_PCM_SAMPLES:
|
| 235 |
+
raise RuntimeError(f"edge-tts ({voice}) produced only {len(samples)} samples")
|
| 236 |
+
return samples
|
| 237 |
+
|
| 238 |
+
|
| 239 |
def _synthesize_hf(text: str) -> np.ndarray:
|
|
|
|
| 240 |
from huggingface_hub import InferenceClient
|
| 241 |
|
| 242 |
client = InferenceClient(model=HF_TTS_MODEL)
|
|
|
|
| 252 |
|
| 253 |
|
| 254 |
def _synthesize_pyttsx3(text: str) -> np.ndarray:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
import pyttsx3
|
| 256 |
|
| 257 |
engine = pyttsx3.init()
|
|
|
|
| 290 |
|
| 291 |
|
| 292 |
def _synthesize_gtts(text: str) -> np.ndarray:
|
|
|
|
| 293 |
from gtts import gTTS
|
| 294 |
|
| 295 |
buf = io.BytesIO()
|
|
|
|
| 305 |
return samples
|
| 306 |
|
| 307 |
|
| 308 |
+
def synthesize(text: str, voice: str | None = None) -> np.ndarray:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
"""Return mono float32 PCM at 16 kHz speaking ``text``.
|
| 310 |
|
| 311 |
+
Honours ``MARGINALIA_TTS_MODE``. ``voice`` only affects the edge-tts
|
| 312 |
+
backend; the others ignore it. Empty input or total failure returns
|
| 313 |
+
an empty array so the caller never blocks the answer pipeline on a
|
| 314 |
+
broken speaker.
|
| 315 |
"""
|
| 316 |
text = (text or "").strip()
|
| 317 |
if not text:
|
| 318 |
return np.zeros(0, dtype=np.float32)
|
| 319 |
+
voice = (voice or DEFAULT_VOICE).strip()
|
| 320 |
+
if voice not in VOICE_IDS:
|
| 321 |
+
voice = DEFAULT_VOICE
|
| 322 |
|
| 323 |
+
mode = TTS_MODE if TTS_MODE in {"auto", "edge", "hf", "offline", "gtts"} else "auto"
|
|
|
|
| 324 |
if mode == "auto":
|
| 325 |
+
tries = ["edge", "hf", "offline", "gtts"]
|
| 326 |
else:
|
| 327 |
tries = [mode]
|
| 328 |
|
| 329 |
last_err: Exception | None = None
|
| 330 |
for backend in tries:
|
|
|
|
|
|
|
|
|
|
| 331 |
try:
|
| 332 |
+
if backend == "edge":
|
| 333 |
+
logger.info("synthesize via edge-tts (%s)", voice)
|
| 334 |
+
return _synthesize_edge(text, voice)
|
| 335 |
+
if backend == "hf":
|
| 336 |
+
logger.info("synthesize via hf (%s)", HF_TTS_MODEL)
|
| 337 |
+
return _synthesize_hf(text)
|
| 338 |
+
if backend == "offline":
|
| 339 |
+
logger.info("synthesize via pyttsx3")
|
| 340 |
+
return _synthesize_pyttsx3(text)
|
| 341 |
+
if backend == "gtts":
|
| 342 |
+
logger.info("synthesize via gtts")
|
| 343 |
+
return _synthesize_gtts(text)
|
| 344 |
except Exception as e:
|
| 345 |
last_err = e
|
| 346 |
+
logger.warning("TTS backend %s failed: %r", backend, e)
|
|
|
|
|
|
|
|
|
|
| 347 |
if TTS_DEBUG:
|
| 348 |
logger.warning("traceback:\n%s", traceback.format_exc())
|
| 349 |
|
|
@@ -42,6 +42,10 @@
|
|
| 42 |
<span class="pill-icon" aria-hidden="true"></span>
|
| 43 |
<span class="pill-label">Read aloud</span>
|
| 44 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
</div>
|
| 46 |
<p class="hint">Tip — twist either antenna to mute or unmute the spoken answer.</p>
|
| 47 |
</form>
|
|
|
|
| 42 |
<span class="pill-icon" aria-hidden="true"></span>
|
| 43 |
<span class="pill-label">Read aloud</span>
|
| 44 |
</button>
|
| 45 |
+
<label class="voice-picker" for="voice-select" title="Choose Reachy's reading voice">
|
| 46 |
+
<span class="voice-picker-label">Voice</span>
|
| 47 |
+
<select id="voice-select" class="voice-select" aria-label="Reading voice"></select>
|
| 48 |
+
</label>
|
| 49 |
</div>
|
| 50 |
<p class="hint">Tip — twist either antenna to mute or unmute the spoken answer.</p>
|
| 51 |
</form>
|
|
@@ -14,9 +14,12 @@
|
|
| 14 |
const sourcesList = document.getElementById("sources-list");
|
| 15 |
const speechPill = document.getElementById("speech-pill");
|
| 16 |
const pillLabel = speechPill ? speechPill.querySelector(".pill-label") : null;
|
|
|
|
| 17 |
|
| 18 |
let speechEnabled = true;
|
| 19 |
let speakingSpoken = false;
|
|
|
|
|
|
|
| 20 |
|
| 21 |
if (window.marked && marked.setOptions) {
|
| 22 |
marked.setOptions({ gfm: true, breaks: false });
|
|
@@ -47,12 +50,34 @@
|
|
| 47 |
else speechPill.classList.remove("speaking");
|
| 48 |
}
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
async function loadInitialState() {
|
| 51 |
try {
|
| 52 |
const resp = await fetch("/api/state", { headers: { Accept: "application/json" } });
|
| 53 |
if (resp.ok) {
|
| 54 |
const data = await resp.json();
|
| 55 |
applySpeechEnabled(data.speech_enabled !== false);
|
|
|
|
|
|
|
| 56 |
return;
|
| 57 |
}
|
| 58 |
} catch (_) { /* offline-first: keep default true */ }
|
|
@@ -71,6 +96,20 @@
|
|
| 71 |
}
|
| 72 |
}
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
function renderAnswer(text) {
|
| 75 |
let html;
|
| 76 |
if (window.marked && typeof marked.parse === "function") {
|
|
@@ -168,12 +207,15 @@
|
|
| 168 |
try {
|
| 169 |
const d = JSON.parse(ev.data);
|
| 170 |
if (typeof d.speech_enabled === "boolean") applySpeechEnabled(d.speech_enabled);
|
|
|
|
|
|
|
| 171 |
} catch (_) {}
|
| 172 |
});
|
| 173 |
es.addEventListener("state_changed", (ev) => {
|
| 174 |
try {
|
| 175 |
const d = JSON.parse(ev.data);
|
| 176 |
if (typeof d.speech_enabled === "boolean") applySpeechEnabled(d.speech_enabled);
|
|
|
|
| 177 |
} catch (_) {}
|
| 178 |
});
|
| 179 |
es.onerror = () => { /* let the browser reconnect */ };
|
|
@@ -218,6 +260,8 @@
|
|
| 218 |
setSpeakingVisual(true);
|
| 219 |
setStatus("Reachy is reading aloud", "speaking");
|
| 220 |
speakingSpoken = true;
|
|
|
|
|
|
|
| 221 |
} else if (ev.event === "spoken_done") {
|
| 222 |
setSpeakingVisual(false);
|
| 223 |
setStatus("", "");
|
|
@@ -242,6 +286,13 @@
|
|
| 242 |
|
| 243 |
if (speechPill) speechPill.addEventListener("click", toggleSpeech);
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
form.addEventListener("submit", (e) => {
|
| 246 |
e.preventDefault();
|
| 247 |
const q = questionEl.value.trim();
|
|
|
|
| 14 |
const sourcesList = document.getElementById("sources-list");
|
| 15 |
const speechPill = document.getElementById("speech-pill");
|
| 16 |
const pillLabel = speechPill ? speechPill.querySelector(".pill-label") : null;
|
| 17 |
+
const voiceSelect = document.getElementById("voice-select");
|
| 18 |
|
| 19 |
let speechEnabled = true;
|
| 20 |
let speakingSpoken = false;
|
| 21 |
+
let currentVoice = null;
|
| 22 |
+
let voicesPopulated = false;
|
| 23 |
|
| 24 |
if (window.marked && marked.setOptions) {
|
| 25 |
marked.setOptions({ gfm: true, breaks: false });
|
|
|
|
| 50 |
else speechPill.classList.remove("speaking");
|
| 51 |
}
|
| 52 |
|
| 53 |
+
function populateVoices(voices, selected) {
|
| 54 |
+
if (!voiceSelect || voicesPopulated) return;
|
| 55 |
+
voiceSelect.innerHTML = "";
|
| 56 |
+
for (const v of voices || []) {
|
| 57 |
+
const opt = document.createElement("option");
|
| 58 |
+
opt.value = v.id;
|
| 59 |
+
opt.textContent = v.label || v.id;
|
| 60 |
+
if (v.id === selected) opt.selected = true;
|
| 61 |
+
voiceSelect.appendChild(opt);
|
| 62 |
+
}
|
| 63 |
+
voicesPopulated = true;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function applyVoice(voiceId) {
|
| 67 |
+
currentVoice = voiceId;
|
| 68 |
+
if (voiceSelect && voiceSelect.value !== voiceId) {
|
| 69 |
+
voiceSelect.value = voiceId;
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
async function loadInitialState() {
|
| 74 |
try {
|
| 75 |
const resp = await fetch("/api/state", { headers: { Accept: "application/json" } });
|
| 76 |
if (resp.ok) {
|
| 77 |
const data = await resp.json();
|
| 78 |
applySpeechEnabled(data.speech_enabled !== false);
|
| 79 |
+
if (Array.isArray(data.voices)) populateVoices(data.voices, data.voice);
|
| 80 |
+
if (data.voice) applyVoice(data.voice);
|
| 81 |
return;
|
| 82 |
}
|
| 83 |
} catch (_) { /* offline-first: keep default true */ }
|
|
|
|
| 96 |
}
|
| 97 |
}
|
| 98 |
|
| 99 |
+
async function changeVoice(voiceId) {
|
| 100 |
+
try {
|
| 101 |
+
const resp = await fetch("/api/speech/voice", {
|
| 102 |
+
method: "POST",
|
| 103 |
+
headers: { "Content-Type": "application/json" },
|
| 104 |
+
body: JSON.stringify({ voice: voiceId }),
|
| 105 |
+
});
|
| 106 |
+
if (resp.ok) {
|
| 107 |
+
const data = await resp.json();
|
| 108 |
+
if (data.voice) applyVoice(data.voice);
|
| 109 |
+
}
|
| 110 |
+
} catch (_) { /* keep last value */ }
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
function renderAnswer(text) {
|
| 114 |
let html;
|
| 115 |
if (window.marked && typeof marked.parse === "function") {
|
|
|
|
| 207 |
try {
|
| 208 |
const d = JSON.parse(ev.data);
|
| 209 |
if (typeof d.speech_enabled === "boolean") applySpeechEnabled(d.speech_enabled);
|
| 210 |
+
if (Array.isArray(d.voices)) populateVoices(d.voices, d.voice);
|
| 211 |
+
if (d.voice) applyVoice(d.voice);
|
| 212 |
} catch (_) {}
|
| 213 |
});
|
| 214 |
es.addEventListener("state_changed", (ev) => {
|
| 215 |
try {
|
| 216 |
const d = JSON.parse(ev.data);
|
| 217 |
if (typeof d.speech_enabled === "boolean") applySpeechEnabled(d.speech_enabled);
|
| 218 |
+
if (typeof d.voice === "string") applyVoice(d.voice);
|
| 219 |
} catch (_) {}
|
| 220 |
});
|
| 221 |
es.onerror = () => { /* let the browser reconnect */ };
|
|
|
|
| 260 |
setSpeakingVisual(true);
|
| 261 |
setStatus("Reachy is reading aloud", "speaking");
|
| 262 |
speakingSpoken = true;
|
| 263 |
+
} else if (ev.event === "chunk_played") {
|
| 264 |
+
/* Each paragraph push lands here; keeps the UI lively. */
|
| 265 |
} else if (ev.event === "spoken_done") {
|
| 266 |
setSpeakingVisual(false);
|
| 267 |
setStatus("", "");
|
|
|
|
| 286 |
|
| 287 |
if (speechPill) speechPill.addEventListener("click", toggleSpeech);
|
| 288 |
|
| 289 |
+
if (voiceSelect) {
|
| 290 |
+
voiceSelect.addEventListener("change", (e) => {
|
| 291 |
+
const v = e.target.value;
|
| 292 |
+
if (v) changeVoice(v);
|
| 293 |
+
});
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
form.addEventListener("submit", (e) => {
|
| 297 |
e.preventDefault();
|
| 298 |
const q = questionEl.value.trim();
|
|
@@ -278,6 +278,60 @@ button.seal:disabled {
|
|
| 278 |
50% { transform: scale(1.25); }
|
| 279 |
}
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
.hint {
|
| 282 |
margin: 0.2rem 0 0;
|
| 283 |
font-size: 0.82rem;
|
|
@@ -575,6 +629,16 @@ button.seal:disabled {
|
|
| 575 |
margin-left: 0;
|
| 576 |
}
|
| 577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
.sources-list li {
|
| 579 |
grid-template-columns: 1fr;
|
| 580 |
}
|
|
|
|
| 278 |
50% { transform: scale(1.25); }
|
| 279 |
}
|
| 280 |
|
| 281 |
+
/* voice picker */
|
| 282 |
+
.voice-picker {
|
| 283 |
+
display: inline-flex;
|
| 284 |
+
align-items: center;
|
| 285 |
+
gap: 0.45rem;
|
| 286 |
+
padding: 0.3rem 0.55rem 0.3rem 0.7rem;
|
| 287 |
+
background: rgba(27, 33, 56, 0.05);
|
| 288 |
+
border: 1px solid var(--rule);
|
| 289 |
+
border-radius: 999px;
|
| 290 |
+
transition: border-color 0.15s ease, background 0.15s ease;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.voice-picker:hover {
|
| 294 |
+
border-color: var(--emerald-mid);
|
| 295 |
+
background: rgba(14, 53, 35, 0.06);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.voice-picker-label {
|
| 299 |
+
font-family: "IM Fell English SC", "Cormorant Garamond", serif;
|
| 300 |
+
font-size: 0.72rem;
|
| 301 |
+
letter-spacing: 0.16em;
|
| 302 |
+
text-transform: uppercase;
|
| 303 |
+
color: var(--ink-faint);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.voice-select {
|
| 307 |
+
appearance: none;
|
| 308 |
+
-webkit-appearance: none;
|
| 309 |
+
border: none;
|
| 310 |
+
background: transparent;
|
| 311 |
+
color: var(--ink);
|
| 312 |
+
font-family: "EB Garamond", "Garamond", serif;
|
| 313 |
+
font-size: 0.92rem;
|
| 314 |
+
font-style: italic;
|
| 315 |
+
cursor: pointer;
|
| 316 |
+
padding: 0.05rem 1.4rem 0.05rem 0.1rem;
|
| 317 |
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%2358402f' d='M0 0l5 6 5-6z'/></svg>");
|
| 318 |
+
background-repeat: no-repeat;
|
| 319 |
+
background-position: right 0.25rem center;
|
| 320 |
+
background-size: 8px 5px;
|
| 321 |
+
max-width: 16em;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.voice-select:focus {
|
| 325 |
+
outline: none;
|
| 326 |
+
color: var(--emerald);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.voice-select option {
|
| 330 |
+
color: var(--ink);
|
| 331 |
+
background: var(--parchment);
|
| 332 |
+
font-style: normal;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
.hint {
|
| 336 |
margin: 0.2rem 0 0;
|
| 337 |
font-size: 0.82rem;
|
|
|
|
| 629 |
margin-left: 0;
|
| 630 |
}
|
| 631 |
|
| 632 |
+
.voice-picker {
|
| 633 |
+
margin-left: 0;
|
| 634 |
+
flex: 1 1 auto;
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.voice-select {
|
| 638 |
+
max-width: 100%;
|
| 639 |
+
width: 100%;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
.sources-list li {
|
| 643 |
grid-template-columns: 1fr;
|
| 644 |
}
|
|
@@ -15,6 +15,7 @@ dependencies = [
|
|
| 15 |
"reachy-mini>=1.7.0,<2.0.0",
|
| 16 |
"huggingface-hub>=1.0,<2.0",
|
| 17 |
"requests>=2.28",
|
|
|
|
| 18 |
"pyttsx3>=2.90",
|
| 19 |
"soundfile>=0.12",
|
| 20 |
"scipy>=1.10",
|
|
|
|
| 15 |
"reachy-mini>=1.7.0,<2.0.0",
|
| 16 |
"huggingface-hub>=1.0,<2.0",
|
| 17 |
"requests>=2.28",
|
| 18 |
+
"edge-tts>=6.1.10",
|
| 19 |
"pyttsx3>=2.90",
|
| 20 |
"soundfile>=0.12",
|
| 21 |
"scipy>=1.10",
|