Updates to the assistants overall abilities, like always listening, and only responding on messages when activated.

This commit is contained in:
TMN AI
2026-09-30 20:25:15 -06:00
parent 8bcd539b31
commit 34b2d302f9
10 changed files with 82 additions and 7 deletions
+3 -1
View File
@@ -70,6 +70,7 @@ class Assistant:
log: Callable[[str], None] = print,
clock: Callable[[], float] = time.monotonic,
wake_words=None,
anywhere_words=None,
verbose: bool = False,
):
self._transcription = transcription
@@ -79,6 +80,7 @@ class Assistant:
self.log, self.clock, self.verbose = log, clock, verbose
self.tools = tools or Tools(log=log)
self.wake_words = wake_words or config.WAKE_WORDS
self.anywhere_words = config.ANYWHERE_WAKE_WORDS if anywhere_words is None else anywhere_words
self.segmenter = Segmenter(config.VAD_RMS_THRESHOLD, config.VAD_START_FRAMES,
config.VAD_SILENCE_END_SEC, config.MIN_UTTERANCE_SEC,
config.MAX_UTTERANCE_SEC)
@@ -152,7 +154,7 @@ class Assistant:
if not text:
return
self._check_deadline()
woke, rest = find_wake(text, self.wake_words)
woke, rest = find_wake(text, self.wake_words, self.anywhere_words)
if self.state == "idle":
if not woke:
if self.verbose:
+36 -1
View File
@@ -30,7 +30,42 @@ def open_mic(on_frame: Callable[[bytes], None]):
def play(chunks: Iterable[bytes], sample_rate: int = config.TTS_SAMPLE_RATE,
should_stop: Callable[[], bool] = lambda: False) -> bool:
"""Play PCM16 mono chunks as they arrive. False if stopped early."""
"""Play PCM16 mono chunks as they arrive. False if stopped early.
Through `pacat`, not sounddevice: PortAudio only sees raw ALSA devices,
so a Bluetooth or PipeWire sink is invisible to it and the audio goes
somewhere nobody can hear. pacat talks to the audio server, so it
follows the system's default sink like every other player.
"""
import shutil
import subprocess
if shutil.which("pacat") is None:
return _play_sounddevice(chunks, sample_rate, should_stop)
proc = subprocess.Popen(
["pacat", "--rate", str(sample_rate), "--channels", "1",
"--format", "s16le", "--latency-msec", "60"],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
try:
for chunk in chunks:
if should_stop():
return False
proc.stdin.write(chunk)
proc.stdin.close()
proc.wait(timeout=10)
except (BrokenPipeError, OSError):
return False
finally:
if proc.poll() is None:
proc.kill()
return True
def _play_sounddevice(chunks: Iterable[bytes], sample_rate: int,
should_stop: Callable[[], bool]) -> bool:
"""Fallback for a machine without pacat."""
import sounddevice as sd
with sd.RawOutputStream(samplerate=sample_rate, channels=1, dtype="int16",
+4
View File
@@ -68,6 +68,10 @@ WAKE_WORDS = [w.strip().lower() for w in os.environ.get(
"hey assistant,ok assistant,okay assistant,"
"hey bolts,hey volt,hey bold,hey boat,okay volt,hey thunder bolt,thunder bolt",
).split(",") if w.strip()]
# These count anywhere in what was said, not just at the start ("what's the
# weather, assistant?"), and the whole sentence is sent as the request.
ANYWHERE_WAKE_WORDS = [w.strip().lower() for w in os.environ.get(
"ANYWHERE_WAKE_WORDS", "assistant").split(",") if w.strip()]
# ── listening (local voice-activity detection, before anything is sent) ────
# Nothing leaves the machine until this says someone is talking: silence and
+19 -4
View File
@@ -12,6 +12,9 @@ Rules, all aimed at not waking up on ordinary conversation:
that bolt" and "the bolt on the door" never count.
- Whole words only: "boltage" is not "bolt".
- Longest phrase wins, so "hey thunderbolt" isn't read as "hey" + garbage.
- Except *anywhere* words ("assistant"): one of those anywhere in what was
said counts, and the whole transcript is the request ("set a timer,
assistant" asks "set a timer, assistant").
"""
from __future__ import annotations
@@ -30,10 +33,11 @@ def _words(text: str) -> list:
return [(m.group(0), m.start(), m.end()) for m in _WORD.finditer(text.lower())]
def find_wake(transcript: str, phrases: Iterable[str]) -> Tuple[bool, str]:
"""(heard, remainder): whether a wake phrase opens *transcript*, and the
request that followed it in the same breath ("" if it was just the
phrase)."""
def find_wake(transcript: str, phrases: Iterable[str],
anywhere: Iterable[str] = ()) -> Tuple[bool, str]:
"""(heard, remainder): whether a wake phrase opens *transcript* (or an
*anywhere* word appears in it), and the request that came with it in the
same breath ("" if it was just the phrase)."""
words = _words(transcript or "")
if not words:
return False, ""
@@ -47,4 +51,15 @@ def find_wake(transcript: str, phrases: Iterable[str]) -> Tuple[bool, str]:
if len(span) == len(phrase) and all(w == p for (w, _, _), p in zip(span, phrase)):
rest = transcript[span[-1][2]:]
return True, rest.lstrip(" \t,.;:!?-—").strip()
return _find_anywhere(transcript, words, anywhere)
def _find_anywhere(transcript: str, words: list, anywhere: Iterable[str]) -> Tuple[bool, str]:
said = [w for w, _, _ in words]
for phrase in {tuple(_WORD.findall(p.lower())) for p in anywhere if p.strip()}:
n = len(phrase)
if any(tuple(said[i:i + n]) == phrase for i in range(len(said) - n + 1)):
if all(w in phrase or w in _FILLER for w in said):
return True, "" # just the word on its own: wait for the request
return True, transcript.strip()
return False, ""