JARVIS: asistente de voz local para Linux
Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin cuentas ni claves. Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper comparten tarjeta y solo caben porque estan dimensionados para ello. El RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve de una cache de frases. Incluye instalador (install.sh), requisitos, y documentacion del stack, del manejo de root y de las acciones. Los apuntes indexados y el diario NO se incluyen: son privados y el .gitignore los bloquea.
This commit is contained in:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
108
config/comparar_voces_es.py
Normal file
108
config/comparar_voces_es.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Genera la misma frase en castellano con las voces masculinas disponibles.
|
||||
|
||||
flatpak run --command=python3 io.github.qwersyk.Newelle//master \
|
||||
~/COFRE/CODERS/JARVIS/config/comparar_voces_es.py
|
||||
|
||||
Kokoro solo trae DOS voces masculinas en español: em_alex y em_santa. De los
|
||||
motores que Newelle soporta, los unicos locales son kokoro y espeak; el resto
|
||||
—gtts, edge, elevenlabs, openai, groq— son de nube y quedan descartados.
|
||||
|
||||
Como ninguna de las dos es especialmente grave, cada una se genera tambien en
|
||||
version grave: se baja el tono con ffmpeg sin cambiar la velocidad. El truco es
|
||||
reproducir a menos muestras por segundo (baja tono y alarga) y devolver la
|
||||
duracion con atempo. A 0.90 baja unos dos semitonos, que se nota sin sonar a
|
||||
disco rayado; por debajo de 0.85 empieza a sonar artificial.
|
||||
|
||||
Se escuchan las cuatro y se fija la elegida con set_voz_es.py.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app/share/newelle")
|
||||
sys.path.insert(0, os.path.expanduser(
|
||||
"~/.var/app/io.github.qwersyk.Newelle/config/pip"))
|
||||
|
||||
from gi.repository import Gio # noqa: E402
|
||||
|
||||
from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402
|
||||
from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402
|
||||
|
||||
VOCES = ["em_alex", "em_santa"]
|
||||
GRAVEDAD = 0.90 # 1.0 = sin tocar; menos = mas grave
|
||||
MUESTREO = 24000 # el que saca kokoro
|
||||
|
||||
FRASE = ("Buenas tardes. Todos los sistemas funcionan en local. "
|
||||
"La tarjeta grafica esta libre y quedan ochocientos cuarenta y seis "
|
||||
"gigabytes en el disco. Quiere que ejecute el diagnostico?")
|
||||
|
||||
SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras/voces_es")
|
||||
CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config")
|
||||
|
||||
|
||||
def cadena(grave: bool) -> str:
|
||||
"""El tratamiento JARVIS, con o sin bajada de tono delante."""
|
||||
base = jarvis_audio_filter()
|
||||
if not grave:
|
||||
return base
|
||||
tono = (f"asetrate={int(MUESTREO * GRAVEDAD)},"
|
||||
f"aresample={MUESTREO},atempo={1 / GRAVEDAD:.4f}")
|
||||
return f"{tono},{base}" if base else tono
|
||||
|
||||
|
||||
def nivel(path):
|
||||
"""mean/max en dB, para confirmar que ninguna sale floja o recortada."""
|
||||
r = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect",
|
||||
"-f", "null", "/dev/null"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
vals = [l.split(":")[-1].strip() for l in r.stderr.splitlines()
|
||||
if "mean_volume:" in l or "max_volume:" in l]
|
||||
return " / ".join(vals) if vals else "?"
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(SALIDA, exist_ok=True)
|
||||
settings = Gio.Settings.new("io.github.qwersyk.Newelle")
|
||||
handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models"))
|
||||
|
||||
if not handler.is_installed():
|
||||
print("instalando kokoro...")
|
||||
handler.install()
|
||||
|
||||
previa = handler.get_current_voice() if hasattr(handler, "get_current_voice") else None
|
||||
print(f"{'fichero':<28} nivel (mean / max)")
|
||||
|
||||
for voz in VOCES:
|
||||
handler.set_voice(voz)
|
||||
crudo = os.path.join(SALIDA, f"_{voz}.wav")
|
||||
try:
|
||||
handler.save_audio(FRASE, crudo)
|
||||
except Exception as e:
|
||||
print(f"{voz:<28} FALLO al generar: {e}")
|
||||
continue
|
||||
|
||||
for etiqueta, grave in (("", False), ("_grave", True)):
|
||||
final = os.path.join(SALIDA, f"{voz}{etiqueta}.wav")
|
||||
try:
|
||||
subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-i", crudo, "-af", cadena(grave), "-y", final],
|
||||
check=True,
|
||||
)
|
||||
print(f"{os.path.basename(final):<28} {nivel(final)}")
|
||||
except Exception as e:
|
||||
print(f"{voz}{etiqueta:<20} FALLO: {e}")
|
||||
os.remove(crudo)
|
||||
|
||||
if previa:
|
||||
handler.set_voice(previa)
|
||||
print(f"\nescuchalas en: {SALIDA}")
|
||||
print("la elegida se fija con set_voz_es.py <voz> [--grave]")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue