Init commit
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import os, threading
|
||||
from flask import Flask, render_template, request, jsonify
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
from services.hermes_client import ask_hermes
|
||||
|
||||
load_dotenv()
|
||||
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
conversation_lock=threading.Lock()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
if mode: STATE["mode"]=mode
|
||||
STATE.update(extra); socketio.emit("alfred_state",STATE)
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
r = listen_once(
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
answer = ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
STATE["content"] = display
|
||||
socketio.emit("alfred_render", display)
|
||||
|
||||
if speech:
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=done.set)
|
||||
done.wait(timeout=120)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()})
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True})
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on"): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre.
|
||||
|
||||
STT: Vosk + sounddevice, avec fallback arecord/ALSA.
|
||||
TTS: Piper local, avec fallback espeak puis spd-say.
|
||||
Sortie: haut-parleur local via paplay/aplay.
|
||||
|
||||
Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu
|
||||
à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from functools import lru_cache
|
||||
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
def _cmd_ok(cmd, **kwargs):
|
||||
try:
|
||||
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
check=True, text=True, **kwargs)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def audio_backend_detect() -> str:
|
||||
configured = os.getenv("AUDIO_BACKEND", "auto").lower()
|
||||
if configured in ("pactl", "alsa"):
|
||||
return configured
|
||||
if shutil.which("pactl"):
|
||||
info = _cmd_ok(["pactl", "info"])
|
||||
if info and "Server Name" in info.stdout:
|
||||
return "pactl"
|
||||
if shutil.which("amixer"):
|
||||
return "alsa"
|
||||
return "none"
|
||||
|
||||
|
||||
def _which(cmd):
|
||||
return shutil.which(cmd)
|
||||
|
||||
|
||||
def _is_piper_tts(binary: Optional[str]) -> bool:
|
||||
if not binary:
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True, timeout=3)
|
||||
out = (r.stdout or "").lower()
|
||||
if "gtk" in out or "ratbag" in out:
|
||||
return False
|
||||
return (("--model" in out or " -m " in out) and
|
||||
("--output_file" in out or " -o " in out))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _find_piper_binary() -> Optional[str]:
|
||||
candidates = [
|
||||
os.getenv("PIPER_BIN"),
|
||||
"/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"),
|
||||
"/usr/local/bin/piper", "/usr/bin/piper", _which("piper"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate and _is_piper_tts(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _piper_supports_long_flag(binary: str) -> bool:
|
||||
try:
|
||||
r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True, timeout=3)
|
||||
out = (r.stdout or "").lower()
|
||||
return "--model" in out and "--output_file" in out
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
###
|
||||
def tts_backend_detect() -> str:
|
||||
"""Retourne le moteur TTS configuré pour Alfred."""
|
||||
|
||||
if os.getenv("PIPER_HOST"):
|
||||
return "wyoming-piper"
|
||||
|
||||
if _which("espeak-ng"):
|
||||
return "espeak-ng"
|
||||
|
||||
return "none"
|
||||
|
||||
|
||||
'''
|
||||
def tts_backend_detect() -> str:
|
||||
piper = _find_piper_binary()
|
||||
model = os.getenv("PIPER_MODEL", "")
|
||||
if piper and model and os.path.exists(model):
|
||||
return "piper"
|
||||
if _which("espeak"):
|
||||
return "espeak"
|
||||
if _which("spd-say"):
|
||||
return "spd-say"
|
||||
return "none"
|
||||
'''
|
||||
####
|
||||
|
||||
def speak_text(text: str):
|
||||
"""Synthèse vocale via Wyoming Piper, avec secours eSpeak."""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))]
|
||||
|
||||
try:
|
||||
from services.piper_tts import speak as piper_speak
|
||||
|
||||
print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True)
|
||||
piper_speak(text)
|
||||
return
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True)
|
||||
|
||||
# Secours eSpeak
|
||||
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
print("[ALFRED TTS] Secours eSpeak", flush=True)
|
||||
|
||||
subprocess.run(
|
||||
["espeak-ng", "-v", "fr", "-w", wav_path, text],
|
||||
check=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["aplay", "-q", "-D", "plughw:1,0", wav_path],
|
||||
check=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True)
|
||||
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.remove(wav_path)
|
||||
|
||||
'''
|
||||
def speak_text(text: str):
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
start = time.monotonic()
|
||||
print("[ALFRED TTS] Génération audio...", flush=True)
|
||||
|
||||
subprocess.run(
|
||||
["espeak-ng", "-v", "fr", "-w", wav_path, text],
|
||||
check=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
print("[ALFRED TTS] Lecture audio...", flush=True)
|
||||
|
||||
subprocess.run(
|
||||
["aplay", "-q", "-D", "plughw:1,0", wav_path],
|
||||
check=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
print(
|
||||
f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s",
|
||||
flush=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ALFRED TTS] Erreur : {e}", flush=True)
|
||||
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.remove(wav_path)
|
||||
|
||||
|
||||
|
||||
|
||||
def speak_text(text: str):
|
||||
"""Synthétise et joue localement le texte. Retourne (ok, backend, erreur)."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return False, "none", "texte vide"
|
||||
text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))]
|
||||
|
||||
backend = audio_backend_detect()
|
||||
piper = _find_piper_binary()
|
||||
model = os.getenv("PIPER_MODEL", "")
|
||||
|
||||
if piper and model and os.path.exists(model):
|
||||
wav_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
wav_path = tmp.name
|
||||
cmd = [piper]
|
||||
if _piper_supports_long_flag(piper):
|
||||
cmd += ["--model", model, "--output_file", wav_path]
|
||||
else:
|
||||
cmd += ["-m", model, "-o", wav_path]
|
||||
p = subprocess.run(cmd, input=text, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
||||
if p.returncode != 0:
|
||||
return False, "piper", (p.stderr or p.stdout or "")[:2000]
|
||||
if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0:
|
||||
return False, "piper", "Piper n'a pas produit de WAV"
|
||||
if backend == "pactl" and shutil.which("paplay"):
|
||||
play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
elif shutil.which("aplay"):
|
||||
play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
else:
|
||||
return False, "piper", "paplay/aplay introuvable"
|
||||
return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée"
|
||||
except Exception as exc:
|
||||
return False, "piper", str(exc)
|
||||
finally:
|
||||
if wav_path:
|
||||
try: os.unlink(wav_path)
|
||||
except OSError: pass
|
||||
|
||||
if _which("espeak"):
|
||||
args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text]
|
||||
if backend == "pactl" and shutil.which("padsp"):
|
||||
args = ["padsp"] + args
|
||||
ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0
|
||||
return ok, "espeak", None if ok else "espeak a échoué"
|
||||
|
||||
if _which("spd-say"):
|
||||
ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE).returncode == 0
|
||||
return ok, "spd-say", None if ok else "spd-say a échoué"
|
||||
return False, "none", "aucun backend TTS disponible"
|
||||
'''
|
||||
|
||||
_tts_lock = threading.Lock()
|
||||
def speak_async(text: str, on_start: Optional[Callable] = None,
|
||||
on_end: Optional[Callable] = None) -> bool:
|
||||
"""Lecture non bloquante; refuse d'empiler deux réponses vocales."""
|
||||
if _tts_lock.locked():
|
||||
return False
|
||||
def worker():
|
||||
with _tts_lock:
|
||||
if on_start: on_start()
|
||||
try: speak_text(text)
|
||||
finally:
|
||||
if on_end: on_end()
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class STTResult:
|
||||
text: str = ""
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def get_vosk_model(model_path):
|
||||
from vosk import Model
|
||||
|
||||
print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True)
|
||||
model = Model(model_path)
|
||||
print("[ALFRED] Modèle Vosk prêt", flush=True)
|
||||
|
||||
return model
|
||||
|
||||
|
||||
def listen_once(stop_event: Optional[threading.Event] = None,
|
||||
on_partial: Optional[Callable[[str], None]] = None,
|
||||
on_level: Optional[Callable[[float], None]] = None) -> STTResult:
|
||||
"""Attend une phrase finale Vosk et la retourne.
|
||||
|
||||
Reprend le comportement du prototype: sounddevice en priorité, arecord sinon.
|
||||
Le niveau audio transmis à l'UI est une approximation RMS sur int16.
|
||||
"""
|
||||
try:
|
||||
import numpy as np
|
||||
from vosk import Model, KaldiRecognizer
|
||||
except Exception as exc:
|
||||
return STTResult(error=f"Dépendance STT absente: {exc}")
|
||||
|
||||
model_path = os.getenv("VOSK_MODEL_PATH", "")
|
||||
sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000"))
|
||||
if not model_path or not os.path.exists(model_path):
|
||||
return STTResult(error=f"Modèle Vosk introuvable: {model_path}")
|
||||
try:
|
||||
# rec = KaldiRecognizer(Model(model_path), sample_rate)
|
||||
rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate)
|
||||
except Exception as exc:
|
||||
return STTResult(error=f"Init Vosk: {exc}")
|
||||
|
||||
def process(data: bytes) -> Optional[str]:
|
||||
if on_level and data:
|
||||
try:
|
||||
a = np.frombuffer(data, dtype=np.int16).astype(np.float32)
|
||||
rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0
|
||||
on_level(min(1.0, rms / 6000.0))
|
||||
except Exception:
|
||||
pass
|
||||
if rec.AcceptWaveform(data):
|
||||
text = (json.loads(rec.Result() or "{}").get("text") or "").strip()
|
||||
if text:
|
||||
return text
|
||||
elif on_partial:
|
||||
partial = json.loads(rec.PartialResult() or "{}").get("partial", "")
|
||||
print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True)
|
||||
on_partial(partial)
|
||||
return None
|
||||
|
||||
try:
|
||||
import sounddevice as sd
|
||||
q = queue.Queue()
|
||||
def callback(indata, frames, time_info, status):
|
||||
q.put(bytes(indata))
|
||||
device = os.getenv("AUDIO_INPUT_DEVICE") or None
|
||||
with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16",
|
||||
channels=1, callback=callback, device=device):
|
||||
while not (stop_event and stop_event.is_set()):
|
||||
text = process(q.get())
|
||||
if text:
|
||||
return STTResult(text=text)
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True)
|
||||
|
||||
# except Exception:
|
||||
# pass
|
||||
|
||||
if not shutil.which("arecord"):
|
||||
return STTResult(error="sounddevice indisponible et arecord introuvable")
|
||||
cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"]
|
||||
alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip()
|
||||
if alsa_device:
|
||||
cmd += ["-D", alsa_device]
|
||||
proc = None
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
|
||||
while not (stop_event and stop_event.is_set()):
|
||||
chunk = proc.stdout.read(8000)
|
||||
if not chunk:
|
||||
continue
|
||||
text = process(chunk)
|
||||
if text:
|
||||
return STTResult(text=text)
|
||||
return STTResult()
|
||||
except Exception as exc:
|
||||
return STTResult(error=f"arecord: {exc}")
|
||||
finally:
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
|
||||
def devices():
|
||||
try:
|
||||
# import sounddevice as sd
|
||||
# return sd.query_devices()
|
||||
print("[ALFRED STT] Périphériques audio :", flush=True)
|
||||
print(sd.query_devices(), flush=True)
|
||||
print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True)
|
||||
return sd.query_devices()
|
||||
except Exception:
|
||||
return []
|
||||
Reference in New Issue
Block a user