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
Submodule .claude/worktrees/always-send added at 8bcd539b31
Submodule .claude/worktrees/wake-phrase-mic-fix added at 2f53bf3685
+2
View File
@@ -10,6 +10,8 @@ ELEVENLABS_VOICE_ID=
# Wake phrases, comma-separated (matched on the transcript, at the start of what's said) # Wake phrases, comma-separated (matched on the transcript, at the start of what's said)
#WAKE_WORDS=hey bolt,bolt,thunderbolt,hey thunderbolt,ok bolt,okay bolt,hey assistant #WAKE_WORDS=hey bolt,bolt,thunderbolt,hey thunderbolt,ok bolt,okay bolt,hey assistant
# Words that count anywhere in what's said; the whole sentence becomes the request
#ANYWHERE_WAKE_WORDS=assistant
# What Bolt may do on this machine when he relays a command: # What Bolt may do on this machine when he relays a command:
# off | read (default: list and read files) | all (shell commands and file changes too) # off | read (default: list and read files) | all (shell commands and file changes too)
+2
View File
@@ -73,6 +73,8 @@ See `.env.example`. The ones you're most likely to change:
- `VAD_RMS_THRESHOLD`: raise it if background noise keeps getting transcribed, lower it if you - `VAD_RMS_THRESHOLD`: raise it if background noise keeps getting transcribed, lower it if you
have to raise your voice. have to raise your voice.
- `WAKE_WORDS`: the phrases, comma-separated. - `WAKE_WORDS`: the phrases, comma-separated.
- `ANYWHERE_WAKE_WORDS`: words that count anywhere in a sentence, not just at the start
(default `assistant`: "what's the weather, assistant?"). Blank to turn off.
- `COMMAND_WAIT_SEC`, `FOLLOW_UP_SEC`: how long it waits after "Hey Bolt", or after asking you - `COMMAND_WAIT_SEC`, `FOLLOW_UP_SEC`: how long it waits after "Hey Bolt", or after asking you
something. something.
+3 -1
View File
@@ -70,6 +70,7 @@ class Assistant:
log: Callable[[str], None] = print, log: Callable[[str], None] = print,
clock: Callable[[], float] = time.monotonic, clock: Callable[[], float] = time.monotonic,
wake_words=None, wake_words=None,
anywhere_words=None,
verbose: bool = False, verbose: bool = False,
): ):
self._transcription = transcription self._transcription = transcription
@@ -79,6 +80,7 @@ class Assistant:
self.log, self.clock, self.verbose = log, clock, verbose self.log, self.clock, self.verbose = log, clock, verbose
self.tools = tools or Tools(log=log) self.tools = tools or Tools(log=log)
self.wake_words = wake_words or config.WAKE_WORDS 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, self.segmenter = Segmenter(config.VAD_RMS_THRESHOLD, config.VAD_START_FRAMES,
config.VAD_SILENCE_END_SEC, config.MIN_UTTERANCE_SEC, config.VAD_SILENCE_END_SEC, config.MIN_UTTERANCE_SEC,
config.MAX_UTTERANCE_SEC) config.MAX_UTTERANCE_SEC)
@@ -152,7 +154,7 @@ class Assistant:
if not text: if not text:
return return
self._check_deadline() 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 self.state == "idle":
if not woke: if not woke:
if self.verbose: 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, def play(chunks: Iterable[bytes], sample_rate: int = config.TTS_SAMPLE_RATE,
should_stop: Callable[[], bool] = lambda: False) -> bool: 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 import sounddevice as sd
with sd.RawOutputStream(samplerate=sample_rate, channels=1, dtype="int16", 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 assistant,ok assistant,okay assistant,"
"hey bolts,hey volt,hey bold,hey boat,okay volt,hey thunder bolt,thunder bolt", "hey bolts,hey volt,hey bold,hey boat,okay volt,hey thunder bolt,thunder bolt",
).split(",") if w.strip()] ).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) ──── # ── listening (local voice-activity detection, before anything is sent) ────
# Nothing leaves the machine until this says someone is talking: silence and # 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. that bolt" and "the bolt on the door" never count.
- Whole words only: "boltage" is not "bolt". - Whole words only: "boltage" is not "bolt".
- Longest phrase wins, so "hey thunderbolt" isn't read as "hey" + garbage. - 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 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())] 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]: def find_wake(transcript: str, phrases: Iterable[str],
"""(heard, remainder): whether a wake phrase opens *transcript*, and the anywhere: Iterable[str] = ()) -> Tuple[bool, str]:
request that followed it in the same breath ("" if it was just the """(heard, remainder): whether a wake phrase opens *transcript* (or an
phrase).""" *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 "") words = _words(transcript or "")
if not words: if not words:
return False, "" 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)): if len(span) == len(phrase) and all(w == p for (w, _, _), p in zip(span, phrase)):
rest = transcript[span[-1][2]:] rest = transcript[span[-1][2]:]
return True, rest.lstrip(" \t,.;:!?-—").strip() 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, "" return False, ""
Regular → Executable
View File
+14 -1
View File
@@ -44,7 +44,6 @@ W = config.WAKE_WORDS
("tighten that bolt", (False, "")), ("tighten that bolt", (False, "")),
("the bolt on the door", (False, "")), ("the bolt on the door", (False, "")),
("I was bold", (False, "")), # the mishearing alone isn't a wake word ("I was bold", (False, "")), # the mishearing alone isn't a wake word
("assistant set a timer", (False, "")), # "assistant" needs the "hey"
("boltage is high", (False, "")), ("boltage is high", (False, "")),
("", (False, "")), ("", (False, "")),
]) ])
@@ -52,6 +51,20 @@ def test_find_wake(said, expected):
assert find_wake(said, W) == expected assert find_wake(said, W) == expected
@pytest.mark.parametrize("said, expected", [
("assistant set a timer", (True, "assistant set a timer")),
("What's the weather, assistant?", (True, "What's the weather, assistant?")),
("can the assistant turn it up", (True, "can the assistant turn it up")),
("Hey assistant. Set a timer", (True, "Set a timer")), # at the start: stripped as before
("Assistant.", (True, "")), # on its own: wait for the request
("um, assistant", (True, "")),
("my assistants are busy", (False, "")), # whole words only
("tighten that bolt", (False, "")),
])
def test_find_wake_anywhere(said, expected):
assert find_wake(said, W, config.ANYWHERE_WAKE_WORDS) == expected
# ── voice-activity detection ──────────────────────────────────────────────── # ── voice-activity detection ────────────────────────────────────────────────
def seg(**kw): def seg(**kw):