diff --git a/README.md b/README.md index b1575ec..50fce71 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,17 @@ Describe available services. * `version` - version of the model (string, optional) * `satellite` - information about voice satellite (optional) * `area` - name of area where satellite is located (string, optional) - * `snd_format` - optimal audio output format of satellite (optional) + * `has_vad` - true if the end of voice commands will be detected locally (boolean, optional) + * `active_wake_words` - list of wake words that are actively being listend for (list of string, optional) + * `max_active_wake_words` - maximum number of local wake words that can be run simultaneously (number, optional) + * `supports_trigger` - true if satellite supports remotely-triggered pipelines + * `mic` - list of audio input services (optional) + * `mic_format` - audio input format (required) + * `rate` - sample rate in hertz (int, required) + * `width` - sample width in bytes (int, required) + * `channels` - number of channels (int, required) + * `snd` - list of audio output services (optional) + * `snd_format` - audio output format (required) * `rate` - sample rate in hertz (int, required) * `width` - sample width in bytes (int, required) * `channels` - number of channels (int, required) @@ -222,19 +232,48 @@ Play audio stream. Control of one or more remote voice satellites connected to a central server. * `run-satellite` - informs satellite that server is ready to run pipelines - * `start_stage` - request pipelines with a specific starting stage (string, optional) * `pause-satellite` - informs satellite that server is not ready anymore to run pipelines * `satellite-connected` - satellite has connected to the server * `satellite-disconnected` - satellite has been disconnected from the server * `streaming-started` - satellite has started streaming audio to the server * `streaming-stopped` - satellite has stopped streaming audio to the server +Pipelines are run on the server, but can be triggered remotely from the server as well. + +* `run-pipeline` - runs a pipeline on the server or asks the satellite to run it when possible + * `start_stage` - pipeline stage to start at (string, required) + * `end_stage` - pipeline stage to end at (string, required) + * `wake_word_name` - name of detected wake word that started this pipeline (string, optional) + * From client only + * `wake_word_names` - names of wake words to listen for (list of string, optional) + * From server only + * `start_stage` must be "wake" + * `announce_text` - text to speak on the satellite + * From server only + * `start_stage` must be "tts" + * `restart_on_end` - true if the server should re-run the pipeline after it ends (boolean, default is false) + * Only used for always-on streaming satellites + ### Timers * `timer-started` - a new timer has started + * `id` - unique id of timer (string, required) + * `total_seconds` - number of seconds the timer should run for (int, required) + * `name` - user-provided name for timer (string, optional) + * `start_hours` - hours the timer should run for as spoken by user (int, optional) + * `start_minutes` - minutes the timer should run for as spoken by user (int, optional) + * `start_seconds` - seconds the timer should run for as spoken by user (int, optional) + * `command` - optional command that the server will execute when the timer is finished + * `text` - text of command to execute (string, required) + * `language` - language of the command (string, optional) * `timer-updated` - timer has been paused/resumed or time has been added/removed + * `id` - unique id of timer (string, required) + * `is_active` - true if timer is running, false if paused (bool, required) + * `total_seconds` - number of seconds that the timer should run for now (int, required) * `timer-cancelled` - timer was cancelled + * `id` - unique id of timer (string, required) * `timer-finished` - timer finished without being cancelled + * `id` - unique id of timer (string, required) ## Event Flow diff --git a/script/package b/script/package index 1f04c1f..1a2aa98 100755 --- a/script/package +++ b/script/package @@ -8,4 +8,6 @@ _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, _PROGRAM_DIR / "setup.py", "bdist_wheel", "sdist"] +) diff --git a/wyoming/VERSION b/wyoming/VERSION index 94fe62c..dc1e644 100644 --- a/wyoming/VERSION +++ b/wyoming/VERSION @@ -1 +1 @@ -1.5.4 +1.6.0 diff --git a/wyoming/info.py b/wyoming/info.py index bfc8558..69d81e7 100644 --- a/wyoming/info.py +++ b/wyoming/info.py @@ -1,4 +1,5 @@ """Information about available services, models, etc..""" + from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -177,8 +178,39 @@ class Satellite(Artifact): area: Optional[str] = None """Name of the area the satellite is in.""" - snd_format: Optional[AudioFormat] = None - """Format of the satellite's audio output.""" + has_vad: Optional[bool] = None + """True if a local VAD will be used to detect the end of voice commands.""" + + active_wake_words: Optional[List[str]] = None + """Wake words that are currently being listened for.""" + + max_active_wake_words: Optional[int] = None + """Maximum number of local wake words that can be run simultaneously.""" + + supports_trigger: Optional[bool] = None + """Satellite supports remotely triggering pipeline runs.""" + + +# ----------------------------------------------------------------------------- + + +@dataclass +class MicProgram(Artifact): + """Microphone information.""" + + mic_format: AudioFormat + """Input audio format.""" + + +# ----------------------------------------------------------------------------- + + +@dataclass +class SndProgram(Artifact): + """Sound output information.""" + + snd_format: AudioFormat + """Output audio format.""" # ----------------------------------------------------------------------------- @@ -203,6 +235,12 @@ class Info(Eventable): wake: List[WakeProgram] = field(default_factory=list) """Wake word detection services.""" + mic: List[MicProgram] = field(default_factory=list) + """Audio input services.""" + + snd: List[SndProgram] = field(default_factory=list) + """Audio output services.""" + satellite: Optional[Satellite] = None """Satellite information.""" @@ -217,6 +255,8 @@ def event(self) -> Event: "handle": [p.to_dict() for p in self.handle], "intent": [p.to_dict() for p in self.intent], "wake": [p.to_dict() for p in self.wake], + "mic": [p.to_dict() for p in self.mic], + "snd": [p.to_dict() for p in self.snd], } if self.satellite is not None: @@ -239,5 +279,7 @@ def from_event(event: Event) -> "Info": handle=[HandleProgram.from_dict(d) for d in event.data.get("handle", [])], intent=[IntentProgram.from_dict(d) for d in event.data.get("intent", [])], wake=[WakeProgram.from_dict(d) for d in event.data.get("wake", [])], + mic=[MicProgram.from_dict(d) for d in event.data.get("mic", [])], + snd=[SndProgram.from_dict(d) for d in event.data.get("snd", [])], satellite=satellite, ) diff --git a/wyoming/mic.py b/wyoming/mic.py index 2cb3a84..634e039 100644 --- a/wyoming/mic.py +++ b/wyoming/mic.py @@ -10,7 +10,7 @@ from .client import AsyncClient from .event import Event -_LOGGER = logging.getLogger() +_LOGGER = logging.getLogger(__name__) DOMAIN = "mic" diff --git a/wyoming/pipeline.py b/wyoming/pipeline.py index 78b60b9..5dfa633 100644 --- a/wyoming/pipeline.py +++ b/wyoming/pipeline.py @@ -1,9 +1,9 @@ """Pipeline events.""" + from dataclasses import dataclass from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional -from .audio import AudioFormat from .event import Event, Eventable _RUN_PIPELINE_TYPE = "run-pipeline" @@ -38,14 +38,17 @@ class RunPipeline(Eventable): end_stage: PipelineStage """Stage to end the pipeline on.""" - name: Optional[str] = None - """Name of pipeline to run""" + wake_word_name: Optional[str] = None + """Name of wake word that triggered this pipeline.""" restart_on_end: bool = False """True if pipeline should restart automatically after ending.""" - snd_format: Optional[AudioFormat] = None - """Desired format for audio output.""" + wake_word_names: Optional[List[str]] = None + """Wake word names to listen for (start_stage = wake).""" + + announce_text: Optional[str] = None + """Text to announce using text-to-speech (start_stage = tts)""" def __post_init__(self) -> None: start_valid = True @@ -104,33 +107,26 @@ def event(self) -> Event: "restart_on_end": self.restart_on_end, } - if self.name is not None: - data["name"] = self.name + if self.wake_word_name is not None: + data["wake_word_name"] = self.wake_word_name + + if self.wake_word_names: + data["wake_word_names"] = self.wake_word_names - if self.snd_format is not None: - data["snd_format"] = { - "rate": self.snd_format.rate, - "width": self.snd_format.width, - "channels": self.snd_format.channels, - } + if self.announce_text is not None: + data["announce_text"] = self.announce_text return Event(type=_RUN_PIPELINE_TYPE, data=data) @staticmethod def from_event(event: Event) -> "RunPipeline": assert event.data is not None - snd_format = event.data.get("snd_format") return RunPipeline( start_stage=PipelineStage(event.data["start_stage"]), end_stage=PipelineStage(event.data["end_stage"]), - name=event.data.get("name"), + wake_word_name=event.data.get("wake_word_name"), restart_on_end=event.data.get("restart_on_end", False), - snd_format=AudioFormat( - rate=snd_format["rate"], - width=snd_format["width"], - channels=snd_format["channels"], - ) - if snd_format - else None, + wake_word_names=event.data.get("wake_word_names"), + announce_text=event.data.get("announce_text"), ) diff --git a/wyoming/satellite.py b/wyoming/satellite.py index d54b2da..da96b69 100644 --- a/wyoming/satellite.py +++ b/wyoming/satellite.py @@ -1,9 +1,8 @@ """Satellite events.""" + from dataclasses import dataclass -from typing import Any, Dict, Optional from .event import Event, Eventable -from .pipeline import PipelineStage _RUN_SATELLITE_TYPE = "run-satellite" _PAUSE_SATELLITE_TYPE = "pause-satellite" @@ -17,28 +16,16 @@ class RunSatellite(Eventable): """Informs the satellite that the server is ready to run a pipeline.""" - start_stage: Optional[PipelineStage] = None - @staticmethod def is_type(event_type: str) -> bool: return event_type == _RUN_SATELLITE_TYPE def event(self) -> Event: - data: Dict[str, Any] = {} - - if self.start_stage is not None: - data["start_stage"] = self.start_stage.value - - return Event(type=_RUN_SATELLITE_TYPE, data=data) + return Event(type=_RUN_SATELLITE_TYPE) @staticmethod def from_event(event: Event) -> "RunSatellite": - # note: older versions don't send event.data - start_stage = None - if value := (event.data or {}).get("start_stage"): - start_stage = PipelineStage(value) - - return RunSatellite(start_stage=start_stage) + return RunSatellite() @dataclass diff --git a/wyoming/snd.py b/wyoming/snd.py index 0cf2e2c..3623446 100644 --- a/wyoming/snd.py +++ b/wyoming/snd.py @@ -10,7 +10,7 @@ from .client import AsyncClient from .event import Event, Eventable -_LOGGER = logging.getLogger() +_LOGGER = logging.getLogger(__name__) _PLAYED_TYPE = "played" diff --git a/wyoming/wake.py b/wyoming/wake.py index 1600b8d..0a3bf1c 100644 --- a/wyoming/wake.py +++ b/wyoming/wake.py @@ -10,7 +10,7 @@ from .client import AsyncClient from .event import Event, Eventable -_LOGGER = logging.getLogger() +_LOGGER = logging.getLogger(__name__) DOMAIN = "wake" _DETECTION_TYPE = "detection" diff --git a/wyoming/zeroconf.py b/wyoming/zeroconf.py index 3d7a515..676ff27 100644 --- a/wyoming/zeroconf.py +++ b/wyoming/zeroconf.py @@ -4,7 +4,7 @@ import socket from typing import Optional -_LOGGER = logging.getLogger() +_LOGGER = logging.getLogger(__name__) try: from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf