Updates to the assistants overall abilities, like always listening, and only responding on messages when activated.
This commit is contained in:
Submodule
+1
Submodule .claude/worktrees/always-send added at 8bcd539b31
Submodule
+1
Submodule .claude/worktrees/wake-phrase-mic-fix added at 2f53bf3685
@@ -10,6 +10,8 @@ ELEVENLABS_VOICE_ID=
|
||||
|
||||
# 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
|
||||
# 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:
|
||||
# off | read (default: list and read files) | all (shell commands and file changes too)
|
||||
|
||||
@@ -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
|
||||
have to raise your voice.
|
||||
- `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
|
||||
something.
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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, ""
|
||||
|
||||
+14
-1
@@ -44,7 +44,6 @@ W = config.WAKE_WORDS
|
||||
("tighten that bolt", (False, "")),
|
||||
("the bolt on the door", (False, "")),
|
||||
("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, "")),
|
||||
("", (False, "")),
|
||||
])
|
||||
@@ -52,6 +51,20 @@ def test_find_wake(said, 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 ────────────────────────────────────────────────
|
||||
|
||||
def seg(**kw):
|
||||
|
||||
Reference in New Issue
Block a user