SantiPa commited on
Commit
4cb8cce
·
1 Parent(s): d424f4a

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 CHANGED
@@ -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.** Once the LLM finishes, the answer
41
- prose is synthesised and pushed to the robot's speaker while gentle
42
- head, antenna, and body movements pace the sentences. Prefer to read in
43
- silence? Twist *either* antenna and Reachy switches to **read mode** —
 
 
 
 
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
- | `MARGINALIA_HF_TTS_MODEL` | `facebook/mms-tts-eng` | HF Inference text-to-speech model. The default works text-only; SpeechT5 needs speaker embeddings the simple API does not supply. |
 
 
 
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.synthesize()
113
- HF Inference TTS,
114
- pyttsx3 fallback,
 
 
115
  │ 16 kHz mono float32 │
 
 
116
  └──────┬────────────────────┘
117
 
118
  ┌──────────────────────────┐
119
- │ motion state machine
120
- Reachy head, antennas,
121
- body_yaw +
122
- media.push_audio_sample
 
 
 
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
- `synthesize()` tries HF Inference TTS first then falls back to
143
- `pyttsx3`, always returning 16 kHz mono float32 PCM ready for the
144
- robot speaker.
 
 
 
 
145
  * **`marginalia/main.py`** — `Marginalia(ReachyMiniApp)` registers
146
- `/api/health`, `/api/state`, `/api/speech/toggle`, `/api/events`
147
- (ambient SSE), and `/api/ask` (per-question SSE) on the SDK's bundled
148
- FastAPI app, serves the static UI from `static/`, and polls antenna
149
- positions in the run loop to toggle speech on a user twist.
 
 
 
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 text-to-speech model on the HF inference
169
- API via `MARGINALIA_HF_TTS_MODEL`, force the offline voice with
170
- `MARGINALIA_TTS_MODE=offline`, or tweak `MARGINALIA_TTS_RATE`.
 
 
 
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.
index.html CHANGED
@@ -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">♪ Read aloud</span>
 
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 gently sways its
84
- head while it thinks, nods once when the answer begins,
85
- and then reads it aloud through the speaker while small
86
- antenna and body movements punctuate the prose. Each
87
- answer comes with a <em>How&nbsp;to&nbsp;read&nbsp;this</em>
88
- panel of tips tailored to the work, plus
89
- <em>Further&nbsp;reading</em> for amplification.
 
 
 
 
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 the
130
- answer prose aloud while gentle head, antenna, and body
131
- movements pace the sentences. Twist an antenna any time
132
- to switch to silent <em>read mode</em>.
 
 
 
 
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 → theatrical
166
- head/antenna gestures while the answer is read aloud.
 
 
167
  </li>
168
  <li>
169
- <strong>Voice.</strong> After the LLM finishes, the prose
170
- is synthesised via Hugging&nbsp;Face Inference TTS (with
171
- an offline <code>pyttsx3</code> fallback), resampled to
172
- 16&nbsp;kHz, and pushed to the robot speaker through
173
- <code>reachy_mini.media</code>. Twisting either antenna
174
- above ~23&deg; toggles spoken vs silent mode and is
 
 
 
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
+ &mdash; 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&nbsp;to&nbsp;read&nbsp;this</em> panel of tips
93
+ tailored to the work, plus <em>Further&nbsp;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 &mdash; 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 &mdash; Ava, Andrew, Sonia, Ryan, &hellip;) 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&deg; toggles spoken vs silent mode and is
189
  acknowledged with a small head nod.
190
  </li>
191
  </ol>
