mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 09:54:43 +02:00
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:
parent
0c263faac0
commit
7bfedb0901
8 changed files with 75 additions and 5 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 %</span></label>
|
||||
<p class="hint">Monte ou réduit le son d'Asa (0 = muet, 200 % = 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 + ' %';
|
||||
}
|
||||
|
||||
/* 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(); });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue