"""La palabra de activacion: "JARVIS". Sin esto, el asistente obedece a cualquier cosa que se diga en la habitacion —y a la tele—. Con esto, solo escucha de verdad durante un rato despues de oir su nombre. ## Por que sobre el texto y no sobre el audio Lo obvio seria un detector como openwakeword, que mira la forma de onda antes de transcribir. Aqui se hace despues, sobre lo que Whisper ya escribio, y es a proposito: - **Ya tenemos la transcripcion.** El audio pasa por Whisper de todas formas porque la captura la dispara el detector de voz; no hay ahorro que ganar. - **Un modelo menos.** openwakeword son mas dependencias, otro modelo en memoria y otro afinado que mantener. - **Y sobre todo: Whisper oye "JARVIS" de mil maneras.** Medido en el registro de uso real, el nombre sale como "Jarvis", "Yarvis", "Charles", "Chavez", "Harvest"... Sobre el texto eso se arregla comparando como SUENA, que es lo que ya hace `manos/fonetica.py` para el catalogo. Sobre la onda habria que reentrenar. Lo que se pierde: la transcripcion corre siempre, tambien cuando nadie ha llamado. En una maquina donde Whisper va en la grafica y tarda dos segundos, es un coste que ya estabamos pagando. ## Como se comporta Se llama una vez y se queda despierto un rato, para poder encadenar ordenes sin repetir el nombre en cada frase. Y si la frase que trae el nombre lleva ademas la orden —"JARVIS, cuanto espacio queda"— se atiende esa misma, sin obligar a decirlo dos veces. """ import difflib import re import time # Absolutos, como el resto del nucleo: jarvis.py mete su carpeta en sys.path y # todos los paquetes son de primer nivel. Con `..manos` esto solo se podria # importar desde fuera, y las pruebas lo cargan suelto. from manos.acciones import normaliza from manos.fonetica import fonetica NOMBRE = "jarvis" # Cuanto sigue escuchando tras oir su nombre. Corto obliga a repetirlo en cada # frase; largo devuelve al problema de origen, que obedezca a la tele. DESPIERTO_S = 25.0 # Lo que Whisper escribe cuando oye "JARVIS". Salen del registro real de uso, # no de imaginarlas. PARECIDAS = ( "jarvis", "yarvis", "charvis", "jarbis", "yarbis", "harvis", "jervis", "jarvi", "yarvi", "javis", "jarves", "charles", "chavez", "harvest", "jarvix", "sharvis", "arvis", ) # Se compara palabra a palabra, asi que el umbral puede ser alto sin perder # aciertos: "yarvis" contra "jarvis" da 0,83 por letra y sube por sonido. UMBRAL = 0.80 _LIMPIA = re.compile(r"^[\s,.:;¡!¿?]+|[\s,.:;¡!¿?]+$") # Lo que suele acompañar al nombre sin ser una orden. Si al quitar el nombre # solo queda esto, es que llamaron y ya: "oye jarvis" no es "haz oye". LLAMADAS = {"oye", "eh", "hey", "ey", "hola", "escucha", "atiende", "perdona", "por favor", "porfa", "venga", "a ver"} def _suena_al_nombre(palabra: str) -> bool: p = _LIMPIA.sub("", palabra) if len(p) < 4: return False if p in PARECIDAS: return True for candidata in (NOMBRE,) + PARECIDAS[:6]: if difflib.SequenceMatcher(None, p, candidata).ratio() >= UMBRAL: return True if difflib.SequenceMatcher(None, fonetica(p), fonetica(candidata)).ratio() >= UMBRAL: return True return False def parte_el_nombre(texto: str): """(lo llamaron, lo que queda de la frase sin el nombre). Se devuelve el resto porque "JARVIS, cuanto espacio queda" tiene que ejecutarse en la misma frase: obligar a decir el nombre y luego la orden aparte es exactamente lo que hace que un asistente canse. """ palabras = normaliza(texto).split() if not palabras: return False, "" for i, p in enumerate(palabras): if _suena_al_nombre(p): sobra = [q for q in palabras[:i] + palabras[i + 1:] if q not in LLAMADAS] return True, " ".join(sobra).strip() return False, "" class Centinela: """Decide si una frase hay que atenderla o dejarla pasar.""" def __init__(self, activo=True, ventana=DESPIERTO_S): self.activo = activo self.ventana = ventana self._hasta = 0.0 @property def despierto(self) -> bool: return not self.activo or time.monotonic() < self._hasta def despierta(self): self._hasta = time.monotonic() + self.ventana def duerme(self): self._hasta = 0.0 def filtra(self, texto: str): """(hay que atenderla, texto a atender, acaba de despertar). - Sin palabra de activacion configurada: se atiende todo. - Dormido y sin nombre: se ignora. - Dormido y con nombre: despierta. Si la frase traia ademas la orden, se devuelve; si solo traia el nombre, se contesta con un acuse. - Despierto: se atiende y se renueva la ventana, para poder encadenar. """ if not self.activo: return True, texto, False llamado, resto = parte_el_nombre(texto) if llamado: recien = not self.despierto self.despierta() return (True, resto, recien) if resto else (False, "", recien) if self.despierto: self.despierta() # encadenar sin repetir el nombre return True, texto, False return False, "", False