From 7bfedb0901cea4987cb9401d32c5c734790a0483 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Tue, 23 Jun 2026 00:27:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt-client):=20volume=20de=20la=20voix=20r?= =?UTF-8?q?=C3=A9glable=20+=20fen=C3=AAtrage=20persistant=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux demandes côté client : - VOLUME : slider « Volume de la voix » (0–200 %) dans le drawer Réglages du HUD, appliqué À CHAUD (canal settings → engine.cfg.tts_volume) et persisté (localStorage). Gain appliqué au WAV avant lecture, commun Piper/Kokoro (engine._apply_gain, PCM16 ×gain, clip). Config [voice].tts_volume (0.0 muet … 2.0 amplifié, défaut 1.0). 0 = muet (le tour se déroule sans son). - FENÊTRAGE : le mode existait ([ui].window_mode app|kiosk|none) ; `stt --window` PERSISTE désormais le choix → `stt --window app` rend le HUD fenêtré (déplaçable/ redimensionnable, type Discord) durablement, y compris pour le service systemd. Client 0.17.0. Doc : stt.example.toml + admin/ia/stt.md. Validé : gain ×0.5 exact, HUD sans erreur JS (slider + collectSettings + label live). Co-authored-by: Claude Opus 4.8 --- admin/ia/stt.md | 8 ++++++++ stt/client/config/stt.example.toml | 1 + stt/client/pyproject.toml | 2 +- stt/client/stt/cli.py | 17 +++++++++++++---- stt/client/stt/config.py | 3 +++ stt/client/stt/hud/index.html | 19 +++++++++++++++++++ stt/client/stt/ui/app.py | 4 ++++ stt/client/stt/voice/engine.py | 26 ++++++++++++++++++++++++++ 8 files changed, 75 insertions(+), 5 deletions(-) diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 71a2aec..c02b024 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -263,6 +263,14 @@ Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du démarrage et **retombe en silence** proprement si le modèle/paquet manque (`engine._synth_kokoro`). La synthèse est enfichable (`engine._synthesize`) — Piper reste le repli. +**Volume de la voix** : réglable **à chaud** dans le HUD (Réglages → « Volume de la voix », 0–200 %) +ou via `[voice].tts_volume` (0.0 muet … 2.0 amplifié). Gain appliqué au WAV avant lecture, commun +aux deux moteurs (`engine._apply_gain`) ; persisté côté HUD (localStorage). + +**Fenêtrage** : `[ui].window_mode = "app"` (fenêtre déplaçable/redimensionnable, type Discord) | +`"kiosk"` (plein écran) | `"none"`. `stt --window app` **persiste** désormais le choix (relu par le +service au prochain `stt --restart`). + **Changer de voix Piper (homme/femme)** : `stt --voices` liste les voix FR ; `stt --install-voice fr_FR-tom-medium` télécharge la voix (`.onnx` + `.onnx.json` depuis HuggingFace rhasspy/piper-voices) **et la sélectionne** (`tts_engine=piper` + `piper_voice` écrits dans `stt.toml`). Puis `stt --restart`. diff --git a/stt/client/config/stt.example.toml b/stt/client/config/stt.example.toml index 077c2d9..4efdd46 100644 --- a/stt/client/config/stt.example.toml +++ b/stt/client/config/stt.example.toml @@ -13,6 +13,7 @@ model = "hermes-default" # modèle demandé au serveur wake_word = "hermes" # "Ok Hermès" whisper_model = "large-v3" # STT faster-whisper (CPU int8) — "small" pour tester tts_engine = "piper" # "piper" (rapide) | "kokoro" (plus naturel — voir ci-dessous) +tts_volume = 1.0 # volume voix : 0.0 muet · 1.0 normal · 2.0 amplifié (réglable dans le HUD) piper_voice = "fr_FR-upmc-medium" # voix Piper — ~/.local/share/piper/.onnx # Voix Kokoro (si tts_engine = "kokoro") : `stt --install-kokoro` + `pipx inject stt kokoro-onnx` # kokoro_voice = "ff_siwis" # voix FR (femme) ; kokoro_speed = 1.0 ; kokoro_lang = "fr-fr" diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 5f54b1d..653b575 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.16.0" +version = "0.17.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/cli.py b/stt/client/stt/cli.py index 9974c92..0b85bb4 100644 --- a/stt/client/stt/cli.py +++ b/stt/client/stt/cli.py @@ -410,6 +410,10 @@ def _cmd_voice(args) -> int: ui["window_mode"] = ( args.window or ui.get("window_mode") or ("kiosk" if ui.get("kiosk") else "none") ) + # --window PERSISTE le choix → `stt --window app` rend le HUD fenêtré durablement + # (y compris pour le service systemd, relu au prochain `stt --restart`). + if args.window: + _set_config("ui", {"window_mode": args.window}) print(f"STT-server : {client.url} — modèle '{client.model}'") if not client.healthy(): print(" ⚠️ serveur injoignable — vérifie [server] url et le réseau") @@ -492,8 +496,8 @@ def _cmd_voices() -> int: return 0 -def _set_voice_config(updates: dict) -> None: - """Met à jour les clés [voice] dans ~/.config/stt/stt.toml (préserve les autres valeurs).""" +def _set_config(section: str, updates: dict) -> None: + """Met à jour les clés d'une section de ~/.config/stt/stt.toml (préserve le reste).""" import tomllib import tomli_w @@ -504,11 +508,15 @@ def _set_voice_config(updates: dict) -> None: if CONFIG_PATH.exists(): with CONFIG_PATH.open("rb") as f: cfg = tomllib.load(f) - cfg.setdefault("voice", {}).update(updates) + cfg.setdefault(section, {}).update(updates) with CONFIG_PATH.open("wb") as f: tomli_w.dump(cfg, f) +def _set_voice_config(updates: dict) -> None: + _set_config("voice", updates) + + def _cmd_install_voice(name: str) -> int: """Télécharge une voix Piper FR (.onnx + .onnx.json) et la sélectionne dans la config.""" import urllib.error @@ -562,7 +570,8 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--server", help="Force l'URL du STT-server (sinon config)") p.add_argument("--no-tts", action="store_true", help="Réponses texte seulement (voix)") p.add_argument("--window", choices=["app", "kiosk", "none"], - help="Ouverture du HUD : app (fenêtre, type Discord) | kiosk (plein écran) | none") + help="Mode d'ouverture du HUD (PERSISTÉ) : app (fenêtre déplaçable/redimensionnable, " + "type Discord) | kiosk (plein écran) | none") p.add_argument("--start", action="store_true", help="Démarre le service STT (systemd --user)") p.add_argument("--restart", action="store_true", diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index 161b0be..586624b 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -57,6 +57,9 @@ DEFAULT_CONFIG: dict[str, Any] = { # TTS (voix d'Asa) : "piper" (défaut, rapide) | "kokoro" (plus naturel, ONNX/CPU). # Pour Kokoro : `stt --install-kokoro` (modèle) + `pipx inject stt kokoro-onnx`. "tts_engine": "piper", + # Volume de la voix : gain appliqué au TTS (1.0 = normal, 0.0 = muet, 2.0 = amplifié). + # Réglable à chaud depuis le HUD (Réglages → « Volume de la voix »). + "tts_volume": 1.0, "piper_voice": "fr_FR-upmc-medium", # Kokoro — voix FR « ff_siwis » (femme). Vitesse 1.0 ; lang fr-fr (phonémisation espeak-ng). "kokoro_voice": "ff_siwis", diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html index afbeafe..38c4c7c 100644 --- a/stt/client/stt/hud/index.html +++ b/stt/client/stt/hud/index.html @@ -994,6 +994,13 @@ + +
+ +

