Mounir RAJI

Mon pipeline vocal marchait. Sauf que non.

J'avais décrit le switch FR/EN comme fonctionnel. Il ne l'était pas. Les 4 bugs que je n'avais pas vus, et comment les corriger.

· 7 min de lecture 🇬🇧 Read in English

Partie 8 de la série OpenClaw — La suite directe de l’article 7 sur le pipeline vocal.


Il y a cinq jours, j’ai publié un article qui se terminait comme ça :

“Je parle en anglais. Même pipeline. LANG=en. Kokoro utilise af_heart. Réponse en anglais.”

C’était faux.

Pas faux au sens où j’avais menti — faux au sens où le test initial avait semblé fonctionner, et je n’avais pas poussé assez loin pour voir la limite. J’avais parlé en anglais une fois, obtenu une réponse en anglais, et conclu que le switch de langue marchait. Il ne marchait pas.

Ce que j’avais, c’était de la détection de langue au premier message. Pas du switch mid-session. Pas de retour au français après. Pas de cohérence entre la voix et le texte.

Je l’ai découvert cette semaine. Voici ce qui était cassé et comment je l’ai réparé.


Le symptôme

Après avoir utilisé l’agent quelques jours en conditions réelles, j’ai remarqué quelque chose d’étrange. Je parle en français. Je reçois une réponse en français — avec une voix anglaise. Je parle en anglais. Je reçois une réponse en français — avec une voix anglaise.

La voix avait basculé en anglais et ne revenait jamais. Le texte restait systématiquement en français.

En cherchant à comprendre, j’ai découvert non pas un bug, mais quatre. Imbriqués. Chacun cachait le suivant.


Bug 1 — Le script whisper ne mettait à jour voice.conf qu’une fois

Le script whisper — le wrapper CLI qui gère la transcription STT — contenait cette condition :

if [ ! -s "$CONF" ]; then
  echo "LANG=$LANG_CODE" > "$CONF"
fi

-s teste si le fichier est non-vide. Traduction : voice.conf n’est écrit que si le fichier est vide. Au premier message vocal, la détection s’exécute. Au deuxième, au troisième, au vingtième — le fichier existe déjà, donc rien ne change.

Conséquence : la langue détectée au premier message de la session reste figée jusqu’au /new. Si je commence en anglais, tout le reste de la session tourne en anglais, même si je bascule en français.

C’est le genre de condition qui semble logique à l’écriture — “n’écraser que si vide” — et qui crée exactement le comportement inverse de ce qu’on veut.


Bug 2 — OpenClaw charge openclaw.json une seule fois

Le script whisper faisait quelque chose d’autre dans ce bloc conditionnel. En plus d’écrire voice.conf, il modifiait openclaw.json directement :

python3 -c "
import json
p='$OC_JSON'
c = json.load(open(p))
c['messages']['tts']['openai']['voice'] = '$VOICE'
json.dump(c, open(p, 'w'), indent=2)
"

L’idée : dire à OpenClaw d’utiliser af_heart pour l’anglais en modifiant sa config TTS.

Le problème : OpenClaw lit openclaw.json au démarrage du container, charge tout en mémoire, et ne relit jamais le fichier. Modifier openclaw.json à chaud pendant une session n’a strictement aucun effet sur le process en cours. La voix configurée au démarrage reste celle utilisée jusqu’au prochain restart.

Ce code s’exécutait à chaque premier message vocal. Il modifiait un fichier que personne ne lisait.


Bug 3 — Le proxy ignorait voice.conf

Entre OpenClaw et speaches, il y a le speaches-proxy.py — le micro-proxy Python de 50 lignes présenté dans l’article précédent. Son rôle d’origine : intercepter les requêtes TTS et remplacer response_format: opus par mp3.

Ce proxy ne regardait jamais voice.conf. Il transmettait la requête telle quelle, y compris le champ voice envoyé par OpenClaw — qui, lui, utilisait sa config mémoire chargée au démarrage : ff_siwis (français) pour toujours.

Résultat : peu importe ce que voice.conf contenait, la voix envoyée à Kokoro était toujours celle de la config statique.

Le proxy était le seul endroit dans le pipeline où on pouvait intervenir à chaud. Il ne le faisait pas.


Bug 4 — SOUL.md écrasait la détection de langue

Neog a un fichier SOUL.md qui définit son identité et ses règles de base. Il contenait :

## Langue — Non négociable
Toujours répondre en français à Moun, sauf demande explicite contraire.

Le prompt general.md disait bien “lire voice.conf et répondre dans la langue détectée”. Mais SOUL.md est chargé en premier et posé comme règle fondamentale. Avec un modèle 9B qui jongle entre plusieurs instructions, la règle explicite “toujours en français” gagne contre l’instruction plus contextuelle de general.md.

Résultat : même si les bugs 1-3 avaient été corrigés, le texte des réponses serait resté en français.


Les 3 fixes

Fix 1 — Le script whisper : toujours mettre à jour voice.conf

Supprimer la condition. Point.

# Avant
if [ ! -s "$CONF" ]; then
  echo "LANG=$LANG_CODE" > "$CONF"
  # ... python3 patch openclaw.json (code mort)
fi

