Updates to the assistants overall abilities, like always listening, and only responding on messages when activated.
This commit is contained in:
@@ -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, ""
|
||||
|
||||
Reference in New Issue
Block a user