diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b3c0f6..b827650 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,13 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased]
+## [0.29.0] — 2026-06-23 — fin Phase 6 (voix v1)
+### Added
+- `useSpeech` : mode **mains-libres** + wake-word « CHLOVA » (`extractCommand`),
+ écoute en boucle, micro en pause pendant le TTS (anti auto-écoute).
+- Chat : bouton "Libre" (mains-libres) ; en mains-libres les réponses sont lues
+ d'office. Indicateur d'écoute. Build OK. (README web : section voix.)
+
## [0.28.0] — 2026-06-23 — début Phase 6 (voix)
### Added
- `web/src/useSpeech.ts` : hook voix 100 % navigateur (Web Speech API), STT
diff --git a/web/README.md b/web/README.md
index 47571c2..fa4bfb5 100644
--- a/web/README.md
+++ b/web/README.md
@@ -23,5 +23,14 @@ Le backend doit tourner avec l'auth configurée (`CHLOVA_ADMIN_*`, voir
| `src/pages/Chat.tsx` | Conversation agent (v0.23.0). |
| `src/pages/Review.tsx` | Need-review : approuver/refuser (v0.24.0). |
-## Périmètre v1
-Login → Chat → Review. Voix + app RN : phases ultérieures (API commune réutilisée).
+## Voix (Phase 6)
+100 % navigateur (Web Speech API), zéro backend/GPU :
+- **Parler** : dictée push-to-talk (fr-FR) → envoyée à l'agent.
+- **Voix ON/OFF** : lecture vocale des réponses (TTS), réglage persistant.
+- **Libre** : mains-libres, déclenché par le wake-word « CHLOVA … » ; le micro se
+ met en pause pendant la synthèse pour éviter l'auto-écoute.
+
+STT = Chrome/Edge (webkit) ; TTS = large support. Dégrade proprement sinon.
+
+## Périmètre
+Login → Chat (+ voix) → Review. App RN : phase ultérieure (API commune réutilisée).
diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx
index 6d2f7a9..d5a2322 100644
--- a/web/src/pages/Chat.tsx
+++ b/web/src/pages/Chat.tsx
@@ -42,7 +42,7 @@ export function Chat() {
try {
const { reply } = await api.chat(token, t);
setMessages((m) => [...m, { role: "assistant", text: reply }]);
- if (speakReplies) speech.speak(reply);
+ if (speakReplies || speech.handsFree) speech.speak(reply);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
logout();
@@ -62,13 +62,18 @@ export function Chat() {
};
const mic = (): void => {
- if (speech.listening) {
+ if (speech.listening && !speech.handsFree) {
speech.stopListening();
return;
}
speech.listen((text) => void sendText(text)); // dicter → envoyer
};
+ const toggleHandsFree = (): void => {
+ if (speech.handsFree) speech.stopHandsFree();
+ else speech.startHandsFree((text) => void sendText(text)); // « CHLOVA … » → envoyer
+ };
+
return (
@@ -86,6 +91,9 @@ export function Chat() {
))}
{busy &&
CHLOVA réfléchit…
}
+ {speech.handsFree && !busy && !speech.speaking && (
+
Mains libres — dis « CHLOVA … »
+ )}
{speech.speaking &&
Lecture vocale…
}
{error &&
{error}
}
@@ -110,7 +118,7 @@ export function Chat() {
onChange={(e) => setInput(e.target.value)}
disabled={busy}
/>
- {speech.sttSupported && (
+ {speech.sttSupported && !speech.handsFree && (