Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c11e58c4 |
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
*
|
*
|
||||||
!pyproject.toml
|
!requirements.txt
|
||||||
|
!MANIFEST.in
|
||||||
|
!setup.py
|
||||||
!script/setup
|
!script/setup
|
||||||
!script/run
|
!script/run
|
||||||
!wyoming_satellite/*.py
|
!wyoming_satellite/*.py
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 1.4.1
|
|
||||||
|
|
||||||
- Send `played` event to server
|
|
||||||
|
|
||||||
## 1.4.0
|
|
||||||
|
|
||||||
- Migrate to pyproject.toml
|
|
||||||
|
|
||||||
## 1.3.1
|
|
||||||
|
|
||||||
- Send audio-start to wake service after detect
|
|
||||||
|
|
||||||
## 1.3.0
|
## 1.3.0
|
||||||
|
|
||||||
- Bump to wyoming 1.5.4 (timers)
|
- Bump to wyoming 1.5.4 (timers)
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY sounds/ ./sounds/
|
COPY sounds/ ./sounds/
|
||||||
COPY script/setup ./script/
|
COPY script/setup ./script/
|
||||||
COPY pyproject.toml ./
|
COPY setup.py requirements.txt MANIFEST.in ./
|
||||||
COPY wyoming_satellite/ ./wyoming_satellite/
|
COPY wyoming_satellite/ ./wyoming_satellite/
|
||||||
|
|
||||||
RUN script/setup
|
RUN script/setup
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
include requirements.txt
|
||||||
|
include VERSION
|
||||||
@@ -55,7 +55,9 @@ python3 -m venv .venv
|
|||||||
.venv/bin/pip3 install --upgrade wheel setuptools
|
.venv/bin/pip3 install --upgrade wheel setuptools
|
||||||
.venv/bin/pip3 install \
|
.venv/bin/pip3 install \
|
||||||
-f 'https://synesthesiam.github.io/prebuilt-apps/' \
|
-f 'https://synesthesiam.github.io/prebuilt-apps/' \
|
||||||
-e '.[all]'
|
-r requirements.txt \
|
||||||
|
-r requirements_audio_enhancement.txt \
|
||||||
|
-r requirements_vad.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
If the installation was successful, you should be able to run:
|
If the installation was successful, you should be able to run:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from wyoming.server import AsyncEventHandler, AsyncServer
|
|||||||
from wyoming.vad import VoiceStarted
|
from wyoming.vad import VoiceStarted
|
||||||
from wyoming.wake import Detection
|
from wyoming.wake import Detection
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
NUM_LEDS = 3
|
NUM_LEDS = 3
|
||||||
LEDS_GPIO = 12
|
LEDS_GPIO = 12
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from wyoming.server import AsyncEventHandler, AsyncServer
|
|||||||
from wyoming.vad import VoiceStarted
|
from wyoming.vad import VoiceStarted
|
||||||
from wyoming.wake import Detection
|
from wyoming.wake import Detection
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
NUM_LEDS = 12
|
NUM_LEDS = 12
|
||||||
LEDS_GPIO = 5
|
LEDS_GPIO = 5
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from wyoming.snd import Played
|
|||||||
from wyoming.vad import VoiceStarted, VoiceStopped
|
from wyoming.vad import VoiceStarted, VoiceStopped
|
||||||
from wyoming.wake import Detection
|
from wyoming.wake import Detection
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import websockets
|
|||||||
from wyoming.event import Event
|
from wyoming.event import Event
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from .speakers import configure_speakers
|
|||||||
from .wake_word import configure_wake_word
|
from .wake_word import configure_wake_word
|
||||||
from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge
|
from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -123,7 +123,7 @@ def apply_settings(settings: Settings) -> None:
|
|||||||
):
|
):
|
||||||
result = run_with_gauge(
|
result = run_with_gauge(
|
||||||
"Installing vad...",
|
"Installing vad...",
|
||||||
[pip_install("-e", f"{PROGRAM_DIR}[silerovad]")],
|
[pip_install("-r", str(PROGRAM_DIR / "requirements_vad.txt"))],
|
||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
error("installing vad")
|
error("installing vad")
|
||||||
@@ -135,7 +135,11 @@ def apply_settings(settings: Settings) -> None:
|
|||||||
):
|
):
|
||||||
result = run_with_gauge(
|
result = run_with_gauge(
|
||||||
"Installing audio enhancements...",
|
"Installing audio enhancements...",
|
||||||
[pip_install("-e", f"{PROGRAM_DIR}[webrtc]")],
|
[
|
||||||
|
pip_install(
|
||||||
|
"-r", str(PROGRAM_DIR / "requirements_audio_enhancement.txt")
|
||||||
|
)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
error("installing audio enhancements")
|
error("installing audio enhancements")
|
||||||
@@ -150,7 +154,7 @@ def apply_settings(settings: Settings) -> None:
|
|||||||
) and (not can_import("gpiozero", "spidev")):
|
) and (not can_import("gpiozero", "spidev")):
|
||||||
result = run_with_gauge(
|
result = run_with_gauge(
|
||||||
"Installing event requirements...",
|
"Installing event requirements...",
|
||||||
[pip_install("-e", f"{PROGRAM_DIR}[respeaker]")],
|
[pip_install("-r", str(PROGRAM_DIR / "requirements_respeaker.txt"))],
|
||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
error("installing event requirements")
|
error("installing event requirements")
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ from typing import List, Optional
|
|||||||
from .dataclasses_json import DataClassJsonMixin
|
from .dataclasses_json import DataClassJsonMixin
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
_DIR = Path(__file__).parent
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROGRAM_DIR = _DIR.parent
|
PROGRAM_DIR = _DIR.parent
|
||||||
LOCAL_DIR = PROGRAM_DIR / "local"
|
LOCAL_DIR = PROGRAM_DIR / "local"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Dict, List, Optional
|
|||||||
from .const import RECORD_RMS_MIN, RECORD_SECONDS, Settings
|
from .const import RECORD_RMS_MIN, RECORD_SECONDS, Settings
|
||||||
from .whiptail import gauge, inputbox, menu, msgbox, radiolist
|
from .whiptail import gauge, inputbox, menu, msgbox, radiolist
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def configure_microphone(settings: Settings) -> None:
|
def configure_microphone(settings: Settings) -> None:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import subprocess
|
|||||||
from .const import PROGRAM_DIR
|
from .const import PROGRAM_DIR
|
||||||
from .whiptail import run_with_gauge
|
from .whiptail import run_with_gauge
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def packages_installed(*packages) -> bool:
|
def packages_installed(*packages) -> bool:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import List, Optional
|
|||||||
from .const import PROGRAM_DIR, Settings
|
from .const import PROGRAM_DIR, Settings
|
||||||
from .whiptail import checklist, inputbox, menu, msgbox, radiolist
|
from .whiptail import checklist, inputbox, menu, msgbox, radiolist
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def configure_speakers(settings: Settings) -> None:
|
def configure_speakers(settings: Settings) -> None:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from .const import HEIGHT, LIST_HEIGHT, TITLE, WIDTH
|
|||||||
|
|
||||||
ItemType = Union[str, Tuple[Any, str]]
|
ItemType = Union[str, Tuple[Any, str]]
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def whiptail(*args) -> Optional[str]:
|
def whiptail(*args) -> Optional[str]:
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=62.3"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "wyoming_satellite"
|
|
||||||
version = "1.4.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.*"]
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
wyoming==1.5.4
|
||||||
|
zeroconf==0.88.0
|
||||||
|
pyring-buffer==1.0.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
webrtc-noise-gain==1.2.3
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
gpiozero
|
||||||
|
spidev
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pysilero-vad==1.0.0
|
||||||
+1
-1
@@ -8,4 +8,4 @@ _PROGRAM_DIR = _DIR.parent
|
|||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||||
subprocess.check_call([context.env_exe, "-m", "build", "--wheel", "--sdist"])
|
subprocess.check_call([context.env_exe, _PROGRAM_DIR / "setup.py", "bdist_wheel"])
|
||||||
|
|||||||
+4
-3
@@ -20,6 +20,7 @@ builder.create(_VENV_DIR)
|
|||||||
# Upgrade dependencies
|
# Upgrade dependencies
|
||||||
pip = [context.env_exe, "-m", "pip"]
|
pip = [context.env_exe, "-m", "pip"]
|
||||||
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
|
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
|
||||||
|
subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
|
||||||
|
|
||||||
# Install requirements
|
# Install requirements
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
@@ -30,13 +31,13 @@ subprocess.check_call(
|
|||||||
"https://www.piwheels.org/simple",
|
"https://www.piwheels.org/simple",
|
||||||
"-f",
|
"-f",
|
||||||
"https://synesthesiam.github.io/prebuilt-apps/",
|
"https://synesthesiam.github.io/prebuilt-apps/",
|
||||||
"-e",
|
"-r",
|
||||||
str(_PROGRAM_DIR),
|
str(_PROGRAM_DIR / "requirements.txt"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.dev:
|
if args.dev:
|
||||||
# Install dev requirements
|
# Install dev requirements
|
||||||
subprocess.check_call(
|
subprocess.check_call(
|
||||||
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"]
|
pip + ["install", "-r", str(_PROGRAM_DIR / "requirements_dev.txt")]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/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"]
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -30,7 +30,7 @@ from .shared import (
|
|||||||
MicClient,
|
MicClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
TIMEOUT: Final = 1
|
TIMEOUT: Final = 1
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from wyoming_satellite import (
|
|||||||
|
|
||||||
from .shared import MicClient
|
from .shared import MicClient
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class WakeClient(AsyncClient):
|
class WakeClient(AsyncClient):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tox]
|
[tox]
|
||||||
env_list =
|
env_list =
|
||||||
py{39,310,311,312,313}
|
py{39,310,311}
|
||||||
minversion = 4.12.1
|
minversion = 4.12.1
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
1.4.0
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
"""Voice satellite using the Wyoming protocol."""
|
"""Voice satellite using the Wyoming protocol."""
|
||||||
|
|
||||||
import importlib.metadata
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .satellite import (
|
from .satellite import (
|
||||||
@@ -19,7 +17,7 @@ from .settings import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
_DIR = Path(__file__).parent
|
||||||
__version__ = importlib.metadata.version("wyoming-satellite")
|
__version__ = (_DIR / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from wyoming.info import Attribution, Info, Satellite
|
from wyoming.audio import AudioFormat
|
||||||
|
from wyoming.info import Attribution, Info, MicProgram, Satellite, SndProgram
|
||||||
from wyoming.server import AsyncServer, AsyncTcpServer
|
from wyoming.server import AsyncServer, AsyncTcpServer
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
@@ -26,7 +28,6 @@ from .settings import (
|
|||||||
TimerSettings,
|
TimerSettings,
|
||||||
VadSettings,
|
VadSettings,
|
||||||
WakeSettings,
|
WakeSettings,
|
||||||
WakeWordAndPipeline,
|
|
||||||
)
|
)
|
||||||
from .utils import (
|
from .utils import (
|
||||||
get_mac_address,
|
get_mac_address,
|
||||||
@@ -36,7 +37,7 @@ from .utils import (
|
|||||||
split_command,
|
split_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_DIR = Path(__file__).parent
|
_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
@@ -122,9 +123,7 @@ async def main() -> None:
|
|||||||
"--wake-word-name",
|
"--wake-word-name",
|
||||||
action="append",
|
action="append",
|
||||||
default=[],
|
default=[],
|
||||||
nargs="+",
|
help="Name of wake word to listen for (requires --wake-uri)",
|
||||||
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("--wake-command", help="Program to run for wake word detection")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -154,9 +153,14 @@ async def main() -> None:
|
|||||||
|
|
||||||
# Voice activity detector
|
# Voice activity detector
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--vad", action="store_true", help="Wait for speech before streaming audio"
|
"--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)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--vad-threshold", type=float, default=0.5)
|
|
||||||
parser.add_argument("--vad-trigger-level", type=int, default=1)
|
parser.add_argument("--vad-trigger-level", type=int, default=1)
|
||||||
parser.add_argument("--vad-buffer-seconds", type=float, default=2)
|
parser.add_argument("--vad-buffer-seconds", type=float, default=2)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -165,6 +169,7 @@ async def main() -> None:
|
|||||||
default=5.0,
|
default=5.0,
|
||||||
help="Seconds before going back to waiting for speech when wake word isn't detected",
|
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
|
# External event handlers
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -330,9 +335,6 @@ async def main() -> None:
|
|||||||
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
|
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
|
||||||
sys.exit(1)
|
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(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
|
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
|
||||||
)
|
)
|
||||||
@@ -350,9 +352,46 @@ async def main() -> None:
|
|||||||
attribution=Attribution(name="", url=""),
|
attribution=Attribution(name="", url=""),
|
||||||
installed=True,
|
installed=True,
|
||||||
version=__version__,
|
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(
|
settings = SatelliteSettings(
|
||||||
mic=MicSettings(
|
mic=MicSettings(
|
||||||
uri=args.mic_uri,
|
uri=args.mic_uri,
|
||||||
@@ -374,13 +413,12 @@ async def main() -> None:
|
|||||||
trigger_level=args.vad_trigger_level,
|
trigger_level=args.vad_trigger_level,
|
||||||
buffer_seconds=args.vad_buffer_seconds,
|
buffer_seconds=args.vad_buffer_seconds,
|
||||||
wake_word_timeout=args.vad_wake_word_timeout,
|
wake_word_timeout=args.vad_wake_word_timeout,
|
||||||
|
finished_speaking_seconds=args.vad_finished_speaking_seconds,
|
||||||
),
|
),
|
||||||
wake=WakeSettings(
|
wake=WakeSettings(
|
||||||
uri=args.wake_uri,
|
uri=args.wake_uri,
|
||||||
command=split_command(args.wake_command),
|
command=split_command(args.wake_command),
|
||||||
names=[
|
names=args.wake_word_name,
|
||||||
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
|
|
||||||
],
|
|
||||||
refractory_seconds=(
|
refractory_seconds=(
|
||||||
args.wake_refractory_seconds
|
args.wake_refractory_seconds
|
||||||
if args.wake_refractory_seconds > 0
|
if args.wake_refractory_seconds > 0
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from wyoming.server import AsyncEventHandler
|
|||||||
|
|
||||||
from .satellite import SatelliteBase
|
from .satellite import SatelliteBase
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SatelliteEventHandler(AsyncEventHandler):
|
class SatelliteEventHandler(AsyncEventHandler):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from functools import partial
|
|||||||
from wyoming.event import Event
|
from wyoming.event import Event
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
|
|||||||
+235
-100
@@ -9,11 +9,12 @@ import wave
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from queue import Queue
|
||||||
from typing import Callable, Dict, Final, List, Optional, Set, Union
|
from typing import Callable, Dict, Final, List, Optional, Set, Union
|
||||||
|
|
||||||
from pyring_buffer import RingBuffer
|
from pyring_buffer import RingBuffer
|
||||||
from wyoming.asr import Transcript
|
from wyoming.asr import Transcript
|
||||||
from wyoming.audio import AudioChunk, AudioFormat, AudioStart, AudioStop
|
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||||
from wyoming.client import AsyncClient
|
from wyoming.client import AsyncClient
|
||||||
from wyoming.error import Error
|
from wyoming.error import Error
|
||||||
from wyoming.event import Event, async_write_event
|
from wyoming.event import Event, async_write_event
|
||||||
@@ -43,10 +44,10 @@ from .utils import (
|
|||||||
run_event_command,
|
run_event_command,
|
||||||
wav_to_events,
|
wav_to_events,
|
||||||
)
|
)
|
||||||
from .vad import SileroVad
|
from .vad import SileroVad, VoiceCommandSegmenter
|
||||||
from .webrtc import WebRtcAudio
|
from .webrtc import WebRtcAudio
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
_PONG_TIMEOUT: Final = 5
|
_PONG_TIMEOUT: Final = 5
|
||||||
_PING_SEND_DELAY: Final = 2
|
_PING_SEND_DELAY: Final = 2
|
||||||
@@ -110,6 +111,11 @@ class SatelliteBase:
|
|||||||
settings.debug_recording_dir, "stt"
|
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
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""True if not stopping/stopped."""
|
"""True if not stopping/stopped."""
|
||||||
@@ -280,13 +286,6 @@ class SatelliteBase:
|
|||||||
# TTS stopped
|
# TTS stopped
|
||||||
await self.event_to_snd(event)
|
await self.event_to_snd(event)
|
||||||
await self.trigger_tts_stop()
|
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):
|
elif VoiceStarted.is_type(event.type):
|
||||||
# STT start
|
# STT start
|
||||||
await self.trigger_stt_start()
|
await self.trigger_stt_start()
|
||||||
@@ -321,34 +320,38 @@ class SatelliteBase:
|
|||||||
if forward_event:
|
if forward_event:
|
||||||
await self.forward_event(event)
|
await self.forward_event(event)
|
||||||
|
|
||||||
async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None:
|
async def _send_run_pipeline(
|
||||||
|
self,
|
||||||
|
start_stage: Optional[PipelineStage] = None,
|
||||||
|
end_stage: Optional[PipelineStage] = None,
|
||||||
|
wake_word_name: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
"""Sends a RunPipeline event with the correct stages."""
|
"""Sends a RunPipeline event with the correct stages."""
|
||||||
if self.settings.wake.enabled:
|
restart_on_end = False
|
||||||
# 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 self.settings.snd.enabled:
|
if start_stage is None:
|
||||||
# Play TTS response
|
if self.settings.wake.enabled:
|
||||||
end_stage = PipelineStage.TTS
|
# Local wake word detection
|
||||||
else:
|
start_stage = PipelineStage.ASR
|
||||||
# No audio output
|
restart_on_end = False
|
||||||
end_stage = PipelineStage.HANDLE
|
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
|
||||||
|
|
||||||
run_pipeline = RunPipeline(
|
run_pipeline = RunPipeline(
|
||||||
start_stage=start_stage,
|
start_stage=start_stage,
|
||||||
end_stage=end_stage,
|
end_stage=end_stage,
|
||||||
name=pipeline_name,
|
wake_word_name=wake_word_name,
|
||||||
restart_on_end=restart_on_end,
|
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()
|
).event()
|
||||||
_LOGGER.debug(run_pipeline)
|
_LOGGER.debug(run_pipeline)
|
||||||
await self.event_to_server(run_pipeline)
|
await self.event_to_server(run_pipeline)
|
||||||
@@ -477,7 +480,10 @@ class SatelliteBase:
|
|||||||
mic_client = self._make_mic_client()
|
mic_client = self._make_mic_client()
|
||||||
assert mic_client is not None
|
assert mic_client is not None
|
||||||
await mic_client.connect()
|
await mic_client.connect()
|
||||||
_LOGGER.debug("Connected to mic service")
|
_LOGGER.info(
|
||||||
|
"Connected to mic service: %s",
|
||||||
|
self.settings.mic.uri or self.settings.mic.command,
|
||||||
|
)
|
||||||
|
|
||||||
event = await mic_client.read_event()
|
event = await mic_client.read_event()
|
||||||
if event is None:
|
if event is None:
|
||||||
@@ -598,7 +604,10 @@ class SatelliteBase:
|
|||||||
snd_client = self._make_snd_client()
|
snd_client = self._make_snd_client()
|
||||||
assert snd_client is not None
|
assert snd_client is not None
|
||||||
await snd_client.connect()
|
await snd_client.connect()
|
||||||
_LOGGER.debug("Connected to snd service")
|
_LOGGER.info(
|
||||||
|
"Connected to snd service: %s",
|
||||||
|
self.settings.snd.uri or self.settings.snd.command,
|
||||||
|
)
|
||||||
|
|
||||||
# Audio processing
|
# Audio processing
|
||||||
if self.settings.snd.needs_processing and AudioChunk.is_type(
|
if self.settings.snd.needs_processing and AudioChunk.is_type(
|
||||||
@@ -620,8 +629,9 @@ class SatelliteBase:
|
|||||||
):
|
):
|
||||||
await _disconnect()
|
await _disconnect()
|
||||||
if snd_event.is_tts:
|
if snd_event.is_tts:
|
||||||
await self.event_to_server(Played().event())
|
|
||||||
await self.trigger_played()
|
await self.trigger_played()
|
||||||
|
await self.event_to_server(Played().event())
|
||||||
|
|
||||||
snd_client = None # reconnect on next event
|
snd_client = None # reconnect on next event
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
@@ -740,7 +750,10 @@ class SatelliteBase:
|
|||||||
wake_client = self._make_wake_client()
|
wake_client = self._make_wake_client()
|
||||||
assert wake_client is not None
|
assert wake_client is not None
|
||||||
await wake_client.connect()
|
await wake_client.connect()
|
||||||
_LOGGER.debug("Connected to wake service")
|
_LOGGER.info(
|
||||||
|
"Connected to wake service: %s",
|
||||||
|
self.settings.wake.uri or self.settings.wake.command,
|
||||||
|
)
|
||||||
|
|
||||||
# Reset
|
# Reset
|
||||||
from_client_task = None
|
from_client_task = None
|
||||||
@@ -804,18 +817,7 @@ class SatelliteBase:
|
|||||||
|
|
||||||
async def _send_wake_detect(self) -> None:
|
async def _send_wake_detect(self) -> None:
|
||||||
"""Inform wake word service of which wake words to detect."""
|
"""Inform wake word service of which wake words to detect."""
|
||||||
wake_names: Optional[List[str]] = None
|
await self.event_to_wake(Detect(names=self._wake_names).event())
|
||||||
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()
|
await self.trigger_detect()
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -946,7 +948,10 @@ class SatelliteBase:
|
|||||||
event_client = self._make_event_client()
|
event_client = self._make_event_client()
|
||||||
assert event_client is not None
|
assert event_client is not None
|
||||||
await event_client.connect()
|
await event_client.connect()
|
||||||
_LOGGER.debug("Connected to event service")
|
_LOGGER.info(
|
||||||
|
"Connected to event service: %s",
|
||||||
|
self.settings.event.uri or self.settings.event.command,
|
||||||
|
)
|
||||||
|
|
||||||
await event_client.write_event(event)
|
await event_client.write_event(event)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -995,10 +1000,17 @@ class AlwaysStreamingSatellite(SatelliteBase):
|
|||||||
elif PauseSatellite.is_type(event.type):
|
elif PauseSatellite.is_type(event.type):
|
||||||
self.is_streaming = False
|
self.is_streaming = False
|
||||||
_LOGGER.info("Satellite paused")
|
_LOGGER.info("Satellite paused")
|
||||||
|
elif Detect.is_type(event.type):
|
||||||
|
# Wake word detection started
|
||||||
|
await self.trigger_detect()
|
||||||
elif Detection.is_type(event.type):
|
elif Detection.is_type(event.type):
|
||||||
# Start debug recording
|
# Start debug recording
|
||||||
if self.stt_audio_writer is not None:
|
if self.stt_audio_writer is not None:
|
||||||
self.stt_audio_writer.start()
|
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):
|
elif Transcript.is_type(event.type) or Error.is_type(event.type):
|
||||||
# Stop debug recording
|
# Stop debug recording
|
||||||
if self.stt_audio_writer is not None:
|
if self.stt_audio_writer is not None:
|
||||||
@@ -1070,10 +1082,16 @@ class VadStreamingSatellite(SatelliteBase):
|
|||||||
if RunSatellite.is_type(event.type):
|
if RunSatellite.is_type(event.type):
|
||||||
self._is_paused = False
|
self._is_paused = False
|
||||||
_LOGGER.info("Waiting for speech")
|
_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):
|
elif Detection.is_type(event.type):
|
||||||
# Start debug recording
|
# Start debug recording
|
||||||
if self.stt_audio_writer is not None:
|
if self.stt_audio_writer is not None:
|
||||||
self.stt_audio_writer.start()
|
self.stt_audio_writer.start()
|
||||||
|
|
||||||
|
_LOGGER.debug("Wake word detected")
|
||||||
|
await self.trigger_detection(Detection.from_event(event))
|
||||||
elif (
|
elif (
|
||||||
Transcript.is_type(event.type)
|
Transcript.is_type(event.type)
|
||||||
or Error.is_type(event.type)
|
or Error.is_type(event.type)
|
||||||
@@ -1210,8 +1228,17 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
# wake word id -> seconds
|
# wake word id -> seconds
|
||||||
self.refractory_timestamp: Dict[Optional[str], float] = {}
|
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:
|
if settings.vad.enabled:
|
||||||
_LOGGER.warning("VAD is enabled but will not be used")
|
_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
|
||||||
|
)
|
||||||
|
|
||||||
# Used for debug audio recording so both wake and stt WAV files have the
|
# Used for debug audio recording so both wake and stt WAV files have the
|
||||||
# same timestamp.
|
# same timestamp.
|
||||||
@@ -1222,6 +1249,9 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
self._wake_info: Optional[Info] = None
|
self._wake_info: Optional[Info] = None
|
||||||
self._wake_info_ready = asyncio.Event()
|
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:
|
async def event_from_server(self, event: Event) -> None:
|
||||||
# Only check event types once
|
# Only check event types once
|
||||||
is_run_satellite = False
|
is_run_satellite = False
|
||||||
@@ -1232,13 +1262,42 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
if RunSatellite.is_type(event.type):
|
if RunSatellite.is_type(event.type):
|
||||||
is_run_satellite = True
|
is_run_satellite = True
|
||||||
self._is_paused = False
|
self._is_paused = False
|
||||||
|
|
||||||
elif PauseSatellite.is_type(event.type):
|
elif PauseSatellite.is_type(event.type):
|
||||||
is_pause_satellite = True
|
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):
|
elif Transcript.is_type(event.type):
|
||||||
is_transcript = True
|
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):
|
elif Error.is_type(event.type):
|
||||||
is_error = True
|
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:
|
if is_transcript or is_pause_satellite:
|
||||||
# Stop streaming before event_from_server is called because it will
|
# Stop streaming before event_from_server is called because it will
|
||||||
@@ -1259,20 +1318,20 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
self._is_paused = True
|
self._is_paused = True
|
||||||
_LOGGER.debug("Satellite is paused")
|
_LOGGER.debug("Satellite is paused")
|
||||||
else:
|
else:
|
||||||
# Go back to wake word detection
|
# Go back to wake word detection or run next pipeline
|
||||||
await self.trigger_streaming_stop()
|
await self.trigger_streaming_stop()
|
||||||
|
|
||||||
# It's possible to be paused in the middle of streaming
|
# It's possible to be paused in the middle of streaming
|
||||||
if not self._is_paused:
|
if self._is_paused:
|
||||||
await self._send_wake_detect()
|
return
|
||||||
_LOGGER.info("Waiting for wake word")
|
|
||||||
|
|
||||||
# Start debug recording (wake)
|
if self._run_pipeline_queue.empty():
|
||||||
self._debug_recording_timestamp = time.monotonic_ns()
|
# Back to wake word detection
|
||||||
if self.wake_audio_writer is not None:
|
await self._start_wake_word_detection()
|
||||||
self.wake_audio_writer.start(
|
else:
|
||||||
timestamp=self._debug_recording_timestamp
|
# Next run from queue
|
||||||
)
|
self._next_run_pipeline = self._run_pipeline_queue.get()
|
||||||
|
await self._trigger_pipeline(self._next_run_pipeline)
|
||||||
|
|
||||||
async def trigger_server_disonnected(self) -> None:
|
async def trigger_server_disonnected(self) -> None:
|
||||||
await super().trigger_server_disonnected()
|
await super().trigger_server_disonnected()
|
||||||
@@ -1308,13 +1367,26 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
self.stt_audio_writer.write(audio_bytes)
|
self.stt_audio_writer.write(audio_bytes)
|
||||||
|
|
||||||
if self.is_streaming:
|
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
|
# Forward to server
|
||||||
await self.event_to_server(event)
|
await self.event_to_server(event)
|
||||||
else:
|
else:
|
||||||
# Forward to wake word service
|
# Forward to wake word service
|
||||||
await self.event_to_wake(event)
|
await self.event_to_wake(event)
|
||||||
|
|
||||||
async def event_from_wake(self, event: Event) -> None:
|
async def event_from_wake(self, event: Event, is_fake: bool = False) -> None:
|
||||||
if Info.is_type(event.type):
|
if Info.is_type(event.type):
|
||||||
self._wake_info = Info.from_event(event)
|
self._wake_info = Info.from_event(event)
|
||||||
self._wake_info_ready.set()
|
self._wake_info_ready.set()
|
||||||
@@ -1324,53 +1396,85 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
# Not detecting or no server connected
|
# Not detecting or no server connected
|
||||||
return
|
return
|
||||||
|
|
||||||
if Detection.is_type(event.type):
|
if not Detection.is_type(event.type):
|
||||||
detection = Detection.from_event(event)
|
return
|
||||||
|
|
||||||
# Check refractory period to avoid multiple back-to-back detections
|
detection = Detection.from_event(event)
|
||||||
refractory_timestamp = self.refractory_timestamp.get(detection.name)
|
|
||||||
if (refractory_timestamp is not None) and (
|
if (self._next_run_pipeline is not None) and (
|
||||||
refractory_timestamp > time.monotonic()
|
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
|
||||||
):
|
):
|
||||||
_LOGGER.debug("Wake word detection occurred during refractory period")
|
self._next_run_pipeline = None
|
||||||
return
|
await self.event_to_server(event)
|
||||||
|
await self._send_wake_detect()
|
||||||
# Stop debug recording (wake)
|
_LOGGER.info("Waiting for wake word")
|
||||||
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
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# No refractory period
|
_LOGGER.debug(
|
||||||
self.refractory_timestamp.pop(detection.name, None)
|
"Got %s but listening for %s",
|
||||||
|
wake_word_name,
|
||||||
|
self._next_run_pipeline.wake_word_names,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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
|
# Forward to the server
|
||||||
await self.event_to_server(event)
|
await self.event_to_server(event)
|
||||||
|
|
||||||
# Match detected wake word name with pipeline name
|
start_stage: Optional[PipelineStage] = None
|
||||||
pipeline_name: Optional[str] = None
|
end_stage: Optional[PipelineStage] = 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
|
|
||||||
|
|
||||||
await self._send_run_pipeline(pipeline_name=pipeline_name)
|
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.forward_event(event) # forward to event service
|
await self.forward_event(event) # forward to event service
|
||||||
await self.trigger_detection(Detection.from_event(event))
|
await self.trigger_detection(Detection.from_event(event))
|
||||||
await self.trigger_streaming_start()
|
await self.trigger_streaming_start()
|
||||||
@@ -1390,3 +1494,34 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
info.wake = self._wake_info.wake
|
info.wake = self._wake_info.wake
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
_LOGGER.warning("Failed to get info from wake service")
|
_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())
|
||||||
|
|||||||
@@ -104,19 +104,11 @@ class SndSettings(ServiceSettings):
|
|||||||
return self.enabled and (self.volume_multiplier != 1.0)
|
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)
|
@dataclass(frozen=True)
|
||||||
class WakeSettings(ServiceSettings):
|
class WakeSettings(ServiceSettings):
|
||||||
"""Wake word service settings."""
|
"""Wake word service settings."""
|
||||||
|
|
||||||
names: Optional[List[WakeWordAndPipeline]] = None
|
names: Optional[List[str]] = None
|
||||||
"""List of wake word names to listen for."""
|
"""List of wake word names to listen for."""
|
||||||
|
|
||||||
rate: int = 16000
|
rate: int = 16000
|
||||||
@@ -151,6 +143,9 @@ class VadSettings:
|
|||||||
wake_word_timeout: Optional[float] = 5.0
|
wake_word_timeout: Optional[float] = 5.0
|
||||||
"""Seconds before going back to sleep if wake word is not detected."""
|
"""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)
|
@dataclass(frozen=True)
|
||||||
class EventSettings(ServiceSettings):
|
class EventSettings(ServiceSettings):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pyring_buffer import RingBuffer
|
|||||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||||
from wyoming.event import Event
|
from wyoming.event import Event
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AudioBuffer:
|
class AudioBuffer:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from typing import List, Optional, Union
|
|||||||
|
|
||||||
from wyoming.event import Eventable
|
from wyoming.event import Eventable
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def run_event_command(
|
async def run_event_command(
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"""Voice activity detection."""
|
"""Voice activity detection."""
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SileroVad:
|
class SileroVad:
|
||||||
"""Voice activity detection with silero VAD."""
|
"""Voice activity detection with silero VAD."""
|
||||||
@@ -31,3 +35,95 @@ class SileroVad:
|
|||||||
self._activation = max(0, self._activation - 1)
|
self._activation = max(0, self._activation - 1)
|
||||||
|
|
||||||
return False
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user