marginalia/main.py CHANGED
@@ -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 full answer is then
14
- synthesised with `marginalia.speech` and pushed to the robot's speaker.
 
 
 
 
 
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) and a ``speech_enabled``
47
- flag (drives whether spoken answers play through the robot speaker).
48
- State changes notify any subscribed asyncio queue so the FastAPI
49
- handlers can broadcast `state_changed` SSE frames.
 
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 {"state": self._state, "speech_enabled": self._speech_enabled}
 
 
 
 
 
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 the answer prose and play it through the robot speaker."""
 
 
 
 
 
 
 
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
- pcm = await loop.run_in_executor(None, speech.synthesize, spoken_text)
262
  except Exception as e:
263
- logger.warning("synthesize failed: %s", e)
264
  yield _sse("speech_error", {"message": str(e)})
265
  return
266
 
267
- if pcm is None or len(pcm) == 0:
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
- duration_s = float(len(pcm)) / float(speech.ROBOT_SAMPLE_RATE)
280
- self._app_state.set("speaking")
281
- yield _sse("speaking", {"duration_s": duration_s})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
- played = False
284
- if self._reachy is not None:
285
  try:
286
  with self._media_lock:
287
- media = self._reachy.media
288
- media.start_playing()
289
- media.push_audio_sample(np.asarray(pcm, dtype=np.float32))
290
- played = True
 
 
 
 
 
291
  except Exception as e:
292
- logger.warning("audio push failed: %s", e)
 
 
293
 
294
- if played:
295
- sleep_s = duration_s
296
- while sleep_s > 0 and self._app_state.speech_enabled:
297
- tick = min(0.25, sleep_s)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: %s", e)
308
- else:
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
- frame = motion.pose_for_state(state, t_state)
 
 
 
 
 
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(
marginalia/motion.py CHANGED
@@ -55,46 +55,61 @@ class MotionFrame:
55
  body_yaw: float
56
 
57
 
58
- IDLE_YAW_AMP_DEG = 4.0
59
- IDLE_PITCH_AMP_DEG = 1.5
60
- IDLE_HEAD_PERIOD_S = 6.0
61
- IDLE_BODY_AMP_DEG = 5.0
62
- IDLE_BODY_PERIOD_S = 20.0
63
- IDLE_ANTENNA_REST_DEG = -10.0
64
- IDLE_ANTENNA_BREATH_DEG = 3.0
65
-
66
- THINKING_YAW_AMP_DEG = 14.0
67
- THINKING_PITCH_AMP_DEG = 6.0
68
- THINKING_HEAD_PERIOD_S = 3.2
69
- THINKING_BODY_AMP_DEG = 12.0
70
- THINKING_BODY_PERIOD_S = 5.0
71
- THINKING_ANTENNA_FORWARD_DEG = 25.0
72
- THINKING_ANTENNA_PERIOD_S = 2.4
73
-
74
- NODDING_DURATION_S = 0.7
75
- NODDING_PITCH_DOWN_DEG = 14.0
76
- NODDING_PITCH_UP_DEG = -6.0
77
- NODDING_ANTENNA_DEG = 18.0
78
-
79
- RESPONDING_BOB_AMP_DEG = 2.5
80
- RESPONDING_PITCH_BIAS_DEG = 6.0
81
- RESPONDING_PERIOD_S = 4.0
82
- RESPONDING_ANTENNA_AMP_DEG = 10.0
83
- RESPONDING_ANTENNA_PERIOD_S = 3.5
84
-
85
- SPEAKING_NOD_PERIOD_S = 2.0
86
- SPEAKING_NOD_AMP_DEG = 5.5
87
- SPEAKING_PITCH_BIAS_DEG = 4.0
88
- SPEAKING_YAW_AMP_DEG = 6.0
89
- SPEAKING_YAW_PERIOD_S = 7.0
90
- SPEAKING_BODY_AMP_DEG = 8.0
91
- SPEAKING_BODY_PERIOD_S = 11.0
92
- SPEAKING_ANTENNA_AMP_DEG = 14.0
93
- SPEAKING_ANTENNA_PERIOD_S = 2.6
94
-
95
- ACK_DURATION_S = 0.5
96
- ACK_ANTENNA_DEG = 30.0
97
- ACK_PITCH_DOWN_DEG = 10.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 / 8.0)
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.5))
122
- roll = 0.6 * THINKING_PITCH_AMP_DEG * math.sin(head_phase * 0.5)
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 = 0.6 * RESPONDING_BOB_AMP_DEG * math.sin(phase * 0.7)
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=0.0,
163
  )
164
 
165
 
166
- def _speaking_frame(t: float) -> MotionFrame:
 
 
 
 
 
 
 
 
167
  nod_phase = 2.0 * math.pi * t / SPEAKING_NOD_PERIOD_S
168
- pitch = SPEAKING_PITCH_BIAS_DEG + SPEAKING_NOD_AMP_DEG * (
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
- right = SPEAKING_ANTENNA_AMP_DEG * math.sin(ant_phase)
180
- left = SPEAKING_ANTENNA_AMP_DEG * math.sin(ant_phase + math.pi * 0.7)
 
 
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.5
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=0.0,
198
  )
199
 
200
 
201
- def pose_for_state(state: State, t_state: float) -> MotionFrame:
 
 
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)
marginalia/speech.py CHANGED
@@ -1,20 +1,20 @@
1
  """Text-to-speech for Marginalia.
2
 
3
- Three synthesis backends, tried in order until one returns real audio:
4
-
5
- * **Hugging Face Inference TTS** — ``InferenceClient.text_to_speech()``.
6
- Default model is ``facebook/mms-tts-eng`` because it works directly with
7
- a plain text input; ``microsoft/speecht5_tts`` and many other TTS models
8
- require speaker-embedding tensors that the simple API doesn't supply.
9
- * **pyttsx3** offline. Reliable on macOS (NSSpeech), but on Linux/espeak
10
- it sometimes writes a header-only WAV; we validate the output and treat
11
- empty WAVs as a failure so we move on instead of silently playing
12
- nothing.
13
- * **gTTS** — Google TTS over HTTP, MP3 output. Cloud, but no token, and
14
- almost always works as a last resort.
15
 
16
  ``MARGINALIA_TTS_MODE``:
17
- ``auto`` (default) — HF → pyttsx3 → gTTS.
 
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 ("### How to read this", "### Further reading", "## How to read", "## Further reading"):
 
 
 
 
 
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
- _BACKENDS = {
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``. Empty input returns an empty array.
217
- Errors return an empty array (with a warning) so the caller never
218
- blocks the answer pipeline on a broken speaker.
 
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
- logger.info(
238
- "synthesize via %s%s",
239
- backend,
240
- f" ({HF_TTS_MODEL})" if backend == "hf" else "",
241
- )
242
- return fn(text)
 
 
 
 
 
 
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) — edgehf → 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
 
marginalia/static/index.html CHANGED
@@ -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>
marginalia/static/main.js CHANGED
@@ -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();
marginalia/static/style.css CHANGED
@@ -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
  }
pyproject.toml CHANGED
@@ -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",