Monte ou réduit le son d'Asa (0 = muet, 200 % = amplifié).

+ +
+
Mode cerveau @@ -1597,6 +1604,8 @@ const charName = el('charName'); const avatarSet = el('avatarSet'); const voice = el('voice'); const wakeword = el('wakeword'); +const ttsVolume = el('ttsVolume'); +const ttsVolumeVal = el('ttsVolumeVal'); const nothink = el('nothink'); const brainLocal= el('brainLocal'); const brainAdv = el('brainAdv'); @@ -1637,11 +1646,16 @@ function collectSettings() { avatarSet: avatarSet.value, voice: voice.value, wakeword: wakeword.value.trim(), + volume: Number(ttsVolume.value) / 100, // gain TTS (1.0 = normal) brainMode: brainMode, // 'local' (qwen3-8b) | 'advanced' (claude) noThink: nothink.checked, }; } +function updateVolumeLabel() { + ttsVolumeVal.innerHTML = ttsVolume.value + ' %'; +} + /* Sauvegarde locale (le thème surtout, mais on garde tout pour la commodité) */ function persistLocal() { try { localStorage.setItem(LS_KEY, JSON.stringify(collectSettings())); } catch {} @@ -1656,8 +1670,10 @@ function restoreLocal() { if (s.avatarSet) avatarSet.value = s.avatarSet; if (s.voice) voice.value = s.voice; if (s.wakeword) wakeword.value = s.wakeword; + if (s.volume != null) ttsVolume.value = Math.round(s.volume * 100); if (s.brainMode) setBrain(s.brainMode); if (typeof s.noThink === 'boolean') nothink.checked = s.noThink; + updateVolumeLabel(); applyIntensity(Number(intensity.value)); loadAvatar(s.avatarSet || 'asa'); } @@ -1710,6 +1726,9 @@ document.addEventListener('keydown', (e) => { intensity.addEventListener('input', () => { applyIntensity(Number(intensity.value)); }); intensity.addEventListener('change', () => { persistLocal(); sendSettings(); }); +ttsVolume.addEventListener('input', updateVolumeLabel); // label live +ttsVolume.addEventListener('change', () => { persistLocal(); sendSettings(); }); // applique à la relâche + avatarSet.addEventListener('change', () => { loadAvatar(avatarSet.value); persistLocal(); sendSettings(); }); [charName, wakeword].forEach(i => i.addEventListener('change', () => { persistLocal(); sendSettings(); })); voice.addEventListener('change', () => { persistLocal(); sendSettings(); }); diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index 6c3d0f6..2164678 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -387,5 +387,9 @@ def _apply_settings(client, engine, data: dict, loop) -> None: parts = wake.split() engine.cfg["wake_word"] = _deaccent(parts[-1] if parts else wake) + vol = data.get("volume") + if vol is not None and engine is not None: + # gain TTS appliqué à chaud (0.0 = muet … 2.0 = amplifié). Relu par engine._speak. + engine.cfg["tts_volume"] = max(0.0, min(2.0, float(vol))) except Exception: # noqa: BLE001 — un réglage invalide ne doit jamais tuer la connexion pass diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py index 1907f5d..be8e392 100644 --- a/stt/client/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -176,6 +176,12 @@ class VoiceEngine: if wav is not None: wav.unlink(missing_ok=True) return + vol = float(self.cfg.get("tts_volume", 1.0)) + if vol <= 0.01: # volume à zéro → muet (mais le tour s'est déroulé) + wav.unlink(missing_ok=True) + return + if abs(vol - 1.0) > 0.01: # applique le gain (commun Piper/Kokoro) + self._apply_gain(wav, vol) try: # Popen (pas run) → on garde la main pour tuer la lecture sur « stop » self._tts_proc = subprocess.Popen(["aplay", "-q", str(wav)]) @@ -186,6 +192,26 @@ class VoiceEngine: self._tts_proc = None wav.unlink(missing_ok=True) + @staticmethod + def _apply_gain(wav: "Path", gain: float) -> None: + """Multiplie le volume du WAV (PCM 16-bit) par `gain`, en place. Best-effort.""" + try: + import wave + + import numpy as np + with wave.open(str(wav), "rb") as w: + params = w.getparams() + frames = w.readframes(w.getnframes()) + if params.sampwidth != 2: # on ne gère que le 16-bit (Piper & Kokoro le sont) + return + data = np.frombuffer(frames, dtype=" "Path | None": """Synthétise `text` en WAV temporaire selon `[voice].tts_engine`. None si KO.""" if (self.cfg.get("tts_engine") or "piper").lower() == "kokoro":