Author SHA1 Message Date
Michael Hansen b2409a4a40 Move other requirements to pyproject.toml 2025-01-24 09:42:20 -06:00
Michael Hansen 2c72ad0e01 Migrate to pyproject.toml 2025-01-24 09:28:51 -06:00
Michael Hansen 3576c0fc5f Lint 2025-01-24 09:28:38 -06:00
Michael Hansen 1915d3fce3 Send audio-start to wake service after detect 2025-01-24 09:17:54 -06:00
36 changed files with 213 additions and 505 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
*
!requirements.txt
!MANIFEST.in
!setup.py
!pyproject.toml
!script/setup
!script/run
!wyoming_satellite/*.py
+1 -1
View File
@@ -10,7 +10,7 @@ WORKDIR /app
COPY sounds/ ./sounds/
COPY script/setup ./script/
COPY setup.py requirements.txt MANIFEST.in ./
COPY pyproject.toml ./
COPY wyoming_satellite/ ./wyoming_satellite/
RUN script/setup
-2
View File
@@ -1,2 +0,0 @@
include requirements.txt
include VERSION
+1 -3
View File
@@ -55,9 +55,7 @@ python3 -m venv .venv
.venv/bin/pip3 install --upgrade wheel setuptools
.venv/bin/pip3 install \
-f 'https://synesthesiam.github.io/prebuilt-apps/' \
-r requirements.txt \
-r requirements_audio_enhancement.txt \
-r requirements_vad.txt
-e '.[all]'
```
If the installation was successful, you should be able to run:
+1 -1
View File
@@ -23,7 +23,7 @@ from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted
from wyoming.wake import Detection
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
NUM_LEDS = 3
LEDS_GPIO = 12
+1 -1
View File
@@ -23,7 +23,7 @@ from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted
from wyoming.wake import Detection
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
NUM_LEDS = 12
LEDS_GPIO = 5
+1 -1
View File
@@ -19,7 +19,7 @@ from wyoming.snd import Played
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detection
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
async def main() -> None:
+1 -1
View File
@@ -10,7 +10,7 @@ import websockets
from wyoming.event import Event
from wyoming.server import AsyncEventHandler, AsyncServer
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
async def main() -> None:
+4 -8
View File
@@ -17,7 +17,7 @@ from .speakers import configure_speakers
from .wake_word import configure_wake_word
from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
def main() -> None:
@@ -123,7 +123,7 @@ def apply_settings(settings: Settings) -> None:
):
result = run_with_gauge(
"Installing vad...",
[pip_install("-r", str(PROGRAM_DIR / "requirements_vad.txt"))],
[pip_install("-e", f"{PROGRAM_DIR}[silerovad]")],
)
if not result:
error("installing vad")
@@ -135,11 +135,7 @@ def apply_settings(settings: Settings) -> None:
):
result = run_with_gauge(
"Installing audio enhancements...",
[
pip_install(
"-r", str(PROGRAM_DIR / "requirements_audio_enhancement.txt")
)
],
[pip_install("-e", f"{PROGRAM_DIR}[webrtc]")],
)
if not result:
error("installing audio enhancements")
@@ -154,7 +150,7 @@ def apply_settings(settings: Settings) -> None:
) and (not can_import("gpiozero", "spidev")):
result = run_with_gauge(
"Installing event requirements...",
[pip_install("-r", str(PROGRAM_DIR / "requirements_respeaker.txt"))],
[pip_install("-e", f"{PROGRAM_DIR}[respeaker]")],
)
if not result:
error("installing event requirements")
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import List, Optional
from .dataclasses_json import DataClassJsonMixin
_DIR = Path(__file__).parent
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
PROGRAM_DIR = _DIR.parent
LOCAL_DIR = PROGRAM_DIR / "local"
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Dict, List, Optional
from .const import RECORD_RMS_MIN, RECORD_SECONDS, Settings
from .whiptail import gauge, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
def configure_microphone(settings: Settings) -> None:
+1 -1
View File
@@ -5,7 +5,7 @@ import subprocess
from .const import PROGRAM_DIR
from .whiptail import run_with_gauge
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
def packages_installed(*packages) -> bool:
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import List, Optional
from .const import PROGRAM_DIR, Settings
from .whiptail import checklist, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
def configure_speakers(settings: Settings) -> None:
+1 -1
View File
@@ -11,7 +11,7 @@ from .const import HEIGHT, LIST_HEIGHT, TITLE, WIDTH
ItemType = Union[str, Tuple[Any, str]]
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
def whiptail(*args) -> Optional[str]:
+61
View File
@@ -0,0 +1,61 @@
[build-system]
requires = ["setuptools>=62.3"]
build-backend = "setuptools.build_meta"
[project]
name = "wyoming_satellite"
version = "1.3.1"
license = {text = "MIT"}
description = "Wyoming server for remote voice satellite"
readme = "README.md"
authors = [
{name = "Michael Hansen", email = "mike@rhasspy.org"}
]
keywords = ["rhasspy", "wyoming", "satellite"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
requires-python = ">=3.9.0"
dependencies = [
"wyoming==1.5.4",
"zeroconf==0.88.0",
"pyring-buffer>=1,<2",
]
[project.optional-dependencies]
all = ["pysilero-vad==1.0.0", "webrtc-noise-gain==1.2.3"]
silerovad = ["pysilero-vad==1.0.0"]
webrtc = ["webrtc-noise-gain==1.2.3"]
respeaker = ["gpiozero", "spidev"]
dev = [
"black==22.12.0",
"flake8==6.0.0",
"isort==5.11.3",
"mypy==0.991",
"pylint==2.15.9",
"pytest==7.4.3",
"pytest-asyncio==0.23.3",
"build==1.2.2"
]
[project.scripts]
wyoming-satellite = "wyoming_satellite:__main__.run"
[project.urls]
"Source Code" = "http://github.com/rhasspy/wyoming-satellite"
[tool.setuptools]
platforms = ["any"]
zip-safe = true
include-package-data = true
[tool.setuptools.packages.find]
include = ["wyoming_satellite"]
exclude = ["tests", "tests.*"]
-3
View File
@@ -1,3 +0,0 @@
wyoming==1.5.4
zeroconf==0.88.0
pyring-buffer==1.0.0
-1
View File
@@ -1 +0,0 @@
webrtc-noise-gain==1.2.3
-7
View File
@@ -1,7 +0,0 @@
black==22.12.0
flake8==6.0.0
isort==5.11.3
mypy==0.991
pylint==2.15.9
pytest==7.4.3
pytest-asyncio==0.23.3
-2
View File
@@ -1,2 +0,0 @@
gpiozero
spidev
-1
View File
@@ -1 +0,0 @@
pysilero-vad==1.0.0
+1 -1
View File
@@ -8,4 +8,4 @@ _PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, _PROGRAM_DIR / "setup.py", "bdist_wheel"])
subprocess.check_call([context.env_exe, "-m", "build", "--wheel", "--sdist"])
+3 -4
View File
@@ -20,7 +20,6 @@ builder.create(_VENV_DIR)
# Upgrade dependencies
pip = [context.env_exe, "-m", "pip"]
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
# Install requirements
subprocess.check_call(
@@ -31,13 +30,13 @@ subprocess.check_call(
"https://www.piwheels.org/simple",
"-f",
"https://synesthesiam.github.io/prebuilt-apps/",
"-r",
str(_PROGRAM_DIR / "requirements.txt"),
"-e",
str(_PROGRAM_DIR),
]
)
if args.dev:
# Install dev requirements
subprocess.check_call(
pip + ["install", "-r", str(_PROGRAM_DIR / "requirements_dev.txt")]
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"]
)
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
from typing import List
import setuptools
from setuptools import setup
this_dir = Path(__file__).parent
module_dir = this_dir / "wyoming_satellite"
version_path = module_dir / "VERSION"
version = version_path.read_text(encoding="utf-8").strip()
def get_requirements(req_path: Path) -> List[str]:
if not req_path.is_file():
return []
requirements: List[str] = []
with open(req_path, "r", encoding="utf-8") as req_file:
for line in req_file:
line = line.strip()
if not line:
continue
requirements.append(line)
return requirements
install_requires = get_requirements(this_dir / "requirements.txt")
extras_require = {
"silerovad": get_requirements(this_dir / "requirements_vad.txt"),
"webrtc": get_requirements(this_dir / "requirements_audio_enhancement.txt"),
}
# -----------------------------------------------------------------------------
setup(
name="wyoming_satellite",
version="1.1.0",
description="Wyoming server for remote voice satellite",
url="http://github.com/rhasspy/wyoming-satellite",
author="Michael Hansen",
author_email="mike@rhasspy.org",
packages=setuptools.find_packages(),
package_data={
"wyoming_satellite": [str(p.relative_to(module_dir)) for p in (version_path,)]
},
install_requires=install_requires,
classifiers=[
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
],
keywords="rhasspy wyoming satellite",
extras_require=extras_require,
entry_points={
"console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"]
},
)
+1 -1
View File
@@ -30,7 +30,7 @@ from .shared import (
MicClient,
)
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
TIMEOUT: Final = 1
+1 -1
View File
@@ -22,7 +22,7 @@ from wyoming_satellite import (
from .shared import MicClient
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
class WakeClient(AsyncClient):
+1 -1
View File
@@ -1,6 +1,6 @@
[tox]
env_list =
py{39,310,311}
py{39,310,311,312,313}
minversion = 4.12.1
[testenv]
-1
View File
@@ -1 +0,0 @@
1.4.0
+3 -1
View File
@@ -1,4 +1,6 @@
"""Voice satellite using the Wyoming protocol."""
import importlib.metadata
from pathlib import Path
from .satellite import (
@@ -17,7 +19,7 @@ from .settings import (
)
_DIR = Path(__file__).parent
__version__ = (_DIR / "VERSION").read_text(encoding="utf-8").strip()
__version__ = importlib.metadata.version("wyoming-satellite")
__all__ = [
"__version__",
+14 -52
View File
@@ -3,13 +3,11 @@
import argparse
import asyncio
import logging
import shlex
import sys
from functools import partial
from pathlib import Path
from wyoming.audio import AudioFormat
from wyoming.info import Attribution, Info, MicProgram, Satellite, SndProgram
from wyoming.info import Attribution, Info, Satellite
from wyoming.server import AsyncServer, AsyncTcpServer
from . import __version__
@@ -28,6 +26,7 @@ from .settings import (
TimerSettings,
VadSettings,
WakeSettings,
WakeWordAndPipeline,
)
from .utils import (
get_mac_address,
@@ -37,7 +36,7 @@ from .utils import (
split_command,
)
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
_DIR = Path(__file__).parent
@@ -123,7 +122,9 @@ async def main() -> None:
"--wake-word-name",
action="append",
default=[],
help="Name of wake word to listen for (requires --wake-uri)",
nargs="+",
metavar=("name", "pipeline"),
help="Name of wake word to listen for and optional pipeline name to run (requires --wake-uri)",
)
parser.add_argument("--wake-command", help="Program to run for wake word detection")
parser.add_argument(
@@ -153,14 +154,9 @@ async def main() -> None:
# Voice activity detector
parser.add_argument(
"--vad", action="store_true", help="Enable voice activity detector"
)
parser.add_argument(
"--vad-threshold",
type=float,
default=0.5,
help="Threshold for speech (0-1, default: 0.5)",
"--vad", action="store_true", help="Wait for speech before streaming audio"
)
parser.add_argument("--vad-threshold", type=float, default=0.5)
parser.add_argument("--vad-trigger-level", type=int, default=1)
parser.add_argument("--vad-buffer-seconds", type=float, default=2)
parser.add_argument(
@@ -169,7 +165,6 @@ async def main() -> None:
default=5.0,
help="Seconds before going back to waiting for speech when wake word isn't detected",
)
parser.add_argument("--vad-finished-speaking-seconds", type=float, default=0.8)
# External event handlers
parser.add_argument(
@@ -335,6 +330,9 @@ async def main() -> None:
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
sys.exit(1)
if args.vad and (args.wake_uri or args.wake_command):
_LOGGER.warning("VAD is not used with local wake word detection")
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
)
@@ -352,46 +350,9 @@ async def main() -> None:
attribution=Attribution(name="", url=""),
installed=True,
version=__version__,
has_vad=args.vad,
supports_trigger=True,
active_wake_words=args.wake_word_name,
)
)
if args.mic_command:
mic_program = shlex.split(args.mic_command)[0]
wyoming_info.mic = [
MicProgram(
name=mic_program,
description=args.mic_command,
attribution=Attribution(name="", url=""),
installed=True,
version=None,
mic_format=AudioFormat(
rate=args.mic_command_rate,
width=args.mic_command_width,
channels=args.mic_command_channels,
),
)
]
if args.snd_command:
snd_program = shlex.split(args.snd_command)[0]
wyoming_info.snd = [
SndProgram(
name=snd_program,
description=args.snd_command,
attribution=Attribution(name="", url=""),
installed=True,
version=None,
snd_format=AudioFormat(
rate=args.snd_command_rate,
width=args.snd_command_width,
channels=args.snd_command_channels,
),
)
]
settings = SatelliteSettings(
mic=MicSettings(
uri=args.mic_uri,
@@ -413,12 +374,13 @@ async def main() -> None:
trigger_level=args.vad_trigger_level,
buffer_seconds=args.vad_buffer_seconds,
wake_word_timeout=args.vad_wake_word_timeout,
finished_speaking_seconds=args.vad_finished_speaking_seconds,
),
wake=WakeSettings(
uri=args.wake_uri,
command=split_command(args.wake_command),
names=args.wake_word_name,
names=[
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
],
refractory_seconds=(
args.wake_refractory_seconds
if args.wake_refractory_seconds > 0
+1 -1
View File
@@ -9,7 +9,7 @@ from wyoming.server import AsyncEventHandler
from .satellite import SatelliteBase
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
class SatelliteEventHandler(AsyncEventHandler):
+1 -1
View File
@@ -8,7 +8,7 @@ from functools import partial
from wyoming.event import Event
from wyoming.server import AsyncEventHandler, AsyncServer
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
async def main() -> None:
+99 -235
View File
@@ -9,12 +9,11 @@ import wave
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from queue import Queue
from typing import Callable, Dict, Final, List, Optional, Set, Union
from pyring_buffer import RingBuffer
from wyoming.asr import Transcript
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.audio import AudioChunk, AudioFormat, AudioStart, AudioStop
from wyoming.client import AsyncClient
from wyoming.error import Error
from wyoming.event import Event, async_write_event
@@ -44,10 +43,10 @@ from .utils import (
run_event_command,
wav_to_events,
)
from .vad import SileroVad, VoiceCommandSegmenter
from .vad import SileroVad
from .webrtc import WebRtcAudio
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
_PONG_TIMEOUT: Final = 5
_PING_SEND_DELAY: Final = 2
@@ -111,11 +110,6 @@ class SatelliteBase:
settings.debug_recording_dir, "stt"
)
# Names of wake words to listen for
self._wake_names: Optional[List[str]] = None
if self.settings.wake.names:
self._wake_names = self.settings.wake.names
@property
def is_running(self) -> bool:
"""True if not stopping/stopped."""
@@ -286,6 +280,13 @@ class SatelliteBase:
# TTS stopped
await self.event_to_snd(event)
await self.trigger_tts_stop()
elif Detect.is_type(event.type):
# Wake word detection started
await self.trigger_detect()
elif Detection.is_type(event.type):
# Wake word detected
_LOGGER.debug("Wake word detected")
await self.trigger_detection(Detection.from_event(event))
elif VoiceStarted.is_type(event.type):
# STT start
await self.trigger_stt_start()
@@ -320,38 +321,34 @@ class SatelliteBase:
if forward_event:
await self.forward_event(event)
async def _send_run_pipeline(
self,
start_stage: Optional[PipelineStage] = None,
end_stage: Optional[PipelineStage] = None,
wake_word_name: Optional[str] = None,
) -> None:
async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None:
"""Sends a RunPipeline event with the correct stages."""
restart_on_end = False
if self.settings.wake.enabled:
# Local wake word detection
start_stage = PipelineStage.ASR
restart_on_end = False
else:
# Remote wake word detection
start_stage = PipelineStage.WAKE
restart_on_end = not self.settings.vad.enabled
if start_stage is None:
if self.settings.wake.enabled:
# Local wake word detection
start_stage = PipelineStage.ASR
restart_on_end = False
else:
# Remote wake word detection
start_stage = PipelineStage.WAKE
restart_on_end = not self.settings.vad.enabled
if end_stage is None:
if self.settings.snd.enabled:
# Play TTS response
end_stage = PipelineStage.TTS
else:
# No audio output
end_stage = PipelineStage.HANDLE
if self.settings.snd.enabled:
# Play TTS response
end_stage = PipelineStage.TTS
else:
# No audio output
end_stage = PipelineStage.HANDLE
run_pipeline = RunPipeline(
start_stage=start_stage,
end_stage=end_stage,
wake_word_name=wake_word_name,
name=pipeline_name,
restart_on_end=restart_on_end,
snd_format=AudioFormat(
rate=self.settings.snd.rate,
width=self.settings.snd.width,
channels=self.settings.snd.channels,
),
).event()
_LOGGER.debug(run_pipeline)
await self.event_to_server(run_pipeline)
@@ -480,10 +477,7 @@ class SatelliteBase:
mic_client = self._make_mic_client()
assert mic_client is not None
await mic_client.connect()
_LOGGER.info(
"Connected to mic service: %s",
self.settings.mic.uri or self.settings.mic.command,
)
_LOGGER.debug("Connected to mic service")
event = await mic_client.read_event()
if event is None:
@@ -604,10 +598,7 @@ class SatelliteBase:
snd_client = self._make_snd_client()
assert snd_client is not None
await snd_client.connect()
_LOGGER.info(
"Connected to snd service: %s",
self.settings.snd.uri or self.settings.snd.command,
)
_LOGGER.debug("Connected to snd service")
# Audio processing
if self.settings.snd.needs_processing and AudioChunk.is_type(
@@ -630,8 +621,6 @@ class SatelliteBase:
await _disconnect()
if snd_event.is_tts:
await self.trigger_played()
await self.event_to_server(Played().event())
snd_client = None # reconnect on next event
except asyncio.CancelledError:
break
@@ -750,10 +739,7 @@ class SatelliteBase:
wake_client = self._make_wake_client()
assert wake_client is not None
await wake_client.connect()
_LOGGER.info(
"Connected to wake service: %s",
self.settings.wake.uri or self.settings.wake.command,
)
_LOGGER.debug("Connected to wake service")
# Reset
from_client_task = None
@@ -817,7 +803,18 @@ class SatelliteBase:
async def _send_wake_detect(self) -> None:
"""Inform wake word service of which wake words to detect."""
await self.event_to_wake(Detect(names=self._wake_names).event())
wake_names: Optional[List[str]] = None
if self.settings.wake.names:
wake_names = [w.name for w in self.settings.wake.names]
await self.event_to_wake(Detect(names=wake_names).event())
await self.event_to_wake(
AudioStart(
rate=self.settings.mic.rate,
width=self.settings.mic.width,
channels=self.settings.mic.channels,
).event()
)
await self.trigger_detect()
# -------------------------------------------------------------------------
@@ -948,10 +945,7 @@ class SatelliteBase:
event_client = self._make_event_client()
assert event_client is not None
await event_client.connect()
_LOGGER.info(
"Connected to event service: %s",
self.settings.event.uri or self.settings.event.command,
)
_LOGGER.debug("Connected to event service")
await event_client.write_event(event)
except asyncio.CancelledError:
@@ -1000,17 +994,10 @@ class AlwaysStreamingSatellite(SatelliteBase):
elif PauseSatellite.is_type(event.type):
self.is_streaming = False
_LOGGER.info("Satellite paused")
elif Detect.is_type(event.type):
# Wake word detection started
await self.trigger_detect()
elif Detection.is_type(event.type):
# Start debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.start()
# Wake word detected
_LOGGER.debug("Wake word detected")
await self.trigger_detection(Detection.from_event(event))
elif Transcript.is_type(event.type) or Error.is_type(event.type):
# Stop debug recording
if self.stt_audio_writer is not None:
@@ -1082,16 +1069,10 @@ class VadStreamingSatellite(SatelliteBase):
if RunSatellite.is_type(event.type):
self._is_paused = False
_LOGGER.info("Waiting for speech")
elif Detect.is_type(event.type):
# Wake word detection started
await self.trigger_detect()
elif Detection.is_type(event.type):
# Start debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.start()
_LOGGER.debug("Wake word detected")
await self.trigger_detection(Detection.from_event(event))
elif (
Transcript.is_type(event.type)
or Error.is_type(event.type)
@@ -1228,17 +1209,8 @@ class WakeStreamingSatellite(SatelliteBase):
# wake word id -> seconds
self.refractory_timestamp: Dict[Optional[str], float] = {}
# Local VAD for detecting the end of a voice command.
# If this is disabled, the server is expected to do it remotely.
self.vad: Optional[SileroVad] = None
if settings.vad.enabled:
_LOGGER.debug("Loading VAD model")
self.vad = SileroVad(threshold=settings.vad.threshold, trigger_level=1)
_LOGGER.info("Loaded VAD model")
self.segmenter = VoiceCommandSegmenter(
silence_seconds=settings.vad.finished_speaking_seconds
)
_LOGGER.warning("VAD is enabled but will not be used")
# Used for debug audio recording so both wake and stt WAV files have the
# same timestamp.
@@ -1249,9 +1221,6 @@ class WakeStreamingSatellite(SatelliteBase):
self._wake_info: Optional[Info] = None
self._wake_info_ready = asyncio.Event()
self._run_pipeline_queue: Queue[RunPipeline] = Queue()
self._next_run_pipeline: Optional[RunPipeline] = None
async def event_from_server(self, event: Event) -> None:
# Only check event types once
is_run_satellite = False
@@ -1262,42 +1231,13 @@ class WakeStreamingSatellite(SatelliteBase):
if RunSatellite.is_type(event.type):
is_run_satellite = True
self._is_paused = False
elif PauseSatellite.is_type(event.type):
is_pause_satellite = True
elif Detect.is_type(event.type):
# Request to change active wake words
detect = Detect.from_event(event)
self._wake_names = detect.names
if not self.is_streaming:
await self._send_wake_detect()
_LOGGER.debug("Updated wake word names: %s", self._wake_names)
elif Transcript.is_type(event.type):
is_transcript = True
if (self._next_run_pipeline is not None) and (
self._next_run_pipeline.end_stage == PipelineStage.ASR
):
# Done with remotely triggered pipeline
self._next_run_pipeline = None
elif Error.is_type(event.type):
is_error = True
elif RunPipeline.is_type(event.type):
run_pipeline = RunPipeline.from_event(event)
if not self.is_streaming:
# Run right now
self._next_run_pipeline = run_pipeline
await self._trigger_pipeline(run_pipeline)
return
# Run after streaming is finished
self._run_pipeline_queue.put(run_pipeline)
elif AudioStop.is_type(event.type):
if (self._next_run_pipeline is not None) and (
self._next_run_pipeline.end_stage == PipelineStage.TTS
):
# Done with remotely triggered pipeline
self._next_run_pipeline = None
if is_transcript or is_pause_satellite:
# Stop streaming before event_from_server is called because it will
@@ -1318,20 +1258,20 @@ class WakeStreamingSatellite(SatelliteBase):
self._is_paused = True
_LOGGER.debug("Satellite is paused")
else:
# Go back to wake word detection or run next pipeline
# Go back to wake word detection
await self.trigger_streaming_stop()
# It's possible to be paused in the middle of streaming
if self._is_paused:
return
if not self._is_paused:
await self._send_wake_detect()
_LOGGER.info("Waiting for wake word")
if self._run_pipeline_queue.empty():
# Back to wake word detection
await self._start_wake_word_detection()
else:
# Next run from queue
self._next_run_pipeline = self._run_pipeline_queue.get()
await self._trigger_pipeline(self._next_run_pipeline)
# Start debug recording (wake)
self._debug_recording_timestamp = time.monotonic_ns()
if self.wake_audio_writer is not None:
self.wake_audio_writer.start(
timestamp=self._debug_recording_timestamp
)
async def trigger_server_disonnected(self) -> None:
await super().trigger_server_disonnected()
@@ -1367,26 +1307,13 @@ class WakeStreamingSatellite(SatelliteBase):
self.stt_audio_writer.write(audio_bytes)
if self.is_streaming:
if self.vad is not None:
if audio_bytes is None:
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
is_speech = self.vad(audio_bytes)
chunk_seconds = (len(audio_bytes) // 2) / 16000
if not self.segmenter.process(chunk_seconds, is_speech):
# Send empty audio chunk to indicate command is done
event = AudioChunk(
rate=16000, width=2, channels=1, audio=bytes()
).event()
# Forward to server
await self.event_to_server(event)
else:
# Forward to wake word service
await self.event_to_wake(event)
async def event_from_wake(self, event: Event, is_fake: bool = False) -> None:
async def event_from_wake(self, event: Event) -> None:
if Info.is_type(event.type):
self._wake_info = Info.from_event(event)
self._wake_info_ready.set()
@@ -1396,85 +1323,53 @@ class WakeStreamingSatellite(SatelliteBase):
# Not detecting or no server connected
return
if not Detection.is_type(event.type):
return
if Detection.is_type(event.type):
detection = Detection.from_event(event)
detection = Detection.from_event(event)
if (self._next_run_pipeline is not None) and (
self._next_run_pipeline.end_stage == PipelineStage.WAKE
):
# Remote pipeline was triggered with only wake word detection
wake_word_name: Optional[str] = None
if detection.name:
wake_word_name = normalize_wake_word(detection.name)
if (not self._next_run_pipeline.wake_word_names) or (
wake_word_name in self._next_run_pipeline.wake_word_names
# Check refractory period to avoid multiple back-to-back detections
refractory_timestamp = self.refractory_timestamp.get(detection.name)
if (refractory_timestamp is not None) and (
refractory_timestamp > time.monotonic()
):
self._next_run_pipeline = None
await self.event_to_server(event)
await self._send_wake_detect()
_LOGGER.info("Waiting for wake word")
else:
_LOGGER.debug(
"Got %s but listening for %s",
wake_word_name,
self._next_run_pipeline.wake_word_names,
_LOGGER.debug("Wake word detection occurred during refractory period")
return
# Stop debug recording (wake)
if self.wake_audio_writer is not None:
self.wake_audio_writer.stop()
# Start debug recording (stt)
if self.stt_audio_writer is not None:
self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp)
_LOGGER.debug(detection)
self.is_streaming = True
_LOGGER.debug("Streaming audio")
if self.settings.wake.refractory_seconds is not None:
# Another detection may not occur for this wake word until
# refractory period is over.
self.refractory_timestamp[detection.name] = (
time.monotonic() + self.settings.wake.refractory_seconds
)
return
else:
# No refractory period
self.refractory_timestamp.pop(detection.name, None)
# Check refractory period to avoid multiple back-to-back detections
refractory_timestamp = self.refractory_timestamp.get(detection.name)
if (refractory_timestamp is not None) and (
refractory_timestamp > time.monotonic()
):
_LOGGER.debug("Wake word detection occurred during refractory period")
return
# Stop debug recording (wake)
if self.wake_audio_writer is not None:
self.wake_audio_writer.stop()
# Start debug recording (stt)
if self.stt_audio_writer is not None:
self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp)
_LOGGER.debug(detection)
self.segmenter.reset()
self.is_streaming = True
_LOGGER.debug("Streaming audio")
if (not is_fake) and (self.settings.wake.refractory_seconds is not None):
# Another detection may not occur for this wake word until
# refractory period is over.
self.refractory_timestamp[detection.name] = (
time.monotonic() + self.settings.wake.refractory_seconds
)
else:
# No refractory period
self.refractory_timestamp.pop(detection.name, None)
if not is_fake:
# Forward to the server
await self.event_to_server(event)
start_stage: Optional[PipelineStage] = None
end_stage: Optional[PipelineStage] = None
# Match detected wake word name with pipeline name
pipeline_name: Optional[str] = None
if self.settings.wake.names:
detection_name = normalize_wake_word(detection.name)
for wake_name in self.settings.wake.names:
if normalize_wake_word(wake_name.name) == detection_name:
pipeline_name = wake_name.pipeline
break
if self._next_run_pipeline is not None:
# Remote pipeline has been triggered
start_stage = self._next_run_pipeline.start_stage
end_stage = self._next_run_pipeline.end_stage
await self._send_run_pipeline(
start_stage=start_stage,
end_stage=end_stage,
wake_word_name=detection.name,
)
if not is_fake:
await self._send_run_pipeline(pipeline_name=pipeline_name)
await self.forward_event(event) # forward to event service
await self.trigger_detection(Detection.from_event(event))
await self.trigger_streaming_start()
@@ -1494,34 +1389,3 @@ class WakeStreamingSatellite(SatelliteBase):
info.wake = self._wake_info.wake
except asyncio.TimeoutError:
_LOGGER.warning("Failed to get info from wake service")
async def _start_wake_word_detection(self) -> None:
await self._send_wake_detect()
_LOGGER.info("Waiting for wake word")
# Start debug recording (wake)
self._debug_recording_timestamp = time.monotonic_ns()
if self.wake_audio_writer is not None:
self.wake_audio_writer.start(timestamp=self._debug_recording_timestamp)
async def _trigger_pipeline(self, run_pipeline: RunPipeline) -> None:
_LOGGER.debug("Pipeline remotely triggered: %s", run_pipeline)
if run_pipeline.start_stage == PipelineStage.WAKE:
if run_pipeline.wake_word_names:
run_pipeline.wake_word_names = [
normalize_wake_word(name) for name in run_pipeline.wake_word_names
]
_LOGGER.debug(
"Listening once for wake words: %s", run_pipeline.wake_word_names
)
await self.event_to_wake(Detect(names=run_pipeline.wake_word_names).event())
elif run_pipeline.start_stage == PipelineStage.ASR:
_LOGGER.debug("Listening for voice command once")
# Fake a detection
await self.event_from_wake(Detection().event(), is_fake=True)
elif run_pipeline.start_stage == PipelineStage.TTS:
_LOGGER.debug("Speaking once: %s", run_pipeline.announce_text)
await self.event_to_server(run_pipeline.event())
+9 -4
View File
@@ -104,11 +104,19 @@ class SndSettings(ServiceSettings):
return self.enabled and (self.volume_multiplier != 1.0)
@dataclass(frozen=True)
class WakeWordAndPipeline:
"""Wake word name + optional pipeline name."""
name: str
pipeline: Optional[str] = None
@dataclass(frozen=True)
class WakeSettings(ServiceSettings):
"""Wake word service settings."""
names: Optional[List[str]] = None
names: Optional[List[WakeWordAndPipeline]] = None
"""List of wake word names to listen for."""
rate: int = 16000
@@ -143,9 +151,6 @@ class VadSettings:
wake_word_timeout: Optional[float] = 5.0
"""Seconds before going back to sleep if wake word is not detected."""
finished_speaking_seconds: float = 0.8
"""Seconds to wait after speech before voice command is finished."""
@dataclass(frozen=True)
class EventSettings(ServiceSettings):
+1 -1
View File
@@ -10,7 +10,7 @@ from pyring_buffer import RingBuffer
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.event import Event
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
class AudioBuffer:
+1 -1
View File
@@ -12,7 +12,7 @@ from typing import List, Optional, Union
from wyoming.event import Eventable
_LOGGER = logging.getLogger(__name__)
_LOGGER = logging.getLogger()
async def run_event_command(
-96
View File
@@ -1,10 +1,6 @@
"""Voice activity detection."""
import logging
from dataclasses import dataclass
from typing import Optional
_LOGGER = logging.getLogger(__name__)
class SileroVad:
"""Voice activity detection with silero VAD."""
@@ -35,95 +31,3 @@ class SileroVad:
self._activation = max(0, self._activation - 1)
return False
@dataclass
class VoiceCommandSegmenter:
"""Segments an audio stream into voice commands."""
speech_seconds: float = 0.3
"""Seconds of speech before voice command has started."""
silence_seconds: float = 1.0
"""Seconds of silence after voice command has ended."""
timeout_seconds: float = 15.0
"""Maximum number of seconds before stopping with timeout=True."""
reset_seconds: float = 1.0
"""Seconds before reset start/stop time counters."""
in_command: bool = False
"""True if inside voice command."""
_speech_seconds_left: float = 0.0
"""Seconds left before considering voice command as started."""
_silence_seconds_left: float = 0.0
"""Seconds left before considering voice command as stopped."""
_timeout_seconds_left: float = 0.0
"""Seconds left before considering voice command timed out."""
_reset_seconds_left: float = 0.0
"""Seconds left before resetting start/stop time counters."""
def __post_init__(self) -> None:
"""Reset after initialization."""
self.reset()
def reset(self) -> None:
"""Reset all counters and state."""
self._speech_seconds_left = self.speech_seconds
self._silence_seconds_left = self.silence_seconds
self._timeout_seconds_left = self.timeout_seconds
self._reset_seconds_left = self.reset_seconds
self.in_command = False
def process(self, chunk_seconds: float, is_speech: bool | None) -> bool:
"""Process samples using external VAD.
Returns False when command is done.
"""
self._timeout_seconds_left -= chunk_seconds
if self._timeout_seconds_left <= 0:
_LOGGER.warning(
"VAD end of speech detection timed out after %s seconds",
self.timeout_seconds,
)
self.reset()
return False
if not self.in_command:
if is_speech:
self._reset_seconds_left = self.reset_seconds
self._speech_seconds_left -= chunk_seconds
if self._speech_seconds_left <= 0:
# Inside voice command
self.in_command = True
self._silence_seconds_left = self.silence_seconds
_LOGGER.debug("Voice command started")
else:
# Reset if enough silence
self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:
self._speech_seconds_left = self.speech_seconds
self._reset_seconds_left = self.reset_seconds
elif not is_speech:
# Silence in command
self._reset_seconds_left = self.reset_seconds
self._silence_seconds_left -= chunk_seconds
if self._silence_seconds_left <= 0:
# Command finished successfully
self.reset()
_LOGGER.debug("Voice command finished")
return False
else:
# Speech in command.
# Reset silence counter if enough speech.
self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:
self._silence_seconds_left = self.silence_seconds
self._reset_seconds_left = self.reset_seconds
return True