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 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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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, ""
|
||||||
|
|||||||
+14
-1
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user