# Après
echo "LANG=$LANG_CODE" > "$CONF"

voice.conf est maintenant mis à jour à chaque message vocal. Le switch FR→EN→FR mid-session fonctionne. Le bloc python3 qui patchait openclaw.json à chaud est supprimé — il était du code mort depuis le début.

Fix 2 — Le proxy : lire voice.conf à chaque requête TTS

Le proxy monte déjà le volume config/bin/ — il a accès à voice.conf via /scripts/voice.conf. Il suffit de le lire avant chaque requête TTS et d’écraser le champ voice :

VOICE_CONF = "/scripts/voice.conf"

VOICE_MAP = {
    "fr": "ff_siwis",
    "en": "af_heart",
    "ar": "ff_siwis",  # Kokoro n'a pas de voix arabe native
}

def get_voice_from_conf():
    try:
        with open(VOICE_CONF, "r") as f:
            content = f.read().strip()
        for line in content.splitlines():
            if line.startswith("LANG="):
                lang = line.split("=", 1)[1].strip().lower()
                return VOICE_MAP.get(lang, "ff_siwis")
    except (FileNotFoundError, OSError):
        pass
    return None

# Dans do_POST, sur /v1/audio/speech :
voice = get_voice_from_conf()
if voice:
    data["voice"] = voice

Le proxy devient le point d’intervention runtime. OpenClaw envoie ff_siwis dans sa requête — le proxy lit voice.conf, voit LANG=en, écrase avec af_heart avant de transmettre à speaches. OpenClaw n’a pas besoin de recharger sa config.

Fix 3 — SOUL.md et general.md : exception explicite pour les vocaux

Dans SOUL.md :

## Langue — Non négociable

Toujours répondre en français à Moun, sauf :
- Moun écrit dans une autre langue → répondre dans cette langue
- **Message vocal + `voice.conf` indique une autre langue → répondre dans cette langue**

Dans general.md, remplacer “lire voice.conf au début de la session” par :

**Voice messages — règle stricte, avant chaque réponse :**
Lire `/home/node/.openclaw/bin/voice.conf` avec le tool `fs` avant de répondre.
- `LANG=en` → répondre en anglais (obligatoire — prend le dessus sur le défaut FR de SOUL.md)

L’exception est maintenant explicite et référencée dans les deux fichiers. Le modèle a une instruction claire sur quelle règle prime.


Ce que ça révèle sur l’architecture d’OpenClaw

Le bug 2 m’a forcé à comprendre quelque chose que la documentation ne dit pas explicitement : OpenClaw est un process node.js qui charge sa configuration au démarrage et ne la relit pas.

Les paramètres TTS dans openclaw.json — provider, modèle, voix — sont fixés à l’initialisation. On peut modifier le fichier pendant que le container tourne, ça n’aura aucun effet avant le prochain restart.

Ça change la façon de penser les adaptations dynamiques. Si on veut modifier un comportement à chaud, il faut intervenir en dehors d’OpenClaw — dans un proxy, un wrapper, un fichier que l’agent lui-même peut lire via ses outils (fs). Le proxy est l’endroit naturel pour les modifications de requêtes. Les fichiers workspace (voice.conf, SOUL.md) sont l’endroit pour les modifications de comportement agent.

Cette distinction — config statique vs comportement dynamique — mérite d’être posée dès la conception d’un setup OpenClaw.


Le résultat

Après les trois fixes :

Je parle en français → voice.conf = LANG=fr → proxy envoie ff_siwis → agent répond en français Je parle en anglais → voice.conf = LANG=en → proxy envoie af_heart → agent répond en anglais Je rebascule en français → voice.conf mis à jour immédiatement → switch propre

Le switch de langue mid-session fonctionne. Vraiment cette fois.


Ce que j’ai appris

Tester le cas nominal ne suffit pas. J’avais testé “parler en anglais → réponse en anglais”. Je n’avais pas testé “parler en anglais, puis revenir en français, puis revenir en anglais”. Le premier test passe. Les suivants non.

“Ça semble marcher” est une information dangereuse. Le pipeline initial donnait l’impression de fonctionner parce que le premier message était correctement détecté. La condition if [ ! -s ] était invisible au test rapide.

Un proxy est plus puissant qu’il n’y paraît. Intercaler une couche de traduction entre deux systèmes permet de modifier le comportement d’un composant sans y toucher. OpenClaw ne sait pas que la voix a changé. Speaches ne sait pas d’où vient l’instruction. Le proxy est le seul endroit qui voit les deux.

Les règles fondamentales nécessitent des exceptions explicites. Une instruction générale (“lire voice.conf et répondre en conséquence”) perd face à une règle fondamentale (“toujours en français”) quand les deux s’appliquent. L’exception doit être écrite là où est la règle — pas seulement là où elle est utilisée.


Pour l’article 6 original : TTS 100% local — Kokoro, speaches GPU, et ce qui a cassé

Prochain article : la voix arabe. Kokoro n’a pas de voix arabe — il faudra intégrer une solution alternative. L’exploration est en cours.


Série OpenClaw — Article 1 · Article 2 · Article 3 · Article 4 · Article 5 · Article 6

Partager cet article

Articles similaires