feat(stt-client): volume de la voix réglable + fenêtrage persistant (#58)

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 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-23 00:27:45 +02:00 committed by GitHub
parent 0c263faac0
commit 7bfedb0901
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 75 additions and 5 deletions

View file

@ -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 », 0200 %)
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`.

View file

@ -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/<voix>.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"

View file

@ -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"

View file

@ -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",

View file

@ -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",

View file

@ -994,6 +994,13 @@
<input type="text" id="wakeword" value="Dis Asa" placeholder="ex. Dis Asa" />
</div>
<!-- Volume de la voix -->
<div class="field">
<label for="ttsVolume" class="flabel">Volume de la voix <span id="ttsVolumeVal">100&nbsp;%</span></label>
<p class="hint">Monte ou réduit le son d'Asa (0&nbsp;= muet, 200&nbsp;% = amplifié).</p>
<input type="range" id="ttsVolume" min="0" max="200" step="5" value="100" aria-label="Volume de la voix" />
</div>
<!-- Mode cerveau -->
<div class="field">
<span class="flabel">Mode cerveau</span>
@ -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 + '&nbsp;%';
}
/* 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(); });

View file

@ -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

View file

@ -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="<i2").astype(np.float32) * gain
data = np.clip(data, -32768, 32767).astype("<i2")
with wave.open(str(wav), "wb") as w:
w.setparams(params)
w.writeframes(data.tobytes())
except Exception as e: # noqa: BLE001 — au pire le gain n'est pas appliqué
_log(f"gain TTS non appliqué ({e}).")
def _synthesize(self, text: str) -> "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":