From b2ab8a200ab94c4d1e4ae9c511b2961751752a49 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:06:22 -0700 Subject: [PATCH 01/21] Add emet.wake plugin category Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-hal/emet_hal/mock.py | 79 +++++++++++++++++- emet-hal/pyproject.toml | 3 + emet-hal/tests/test_hal.py | 33 +++++++- emet-sdk/emet_sdk/discovery.py | 36 +++++++- emet-sdk/emet_sdk/plugin.py | 82 +++++++++++++++++- emet-sdk/emet_sdk/types.py | 40 +++++++++ emet-sdk/tests/test_wake.py | 147 +++++++++++++++++++++++++++++++++ 7 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 emet-sdk/tests/test_wake.py diff --git a/emet-hal/emet_hal/mock.py b/emet-hal/emet_hal/mock.py index 10b662b..be0d613 100644 --- a/emet-hal/emet_hal/mock.py +++ b/emet-hal/emet_hal/mock.py @@ -20,10 +20,17 @@ import logging from typing import Any, Mapping -from emet_sdk.plugin import ActuatorPlugin, SensorPlugin -from emet_sdk.types import Action, CapabilityDescriptor, Health, Reading - -__all__ = ["MockActuator", "MockSensor"] +from emet_sdk.plugin import ActuatorPlugin, SensorPlugin, WakePlugin +from emet_sdk.types import ( + Action, + CapabilityDescriptor, + Health, + Reading, + WakeDescriptor, + WakeEvent, +) + +__all__ = ["MockActuator", "MockSensor", "MockWake"] log = logging.getLogger("emet_hal.mock") @@ -139,3 +146,67 @@ async def poll(self) -> Reading: values={str(k): float(v) for k, v in raw.items()}, stale=bool(self.params.get("stale", False)), ) + + +class MockWake(WakePlugin): + """A wake detector that fires when a frame literally contains the phrase. + + No audio, no model, no threshold. A frame "hears" the phrase when the + phrase's UTF-8 bytes appear in it, which makes every test of the wake path + a one-liner and keeps the whole 0.3 loop runnable on a laptop with no + microphone. + + `params.phrases` overrides what this instance claims it loaded a model for, + so a test can build the case that actually matters: an engine that starts + perfectly well and cannot hear the name this particular soul answers to. + """ + + engine = "mock" + + def __init__(self, config: Mapping[str, Any], phrase: str) -> None: + super().__init__(config, phrase) + self.frames = 0 + self.resets = 0 + self._started = False + + async def start(self) -> None: + if self.params.get("fail_on_start"): + log.warning("mock wake: simulated engine failure") + return + self._started = True + + def describe(self) -> WakeDescriptor: + declared = self.params.get("phrases") + phrases = ( + frozenset(str(p) for p in declared) + if declared is not None + else frozenset({self.phrase}) + ) + return WakeDescriptor( + engine=self.engine, + phrases=phrases, + supports_custom_phrases=bool(self.params.get("supports_custom", False)), + healthy=self._started, + ) + + async def process(self, frame: bytes) -> WakeEvent | None: + self.frames += 1 + if not self._started: + return None + if self.phrase.encode("utf-8") not in frame: + return None + return WakeEvent( + phrase=self.phrase, + confidence=float(self.params.get("confidence", 0.9)), + ) + + async def reset(self) -> None: + self.resets += 1 + + async def shutdown(self) -> None: + self._started = False + + def health(self) -> Health: + if not self._started: + return Health(ok=False, detail="wake engine not started", faults=("start_failed",)) + return Health() diff --git a/emet-hal/pyproject.toml b/emet-hal/pyproject.toml index fddf041..10b225e 100644 --- a/emet-hal/pyproject.toml +++ b/emet-hal/pyproject.toml @@ -30,6 +30,9 @@ dev = ["pytest>=8.0"] [project.entry-points."emet.sensors"] "emet_hal.mock_sensor" = "emet_hal.mock:MockSensor" +[project.entry-points."emet.wake"] +"mock" = "emet_hal.mock:MockWake" + [project.entry-points."emet.locomotion"] "differential" = "emet_hal.differential:DifferentialDrive" "tracked" = "emet_hal.tracked:TrackedDrive" diff --git a/emet-hal/tests/test_hal.py b/emet-hal/tests/test_hal.py index a7778a5..251e89f 100644 --- a/emet-hal/tests/test_hal.py +++ b/emet-hal/tests/test_hal.py @@ -15,7 +15,7 @@ from emet_sdk.types import Action, Twist from emet_hal.differential import DifferentialDrive -from emet_hal.mock import MockActuator, MockSensor +from emet_hal.mock import MockActuator, MockSensor, MockWake from emet_hal.tracked import TrackedDrive # r = 0.05 m, W = 0.20 m — chosen so the sums come out in round numbers. @@ -187,3 +187,34 @@ def test_mock_sensor_polls_and_can_admit_staleness(): stale_block = {**block, "driver": {"plugin": "x", "params": {"values": {}, "stale": True}}} assert run(MockSensor(stale_block).poll()).stale + + +def test_mock_wake_fires_on_the_phrase_it_was_given(): + wake = MockWake({"engine": "mock", "params": {}}, "hey emet") + run(wake.start()) + assert run(wake.process(b"nothing here")) is None + event = run(wake.process(b"...hey emet...")) + assert event is not None and event.phrase == "hey emet" + assert wake.frames == 2 + + +def test_a_wake_engine_that_failed_to_start_hears_nothing(): + """The path with no fallback beneath it. An actuator that fails to start + gets bound past; a wake engine that fails just leaves the robot deaf, so + it has to be visible in the descriptor rather than only at runtime.""" + wake = MockWake({"engine": "mock", "params": {"fail_on_start": True}}, "hey emet") + run(wake.start()) + assert not wake.describe().healthy + assert not wake.describe().can_detect("hey emet") + assert run(wake.process(b"...hey emet...")) is None + assert not wake.health().ok + + +def test_a_wake_engine_can_start_and_still_not_know_the_name(): + """Distinct from a failed start, and the likelier failure in practice: + a healthy engine carrying models for other words.""" + wake = MockWake({"engine": "mock", "params": {"phrases": ["hey jarvis"]}}, "hey emet") + run(wake.start()) + descriptor = wake.describe() + assert descriptor.healthy + assert not descriptor.can_detect("hey emet") diff --git a/emet-sdk/emet_sdk/discovery.py b/emet-sdk/emet_sdk/discovery.py index fd73d71..4f5387b 100644 --- a/emet-sdk/emet_sdk/discovery.py +++ b/emet-sdk/emet_sdk/discovery.py @@ -5,16 +5,22 @@ engine has no list of known drivers compiled into it — installing a package is what makes a driver exist. -Three groups: +Four groups: emet.actuators name = the string used in `driver.plugin` emet.sensors name = the string used in `driver.plugin` emet.locomotion name = the string used in `drive.kinematics` + emet.wake name = the string used in `audio.wake.engine` -For locomotion the entry-point name *is* the kinematics value, which is what -makes that enum genuinely open: `kinematics: legged` is legal today and +For locomotion and wake the entry-point name *is* the manifest value, which is +what makes those enums genuinely open: `kinematics: legged` is legal today and resolves the moment somebody publishes a package registering `legged`. +That openness is not theoretical for wake. Picovoice disabled every free +Porcupine access key on 30 June 2026, and every project that had wired one +detector in directly stopped waking. Here it would have been one line of a +manifest. + **Errors from this module are never schema errors.** A name that resolves to no installed package is a `MissingPluginError` — the document is well-formed and the value is legal, the software simply is not present. Conflating that @@ -33,6 +39,7 @@ "GROUP_ACTUATOR", "GROUP_SENSOR", "GROUP_LOCOMOTION", + "GROUP_WAKE", "PluginRegistry", "discover", ] @@ -40,6 +47,7 @@ GROUP_ACTUATOR = "emet.actuators" GROUP_SENSOR = "emet.sensors" GROUP_LOCOMOTION = "emet.locomotion" +GROUP_WAKE = "emet.wake" def _entry_points(group: str) -> dict[str, EntryPoint]: @@ -59,12 +67,14 @@ def __init__( actuators: Mapping[str, EntryPoint] | None = None, sensors: Mapping[str, EntryPoint] | None = None, locomotion: Mapping[str, EntryPoint] | None = None, + wake: Mapping[str, EntryPoint] | None = None, *, verify_drivers: bool = False, ) -> None: self._actuators = dict(actuators or {}) self._sensors = dict(sensors or {}) self._locomotion = dict(locomotion or {}) + self._wake = dict(wake or {}) #: When False, an unrecognised *driver* name is reported as a warning #: rather than an error. See `validate` for why the two callers differ: #: linting a manifest for hardware you have not wired yet is a normal @@ -79,6 +89,7 @@ def discover(cls) -> "PluginRegistry": actuators=_entry_points(GROUP_ACTUATOR), sensors=_entry_points(GROUP_SENSOR), locomotion=_entry_points(GROUP_LOCOMOTION), + wake=_entry_points(GROUP_WAKE), ) def with_verification(self, verify_drivers: bool) -> "PluginRegistry": @@ -91,6 +102,7 @@ def with_verification(self, verify_drivers: bool) -> "PluginRegistry": actuators=self._actuators, sensors=self._sensors, locomotion=self._locomotion, + wake=self._wake, verify_drivers=verify_drivers, ) @@ -102,6 +114,9 @@ def has_driver(self, plugin: str) -> bool: def has_locomotion(self, kinematics: str) -> bool: return kinematics in self._locomotion + def has_wake(self, engine: str) -> bool: + return engine in self._wake + @property def driver_names(self) -> list[str]: return sorted({*self._actuators, *self._sensors}) @@ -110,8 +125,12 @@ def driver_names(self) -> list[str]: def locomotion_names(self) -> list[str]: return sorted(self._locomotion) + @property + def wake_names(self) -> list[str]: + return sorted(self._wake) + def __bool__(self) -> bool: - return bool(self._actuators or self._sensors or self._locomotion) + return bool(self._actuators or self._sensors or self._locomotion or self._wake) def __iter__(self) -> Iterator[tuple[str, str]]: """(group, name) for everything installed. Used by `emet explain`.""" @@ -121,6 +140,8 @@ def __iter__(self) -> Iterator[tuple[str, str]]: yield ("sensor", name) for name in sorted(self._locomotion): yield ("locomotion", name) + for name in sorted(self._wake): + yield ("wake", name) # ----------------------------------------------------------------- load @@ -138,6 +159,13 @@ def load_locomotion(self, kinematics: str) -> type: raise _missing(kinematics, self.locomotion_names, "locomotion plugin") return ep.load() + def load_wake(self, engine: str) -> type: + """Import and return the plugin class for an `audio.wake.engine` name.""" + ep = self._wake.get(engine) + if ep is None: + raise _missing(engine, self.wake_names, "wake word plugin") + return ep.load() + def _missing(name: str, available: Iterable[str], what: str) -> Exception: # Imported lazily: validate imports discovery, so discovery must not diff --git a/emet-sdk/emet_sdk/plugin.py b/emet-sdk/emet_sdk/plugin.py index 7122302..27983bb 100644 --- a/emet-sdk/emet_sdk/plugin.py +++ b/emet-sdk/emet_sdk/plugin.py @@ -1,6 +1,6 @@ """The plugin contract. Breaking it is a major version bump. -Three categories, and the split is not arbitrary: +Four categories, and the split is not arbitrary: * **Actuators** receive `Action`s and do something physical. The engine tells them what should happen; how is theirs. @@ -12,6 +12,9 @@ Wheels solve it with arithmetic, treads with the same arithmetic and different slip assumptions, and a legged plugin with a gait generator. The engine above them does not know or care. +* **Wake** plugins listen for the robot's name and nothing else. They are the + only category configured jointly by a body and a soul, and the only one with + no fallback beneath it. Everything here is hardware-agnostic on purpose. A plugin may talk to a servo board over I2C; nothing in this module knows that, and nothing in the soul @@ -38,6 +41,8 @@ LocomotionDescriptor, Reading, Twist, + WakeDescriptor, + WakeEvent, ) __all__ = [ @@ -45,6 +50,7 @@ "ActuatorPlugin", "SensorPlugin", "LocomotionPlugin", + "WakePlugin", "PluginError", ] @@ -181,3 +187,77 @@ async def command(self, twist: Twist) -> None: async def stop(self, hard: bool = False) -> None: """Come to rest. `hard=True` means brake now, safety is preempting.""" await self.command(Twist()) + + +class WakePlugin(Plugin): + """What listens for the robot's name. + + Wake sits oddly among the categories, on purpose. It is not a manifest + capability, for the same reason voice is not: the hardware floor already + guarantees a microphone, so there is nothing to declare. What varies is + which detector runs on that microphone, and that is a deployment fact, so + it is configured from `audio.wake` on the body. + + The *phrase* arrives from the other side. `identity.wake_word` is a soul + field, which makes this the one place where a soul-declared value reaches + a plugin constructor. That does not breach principle 1: the soul says + which name it answers to, never which engine hears it, on what device, at + what threshold. + + **Why this is a plugin category rather than a dependency.** Picovoice + disabled every free Porcupine access key on 30 June 2026. Anything that + had wired a single wake word engine in directly stopped waking that day, + and could not be fixed by its owner. The seam is the whole defence, and it + is the same seam as `drive.kinematics`: the engine holds no list of known + detectors, so a better one is a package nobody has published yet. + """ + + #: The string matched against `audio.wake.engine` in the manifest, and the + #: entry-point name this plugin registers under. + engine: ClassVar[str] = "" + + def __init__(self, config: Mapping[str, Any], phrase: str) -> None: + """Receive the `audio.wake` block and the phrase to listen for. + + Deliberately not the base signature. Wake is not a manifest + capability, so there is no `driver.params` to unwrap and no capability + id to carry; `params` is read straight off the wake block. Taking + `phrase` here rather than through a later `configure()` means an + instance cannot exist without knowing what it is listening for. + """ + self.capability = config + self.capability_id = "wake" + self.params = dict(config.get("params") or {}) + #: The phrase from `identity.wake_word`. + self.phrase = phrase + + @abstractmethod + def describe(self) -> WakeDescriptor: + """Report what this instance can actually hear, after `start()`. + + Report narrowly. An engine that could not load a model for + `self.phrase` must leave it out of `phrases` rather than claim it, + because there is no next rung here. A chain that cannot find a head + falls through to a light ring; a robot that cannot hear its name just + never answers, and looks broken rather than limited. + """ + + @abstractmethod + async def process(self, frame: bytes) -> WakeEvent | None: + """Consume one frame of audio. Return an event if the phrase was heard. + + Audio is pushed in rather than pulled out so that the engine keeps sole + ownership of the microphone: one capture loop, one buffer, and a + detector that never competes with speech recognition for the device. + + Frames arrive at the rate and size the descriptor asked for. **Must + return promptly** — this runs on every frame of the capture path, so + blocking here drops audio and delays the wake it is meant to catch. + """ + + async def reset(self) -> None: + """Drop accumulated audio state. Called once after a wake fires. + + Default is a no-op. Streaming detectors holding a rolling buffer + override it so that one utterance cannot trigger twice. + """ diff --git a/emet-sdk/emet_sdk/types.py b/emet-sdk/emet_sdk/types.py index cb3aa25..c860c2c 100644 --- a/emet-sdk/emet_sdk/types.py +++ b/emet-sdk/emet_sdk/types.py @@ -25,6 +25,8 @@ "Twist", "CapabilityDescriptor", "LocomotionDescriptor", + "WakeDescriptor", + "WakeEvent", "Health", "Reading", "Sensitivity", @@ -179,6 +181,44 @@ class LocomotionDescriptor: holonomic: bool = False +@dataclass(frozen=True, slots=True) +class WakeDescriptor: + """What a wake word engine can actually hear, reported after `start()`. + + No chain binds against this, because there is no wake chain — but the boot + check does. A soul asking for a phrase this instance cannot detect is a + robot that will never answer to its own name, and unlike a missing head + there is nothing to degrade to. That has to fail at boot, loudly. + """ + + engine: str + #: Phrases this instance loaded a model for. Empty is legal and honest for + #: an engine that failed to load one; it is not the same as `healthy=False`, + #: which means the engine itself is broken. + phrases: frozenset[str] = frozenset() + #: Whether arbitrary phrases work without a per-phrase trained model. + #: True for phonetic keyword spotters, False for trained classifiers. + supports_custom_phrases: bool = False + sample_rate: int = 16000 + frame_samples: int = 1280 + healthy: bool = True + + def can_detect(self, phrase: str) -> bool: + return self.healthy and (self.supports_custom_phrases or phrase in self.phrases) + + +@dataclass(frozen=True, slots=True) +class WakeEvent: + """The robot heard its name.""" + + phrase: str + confidence: float = 1.0 + + def __post_init__(self) -> None: + if not 0.0 <= self.confidence <= 1.0: + raise ValueError(f"confidence must be in [0.0, 1.0], got {self.confidence}") + + @dataclass(frozen=True, slots=True) class Health: """RSV. Polled by the engine; feeds proprioceptive self-model updates so diff --git a/emet-sdk/tests/test_wake.py b/emet-sdk/tests/test_wake.py new file mode 100644 index 0000000..e5596b6 --- /dev/null +++ b/emet-sdk/tests/test_wake.py @@ -0,0 +1,147 @@ +"""The wake plugin contract. + +Wake is the one category with nothing beneath it. Every other kind of failure +in Emet degrades: a chain that cannot find a head falls through to a light +ring, and a chain that finds nothing at all still speaks. A robot that cannot +hear its own name has no next rung. It just never answers, and looks broken +rather than limited. + +So these tests are mostly about honesty at the boundary — an engine reporting +what it can actually hear, rather than what the manifest hoped it would — and +about keeping the choice of engine swappable. Picovoice disabled every free +Porcupine access key on 30 June 2026. The seam tested here is what makes that +a one-line manifest change instead of a dead robot. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Mapping + +import pytest + +from emet_sdk.discovery import GROUP_WAKE, PluginRegistry +from emet_sdk.plugin import WakePlugin +from emet_sdk.types import WakeDescriptor, WakeEvent +from emet_sdk.validate import MissingPluginError + + +class _FakeWake(WakePlugin): + """Defined here rather than imported from emet_hal: the SDK's own tests + must not depend on the layer above it.""" + + engine = "fake" + + def describe(self) -> WakeDescriptor: + declared = self.params.get("phrases") + return WakeDescriptor( + engine=self.engine, + phrases=frozenset(declared) if declared is not None else frozenset({self.phrase}), + supports_custom_phrases=bool(self.params.get("supports_custom", False)), + healthy=bool(self.params.get("healthy", True)), + ) + + async def process(self, frame: bytes) -> WakeEvent | None: + if self.phrase.encode("utf-8") not in frame: + return None + return WakeEvent(phrase=self.phrase, confidence=0.8) + + +def make(phrase: str = "hey emet", **params: Any) -> _FakeWake: + return _FakeWake({"engine": "fake", "params": params}, phrase) + + +# --------------------------------------------------------------- discovery + + +def test_wake_is_a_real_entry_point_group(): + registry = PluginRegistry.discover() + assert GROUP_WAKE == "emet.wake" + assert "mock" in registry.wake_names, ( + "emet-hal registers a mock wake engine; if this fails the package " + "needs reinstalling so its entry points are picked up" + ) + assert registry.has_wake("mock") + + +def test_an_uninstalled_engine_is_a_missing_plugin_not_a_schema_error(): + """The same distinction locomotion makes. `engine: openwakeword` is a + legal value the day before anyone packages it.""" + registry = PluginRegistry.discover() + with pytest.raises(MissingPluginError) as exc: + registry.load_wake("openwakeword") + assert "openwakeword" in str(exc.value.report) + + +def test_wake_appears_in_the_registry_listing(): + registry = PluginRegistry(wake={}) + assert not registry + listed = dict.fromkeys(group for group, _ in PluginRegistry.discover()) + assert "wake" in listed + + +# -------------------------------------------------------------- descriptor + + +def test_an_engine_reports_only_the_phrases_it_loaded(): + """The case that matters: the engine started fine, and cannot hear the + name this particular soul answers to.""" + plugin = make("hey barnaby", phrases=["hey jarvis", "alexa"]) + assert not plugin.describe().can_detect("hey barnaby") + assert plugin.describe().can_detect("alexa") + + +def test_a_phonetic_engine_can_detect_anything(): + plugin = make("hey barnaby", phrases=[], supports_custom=True) + assert plugin.describe().can_detect("hey barnaby") + + +def test_an_unhealthy_engine_detects_nothing_it_claims(): + """A broken engine listing a phrase must not read as able to hear it.""" + plugin = make("hey emet", healthy=False) + descriptor = plugin.describe() + assert "hey emet" in descriptor.phrases + assert not descriptor.can_detect("hey emet") + + +def test_confidence_is_bounded(): + WakeEvent(phrase="hey emet", confidence=0.0) + WakeEvent(phrase="hey emet", confidence=1.0) + with pytest.raises(ValueError): + WakeEvent(phrase="hey emet", confidence=1.4) + + +# ----------------------------------------------------------------- contract + + +def test_the_phrase_comes_from_the_soul_and_the_engine_from_the_body(): + """The one place a soul-declared value reaches a plugin constructor. + `identity.wake_word` supplies the phrase; `audio.wake` supplies the rest.""" + plugin = make("hey emet", threshold=0.62) + assert plugin.phrase == "hey emet" + assert plugin.params["threshold"] == 0.62 + assert plugin.capability["engine"] == "fake" + + +def test_params_are_read_from_the_wake_block_not_driver_params(): + """Wake is not a manifest capability, so there is no `driver` to unwrap.""" + plugin = _FakeWake({"engine": "fake", "driver": {"params": {"wrong": 1}}}, "hey emet") + assert plugin.params == {} + + +def test_process_fires_only_on_the_phrase(): + plugin = make("hey emet") + + async def scenario(): + assert await plugin.process(b"unrelated audio") is None + event = await plugin.process(b"....hey emet....") + assert event is not None + assert event.phrase == "hey emet" + return event + + assert asyncio.run(scenario()).confidence == 0.8 + + +def test_reset_defaults_to_a_no_op(): + """A stateless detector should not have to implement it.""" + asyncio.run(make().reset()) From cdc488748a88232f8615907e5d0d109d753d75be Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:44:54 -0700 Subject: [PATCH 02/21] Make the wake phrase free text and split capability plugins from lifecycle Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- DESIGN.md | 26 +++--- emet-sdk/emet_sdk/__init__.py | 8 ++ emet-sdk/emet_sdk/plugin.py | 90 ++++++++++++------- emet-sdk/emet_sdk/validate.py | 85 +++++++++++++----- emet-sdk/examples/emet-soul.yaml | 11 ++- .../examples/invalid/unknown-wake-engine.yaml | 45 ++++++++++ .../examples/invalid/unknown-wake-word.yaml | 32 ------- emet-sdk/schemas/body-manifest.schema.json | 13 +++ emet-sdk/schemas/soul-bundle.schema.json | 2 +- emet-sdk/tests/test_acceptance.py | 46 +++++++--- emet-sdk/tests/test_wake.py | 2 +- 11 files changed, 246 insertions(+), 114 deletions(-) create mode 100644 emet-sdk/examples/invalid/unknown-wake-engine.yaml delete mode 100644 emet-sdk/examples/invalid/unknown-wake-word.yaml diff --git a/DESIGN.md b/DESIGN.md index cab7848..30c9d6a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -572,7 +572,9 @@ emet.emet/ # the reference soul; others: hugr.emet, neuma. default.pack.yaml # P0 recorded clips, see §10 voice/ # RSV bundled TTS model, or a reference wake/ - emet.ppn # RSV custom wake word (Porcupine, licensing pending) + lexicon.dict # RSV pronunciation for this soul's wake phrase, for + # engines that need one. Engine-neutral: a trained + # model may sit here instead. bundle.lock # P0 engine version, schema version, checksum ``` @@ -583,8 +585,10 @@ bundle_version: "0.1" identity: name: "Emet" # P0 what it is called. Free text, any name. - wake_word: "emet" # P0 which pretrained wake word wakes it. - # MUST be one of the shipped set (§14). + wake_word: "hey emet" # P0 the phrase that wakes it. Free text. + # Whether a body can hear it depends on the + # wake engine it installs (§14), which this + # document does not know about. # Deliberately separate from `name` — see 8.1.1. line: emet # P0 emet | hugr | neuma | custom (soul line, §1.2) pronouns: "it/its" # P0 @@ -645,16 +649,18 @@ models: # P0 BYOK #### 8.1.1 Why `name` and `wake_word` are separate fields -P0 ships a fixed set of pretrained wake words (§14): custom wake words are `RSV`, pending licensing. If a single field served as both the robot's name and its wake word, then naming a soul "Barnaby" would silently produce a robot that cannot be woken, and the only fix once souls exist in the wild would be a schema migration. +An earlier draft split them because P0 could only hear a fixed set of pretrained names, so a soul called "Barnaby" would have been silently unwakeable. That constraint is gone: wake is an open plugin category and the shipped default is a phonetic spotter, which takes any phrase and a pronunciation. `wake_word` is free text. -Two fields, decided now, cost one line: +The fields stay separate for a better reason. **A name is chosen for meaning; a wake phrase has to survive a room.** They optimise against each other: -- **`name`** is free text. Call your robot anything. It appears in the persona, the greeting, and the docs. -- **`wake_word`** must be a member of the shipped set. The validator rejects anything else, naming the available options. +- **`name`** is what the robot is. It appears in the persona, the greeting, and the docs, and it should be whatever you want. +- **`wake_word`** is an acoustic target. It wants three or more syllables, an uncommon shape, and enough distinctiveness that ordinary conversation does not trip it. Two-syllable names are measurably worse at this, which is why nearly every shipped wake phrase in the industry is "hey *something*". -So a soul may be called Barnaby and answer to "Emet", and the persona can be told as much: *"Your name is Barnaby, but you only hear people when they say 'Emet', and you find this a little undignified."* Turning a P0 limitation into character is exactly the move principle 6 asks for: the constraint is real, so say so rather than hiding it. +So a soul is called Barnaby and wakes on "hey barnaby", and both fields got to be right. A household that finds the phrase trips too often changes one field without renaming the robot. -When custom wake words arrive, `wake_word` simply accepts more values. No migration, and every existing soul keeps working. +The persona can still be told about the gap when there is one: *"Your name is Barnaby, but people have to say 'hey barnaby' to get your attention, and you find the formality a little undignified."* Principle 6 applies whether the constraint is a licence or a microphone. + +Whether a particular body can hear a particular phrase is not knowable from these documents, because it depends on which engine that body installs. It is answered at boot against a live `WakeDescriptor`. A validator that answered it here would make the same soul valid on one machine and invalid on another. ### 8.2 The soul line field @@ -877,7 +883,7 @@ Four decisions, defaults chosen. All `P0`. Turn-taking is what separates charmin | Stage | Placement | Note | |---|---|---| -| Wake word | Local | Fixed pretrained set in P0; custom (Porcupine) is `RSV`, pending licensing. | +| Wake word | Local | Open plugin category (`emet.wake`). Default is a phonetic spotter, so any phrase works without a trained model. | | VAD, speaker ID | Local | Reflex tier. | | Gaze / DoA / face tracking | Local | Reflex tier. | | STT | Cloud (streaming) | BYOK. | diff --git a/emet-sdk/emet_sdk/__init__.py b/emet-sdk/emet_sdk/__init__.py index 2052552..7058747 100644 --- a/emet-sdk/emet_sdk/__init__.py +++ b/emet-sdk/emet_sdk/__init__.py @@ -12,9 +12,11 @@ from emet_sdk.plugin import ( ActuatorPlugin, + CapabilityPlugin, LocomotionPlugin, PluginError, SensorPlugin, + WakePlugin, ) from emet_sdk.types import ( Action, @@ -30,6 +32,8 @@ Target, TargetKind, Twist, + WakeDescriptor, + WakeEvent, ) __version__ = "0.2.0" @@ -44,10 +48,12 @@ "SCHEMA_VERSION", "Action", "ActuatorPlugin", + "CapabilityPlugin", "LocomotionPlugin", "PluginError", "Reading", "SensorPlugin", + "WakePlugin", "CapabilityDescriptor", "Health", "Intent", @@ -59,4 +65,6 @@ "Target", "TargetKind", "Twist", + "WakeDescriptor", + "WakeEvent", ] diff --git a/emet-sdk/emet_sdk/plugin.py b/emet-sdk/emet_sdk/plugin.py index 27983bb..c36b86a 100644 --- a/emet-sdk/emet_sdk/plugin.py +++ b/emet-sdk/emet_sdk/plugin.py @@ -16,6 +16,15 @@ only category configured jointly by a body and a soul, and the only one with no fallback beneath it. +**Why there is no VAD category.** Voice activity detection looks like it +belongs beside wake, and does not. It is not a swap point: there is one real +answer (Silero), it is MIT licensed so it carries none of the rug-pull risk +that made wake a category, and its output feeds turn-taking — `patience_ms`, +the trailing-clause heuristic — which is personality rather than hardware. A +seam there would decouple nothing. The asymmetry settles it: adding a category +later is a minor version bump, removing one is a major bump, so under +uncertainty the cheap direction is to leave it out. + Everything here is hardware-agnostic on purpose. A plugin may talk to a servo board over I2C; nothing in this module knows that, and nothing in the soul layer ever will. @@ -47,6 +56,7 @@ __all__ = [ "Plugin", + "CapabilityPlugin", "ActuatorPlugin", "SensorPlugin", "LocomotionPlugin", @@ -66,26 +76,15 @@ class PluginError(RuntimeError): class Plugin(ABC): - """Common lifecycle for every plugin category.""" - - #: The manifest `type` this plugin implements — joint_group, drive, - #: display, light, camera, sensor. Locomotion plugins leave this empty and - #: set `kinematics` instead. - capability_type: ClassVar[str] = "" - - def __init__(self, capability: Mapping[str, Any]) -> None: - """Receive the whole capability block from the manifest. - - Not just `driver.params`: a plugin needs `role`, `joints`, `form` and - the rest to answer `describe()` honestly. What it does with the - wiring-specific `params` is entirely its own business — Emet passes - them through without looking at them. - """ - self.capability: Mapping[str, Any] = capability - self.capability_id: str = str(capability.get("id", "")) - self.params: Mapping[str, Any] = dict( - (capability.get("driver") or {}).get("params") or {} - ) + """Lifecycle, and only lifecycle. + + Every category starts, shuts down, and reports health the same way. What a + plugin is *constructed from* differs: three categories are built from one + entry in a manifest's `capabilities` list, and wake is built from + `audio.wake` plus a phrase the soul supplies. Construction therefore + belongs to the subclasses, so that no category inherits a signature it has + to contradict. + """ async def start(self) -> None: """Bring the hardware up. Raise `PluginError` if it is not there. @@ -109,7 +108,34 @@ def health(self) -> Health: return Health() -class ActuatorPlugin(Plugin): +class CapabilityPlugin(Plugin): + """A plugin built from one entry in the manifest's `capabilities` list. + + The common case, and the reason `capability_type`, `capability_id` and + `params` live here rather than on `Plugin`: wake has none of them. + """ + + #: The manifest `type` this plugin implements — joint_group, drive, + #: display, light, camera, sensor. Locomotion plugins leave this empty and + #: set `kinematics` instead. + capability_type: ClassVar[str] = "" + + def __init__(self, capability: Mapping[str, Any]) -> None: + """Receive the whole capability block from the manifest. + + Not just `driver.params`: a plugin needs `role`, `joints`, `form` and + the rest to answer `describe()` honestly. What it does with the + wiring-specific `params` is entirely its own business — Emet passes + them through without looking at them. + """ + self.capability: Mapping[str, Any] = capability + self.capability_id: str = str(capability.get("id", "")) + self.params: Mapping[str, Any] = dict( + (capability.get("driver") or {}).get("params") or {} + ) + + +class ActuatorPlugin(CapabilityPlugin): """Something the robot can act with: joints, displays, lights.""" @abstractmethod @@ -136,7 +162,7 @@ async def home(self) -> None: """Return to the resting pose declared in the manifest.""" -class SensorPlugin(Plugin): +class SensorPlugin(CapabilityPlugin): """Something the robot observes with. Never emits intents.""" @abstractmethod @@ -154,7 +180,7 @@ async def poll(self) -> Reading: """ -class LocomotionPlugin(Plugin): +class LocomotionPlugin(CapabilityPlugin): """How a body moves. The engine asks; it does not know. This is the seam that keeps `drive.kinematics` an open enum: the engine @@ -219,16 +245,16 @@ class WakePlugin(Plugin): def __init__(self, config: Mapping[str, Any], phrase: str) -> None: """Receive the `audio.wake` block and the phrase to listen for. - Deliberately not the base signature. Wake is not a manifest - capability, so there is no `driver.params` to unwrap and no capability - id to carry; `params` is read straight off the wake block. Taking - `phrase` here rather than through a later `configure()` means an - instance cannot exist without knowing what it is listening for. + Not a `CapabilityPlugin`, which is why this signature is free to + differ rather than contradict one: wake declares no capability, so + there is no `driver.params` to unwrap and no capability id to carry. + + Taking `phrase` here rather than through a later `configure()` means + an instance cannot exist without knowing what it listens for. """ - self.capability = config - self.capability_id = "wake" - self.params = dict(config.get("params") or {}) - #: The phrase from `identity.wake_word`. + self.config: Mapping[str, Any] = config + self.params: Mapping[str, Any] = dict(config.get("params") or {}) + #: The phrase from the soul's `identity.wake_word`. self.phrase = phrase @abstractmethod diff --git a/emet-sdk/emet_sdk/validate.py b/emet-sdk/emet_sdk/validate.py index 05ef87a..bdfbab3 100644 --- a/emet-sdk/emet_sdk/validate.py +++ b/emet-sdk/emet_sdk/validate.py @@ -34,7 +34,7 @@ from dataclasses import dataclass, field from functools import lru_cache from pathlib import Path -from typing import Any, Iterable, Literal, Mapping, Sequence +from typing import Any, Literal, Mapping, Sequence import json @@ -49,8 +49,8 @@ "ValidationError", "MissingPluginError", "PluginRegistry", - "SHIPPED_WAKE_WORDS", "BUILTIN_LOCOMOTION", + "BUILTIN_WAKE", "load_yaml", "validate_manifest", "validate_soul", @@ -71,10 +71,19 @@ BUILTIN_LOCOMOTION: frozenset[str] = frozenset({"differential", "tracked"}) -#: Provisional. Which pretrained wake words ship is not settled yet. What is -#: settled is that `identity.wake_word` is a separate field from -#: `identity.name`, so that the set can grow without a schema change. -SHIPPED_WAKE_WORDS: frozenset[str] = frozenset({"emet", "hugr", "neuma"}) +#: What `emet-hal` ships today. Same status as `BUILTIN_LOCOMOTION`: not what +#: the validator checks against, because `audio.wake.engine` is an open enum +#: resolved against real entry points. +#: +#: `identity.wake_word` used to be checked against a fixed set of pretrained +#: names, on the assumption that a wake engine can only hear words somebody +#: trained a model for. That assumption belonged to one class of engine. A +#: phonetic keyword spotter takes any phrase and a pronunciation, so the +#: shipped default is `pocketsphinx` (0.3) and the field is free text: a soul +#: may answer to whatever it likes, and whether a given engine can actually +#: hear it is answered by `WakeDescriptor.can_detect` after `start()`, not by +#: a list in this file. +BUILTIN_WAKE: frozenset[str] = frozenset({"mock"}) # -------------------------------------------------------------------------- @@ -238,10 +247,46 @@ def validate_manifest( _check_single_drive(capabilities, report) _check_mount_references(capabilities, doc, report) _check_plugins(capabilities, registry, report) + _check_wake_engine(doc, registry, report) return report +def _check_wake_engine( + doc: Mapping[str, Any], + registry: PluginRegistry, + report: ValidationReport, +) -> None: + """Resolve `audio.wake.engine` the same way `driver.plugin` resolves. + + Absent is fine — a manifest that says nothing about wake gets the default, + and most will. What is checked is a name that was written down and does + not resolve, which is the failure that took out every free Porcupine user + on 30 June 2026 and is worth naming precisely. + """ + engine = ((doc.get("audio") or {}).get("wake") or {}).get("engine") + if not isinstance(engine, str) or registry.has_wake(engine): + return + installed = ", ".join(registry.wake_names) or "(none)" + if registry.verify_drivers: + report.error( + "missing_plugin", + f"no wake word plugin provides {engine!r}. Installed: {installed}. " + f"`audio.wake.engine` is an open enum — this value is legal, the " + f"plugin simply is not installed.", + "/audio/wake/engine", + ) + else: + report.warn( + "wake_engine_not_installed", + f"wake engine {engine!r} is not installed. Installed: {installed}. " + f"Legal in a manifest, but the engine will refuse to boot against " + f"it — a robot that cannot hear its name has no fallback to degrade " + f"to. Run with --verify-drivers to treat this as an error.", + "/audio/wake/engine", + ) + + def _check_unique_ids(caps: Sequence[Mapping[str, Any]], report: ValidationReport) -> None: seen: dict[str, int] = {} for i, cap in enumerate(caps): @@ -371,28 +416,20 @@ def _check_plugins( # -------------------------------------------------------------------------- -def validate_soul( - doc: Any, - *, - wake_words: Iterable[str] | None = None, -) -> ValidationReport: +def validate_soul(doc: Any) -> ValidationReport: + """Validate a soul bundle. + + Note what is *not* checked here. `identity.wake_word` is free text, and + whether it can actually be heard depends on which engine a given body + installs — which this document does not know and must not care about. + That check is a boot-time one against a live `WakeDescriptor`, and putting + it here would have made a soul valid or invalid depending on the machine + it was linted on. + """ report = ValidationReport() if not _check_schema(doc, "soul-bundle", report): return report - allowed = frozenset(wake_words) if wake_words is not None else SHIPPED_WAKE_WORDS - identity = doc.get("identity") or {} - wake = identity.get("wake_word") - if isinstance(wake, str) and wake not in allowed: - report.error( - "unknown_wake_word", - f"wake_word {wake!r} is not in the shipped set: " - f"{', '.join(sorted(allowed))}. Custom wake words are RSV, pending " - f"licensing. `identity.name` is unconstrained — a soul may be called " - f"anything and still answer to one of these.", - "/identity/wake_word", - ) - weights = ((doc.get("idle") or {}).get("weights")) or {} if weights: total = sum(v for v in weights.values() if isinstance(v, (int, float))) diff --git a/emet-sdk/examples/emet-soul.yaml b/emet-sdk/examples/emet-soul.yaml index df396da..14a6776 100644 --- a/emet-sdk/examples/emet-soul.yaml +++ b/emet-sdk/examples/emet-soul.yaml @@ -1,9 +1,12 @@ # The reference soul, Emet. The house character and the reference # implementation for documentation. # -# Note `identity.name` and `identity.wake_word` are separate fields. This soul -# happens to set both to the same word; a soul named Barnaby that answers to -# "emet" is equally valid, and can be told so in its own persona. +# Note `identity.name` and `identity.wake_word` are separate fields, and that +# this soul does not set them to the same string. The name is chosen for +# meaning; the wake phrase has to survive a room full of other conversation, +# which two syllables does badly. Both get to be right. A soul named Barnaby +# that wakes on "hey barnaby" is equally valid, and one that wakes on +# something unrelated can be told so in its own persona. # # Every RSV field is populated. A clean run proves the reserved-now, # implemented-later discipline actually works. @@ -12,7 +15,7 @@ bundle_version: "0.1" identity: name: "Emet" - wake_word: "emet" + wake_word: "hey emet" line: emet pronouns: "it/its" author: "The Emet Authors" diff --git a/emet-sdk/examples/invalid/unknown-wake-engine.yaml b/emet-sdk/examples/invalid/unknown-wake-engine.yaml new file mode 100644 index 0000000..a1f138a --- /dev/null +++ b/emet-sdk/examples/invalid/unknown-wake-engine.yaml @@ -0,0 +1,45 @@ +# INVALID under --verify-drivers: a wake engine that is not installed. +# +# This replaces the old `unknown-wake-word.yaml`, whose rule no longer exists. +# That fixture rejected `wake_word: barnaby` because P0 assumed wake detection +# meant a pretrained model, so only a fixed set of names could ever be heard. +# The shipped default is now a phonetic keyword spotter, which takes any phrase +# and a pronunciation, so `identity.wake_word` is free text and the constraint +# it guarded is gone. +# +# What replaces it is the same rule `driver.plugin` already follows. The name +# below is legal: `audio.wake.engine` is an open enum, and a manifest may +# describe an engine you have not installed yet. It is a warning when linting +# and an error when booting, because a robot that cannot hear its own name has +# nothing to degrade to — unlike a missing head, there is no next rung. +# +# The value is not a strawman. Picovoice disabled every free Porcupine access +# key on 30 June 2026, which turned working manifests into this exact case +# overnight, and is the reason wake is a plugin category at all. +# +# Expected: wake_engine_not_installed (lint), missing_plugin (--verify-drivers) + +manifest_version: "0.1" + +body: + id: deaf + name: "a body whose ears were discontinued" + scale: desk + power: plugged_in + +audio: + input: + device: "plughw:1,0" + sample_rate: 16000 + channels: 1 + aec: hardware + doa: false + output: + device: "plughw:1,0" + gain_db: -6.0 + wake: + engine: porcupine + params: + sensitivity: 0.6 + +capabilities: [] diff --git a/emet-sdk/examples/invalid/unknown-wake-word.yaml b/emet-sdk/examples/invalid/unknown-wake-word.yaml deleted file mode 100644 index 04b2eb7..0000000 --- a/emet-sdk/examples/invalid/unknown-wake-word.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# INVALID: a wake word outside the shipped pretrained set. -# -# `identity.name` is free text — this soul is legitimately called Barnaby, and -# nothing rejects that. `identity.wake_word` must name a wake word the engine -# can actually detect, and P0 ships a fixed pretrained set because custom wake -# words are RSV pending licensing. -# -# The fix is one line: set `wake_word: emet` and keep the name. The soul is -# then called Barnaby and answers to "Emet" — which its persona can be told -# about, turning a P0 limitation into character rather than hiding it. -# -# Expected: unknown_wake_word, /identity/wake_word - -bundle_version: "0.1" - -identity: - name: "Barnaby" - wake_word: "barnaby" - line: custom - pronouns: "he/him" - author: "a builder who has not read section 8.1.1" - created: "2026-08-11" - -persona: - summary: > - Fussy, formal, and quietly delighted by procedure. - traits: - warmth: 0.5 - humor: 0.2 - verbosity: 0.6 - curiosity: 0.4 - formality: 0.9 diff --git a/emet-sdk/schemas/body-manifest.schema.json b/emet-sdk/schemas/body-manifest.schema.json index 25ea7c0..adf37e8 100644 --- a/emet-sdk/schemas/body-manifest.schema.json +++ b/emet-sdk/schemas/body-manifest.schema.json @@ -93,6 +93,19 @@ "device": { "type": "string", "minLength": 1 }, "gain_db": { "type": "number" } } + }, + "wake": { + "description": "P0, optional. Which wake word engine listens on this body, and how it is tuned. Omit to take the shipped default. The name resolves against installed emet.wake plugins, so this is an open enum: an unknown value is a missing plugin, never a schema error. The wake PHRASE is not here — it belongs to the soul, as identity.wake_word.", + "type": "object", + "additionalProperties": false, + "required": ["engine"], + "properties": { + "engine": { "type": "string", "minLength": 1 }, + "params": { + "description": "Passed to the plugin untouched. Emet never inspects these.", + "type": "object" + } + } } } }, diff --git a/emet-sdk/schemas/soul-bundle.schema.json b/emet-sdk/schemas/soul-bundle.schema.json index 41d0fa5..e9ab2ac 100644 --- a/emet-sdk/schemas/soul-bundle.schema.json +++ b/emet-sdk/schemas/soul-bundle.schema.json @@ -25,7 +25,7 @@ "minLength": 1 }, "wake_word": { - "description": "P0. Which pretrained wake word wakes this soul. Must be a member of the shipped set — membership is a semantic rule so that the set can grow without a schema change. A soul may be named Barnaby and answer to 'emet'.", + "description": "P0. The phrase that wakes this soul. Free text: the shipped default engine is a phonetic spotter, so any phrase works given a pronunciation. Prefer three or more syllables — a wake phrase is an acoustic target, which is why it is separate from `name`. Whether a given body can hear it depends on the wake engine it installs, and is checked at boot, not here.", "type": "string", "minLength": 1 }, diff --git a/emet-sdk/tests/test_acceptance.py b/emet-sdk/tests/test_acceptance.py index 6cecc17..402162d 100644 --- a/emet-sdk/tests/test_acceptance.py +++ b/emet-sdk/tests/test_acceptance.py @@ -16,6 +16,7 @@ import pytest from emet_sdk import chains, intents +from emet_sdk.discovery import PluginRegistry from emet_sdk.validate import ( load_yaml, validate_chain_document, @@ -33,6 +34,10 @@ def codes(report) -> set[str]: return {f.code for f in report.errors} +def warnings(report) -> set[str]: + return {f.code for f in report.warnings} + + # ---------------------------------------------------------------- valid @@ -78,19 +83,40 @@ def test_invalid_manifests_are_rejected(fixture: str, expected: str): assert expected in codes(report), report.errors -def test_unknown_wake_word_is_rejected(): - report = validate_soul(load_yaml(EXAMPLES / "invalid" / "unknown-wake-word.yaml")) - assert not report.ok - assert "unknown_wake_word" in codes(report) - +def test_a_soul_may_answer_to_any_phrase(): + """This used to be rejected, and the rule was wrong rather than strict. -def test_name_is_unconstrained_when_wake_word_is_legal(): - """A soul may be called anything and answer to a shipped wake word.""" - doc = load_yaml(EXAMPLES / "invalid" / "unknown-wake-word.yaml") - doc["identity"]["wake_word"] = "emet" + P0 assumed wake detection meant a pretrained model, so only a fixed set of + names could be heard and `wake_word: barnaby` was an error. The shipped + default is a phonetic keyword spotter, which takes any phrase and a + pronunciation. The field is free text, and whether a particular engine can + hear a particular phrase is answered at boot against a live descriptor — + not by a list in the validator, which would make a soul valid or invalid + depending on which machine linted it. + """ + doc = load_yaml(EXAMPLES / "emet-soul.yaml") + doc["identity"]["name"] = "Barnaby" + doc["identity"]["wake_word"] = "hey barnaby" report = validate_soul(doc) assert report.ok, report.errors - assert doc["identity"]["name"] == "Barnaby" + + +def test_an_uninstalled_wake_engine_is_a_warning_when_linting(): + """Describing a body you have not finished building is normal.""" + report = validate_manifest(load_yaml(EXAMPLES / "invalid" / "unknown-wake-engine.yaml")) + assert report.ok, report.errors + assert "wake_engine_not_installed" in warnings(report) + + +def test_an_uninstalled_wake_engine_is_an_error_when_booting(): + """Booting against one is not normal. Wake has no rung beneath it.""" + registry = PluginRegistry.discover().with_verification(True) + report = validate_manifest( + load_yaml(EXAMPLES / "invalid" / "unknown-wake-engine.yaml"), + registry=registry, + ) + assert not report.ok + assert "missing_plugin" in codes(report) # ------------------------------------------- the two that carry the design diff --git a/emet-sdk/tests/test_wake.py b/emet-sdk/tests/test_wake.py index e5596b6..40c8c21 100644 --- a/emet-sdk/tests/test_wake.py +++ b/emet-sdk/tests/test_wake.py @@ -120,7 +120,7 @@ def test_the_phrase_comes_from_the_soul_and_the_engine_from_the_body(): plugin = make("hey emet", threshold=0.62) assert plugin.phrase == "hey emet" assert plugin.params["threshold"] == 0.62 - assert plugin.capability["engine"] == "fake" + assert plugin.config["engine"] == "fake" def test_params_are_read_from_the_wake_block_not_driver_params(): From feb36be9cafda0d985c0a723b6ffc29cd1c9373b Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:04:13 -0700 Subject: [PATCH 03/21] Add the pocketsphinx wake engine Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- .github/workflows/ci.yml | 10 + emet-hal/emet_hal/pocketsphinx_wake.py | 234 +++++++++++++++++++++++ emet-hal/pyproject.toml | 4 + emet-hal/tests/test_pocketsphinx_wake.py | 142 ++++++++++++++ emet-sdk/emet_sdk/types.py | 19 +- emet-sdk/examples/mock-scout.yaml | 5 + emet-sdk/examples/scout-01.yaml | 9 + emet-sdk/tests/test_wake.py | 13 +- 8 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 emet-hal/emet_hal/pocketsphinx_wake.py create mode 100644 emet-hal/tests/test_pocketsphinx_wake.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b28623..3372e96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,6 +165,16 @@ jobs: python -m pip install -e "emet-sdk[dev]" python -m pip install -e "emet-hal[dev]" + # The shipped wake engine, which is an optional extra rather than a hard + # dependency. pocketsphinx publishes cp311 and cp313 wheels and no cp312 + # one, so installing it everywhere would make the 3.12 job compile a + # speech decoder from source. The wake tests `importorskip`, so 3.12 runs + # the rest of the suite and skips them, and the engine is still exercised + # on two of the three versions. + - name: Install the wake engine where a wheel exists + if: matrix.python-version != '3.12' + run: python -m pip install -e "emet-hal[wake]" + - name: Test emet-sdk working-directory: emet-sdk run: python -m pytest -q diff --git a/emet-hal/emet_hal/pocketsphinx_wake.py b/emet-hal/emet_hal/pocketsphinx_wake.py new file mode 100644 index 0000000..c72e33b --- /dev/null +++ b/emet-hal/emet_hal/pocketsphinx_wake.py @@ -0,0 +1,234 @@ +"""The shipped wake word engine: phonetic keyword spotting. + +Emet wakes on a phrase, not on a name somebody trained a model for. That is a +deliberate reversal of how most wake word detection works, and it is what lets +`identity.wake_word` be free text. + +A trained detector — openWakeWord, and Porcupine before its access keys were +disabled on 30 June 2026 — learns one phrase from thousands of examples. It is +more accurate, and it means a soul can only answer to a name somebody has +already trained. Naming your robot Barnaby would make it deaf. + +A phonetic spotter works the other way round. It knows how English *sounds*, +and a phrase is a sequence of phonemes to watch for. "hey barnaby" needs no +training at all, because `barnaby` is already in the pronunciation dictionary. +A name that is not — `emet` is not an English word — needs one line of +phonemes, which is what `SHIPPED_LEXICON` below is. + +The tradeoff is real and worth stating plainly: this is less accurate in noise +than a trained model. It is the floor, not the ceiling. An openWakeWord plugin +is the upgrade for people who want that accuracy and will train for it, and +because wake is an open plugin category, installing one is a one-line change. + +**On warm-up.** The decoder is least sensitive in the seconds after it starts, +before its cepstral mean has adapted. That is a real property, not a bug, and +it is why `DEFAULT_THRESHOLD` is set from cold-start measurements rather than +from a decoder that has been running comfortably for a minute. + +**On confidence.** Keyword spotting reports no calibrated score, so every +`WakeEvent` from this engine carries `confidence=1.0`. That is honesty about +the absence of a number rather than a claim of certainty. Tune +`params.threshold` instead: lower is stricter. +""" + +from __future__ import annotations + +import logging +from typing import Any, Mapping + +from emet_sdk.plugin import WakePlugin +from emet_sdk.types import Health, WakeDescriptor, WakeEvent + +__all__ = ["PocketSphinxWake", "SHIPPED_LEXICON", "DEFAULT_THRESHOLD"] + +log = logging.getLogger("emet_hal.pocketsphinx") + +#: Audio this engine expects. 16 kHz mono is what the acoustic model was +#: trained on; feeding it anything else degrades detection silently rather +#: than failing, which is why the descriptor states it and the engine obeys. +SAMPLE_RATE = 16000 +FRAME_SAMPLES = 1280 + +#: Keyword spotting threshold. Lower is stricter: fewer false wakes, more +#: missed ones. +#: +#: This value was measured rather than guessed, and the measurement had a +#: wrinkle worth recording. pocketsphinx adapts its cepstral mean as audio +#: flows, so a decoder that has been listening for a while is more sensitive +#: than one that just booted. The two ends fail in opposite directions: +#: +#: threshold cold (at boot) warm (listening a while) +#: 1e-20 misses "hey neuma" clean +#: 1e-25 clean clean +#: 1e-30 clean false-fires +#: +#: 1e-25 is the only value clean at both ends, which is why it is the default. +#: Measured over four phrases against six synthesised clips, so it is a +#: defensible starting point and not a figure from a real room. +DEFAULT_THRESHOLD = 1e-25 + +#: Pronunciations for names that are not English words, in ARPAbet, which is +#: what the bundled dictionary uses. Multiple entries per name are alternate +#: pronunciations: `hugr` is Old Norse and nobody agrees how to say it, so the +#: engine listens for more than one. +#: +#: Every entry here was checked against synthesised speech: each fires on its +#: own phrase and on none of the others, including a conversational control +#: clip. Synthetic speech is a weak proxy for a room, so treat these as a +#: working default a builder may need to extend through `params.lexicon`. +SHIPPED_LEXICON: Mapping[str, tuple[str, ...]] = { + "emet": ("EH M EH T", "EY M EH T", "IH M EH T"), + "hugr": ("HH UH G ER", "HH UW G ER"), + "neuma": ("N UW M AH", "N Y UW M AH"), +} + + +class PocketSphinxWake(WakePlugin): + """Wake detection through pocketsphinx keyword spotting. + + Params, all optional: + + threshold float, default DEFAULT_THRESHOLD. Lower is stricter. + lexicon word -> pronunciation, or word -> [pronunciations]. + Merged over SHIPPED_LEXICON, so a body can override a + shipped name or teach the engine an entirely new one. + model path to an acoustic model directory. Defaults to the one + bundled with pocketsphinx. + verbose bool. Let the decoder log; off by default, it is chatty. + """ + + engine = "pocketsphinx" + + def __init__(self, config: Mapping[str, Any], phrase: str) -> None: + super().__init__(config, phrase) + self._decoder: Any = None + self._fault: str | None = None + self._unpronounceable: tuple[str, ...] = () + self._listening = False + self._fired = False + + # ------------------------------------------------------------ lifecycle + + async def start(self) -> None: + """Build the decoder and teach it the phrase. + + Does not raise, on a missing dependency or an unsayable phrase alike. + Both are reported through `describe()` as `healthy=False`, matching the + other plugins: the boot check then says precisely what is wrong instead + of unwinding a stack trace through a robot that was otherwise fine. + """ + try: + from pocketsphinx import Config, Decoder + except ImportError: + self._fail( + "pocketsphinx is not installed. It is an optional dependency: " + "install `emet-hal[wake]`, or point `audio.wake.engine` at an " + "engine you do have." + ) + return + + settings: dict[str, Any] = { + "loglevel": "INFO" if self.params.get("verbose") else "FATAL", + "kws_threshold": float(self.params.get("threshold", DEFAULT_THRESHOLD)), + } + if self.params.get("model"): + settings["hmm"] = str(self.params["model"]) + + try: + self._decoder = Decoder(Config(**settings)) + except Exception as exc: # noqa: BLE001 - the decoder raises broadly + self._fail(f"could not start the pocketsphinx decoder: {exc}") + return + + missing = self._teach(self.phrase) + if missing: + self._unpronounceable = missing + words = ", ".join(repr(w) for w in missing) + self._fail( + f"no pronunciation for {words} in {self.phrase!r}. The dictionary " + f"does not know the word and no lexicon entry supplies it. Add one " + f"in ARPAbet under `audio.wake.params.lexicon`, or choose a phrase " + f"of ordinary English words, which need nothing." + ) + return + + self._decoder.add_keyphrase("emet_wake", self.phrase) + self._decoder.activate_search("emet_wake") + self._decoder.start_utt() + self._listening = True + + def _fail(self, reason: str) -> None: + self._fault = reason + log.error("wake: %s", reason) + + def _teach(self, phrase: str) -> tuple[str, ...]: + """Add pronunciations for any word the dictionary lacks. + + Returns the words it could not resolve. Alternates use pocketsphinx's + `word(2)` convention, so a name with two plausible pronunciations is + heard either way. + """ + lexicon: dict[str, tuple[str, ...]] = dict(SHIPPED_LEXICON) + for word, pron in (self.params.get("lexicon") or {}).items(): + lexicon[str(word).lower()] = (pron,) if isinstance(pron, str) else tuple(pron) + + missing: list[str] = [] + for word in phrase.lower().split(): + if self._decoder.lookup_word(word) is not None: + continue + prons = lexicon.get(word) + if not prons: + missing.append(word) + continue + for i, pron in enumerate(prons): + self._decoder.add_word(word if i == 0 else f"{word}({i + 1})", pron, True) + return tuple(missing) + + async def shutdown(self) -> None: + if self._listening and self._decoder is not None: + self._decoder.end_utt() + self._listening = False + + # ------------------------------------------------------------ reporting + + def describe(self) -> WakeDescriptor: + return WakeDescriptor( + engine=self.engine, + # Only what this instance actually loaded. `supports_custom_phrases` + # says the engine *could* take any phrase; it is not a claim to be + # listening for one. + phrases=frozenset({self.phrase}) if self._listening else frozenset(), + supports_custom_phrases=True, + sample_rate=SAMPLE_RATE, + frame_samples=FRAME_SAMPLES, + healthy=self._listening, + ) + + def health(self) -> Health: + if self._fault: + fault = "unpronounceable_phrase" if self._unpronounceable else "start_failed" + return Health(ok=False, detail=self._fault, faults=(fault,)) + return Health() + + # ------------------------------------------------------------ detection + + async def process(self, frame: bytes) -> WakeEvent | None: + if not self._listening: + return None + self._decoder.process_raw(frame, False, False) + if self._decoder.hyp() is None: + return None + if self._fired: + # The hypothesis persists until the utterance is reset, so without + # this a caller that forgets `reset()` gets one event per frame for + # the rest of the conversation. + return None + self._fired = True + return WakeEvent(phrase=self.phrase, confidence=1.0) + + async def reset(self) -> None: + """Start a fresh utterance, discarding the hypothesis that just fired.""" + self._fired = False + if self._listening: + self._decoder.end_utt() + self._decoder.start_utt() diff --git a/emet-hal/pyproject.toml b/emet-hal/pyproject.toml index 10b225e..df5447c 100644 --- a/emet-hal/pyproject.toml +++ b/emet-hal/pyproject.toml @@ -15,6 +15,9 @@ keywords = ["emet", "robotics", "hal", "drivers"] dependencies = ["emet-sdk>=0.1"] [project.optional-dependencies] +# The shipped wake engine. Optional because a body that never wakes — a test +# rig, a CI run resolving chains — should not pull an acoustic model down. +wake = ["pocketsphinx>=5.0"] dev = ["pytest>=8.0"] # Installing a package is what makes a driver exist. Nothing scans directories @@ -31,6 +34,7 @@ dev = ["pytest>=8.0"] "emet_hal.mock_sensor" = "emet_hal.mock:MockSensor" [project.entry-points."emet.wake"] +"pocketsphinx" = "emet_hal.pocketsphinx_wake:PocketSphinxWake" "mock" = "emet_hal.mock:MockWake" [project.entry-points."emet.locomotion"] diff --git a/emet-hal/tests/test_pocketsphinx_wake.py b/emet-hal/tests/test_pocketsphinx_wake.py new file mode 100644 index 0000000..868f009 --- /dev/null +++ b/emet-hal/tests/test_pocketsphinx_wake.py @@ -0,0 +1,142 @@ +"""The shipped wake engine. + +These tests cover configuration, dictionary resolution, and every failure +path, none of which need a microphone. What they deliberately do not cover is +whether the engine fires on a human actually saying the phrase. + +That was verified by hand against synthesised speech before this plugin was +written: with the pronunciations in `SHIPPED_LEXICON`, "hey emet", "hey hugr" +and "hey neuma" each fired on their own clip and on none of the other five, +including a conversational control clip. "hey barnaby" fired straight from the +bundled dictionary with no lexicon entry at all, which is the whole argument +for a phonetic engine. + +Committing that audio would put binary blobs of uncertain provenance in the +repository to support a handful of assertions, so the gap is recorded here +rather than papered over. It closes when 0.3 has a real capture path and a +recorded fixture worth keeping. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +pytest.importorskip("pocketsphinx", reason="emet-hal[wake] is not installed") + +from emet_hal.pocketsphinx_wake import ( # noqa: E402 + DEFAULT_THRESHOLD, + SHIPPED_LEXICON, + PocketSphinxWake, +) + + +def run(coro): + return asyncio.run(coro) + + +def started(phrase: str, **params) -> PocketSphinxWake: + plugin = PocketSphinxWake({"engine": "pocketsphinx", "params": params}, phrase) + run(plugin.start()) + return plugin + + +# ------------------------------------------------------- resolving a phrase + + +def test_an_ordinary_english_name_needs_no_lexicon(): + """The argument for a phonetic engine, as a test. + + This exact phrase was rejected by the validator before wake became a + plugin category, on the grounds that nobody had trained a model for it. + """ + plugin = started("hey barnaby") + assert plugin.describe().healthy, plugin.health().detail + assert plugin.describe().can_detect("hey barnaby") + + +def test_a_shipped_name_resolves_through_the_lexicon(): + """`emet` is not an English word, so it needs its five phonemes.""" + assert "emet" in SHIPPED_LEXICON + plugin = started("hey emet") + assert plugin.describe().healthy, plugin.health().detail + + +@pytest.mark.parametrize("name", sorted(SHIPPED_LEXICON)) +def test_every_shipped_name_can_be_taught(name: str): + plugin = started(f"hey {name}") + assert plugin.describe().healthy, plugin.health().detail + + +def test_a_body_can_supply_its_own_pronunciation(): + plugin = started("hey zorbax", lexicon={"zorbax": "Z AO R B AE K S"}) + assert plugin.describe().healthy, plugin.health().detail + + +def test_alternate_pronunciations_are_accepted_as_a_list(): + plugin = started("hey zorbax", lexicon={"zorbax": ["Z AO R B AE K S", "Z ER B AE K S"]}) + assert plugin.describe().healthy, plugin.health().detail + + +# -------------------------------------------------------------- failing well + + +def test_an_unpronounceable_phrase_is_unhealthy_and_says_why(): + """No fallback exists below wake, so this has to be loud and specific.""" + plugin = started("hey zzqqxvv") + assert not plugin.describe().healthy + health = plugin.health() + assert not health.ok + assert "unpronounceable_phrase" in health.faults + assert "zzqqxvv" in health.detail + assert "lexicon" in health.detail + + +def test_an_engine_that_did_not_start_hears_nothing(): + plugin = started("hey zzqqxvv") + assert run(plugin.process(b"\x00\x00" * 1280)) is None + assert plugin.describe().phrases == frozenset() + + +def test_a_started_engine_reports_only_the_phrase_it_loaded(): + """It could have been pointed at any phrase. It was pointed at one.""" + plugin = started("hey emet") + descriptor = plugin.describe() + assert descriptor.supports_custom_phrases + assert descriptor.can_detect("hey emet") + assert not descriptor.can_detect("hey barnaby") + + +# ------------------------------------------------------------ audio contract + + +def test_the_descriptor_states_the_rate_and_frame_it_wants(): + """The engine feeds frames to this spec; guessing would degrade detection + silently rather than failing.""" + descriptor = started("hey emet").describe() + assert descriptor.sample_rate == 16000 + assert descriptor.frame_samples == 1280 + assert descriptor.engine == "pocketsphinx" + + +def test_silence_does_not_wake_it(): + plugin = started("hey emet") + for _ in range(40): + assert run(plugin.process(b"\x00\x00" * 1280)) is None + + +def test_the_threshold_is_tunable_and_has_a_measured_default(): + """1e-25 is the only value that both wakes on a cold decoder and stays + quiet on a warm one. See the table in the plugin.""" + assert DEFAULT_THRESHOLD == 1e-25 + plugin = started("hey emet", threshold=1e-30) + assert plugin.describe().healthy + + +def test_shutdown_stops_listening(): + plugin = started("hey emet") + assert plugin.describe().healthy + run(plugin.shutdown()) + assert not plugin.describe().healthy + assert run(plugin.process(b"\x00\x00" * 1280)) is None diff --git a/emet-sdk/emet_sdk/types.py b/emet-sdk/emet_sdk/types.py index c860c2c..41b15ae 100644 --- a/emet-sdk/emet_sdk/types.py +++ b/emet-sdk/emet_sdk/types.py @@ -196,15 +196,28 @@ class WakeDescriptor: #: an engine that failed to load one; it is not the same as `healthy=False`, #: which means the engine itself is broken. phrases: frozenset[str] = frozenset() - #: Whether arbitrary phrases work without a per-phrase trained model. - #: True for phonetic keyword spotters, False for trained classifiers. + #: Advisory, and deliberately NOT consulted by `can_detect`. Whether this + #: *engine* can be pointed at an arbitrary phrase given a pronunciation, as + #: a phonetic spotter can and a trained classifier cannot. Tooling uses it + #: to explain the options; it says nothing about what this instance is + #: currently listening for, which is `phrases`. supports_custom_phrases: bool = False + #: The audio contract. The engine feeds frames at this rate and size; a + #: plugin that needs something else says so here rather than resampling + #: quietly and drifting. sample_rate: int = 16000 frame_samples: int = 1280 healthy: bool = True def can_detect(self, phrase: str) -> bool: - return self.healthy and (self.supports_custom_phrases or phrase in self.phrases) + """Whether this instance, as configured and started, would hear `phrase`. + + Does not consult `supports_custom_phrases`. An engine that *could* be + configured for any phrase is still only listening for what it actually + loaded, and conflating the two would let a healthy engine claim it + hears a name nobody ever gave it. + """ + return self.healthy and phrase in self.phrases @dataclass(frozen=True, slots=True) diff --git a/emet-sdk/examples/mock-scout.yaml b/emet-sdk/examples/mock-scout.yaml index ed053a4..c1b8753 100644 --- a/emet-sdk/examples/mock-scout.yaml +++ b/emet-sdk/examples/mock-scout.yaml @@ -46,6 +46,11 @@ audio: output: device: "plughw:1,0" gain_db: -6.0 + wake: + # Everything else on this body is mocked, so its ears are too. Fires when + # a frame literally contains the phrase, which makes the whole wake path + # testable on a laptop with no microphone. + engine: mock capabilities: - id: head diff --git a/emet-sdk/examples/scout-01.yaml b/emet-sdk/examples/scout-01.yaml index b85f933..e10aff5 100644 --- a/emet-sdk/examples/scout-01.yaml +++ b/emet-sdk/examples/scout-01.yaml @@ -33,6 +33,15 @@ audio: output: device: "plughw:1,0" gain_db: -6.0 + wake: + # The shipped engine. Phonetic, so it hears whatever phrase the soul + # names without a model trained for it. Omitting this whole block is + # legal — `bodiless.yaml` does, and takes the default. + engine: pocketsphinx + params: + # The shipped default, written out so it is visible. Lower is stricter: + # fewer false wakes, more missed ones. + threshold: 1.0e-25 capabilities: - id: head diff --git a/emet-sdk/tests/test_wake.py b/emet-sdk/tests/test_wake.py index 40c8c21..706988d 100644 --- a/emet-sdk/tests/test_wake.py +++ b/emet-sdk/tests/test_wake.py @@ -91,9 +91,18 @@ def test_an_engine_reports_only_the_phrases_it_loaded(): assert plugin.describe().can_detect("alexa") -def test_a_phonetic_engine_can_detect_anything(): +def test_supports_custom_phrases_is_advisory_not_a_claim_to_hear_anything(): + """Found by implementing a real phonetic engine against this contract. + + pocketsphinx can be pointed at any phrase, and a given instance is still + only listening for the one it loaded. The flag describes the engine's + class; `phrases` describes this instance. `can_detect` must read the + second, or a healthy engine claims it hears a name nobody gave it. + """ plugin = make("hey barnaby", phrases=[], supports_custom=True) - assert plugin.describe().can_detect("hey barnaby") + descriptor = plugin.describe() + assert descriptor.supports_custom_phrases + assert not descriptor.can_detect("hey barnaby") def test_an_unhealthy_engine_detects_nothing_it_claims(): From 78d3c2b10178c625d19c19551567f86192debd3f Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:37:44 -0700 Subject: [PATCH 04/21] Add microphone capture and speaker playback Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-hal/emet_hal/audio.py | 444 +++++++++++++++++++++++++++++++++++ emet-hal/pyproject.toml | 4 + emet-hal/tests/test_audio.py | 331 ++++++++++++++++++++++++++ 3 files changed, 779 insertions(+) create mode 100644 emet-hal/emet_hal/audio.py create mode 100644 emet-hal/tests/test_audio.py diff --git a/emet-hal/emet_hal/audio.py b/emet-hal/emet_hal/audio.py new file mode 100644 index 0000000..511e594 --- /dev/null +++ b/emet-hal/emet_hal/audio.py @@ -0,0 +1,444 @@ +"""The hardware floor: a microphone and a speaker. + +Every fallback chain terminates in a voice rung, which is only a guarantee if +audio actually works. This module is what makes that true, and it is the one +piece of hardware a body cannot decline to have. + +**Why this is not a plugin category.** Wake is one because engines get +discontinued — Picovoice disabled every free Porcupine access key on 30 June +2026. Audio devices do not work that way: PortAudio already abstracts ALSA, +WASAPI, and CoreAudio behind one interface, so the swap point that would +justify a category is already inside the dependency. `AudioSource` is a plain +Protocol instead, which is enough for a wav file to stand in for a microphone +without anything above noticing. + +**On sample rates, which are the thing that will bite you.** The wake engine +needs 16 kHz mono. Almost no sound card runs at 16 kHz — they run at 44.1 or +48 — so something must convert. Who does the converting is not uniform: + +* On Linux, an ALSA `plughw:` device converts for you. That is precisely what + the `plug` layer is for, and it is why the manifests in this repository say + `plughw:1,0` rather than `hw:1,0`. +* On Windows, shared-mode host APIs (MME, DirectSound, WASAPI) convert, and + exclusive-mode WDM-KS refuses anything but the native rate. + +So this module asks PortAudio whether a device will accept the rate, before +opening it, and refuses to start when the answer is no. It does not quietly +fall back to 48 kHz and feed that to a detector expecting 16 kHz: that path +does not fail, it just stops hearing you, which is the worst kind of bug in a +thing whose whole job is to listen. +""" + +from __future__ import annotations + +import asyncio +import logging +import wave +from array import array +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +__all__ = [ + "AudioError", + "AudioFormat", + "AudioSource", + "Device", + "MicrophoneSource", + "Speaker", + "WavSource", + "devices", + "resolve_device", +] + +log = logging.getLogger("emet_hal.audio") + +#: int16. Two bytes a sample, everywhere in this module. +SAMPLE_BYTES = 2 + + +class AudioError(RuntimeError): + """Audio could not be brought up, with a message a person can act on.""" + + +@dataclass(frozen=True, slots=True) +class AudioFormat: + """What a consumer needs. Defaults are what the wake engine asks for. + + `frame_samples` is per channel and always mono by the time it leaves this + module: a four-microphone array is downmixed here, so nothing downstream + has to know how many capsules a body has. + """ + + sample_rate: int = 16000 + frame_samples: int = 1280 + + @property + def frame_bytes(self) -> int: + return self.frame_samples * SAMPLE_BYTES + + @property + def frame_ms(self) -> float: + return 1000.0 * self.frame_samples / self.sample_rate + + +@dataclass(frozen=True, slots=True) +class Device: + index: int + name: str + inputs: int + outputs: int + default_rate: float + host_api: str + + def __str__(self) -> str: + kind = "in" if self.inputs else "out" + return f"[{self.index}] {self.name} ({kind}, {self.host_api}, {self.default_rate:.0f} Hz)" + + +# -------------------------------------------------------------------------- +# Finding a device +# -------------------------------------------------------------------------- + + +def _sd() -> Any: + try: + import sounddevice + except ImportError as exc: # pragma: no cover - depends on the environment + raise AudioError( + "sounddevice is not installed. It is an optional dependency: " + "install `emet-hal[audio]`." + ) from exc + except OSError as exc: # pragma: no cover - depends on the environment + # sounddevice ships as a pure-Python wheel and loads PortAudio from the + # system, so on Linux the import succeeds only if the shared library is + # there. This is the common Raspberry Pi first-run failure. + raise AudioError( + f"sounddevice is installed but the PortAudio library it needs is not. " + f"On Debian and Raspberry Pi OS: `sudo apt install libportaudio2`. " + f"The loader said: {exc}" + ) from exc + return sounddevice + + +def devices(*, want_input: bool | None = None) -> list[Device]: + """Every audio device PortAudio can see. + + `want_input=True` keeps only capture devices, `False` only playback, and + `None` keeps both. Used by error messages, so it must never raise for a + merely unusual device. + """ + sd = _sd() + apis = sd.query_hostapis() + out: list[Device] = [] + for i, d in enumerate(sd.query_devices()): + ins, outs = int(d["max_input_channels"]), int(d["max_output_channels"]) + if want_input is True and not ins: + continue + if want_input is False and not outs: + continue + if not ins and not outs: + continue + api = apis[d["hostapi"]]["name"] if d["hostapi"] < len(apis) else "?" + out.append( + Device( + index=i, + name=str(d["name"]), + inputs=ins, + outputs=outs, + default_rate=float(d["default_samplerate"]), + host_api=str(api), + ) + ) + return out + + +def resolve_device(spec: str | int | None, *, want_input: bool) -> int | None: + """Turn a manifest `device` string into a PortAudio index. + + Returns None for the system default, which is what an absent or "default" + spec means. + + A manifest names a device on the body it was written for. `plughw:1,0` is + correct on the Pi that manifest describes and means nothing on a laptop, + so failing here is normal rather than exceptional and the message lists + what this machine actually has. + """ + if spec is None or spec == "" or spec == "default": + return None + if isinstance(spec, int): + return spec + text = str(spec).strip() + if text.isdigit(): + return int(text) + + available = devices(want_input=want_input) + for d in available: + if d.name == text: + return d.index + hits = [d for d in available if text.lower() in d.name.lower()] + if len(hits) == 1: + return hits[0].index + if len(hits) > 1: + listing = "\n ".join(str(d) for d in hits) + raise AudioError(f"device {text!r} matches more than one device:\n {listing}") + + listing = "\n ".join(str(d) for d in available) or " (none)" + hint = "" + if ":" in text or text.startswith(("hw", "plughw")): + hint = ( + f"\n{text!r} is an ALSA name, so this manifest was written for a Linux " + f"body. That is not a mistake in the manifest — it is the wrong machine " + f"for it. Set the device to one below, or run this on the body it " + f"describes." + ) + raise AudioError( + f"no {'input' if want_input else 'output'} device matches {text!r}.{hint}\n" + f"Available:\n {listing}" + ) + + +def _check_rate(device: int | None, rate: int, channels: int, *, want_input: bool) -> None: + """Ask PortAudio whether it will accept this format, without opening it. + + Deliberately separate from opening the stream: a format query does not + touch the microphone, so a body can be diagnosed without recording anyone. + """ + sd = _sd() + check = sd.check_input_settings if want_input else sd.check_output_settings + try: + check(device=device, samplerate=rate, channels=channels, dtype="int16") + except Exception as exc: + name = "default" if device is None else str(device) + native = "" + try: + info = sd.query_devices(device if device is not None else None) + native = f" Its native rate is {float(info['default_samplerate']):.0f} Hz." + except Exception: # pragma: no cover - only when the device vanished + pass + raise AudioError( + f"device {name} will not accept {rate} Hz, {channels}-channel int16.{native} " + f"Some host APIs convert rates and some refuse: exclusive-mode drivers " + f"(WDM-KS on Windows, `hw:` on Linux) give you the native rate only, while " + f"shared-mode ones (MME, DirectSound, WASAPI, ALSA `plughw:`) convert. " + f"Pick a device that converts rather than running the detector at the " + f"wrong rate, which does not fail, it just stops hearing you. " + f"PortAudio said: {exc}" + ) from exc + + +# -------------------------------------------------------------------------- +# Sources +# -------------------------------------------------------------------------- + + +@runtime_checkable +class AudioSource(Protocol): + """Something that produces mono int16 frames at a known rate. + + The seam that lets a wav file stand in for a microphone. `read()` returns + exactly `format.frame_bytes` bytes, or None when the source has ended — + which a microphone never does and a file always does. + """ + + format: AudioFormat + + async def start(self) -> None: ... + + async def read(self) -> bytes | None: ... + + async def stop(self) -> None: ... + + +def _downmix(raw: bytes, channels: int, *, mix: str) -> bytes: + """Interleaved N-channel int16 to mono int16.""" + if channels == 1: + return raw + samples = array("h") + samples.frombytes(raw) + if mix == "average": + out = array("h", [0]) * (len(samples) // channels) + for i in range(len(out)): + block = samples[i * channels : (i + 1) * channels] + out[i] = sum(block) // channels + return out.tobytes() + return samples[0::channels].tobytes() + + +class WavSource: + """A wav file pretending to be a microphone. + + Not only for tests, though it is good for those. It is how you reproduce a + wake failure someone reports: they send the recording, you run the same + detector over the same audio and get the same answer, with no hardware + involved on either end. + + Refuses to resample or reinterpret. A file at the wrong rate is a wrong + file, and saying so is more useful than silently feeding 44.1 kHz audio to + a detector expecting 16. + """ + + def __init__(self, path: str, fmt: AudioFormat | None = None, *, mix: str = "first") -> None: + self.path = path + self.format = fmt or AudioFormat() + self._mix = mix + self._wav: wave.Wave_read | None = None + + async def start(self) -> None: + try: + wav = wave.open(self.path, "rb") + except Exception as exc: + raise AudioError(f"could not open {self.path!r}: {exc}") from exc + if wav.getsampwidth() != SAMPLE_BYTES: + wav.close() + raise AudioError( + f"{self.path!r} is {wav.getsampwidth() * 8}-bit; this expects 16-bit PCM" + ) + if wav.getframerate() != self.format.sample_rate: + rate = wav.getframerate() + wav.close() + raise AudioError( + f"{self.path!r} is {rate} Hz and this expects " + f"{self.format.sample_rate} Hz. Convert the file rather than " + f"having the detector hear it at the wrong speed." + ) + self._wav = wav + + async def read(self) -> bytes | None: + if self._wav is None: + return None + channels = self._wav.getnchannels() + raw = self._wav.readframes(self.format.frame_samples) + if len(raw) < self.format.frame_samples * channels * SAMPLE_BYTES: + return None # a partial trailing frame ends the stream + return _downmix(raw, channels, mix=self._mix) + + async def stop(self) -> None: + if self._wav is not None: + self._wav.close() + self._wav = None + + +class MicrophoneSource: + """Live capture, downmixed to mono and handed over one frame at a time. + + PortAudio calls back on its own thread; frames cross into asyncio through a + bounded queue. When the consumer falls behind the oldest frame is dropped + and counted, because the alternative is an ever-growing backlog and a robot + that answers a question from a minute ago. `dropped` is not decoration — + a non-zero value means wake words were missed, and the engine should say so + rather than let it pass as bad luck. + """ + + #: Roughly two seconds at the default frame size. Long enough to ride out a + #: garbage collection, short enough that a real stall is visible. + QUEUE_FRAMES = 25 + + def __init__( + self, + config: dict[str, Any] | None = None, + fmt: AudioFormat | None = None, + *, + mix: str = "first", + ) -> None: + """`config` is the manifest's `audio.input` block.""" + self.config = dict(config or {}) + self.format = fmt or AudioFormat() + self.dropped = 0 + # A mic array is downmixed here so nothing downstream counts capsules. + # Channel 0 by default rather than an average: on a ReSpeaker-style + # array that channel carries the hardware-processed output, and + # averaging beamformed audio with raw capsules is worse than either. + self._mix = mix + self._channels = int(self.config.get("channels") or 1) + self._stream: Any = None + self._queue: asyncio.Queue[bytes] | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + async def start(self) -> None: + sd = _sd() + device = resolve_device(self.config.get("device"), want_input=True) + _check_rate(device, self.format.sample_rate, self._channels, want_input=True) + + self._loop = asyncio.get_running_loop() + self._queue = asyncio.Queue(maxsize=self.QUEUE_FRAMES) + + def callback(indata, frames, time_info, status) -> None: # PortAudio thread + if status: + log.debug("audio input status: %s", status) + self._loop.call_soon_threadsafe(self._offer, bytes(indata)) + + try: + self._stream = sd.RawInputStream( + samplerate=self.format.sample_rate, + blocksize=self.format.frame_samples, + device=device, + channels=self._channels, + dtype="int16", + callback=callback, + ) + self._stream.start() + except Exception as exc: + raise AudioError(f"could not open the microphone: {exc}") from exc + + def _offer(self, raw: bytes) -> None: + """On the event loop thread. Never blocks; drops the oldest instead.""" + assert self._queue is not None + if self._queue.full(): + try: + self._queue.get_nowait() + self.dropped += 1 + except asyncio.QueueEmpty: # pragma: no cover - races with a reader + pass + self._queue.put_nowait(_downmix(raw, self._channels, mix=self._mix)) + + async def read(self) -> bytes | None: + if self._queue is None: + return None + return await self._queue.get() + + async def stop(self) -> None: + if self._stream is not None: + self._stream.stop() + self._stream.close() + self._stream = None + self._queue = None + + +class Speaker: + """Playback, so that a voice rung is a sound rather than a promise. + + Deliberately small. It takes int16 PCM at a stated rate and plays it; how + that audio came to exist is the business of whatever synthesises speech, + which does not exist yet. + """ + + def __init__(self, config: dict[str, Any] | None = None, *, sample_rate: int = 22050) -> None: + """`config` is the manifest's `audio.output` block.""" + self.config = dict(config or {}) + self.sample_rate = sample_rate + self._device: int | None = None + self._sd: Any = None + + async def start(self) -> None: + self._sd = _sd() + self._device = resolve_device(self.config.get("device"), want_input=False) + _check_rate(self._device, self.sample_rate, 1, want_input=False) + + async def play(self, pcm: bytes, *, blocking: bool = True) -> None: + """Play mono int16 PCM. Returns when it has finished, unless told not to.""" + if self._sd is None: + raise AudioError("speaker was not started") + samples = array("h") + samples.frombytes(pcm) + self._sd.play( + memoryview(samples).cast("h"), + samplerate=self.sample_rate, + device=self._device, + blocking=False, + ) + if blocking: + await asyncio.to_thread(self._sd.wait) + + async def stop(self) -> None: + if self._sd is not None: + self._sd.stop() diff --git a/emet-hal/pyproject.toml b/emet-hal/pyproject.toml index df5447c..7f8880b 100644 --- a/emet-hal/pyproject.toml +++ b/emet-hal/pyproject.toml @@ -18,6 +18,10 @@ dependencies = ["emet-sdk>=0.1"] # The shipped wake engine. Optional because a body that never wakes — a test # rig, a CI run resolving chains — should not pull an acoustic model down. wake = ["pocketsphinx>=5.0"] +# Microphone and speaker. Optional for the same reason, and because on Linux +# it needs the system PortAudio (`apt install libportaudio2`) that a headless +# build has no use for. +audio = ["sounddevice>=0.4"] dev = ["pytest>=8.0"] # Installing a package is what makes a driver exist. Nothing scans directories diff --git a/emet-hal/tests/test_audio.py b/emet-hal/tests/test_audio.py new file mode 100644 index 0000000..6ce3fa6 --- /dev/null +++ b/emet-hal/tests/test_audio.py @@ -0,0 +1,331 @@ +"""The hardware floor. + +Nothing here touches a microphone or a speaker. Two reasons, and the second +matters more than the first: audio hardware is not present in CI, and a test +suite that records from whatever microphone is attached to the machine running +it is a rude thing to ship. + +So the device-dependent paths are tested through PortAudio's *format query*, +which asks whether a device would accept a format without opening it, and +everything else runs over wav files generated in a temp directory. The fixtures +are written by the tests rather than committed, so the repository stays free of +binary blobs whose provenance nobody can check. +""" + +from __future__ import annotations + +import asyncio +import wave +from pathlib import Path + +import pytest + +from emet_hal.audio import ( + AudioError, + AudioFormat, + AudioSource, + MicrophoneSource, + WavSource, + _downmix, + resolve_device, +) + +try: # the `audio` extra is optional, exactly like `wake` + import sounddevice # noqa: F401 + + HAVE_SOUNDDEVICE = True +except (ImportError, OSError): # pragma: no cover - depends on the environment + # OSError too: the wheel is pure Python and loads PortAudio from the + # system, so an installed sounddevice with no libportaudio2 raises from the + # dynamic loader rather than from the import machinery. + HAVE_SOUNDDEVICE = False + +needs_sounddevice = pytest.mark.skipif( + not HAVE_SOUNDDEVICE, reason="emet-hal[audio] is not installed" +) + + +def run(coro): + return asyncio.run(coro) + + +def write_wav(path: Path, pcm: bytes, *, rate: int = 16000, channels: int = 1) -> Path: + with wave.open(str(path), "wb") as w: + w.setnchannels(channels) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(pcm) + return path + + +def silence(frames: int, channels: int = 1) -> bytes: + return b"\x00\x00" * frames * channels + + +# ----------------------------------------------------------------- format + + +def test_the_format_states_what_a_frame_is(): + fmt = AudioFormat() + assert fmt.sample_rate == 16000 + assert fmt.frame_bytes == 2560 + assert fmt.frame_ms == pytest.approx(80.0) + + +def test_the_default_format_is_what_the_wake_engine_asks_for(): + """These two travelling apart is how a detector ends up hearing nothing.""" + from emet_hal.pocketsphinx_wake import FRAME_SAMPLES, SAMPLE_RATE + + fmt = AudioFormat() + assert (fmt.sample_rate, fmt.frame_samples) == (SAMPLE_RATE, FRAME_SAMPLES) + + +# ---------------------------------------------------------------- downmix + + +def test_mono_passes_through_untouched(): + raw = b"\x01\x02\x03\x04" + assert _downmix(raw, 1, mix="first") is raw + + +def test_taking_the_first_channel_of_a_stereo_frame(): + # interleaved: L0 R0 L1 R1, as int16 little-endian + raw = b"\x01\x00" + b"\x63\x00" + b"\x02\x00" + b"\x64\x00" + assert _downmix(raw, 2, mix="first") == b"\x01\x00\x02\x00" + + +def test_averaging_across_channels(): + raw = b"\x0a\x00" + b"\x14\x00" # 10 and 20 in one stereo frame + assert _downmix(raw, 2, mix="average") == b"\x0f\x00" # 15 + + +def test_a_four_capsule_array_arrives_downstream_as_mono(): + """scout-01 declares `channels: 4`. Nothing above this counts capsules.""" + frame = b"".join(bytes([c, 0]) for c in (1, 2, 3, 4)) + assert _downmix(frame * 3, 4, mix="first") == b"\x01\x00" * 3 + + +# ------------------------------------------------------------- wav source + + +def test_a_file_can_stand_in_for_a_microphone(tmp_path): + src = WavSource(str(write_wav(tmp_path / "a.wav", silence(1280 * 3)))) + assert isinstance(src, AudioSource) + + async def scenario(): + await src.start() + frames = [] + while (f := await src.read()) is not None: + frames.append(f) + await src.stop() + return frames + + frames = run(scenario()) + assert len(frames) == 3 + assert all(len(f) == 2560 for f in frames) + + +def test_a_partial_trailing_frame_ends_the_stream(tmp_path): + """Half a frame is not a frame. Padding it would hand the detector + silence that was never recorded.""" + src = WavSource(str(write_wav(tmp_path / "b.wav", silence(1280 * 2 + 400)))) + + async def scenario(): + await src.start() + n = 0 + while await src.read() is not None: + n += 1 + await src.stop() + return n + + assert run(scenario()) == 2 + + +def test_a_stereo_file_is_downmixed(tmp_path): + path = write_wav(tmp_path / "c.wav", silence(1280, channels=2), channels=2) + src = WavSource(str(path)) + + async def scenario(): + await src.start() + frame = await src.read() + await src.stop() + return frame + + assert len(run(scenario())) == 2560 + + +def test_a_file_at_the_wrong_rate_is_refused_by_name(tmp_path): + """The failure this module exists to prevent, in its cheapest form.""" + src = WavSource(str(write_wav(tmp_path / "d.wav", silence(1280), rate=44100))) + with pytest.raises(AudioError) as exc: + run(src.start()) + assert "44100" in str(exc.value) + assert "16000" in str(exc.value) + + +def test_a_missing_file_says_which_one(tmp_path): + src = WavSource(str(tmp_path / "nope.wav")) + with pytest.raises(AudioError, match="nope.wav"): + run(src.start()) + + +def test_eight_bit_audio_is_refused(tmp_path): + path = tmp_path / "e.wav" + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(1) + w.setframerate(16000) + w.writeframes(b"\x80" * 1280) + with pytest.raises(AudioError, match="8-bit"): + run(WavSource(str(path)).start()) + + +# ------------------------------------------------- the whole path, no mic + + +def test_the_wake_path_runs_over_a_file(tmp_path): + """Source to detector with no hardware anywhere. + + Silence, so nothing should fire. What this proves is the plumbing: frame + sizes line up, the detector accepts what the source produces, and the loop + terminates. + """ + pytest.importorskip("pocketsphinx", reason="emet-hal[wake] is not installed") + from emet_hal.pocketsphinx_wake import PocketSphinxWake + + src = WavSource(str(write_wav(tmp_path / "quiet.wav", silence(1280 * 20)))) + wake = PocketSphinxWake({"engine": "pocketsphinx", "params": {}}, "hey emet") + + async def scenario(): + await src.start() + await wake.start() + assert wake.describe().healthy, wake.health().detail + assert wake.describe().sample_rate == src.format.sample_rate + seen, events = 0, [] + while (frame := await src.read()) is not None: + seen += 1 + if (event := await wake.process(frame)) is not None: + events.append(event) + await wake.shutdown() + await src.stop() + return seen, events + + seen, events = run(scenario()) + assert seen == 20 + assert events == [] + + +def test_a_mock_engine_wakes_from_a_file(tmp_path): + """The positive half, without needing anyone to speak. + + MockWake fires on a frame containing the phrase, so a file whose samples + happen to spell it exercises the event actually propagating from source to + detector to caller. + """ + from emet_hal.mock import MockWake + + phrase = b"hey emet" + pcm = silence(1280) + phrase + silence(1280) * 2 + src = WavSource(str(write_wav(tmp_path / "spelled.wav", pcm))) + wake = MockWake({"engine": "mock", "params": {}}, "hey emet") + + async def scenario(): + await src.start() + await wake.start() + events = [] + while (frame := await src.read()) is not None: + if (event := await wake.process(frame)) is not None: + events.append(event) + await wake.reset() + await src.stop() + return events + + events = run(scenario()) + assert len(events) == 1 + assert events[0].phrase == "hey emet" + + +# ------------------------------------------------------- device resolution + + +def test_the_default_device_needs_no_lookup(): + """Must not touch PortAudio: it is the answer when nothing is configured.""" + assert resolve_device(None, want_input=True) is None + assert resolve_device("", want_input=True) is None + assert resolve_device("default", want_input=False) is None + + +def test_a_numeric_device_is_an_index(): + assert resolve_device(3, want_input=True) == 3 + assert resolve_device("3", want_input=True) == 3 + + +@needs_sounddevice +def test_an_alsa_name_on_the_wrong_machine_explains_itself(): + """`plughw:1,0` is right on the Pi the manifest describes and meaningless + here. The message has to say that, not just 'not found'.""" + import sys + + if sys.platform.startswith("linux"): # pragma: no cover - platform dependent + pytest.skip("plughw may genuinely resolve on Linux") + with pytest.raises(AudioError) as exc: + resolve_device("plughw:1,0", want_input=True) + message = str(exc.value) + assert "ALSA" in message + assert "Available:" in message + + +@needs_sounddevice +def test_an_unknown_device_lists_what_there_is(): + with pytest.raises(AudioError) as exc: + resolve_device("no such microphone anywhere", want_input=True) + assert "Available:" in str(exc.value) + + +@needs_sounddevice +def test_devices_can_be_listed_without_opening_any(): + from emet_hal.audio import devices + + ins = devices(want_input=True) + outs = devices(want_input=False) + assert all(d.inputs for d in ins) + assert all(d.outputs for d in outs) + # Both lists come from one enumeration, so a machine with neither is the + # only way this is empty, and that is worth knowing rather than asserting. + assert isinstance(ins, list) and isinstance(outs, list) + + +@needs_sounddevice +def test_an_impossible_rate_is_refused_before_the_device_opens(): + """A format query, not a capture. Nothing is recorded to run this.""" + from emet_hal.audio import _check_rate + + with pytest.raises(AudioError) as exc: + _check_rate(None, 999_999, 1, want_input=True) + assert "999999" in str(exc.value) + + +def test_a_microphone_source_carries_the_manifest_block(): + """Construction must not touch hardware; only `start()` may.""" + src = MicrophoneSource({"device": "plughw:1,0", "channels": 4}) + assert isinstance(src, AudioSource) + assert src._channels == 4 + assert src.dropped == 0 + assert src.format.sample_rate == 16000 + + +def test_dropped_frames_are_counted_rather_than_queued(): + """A backlog would make the robot answer a question from a minute ago, so + the oldest frame goes and the loss is recorded.""" + + async def scenario(): + src = MicrophoneSource({"channels": 1}) + src._queue = asyncio.Queue(maxsize=2) + src._loop = asyncio.get_running_loop() + for i in range(5): + src._offer(bytes([i, 0]) * 1280) + return src + + src = run(scenario()) + assert src.dropped == 3 + assert src._queue.qsize() == 2 From be26b9f7eae54b67971885af3e0f652a3e4c03da Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:02:33 -0700 Subject: [PATCH 05/21] Make an uninstalled wake engine a hard error Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- .vscode/settings.json | 11 ++++ CONTRIBUTING.md | 57 ++++++++++++++----- emet-sdk/emet_sdk/validate.py | 52 +++++++++-------- .../examples/invalid/unknown-wake-engine.yaml | 17 ++++-- emet-sdk/tests/test_acceptance.py | 44 ++++++++++---- 5 files changed, 127 insertions(+), 54 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e5803e7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + // Emet uses a DCO, so every commit needs a Signed-off-by line and CI + // rejects pull requests without one. This makes the built-in Git UI add it + // for you, so contributing never involves learning what a DCO is. + // + // There is no git config that does this. `commit.signoff` is not a real + // option and git ignores it silently; `format.signoff` applies only to + // `git format-patch`. The flag has to come from the client, which is why + // this setting is checked in rather than left to each person to discover. + "git.alwaysSignOff": true +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8587d0..a9d71a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,23 +18,54 @@ which appends: Signed-off-by: Your Name ``` -That line is the [Developer Certificate of Origin](https://developercertificate.org/) -It is a statement that you wrote the change, or have the right to submit it +That line is the [Developer Certificate of Origin](https://developercertificate.org/): +a statement that you wrote the change, or otherwise have the right to submit it under Apache 2.0. -**We use a DCO rather than a CLA on purpose.** A CLA asks someone to sign a -legal document before fixing a typo, and most of the people who will improve -this project are building robots in spare rooms. The trade we accept in return -is that nobody, including the maintainer, can unilaterally relicense Emet. Any -future licence change needs the agreement of everyone who has contributed. For -a project whose pitch is that it will not be taken away, that is the right way -round. +### Never thinking about this again -To sign off work you have already committed: +Most contributors should never have to remember the flag: -```sh -git rebase --signoff HEAD~3 # last three commits -``` +- **Editing on github.com** — nothing to do. Web commits are signed off for you. +- **VS Code** — nothing to do. This repository ships a `.vscode/settings.json` + that turns on `git.alwaysSignOff`, and the built-in Git UI honours it. +- **Command line** — use `git commit -s`. If you forget, fix it afterwards + rather than redoing the work: + + ```sh + git rebase --signoff origin/master + ``` + +**There is no git config for this, and looking for one wastes an afternoon.** +`commit.signoff` does not exist; git accepts it into your config file and +silently ignores it. `format.signoff` is real but applies only to +`git format-patch`, never to `git commit`. Sign-off comes from the `-s` flag, a +`prepare-commit-msg` hook, or a client passing the flag on your behalf. + +Signing off is also unrelated to *signing*. `commit.gpgsign` produces a +cryptographic signature; `Signed-off-by` is a line of text asserting the DCO. +A commit can have either, both, or neither. + +### Why a DCO and not a CLA + +A CLA asks someone to sign a legal document before fixing a typo, and most of +the people who will improve this project are building robots in spare rooms. + +Two things are easy to conflate here, and the distinction decides what this +section actually buys: + +- **Nobody, including the maintainer, can unilaterally relicense Emet.** That + follows from there being *no CLA*: contributors keep their copyright and + grant Emet nothing beyond Apache 2.0, so any future licence change needs the + agreement of everyone who has contributed. The DCO does not produce this + property and it would hold without it. +- **The DCO's own job is narrower**: a per-commit record that you had the right + to submit what you submitted. Apache 2.0 §5 already places contributions + under this licence by default. What it does not capture is the assertion that + the code was yours to give, and that is the gap the sign-off fills. + +For a project whose pitch is that it cannot be taken away, the first property +is the load-bearing one, and it is worth knowing which mechanism provides it. ## What is open to contribution, and what is not diff --git a/emet-sdk/emet_sdk/validate.py b/emet-sdk/emet_sdk/validate.py index bdfbab3..424e977 100644 --- a/emet-sdk/emet_sdk/validate.py +++ b/emet-sdk/emet_sdk/validate.py @@ -257,34 +257,40 @@ def _check_wake_engine( registry: PluginRegistry, report: ValidationReport, ) -> None: - """Resolve `audio.wake.engine` the same way `driver.plugin` resolves. - - Absent is fine — a manifest that says nothing about wake gets the default, - and most will. What is checked is a name that was written down and does - not resolve, which is the failure that took out every free Porcupine user - on 30 June 2026 and is worth naming precisely. + """Resolve `audio.wake.engine`, the way `drive.kinematics` resolves. + + Absent is fine — a manifest that says nothing about wake takes the default, + and most will. + + Unconditional, unlike the `driver.plugin` check a few lines up, and the + difference is worth stating because both are open enums resolved against + entry points. `driver.plugin` names a driver for *a physical device*, and + describing a body you have not finished wiring is an ordinary thing to do, + so an absent one is a warning. `drive.kinematics` and `audio.wake.engine` + name an *implementation that has to exist* for the document to mean + anything: there is no half-built state in which a body moves by a + kinematics nobody wrote, or wakes to a detector nobody installed. + + Wake has the stronger claim of the two. Every other missing piece degrades + — a chain that cannot find a head falls through to a light ring. A robot + that cannot hear its own name has no next rung, so this is the last place + a soft warning would be a kindness. It is also not hypothetical: Picovoice + disabled every free Porcupine access key on 30 June 2026, and a manifest + naming one went from working to unbindable overnight. """ engine = ((doc.get("audio") or {}).get("wake") or {}).get("engine") if not isinstance(engine, str) or registry.has_wake(engine): return installed = ", ".join(registry.wake_names) or "(none)" - if registry.verify_drivers: - report.error( - "missing_plugin", - f"no wake word plugin provides {engine!r}. Installed: {installed}. " - f"`audio.wake.engine` is an open enum — this value is legal, the " - f"plugin simply is not installed.", - "/audio/wake/engine", - ) - else: - report.warn( - "wake_engine_not_installed", - f"wake engine {engine!r} is not installed. Installed: {installed}. " - f"Legal in a manifest, but the engine will refuse to boot against " - f"it — a robot that cannot hear its name has no fallback to degrade " - f"to. Run with --verify-drivers to treat this as an error.", - "/audio/wake/engine", - ) + report.error( + "missing_plugin", + f"no wake word plugin provides {engine!r}. Installed: {installed}. " + f"`audio.wake.engine` is an open enum — this value is legal, the " + f"plugin simply is not installed. Unlike a missing driver this is an " + f"error rather than a warning, because a robot that cannot hear its " + f"own name has nothing to fall back to.", + "/audio/wake/engine", + ) def _check_unique_ids(caps: Sequence[Mapping[str, Any]], report: ValidationReport) -> None: diff --git a/emet-sdk/examples/invalid/unknown-wake-engine.yaml b/emet-sdk/examples/invalid/unknown-wake-engine.yaml index a1f138a..0a747cb 100644 --- a/emet-sdk/examples/invalid/unknown-wake-engine.yaml +++ b/emet-sdk/examples/invalid/unknown-wake-engine.yaml @@ -7,17 +7,22 @@ # and a pronunciation, so `identity.wake_word` is free text and the constraint # it guarded is gone. # -# What replaces it is the same rule `driver.plugin` already follows. The name -# below is legal: `audio.wake.engine` is an open enum, and a manifest may -# describe an engine you have not installed yet. It is a warning when linting -# and an error when booting, because a robot that cannot hear its own name has -# nothing to degrade to — unlike a missing head, there is no next rung. +# What replaces it is the rule `drive.kinematics` already follows. The name +# below is legal — `audio.wake.engine` is an open enum — but it does not +# resolve, and that is an error rather than a warning. +# +# The distinction is worth holding onto. A missing `driver.plugin` is a warning +# because it names a physical device you may not have wired yet, and describing +# a half-built body is normal. `kinematics` and `wake.engine` name software +# that has to exist for the document to mean anything. Wake has the stronger +# claim of the two: everything else degrades, and a robot that cannot hear its +# own name has no next rung to fall through to. # # The value is not a strawman. Picovoice disabled every free Porcupine access # key on 30 June 2026, which turned working manifests into this exact case # overnight, and is the reason wake is a plugin category at all. # -# Expected: wake_engine_not_installed (lint), missing_plugin (--verify-drivers) +# Expected: missing_plugin, /audio/wake/engine manifest_version: "0.1" diff --git a/emet-sdk/tests/test_acceptance.py b/emet-sdk/tests/test_acceptance.py index 402162d..9276e1c 100644 --- a/emet-sdk/tests/test_acceptance.py +++ b/emet-sdk/tests/test_acceptance.py @@ -101,22 +101,42 @@ def test_a_soul_may_answer_to_any_phrase(): assert report.ok, report.errors -def test_an_uninstalled_wake_engine_is_a_warning_when_linting(): - """Describing a body you have not finished building is normal.""" +def test_an_uninstalled_wake_engine_is_an_error(): + """Not a warning, unlike a missing driver. `audio.wake.engine` names an + implementation that has to exist rather than hardware you might not have + wired yet, and wake is the one thing with no rung beneath it.""" report = validate_manifest(load_yaml(EXAMPLES / "invalid" / "unknown-wake-engine.yaml")) + assert not report.ok + assert "missing_plugin" in codes(report) + + +def test_an_uninstalled_driver_is_still_only_a_warning(): + """The other half of that distinction, pinned so the two do not drift + together. Describing a body you have not finished building is normal.""" + doc = load_yaml(EXAMPLES / "scout-01.yaml") + doc["capabilities"][0]["driver"]["plugin"] = "nobody.ships.this" + report = validate_manifest(doc) assert report.ok, report.errors - assert "wake_engine_not_installed" in warnings(report) + assert "driver_not_installed" in warnings(report) -def test_an_uninstalled_wake_engine_is_an_error_when_booting(): - """Booting against one is not normal. Wake has no rung beneath it.""" - registry = PluginRegistry.discover().with_verification(True) - report = validate_manifest( - load_yaml(EXAMPLES / "invalid" / "unknown-wake-engine.yaml"), - registry=registry, - ) - assert not report.ok - assert "missing_plugin" in codes(report) +def test_every_invalid_fixture_is_actually_rejected(): + """The contract of `examples/invalid/`: plain `emet validate` rejects all + of it. + + That rule lived only in the CI workflow, so a fixture that merely warned + could be added, pass every test here, and turn the pipeline red afterwards. + Which is exactly what happened. Sweeping the directory keeps the rule and + the fixtures in the same place. + """ + from emet_sdk.cli import _validate_path + + registry = PluginRegistry.discover() + fixtures = sorted((EXAMPLES / "invalid").glob("*.yaml")) + assert fixtures, "no invalid fixtures found; the glob or the path is wrong" + for path in fixtures: + kind, report = _validate_path(path, registry) + assert not report.ok, f"{path.name} validated clean as {kind!r}" # ------------------------------------------- the two that carry the design From 9032eafcbb507a70e25a0ad0c4ff1e05d4bc4206 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:17:52 -0700 Subject: [PATCH 06/21] Move the audio contract into the SDK Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-hal/emet_hal/audio.py | 46 ++---------------------------- emet-hal/tests/test_audio.py | 11 ++++++++ emet-sdk/emet_sdk/__init__.py | 4 +++ emet-sdk/emet_sdk/types.py | 53 ++++++++++++++++++++++++++++++++++- 4 files changed, 70 insertions(+), 44 deletions(-) diff --git a/emet-hal/emet_hal/audio.py b/emet-hal/emet_hal/audio.py index 511e594..b593148 100644 --- a/emet-hal/emet_hal/audio.py +++ b/emet-hal/emet_hal/audio.py @@ -36,7 +36,9 @@ import wave from array import array from dataclasses import dataclass -from typing import Any, Protocol, runtime_checkable +from typing import Any + +from emet_sdk.types import SAMPLE_BYTES, AudioFormat, AudioSource __all__ = [ "AudioError", @@ -52,35 +54,11 @@ log = logging.getLogger("emet_hal.audio") -#: int16. Two bytes a sample, everywhere in this module. -SAMPLE_BYTES = 2 - class AudioError(RuntimeError): """Audio could not be brought up, with a message a person can act on.""" -@dataclass(frozen=True, slots=True) -class AudioFormat: - """What a consumer needs. Defaults are what the wake engine asks for. - - `frame_samples` is per channel and always mono by the time it leaves this - module: a four-microphone array is downmixed here, so nothing downstream - has to know how many capsules a body has. - """ - - sample_rate: int = 16000 - frame_samples: int = 1280 - - @property - def frame_bytes(self) -> int: - return self.frame_samples * SAMPLE_BYTES - - @property - def frame_ms(self) -> float: - return 1000.0 * self.frame_samples / self.sample_rate - - @dataclass(frozen=True, slots=True) class Device: index: int @@ -231,24 +209,6 @@ def _check_rate(device: int | None, rate: int, channels: int, *, want_input: boo # -------------------------------------------------------------------------- -@runtime_checkable -class AudioSource(Protocol): - """Something that produces mono int16 frames at a known rate. - - The seam that lets a wav file stand in for a microphone. `read()` returns - exactly `format.frame_bytes` bytes, or None when the source has ended — - which a microphone never does and a file always does. - """ - - format: AudioFormat - - async def start(self) -> None: ... - - async def read(self) -> bytes | None: ... - - async def stop(self) -> None: ... - - def _downmix(raw: bytes, channels: int, *, mix: str) -> bytes: """Interleaved N-channel int16 to mono int16.""" if channels == 1: diff --git a/emet-hal/tests/test_audio.py b/emet-hal/tests/test_audio.py index 6ce3fa6..69231bf 100644 --- a/emet-hal/tests/test_audio.py +++ b/emet-hal/tests/test_audio.py @@ -72,6 +72,17 @@ def test_the_format_states_what_a_frame_is(): assert fmt.frame_ms == pytest.approx(80.0) +def test_the_audio_contract_lives_in_the_sdk(): + """`emet_engine` may import `emet_sdk` and nothing else, so the Protocol + describing the engine's own input cannot live here. This module re-exports + it for convenience; it does not own it.""" + from emet_sdk.types import AudioFormat as SdkFormat + from emet_sdk.types import AudioSource as SdkSource + + assert AudioSource is SdkSource + assert AudioFormat is SdkFormat + + def test_the_default_format_is_what_the_wake_engine_asks_for(): """These two travelling apart is how a detector ends up hearing nothing.""" from emet_hal.pocketsphinx_wake import FRAME_SAMPLES, SAMPLE_RATE diff --git a/emet-sdk/emet_sdk/__init__.py b/emet-sdk/emet_sdk/__init__.py index 7058747..3ead2b0 100644 --- a/emet-sdk/emet_sdk/__init__.py +++ b/emet-sdk/emet_sdk/__init__.py @@ -20,6 +20,8 @@ ) from emet_sdk.types import ( Action, + AudioFormat, + AudioSource, CapabilityDescriptor, Health, Intent, @@ -48,6 +50,8 @@ "SCHEMA_VERSION", "Action", "ActuatorPlugin", + "AudioFormat", + "AudioSource", "CapabilityPlugin", "LocomotionPlugin", "PluginError", diff --git a/emet-sdk/emet_sdk/types.py b/emet-sdk/emet_sdk/types.py index 41b15ae..647407a 100644 --- a/emet-sdk/emet_sdk/types.py +++ b/emet-sdk/emet_sdk/types.py @@ -13,7 +13,7 @@ from dataclasses import dataclass, field from enum import IntEnum, StrEnum -from typing import Any, Mapping +from typing import Any, Mapping, Protocol, runtime_checkable __all__ = [ "Priority", @@ -27,6 +27,9 @@ "LocomotionDescriptor", "WakeDescriptor", "WakeEvent", + "AudioFormat", + "AudioSource", + "SAMPLE_BYTES", "Health", "Reading", "Sensitivity", @@ -232,6 +235,54 @@ def __post_init__(self) -> None: raise ValueError(f"confidence must be in [0.0, 1.0], got {self.confidence}") +#: Audio is int16 throughout. Two bytes a sample, everywhere. +SAMPLE_BYTES = 2 + + +@dataclass(frozen=True, slots=True) +class AudioFormat: + """The shape of the audio crossing the boundary. Mono int16, always. + + Defaults are what the shipped wake engine asks for. A body with a + four-capsule array downmixes before this point, so nothing above the HAL + counts microphones. + """ + + sample_rate: int = 16000 + frame_samples: int = 1280 + + @property + def frame_bytes(self) -> int: + return self.frame_samples * SAMPLE_BYTES + + @property + def frame_ms(self) -> float: + return 1000.0 * self.frame_samples / self.sample_rate + + +@runtime_checkable +class AudioSource(Protocol): + """Something producing mono int16 frames at a known rate. + + Here rather than in `emet_hal` because it is the seam the engine sees. The + engine may import `emet_sdk` and nothing else, so a microphone reaches it + as this Protocol and never as a concrete class — which is the same reason + a servo reaches it as `ActuatorPlugin`. Put this type in the HAL and the + engine cannot describe its own input. + + `read()` returns exactly `format.frame_bytes` bytes, or None when the + source has ended: a file always does, a microphone never should. + """ + + format: AudioFormat + + async def start(self) -> None: ... + + async def read(self) -> bytes | None: ... + + async def stop(self) -> None: ... + + @dataclass(frozen=True, slots=True) class Health: """RSV. Polled by the engine; feeds proprioceptive self-model updates so From 13ac292f52e8cf28799c94ba6c03b61da6a098b6 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:10:21 -0700 Subject: [PATCH 07/21] Add the emet.audio plugin group Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-hal/emet_hal/audio.py | 26 +++- emet-hal/pyproject.toml | 4 + emet-hal/tests/test_audio.py | 37 ++++-- emet-sdk/emet_sdk/discovery.py | 55 +++++++- emet-sdk/emet_sdk/types.py | 6 + emet-sdk/emet_sdk/validate.py | 39 ++++++ emet-sdk/schemas/body-manifest.schema.json | 9 ++ emet-sdk/tests/test_audio_source.py | 145 +++++++++++++++++++++ 8 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 emet-sdk/tests/test_audio_source.py diff --git a/emet-hal/emet_hal/audio.py b/emet-hal/emet_hal/audio.py index b593148..74384ac 100644 --- a/emet-hal/emet_hal/audio.py +++ b/emet-hal/emet_hal/audio.py @@ -237,13 +237,35 @@ class WavSource: a detector expecting 16. """ - def __init__(self, path: str, fmt: AudioFormat | None = None, *, mix: str = "first") -> None: - self.path = path + def __init__( + self, + config: dict[str, Any] | None = None, + fmt: AudioFormat | None = None, + *, + mix: str = "first", + ) -> None: + """`config` is the manifest's `audio.input` block; the file is + `params.path`. Same signature as `MicrophoneSource` because both are + built by name through `emet.audio`, and the engine that builds them + knows only the name.""" + self.config = dict(config or {}) + self.path = str((self.config.get("params") or {}).get("path") or "") self.format = fmt or AudioFormat() self._mix = mix self._wav: wave.Wave_read | None = None + @classmethod + def from_path(cls, path: str, fmt: AudioFormat | None = None, *, mix: str = "first"): + """Build one directly. For tests and for anything holding a real path + rather than a manifest.""" + return cls({"source": "wav", "params": {"path": path}}, fmt, mix=mix) + async def start(self) -> None: + if not self.path: + raise AudioError( + "the wav source needs a file: set `audio.input.params.path` to a " + "16-bit mono PCM wav at the rate the consumer asks for." + ) try: wav = wave.open(self.path, "rb") except Exception as exc: diff --git a/emet-hal/pyproject.toml b/emet-hal/pyproject.toml index 7f8880b..781429b 100644 --- a/emet-hal/pyproject.toml +++ b/emet-hal/pyproject.toml @@ -37,6 +37,10 @@ dev = ["pytest>=8.0"] [project.entry-points."emet.sensors"] "emet_hal.mock_sensor" = "emet_hal.mock:MockSensor" +[project.entry-points."emet.audio"] +"microphone" = "emet_hal.audio:MicrophoneSource" +"wav" = "emet_hal.audio:WavSource" + [project.entry-points."emet.wake"] "pocketsphinx" = "emet_hal.pocketsphinx_wake:PocketSphinxWake" "mock" = "emet_hal.mock:MockWake" diff --git a/emet-hal/tests/test_audio.py b/emet-hal/tests/test_audio.py index 69231bf..6413b04 100644 --- a/emet-hal/tests/test_audio.py +++ b/emet-hal/tests/test_audio.py @@ -120,7 +120,7 @@ def test_a_four_capsule_array_arrives_downstream_as_mono(): def test_a_file_can_stand_in_for_a_microphone(tmp_path): - src = WavSource(str(write_wav(tmp_path / "a.wav", silence(1280 * 3)))) + src = WavSource.from_path(str(write_wav(tmp_path / "a.wav", silence(1280 * 3)))) assert isinstance(src, AudioSource) async def scenario(): @@ -139,7 +139,7 @@ async def scenario(): def test_a_partial_trailing_frame_ends_the_stream(tmp_path): """Half a frame is not a frame. Padding it would hand the detector silence that was never recorded.""" - src = WavSource(str(write_wav(tmp_path / "b.wav", silence(1280 * 2 + 400)))) + src = WavSource.from_path(str(write_wav(tmp_path / "b.wav", silence(1280 * 2 + 400)))) async def scenario(): await src.start() @@ -154,7 +154,7 @@ async def scenario(): def test_a_stereo_file_is_downmixed(tmp_path): path = write_wav(tmp_path / "c.wav", silence(1280, channels=2), channels=2) - src = WavSource(str(path)) + src = WavSource.from_path(str(path)) async def scenario(): await src.start() @@ -167,15 +167,36 @@ async def scenario(): def test_a_file_at_the_wrong_rate_is_refused_by_name(tmp_path): """The failure this module exists to prevent, in its cheapest form.""" - src = WavSource(str(write_wav(tmp_path / "d.wav", silence(1280), rate=44100))) + src = WavSource.from_path(str(write_wav(tmp_path / "d.wav", silence(1280), rate=44100))) with pytest.raises(AudioError) as exc: run(src.start()) assert "44100" in str(exc.value) assert "16000" in str(exc.value) +def test_a_wav_source_can_be_built_the_way_the_engine_builds_it(tmp_path): + """`cls(config, fmt)` with the manifest's `audio.input` block, which is all + the engine has: it holds a name and a mapping, never an imported class.""" + path = write_wav(tmp_path / "cfg.wav", silence(1280)) + src = WavSource({"source": "wav", "params": {"path": str(path)}}) + + async def scenario(): + await src.start() + frame = await src.read() + await src.stop() + return frame + + assert len(run(scenario())) == 2560 + + +def test_a_wav_source_with_no_path_says_so(tmp_path): + src = WavSource({"source": "wav"}) + with pytest.raises(AudioError, match="params.path"): + run(src.start()) + + def test_a_missing_file_says_which_one(tmp_path): - src = WavSource(str(tmp_path / "nope.wav")) + src = WavSource.from_path(str(tmp_path / "nope.wav")) with pytest.raises(AudioError, match="nope.wav"): run(src.start()) @@ -188,7 +209,7 @@ def test_eight_bit_audio_is_refused(tmp_path): w.setframerate(16000) w.writeframes(b"\x80" * 1280) with pytest.raises(AudioError, match="8-bit"): - run(WavSource(str(path)).start()) + run(WavSource.from_path(str(path)).start()) # ------------------------------------------------- the whole path, no mic @@ -204,7 +225,7 @@ def test_the_wake_path_runs_over_a_file(tmp_path): pytest.importorskip("pocketsphinx", reason="emet-hal[wake] is not installed") from emet_hal.pocketsphinx_wake import PocketSphinxWake - src = WavSource(str(write_wav(tmp_path / "quiet.wav", silence(1280 * 20)))) + src = WavSource.from_path(str(write_wav(tmp_path / "quiet.wav", silence(1280 * 20)))) wake = PocketSphinxWake({"engine": "pocketsphinx", "params": {}}, "hey emet") async def scenario(): @@ -237,7 +258,7 @@ def test_a_mock_engine_wakes_from_a_file(tmp_path): phrase = b"hey emet" pcm = silence(1280) + phrase + silence(1280) * 2 - src = WavSource(str(write_wav(tmp_path / "spelled.wav", pcm))) + src = WavSource.from_path(str(write_wav(tmp_path / "spelled.wav", pcm))) wake = MockWake({"engine": "mock", "params": {}}, "hey emet") async def scenario(): diff --git a/emet-sdk/emet_sdk/discovery.py b/emet-sdk/emet_sdk/discovery.py index 4f5387b..2d695d1 100644 --- a/emet-sdk/emet_sdk/discovery.py +++ b/emet-sdk/emet_sdk/discovery.py @@ -5,15 +5,27 @@ engine has no list of known drivers compiled into it — installing a package is what makes a driver exist. -Four groups: +Five groups: emet.actuators name = the string used in `driver.plugin` emet.sensors name = the string used in `driver.plugin` emet.locomotion name = the string used in `drive.kinematics` emet.wake name = the string used in `audio.wake.engine` + emet.audio name = the string used in `audio.input.source` -For locomotion and wake the entry-point name *is* the manifest value, which is -what makes those enums genuinely open: `kinematics: legged` is legal today and +`emet.audio` exists for a reason the others do not share. The engine may import +`emet_sdk` and nothing else, so it cannot reach into `emet_hal` for a +microphone even though that is exactly where microphones live. Discovery is +what carries one across the boundary — the engine asks for `microphone` and +receives a class it never imported. Without this group the layering rule and a +working engine are mutually exclusive. + +It earns its place a second way. Pointing a body at `wav` instead of +`microphone` replays a recording through the identical path, which is how you +reproduce a wake failure somebody reports without owning their room. + +For locomotion, wake and audio the entry-point name *is* the manifest value, +which is what makes those enums genuinely open: `kinematics: legged` is legal today and resolves the moment somebody publishes a package registering `legged`. That openness is not theoretical for wake. Picovoice disabled every free @@ -40,6 +52,7 @@ "GROUP_SENSOR", "GROUP_LOCOMOTION", "GROUP_WAKE", + "GROUP_AUDIO", "PluginRegistry", "discover", ] @@ -48,6 +61,7 @@ GROUP_SENSOR = "emet.sensors" GROUP_LOCOMOTION = "emet.locomotion" GROUP_WAKE = "emet.wake" +GROUP_AUDIO = "emet.audio" def _entry_points(group: str) -> dict[str, EntryPoint]: @@ -68,6 +82,7 @@ def __init__( sensors: Mapping[str, EntryPoint] | None = None, locomotion: Mapping[str, EntryPoint] | None = None, wake: Mapping[str, EntryPoint] | None = None, + audio: Mapping[str, EntryPoint] | None = None, *, verify_drivers: bool = False, ) -> None: @@ -75,6 +90,7 @@ def __init__( self._sensors = dict(sensors or {}) self._locomotion = dict(locomotion or {}) self._wake = dict(wake or {}) + self._audio = dict(audio or {}) #: When False, an unrecognised *driver* name is reported as a warning #: rather than an error. See `validate` for why the two callers differ: #: linting a manifest for hardware you have not wired yet is a normal @@ -90,6 +106,7 @@ def discover(cls) -> "PluginRegistry": sensors=_entry_points(GROUP_SENSOR), locomotion=_entry_points(GROUP_LOCOMOTION), wake=_entry_points(GROUP_WAKE), + audio=_entry_points(GROUP_AUDIO), ) def with_verification(self, verify_drivers: bool) -> "PluginRegistry": @@ -103,6 +120,7 @@ def with_verification(self, verify_drivers: bool) -> "PluginRegistry": sensors=self._sensors, locomotion=self._locomotion, wake=self._wake, + audio=self._audio, verify_drivers=verify_drivers, ) @@ -117,6 +135,9 @@ def has_locomotion(self, kinematics: str) -> bool: def has_wake(self, engine: str) -> bool: return engine in self._wake + def has_audio(self, source: str) -> bool: + return source in self._audio + @property def driver_names(self) -> list[str]: return sorted({*self._actuators, *self._sensors}) @@ -129,8 +150,18 @@ def locomotion_names(self) -> list[str]: def wake_names(self) -> list[str]: return sorted(self._wake) + @property + def audio_names(self) -> list[str]: + return sorted(self._audio) + def __bool__(self) -> bool: - return bool(self._actuators or self._sensors or self._locomotion or self._wake) + return bool( + self._actuators + or self._sensors + or self._locomotion + or self._wake + or self._audio + ) def __iter__(self) -> Iterator[tuple[str, str]]: """(group, name) for everything installed. Used by `emet explain`.""" @@ -142,6 +173,8 @@ def __iter__(self) -> Iterator[tuple[str, str]]: yield ("locomotion", name) for name in sorted(self._wake): yield ("wake", name) + for name in sorted(self._audio): + yield ("audio", name) # ----------------------------------------------------------------- load @@ -166,6 +199,20 @@ def load_wake(self, engine: str) -> type: raise _missing(engine, self.wake_names, "wake word plugin") return ep.load() + def load_audio(self, source: str) -> type: + """Import and return the class for an `audio.input.source` name. + + The returned class is constructed as `cls(config, fmt)`, where `config` + is the manifest's `audio.input` block and `fmt` the `AudioFormat` the + consumer needs. That convention is the audio equivalent of a plugin + receiving its capability block, and it is what lets the engine build a + microphone it cannot import. + """ + ep = self._audio.get(source) + if ep is None: + raise _missing(source, self.audio_names, "audio source") + return ep.load() + def _missing(name: str, available: Iterable[str], what: str) -> Exception: # Imported lazily: validate imports discovery, so discovery must not diff --git a/emet-sdk/emet_sdk/types.py b/emet-sdk/emet_sdk/types.py index 647407a..f0632e7 100644 --- a/emet-sdk/emet_sdk/types.py +++ b/emet-sdk/emet_sdk/types.py @@ -272,6 +272,12 @@ class AudioSource(Protocol): `read()` returns exactly `format.frame_bytes` bytes, or None when the source has ended: a file always does, a microphone never should. + + **Construction.** Implementations discovered through the `emet.audio` + entry-point group are built as `cls(config, fmt)`, where `config` is the + manifest's `audio.input` block and `fmt` the format the consumer needs. + Same shape as a plugin receiving its capability block, and for the same + reason: the caller has a name and a mapping, never a class it imported. """ format: AudioFormat diff --git a/emet-sdk/emet_sdk/validate.py b/emet-sdk/emet_sdk/validate.py index 424e977..bc11712 100644 --- a/emet-sdk/emet_sdk/validate.py +++ b/emet-sdk/emet_sdk/validate.py @@ -51,6 +51,7 @@ "PluginRegistry", "BUILTIN_LOCOMOTION", "BUILTIN_WAKE", + "BUILTIN_AUDIO", "load_yaml", "validate_manifest", "validate_soul", @@ -85,6 +86,14 @@ #: a list in this file. BUILTIN_WAKE: frozenset[str] = frozenset({"mock"}) +#: What `emet-hal` ships as audio sources. Same status again: documentation, +#: not what the validator checks against. +#: +#: `microphone` is the default and needs no manifest entry. `wav` replays a +#: recording through the identical path, which is how a wake failure reported +#: by somebody else gets reproduced without their room. +BUILTIN_AUDIO: frozenset[str] = frozenset({"microphone", "wav"}) + # -------------------------------------------------------------------------- # Findings @@ -248,6 +257,7 @@ def validate_manifest( _check_mount_references(capabilities, doc, report) _check_plugins(capabilities, registry, report) _check_wake_engine(doc, registry, report) + _check_audio_source(doc, registry, report) return report @@ -293,6 +303,35 @@ def _check_wake_engine( ) +def _check_audio_source( + doc: Mapping[str, Any], + registry: PluginRegistry, + report: ValidationReport, +) -> None: + """Resolve `audio.input.source`, on the same terms as the wake engine. + + Absent is the common case and means a live microphone. + + Unconditional, like `kinematics` and `wake.engine` and unlike + `driver.plugin`: this names an implementation that has to exist, not a + device you might not have wired. A body whose audio source does not resolve + produces no frames at all, which takes the wake engine and every voice rung + with it. + """ + source = ((doc.get("audio") or {}).get("input") or {}).get("source") + if not isinstance(source, str) or registry.has_audio(source): + return + installed = ", ".join(registry.audio_names) or "(none)" + report.error( + "missing_plugin", + f"no audio source provides {source!r}. Installed: {installed}. " + f"`audio.input.source` is an open enum — this value is legal, the " + f"plugin simply is not installed. Omit it entirely for a live " + f"microphone, which is the default.", + "/audio/input/source", + ) + + def _check_unique_ids(caps: Sequence[Mapping[str, Any]], report: ValidationReport) -> None: seen: dict[str, int] = {} for i, cap in enumerate(caps): diff --git a/emet-sdk/schemas/body-manifest.schema.json b/emet-sdk/schemas/body-manifest.schema.json index adf37e8..ff8173f 100644 --- a/emet-sdk/schemas/body-manifest.schema.json +++ b/emet-sdk/schemas/body-manifest.schema.json @@ -72,6 +72,15 @@ "additionalProperties": false, "required": ["device"], "properties": { + "source": { + "description": "P0, optional. Which audio source produces frames on this body. Resolves against installed emet.audio plugins, so this is an open enum: an unknown value is a missing plugin, never a schema error. Omit for the default, a live microphone. Set it to `wav` with params.path to replay a recording through the identical path, which is how a wake failure is reproduced without the room it happened in.", + "type": "string", + "minLength": 1 + }, + "params": { + "description": "Passed to the source untouched. Emet never inspects these.", + "type": "object" + }, "device": { "type": "string", "minLength": 1 }, "sample_rate": { "type": "integer", "exclusiveMinimum": 0 }, "channels": { "type": "integer", "minimum": 1 }, diff --git a/emet-sdk/tests/test_audio_source.py b/emet-sdk/tests/test_audio_source.py new file mode 100644 index 0000000..a519cba --- /dev/null +++ b/emet-sdk/tests/test_audio_source.py @@ -0,0 +1,145 @@ +"""How the engine gets a microphone. + +`emet_engine` may import `emet_sdk` and nothing else. Microphones live in +`emet_hal`. Those two facts are only compatible because audio crosses the +boundary the same way plugins do: the engine holds a *name* from the manifest, +asks the registry for it, and receives a class it never imported. + +**Note what this file imports.** Only `emet_sdk`. Everything it exercises is +implemented in `emet_hal`, and none of it is named here — which is exactly the +constraint the engine works under, so these tests fail the way the engine +would. +""" + +from __future__ import annotations + +import asyncio +import wave +from pathlib import Path + +import pytest + +from emet_sdk.discovery import GROUP_AUDIO, PluginRegistry +from emet_sdk.types import AudioFormat, AudioSource +from emet_sdk.validate import MissingPluginError, load_yaml, validate_manifest + +EXAMPLES = Path(__file__).resolve().parent.parent / "examples" + + +def run(coro): + return asyncio.run(coro) + + +def write_wav(path: Path, frames: int) -> Path: + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"\x00\x00" * frames) + return path + + +def codes(report) -> set[str]: + return {f.code for f in report.errors} + + +# ---------------------------------------------------------------- discovery + + +def test_audio_is_a_real_entry_point_group(): + registry = PluginRegistry.discover() + assert GROUP_AUDIO == "emet.audio" + assert "microphone" in registry.audio_names + assert "wav" in registry.audio_names + assert registry.has_audio("microphone") + + +def test_audio_appears_in_the_registry_listing(): + listed = {group for group, _ in PluginRegistry.discover()} + assert "audio" in listed + + +def test_an_uninstalled_source_is_a_missing_plugin_not_a_schema_error(): + registry = PluginRegistry.discover() + with pytest.raises(MissingPluginError) as exc: + registry.load_audio("some_network_stream") + assert "some_network_stream" in str(exc.value.report) + + +# ------------------------------------------------- the constraint, exercised + + +def test_a_source_arrives_without_being_imported(): + """The crux. This module imports `emet_sdk` only, and still ends up + holding a class defined in `emet_hal`.""" + cls = PluginRegistry.discover().load_audio("microphone") + assert cls.__module__.startswith("emet_hal"), cls.__module__ + + +def test_the_engine_can_build_and_drain_a_source_it_never_imported(tmp_path): + """What the listen loop will do, minus the loop. + + Take a name from a manifest, ask the registry for the class, construct it + with the `audio.input` block, and read frames. No import of `emet_hal` + anywhere above. + """ + manifest_audio_input = { + "source": "wav", + "params": {"path": str(write_wav(tmp_path / "quiet.wav", 1280 * 3))}, + } + + registry = PluginRegistry.discover() + cls = registry.load_audio(manifest_audio_input["source"]) + source = cls(manifest_audio_input, AudioFormat()) + + assert isinstance(source, AudioSource) + + async def scenario(): + await source.start() + frames = [] + while (frame := await source.read()) is not None: + frames.append(frame) + await source.stop() + return frames + + frames = run(scenario()) + assert len(frames) == 3 + assert all(len(f) == AudioFormat().frame_bytes for f in frames) + + +def test_every_shipped_source_takes_the_documented_constructor(): + """`cls(config, fmt)` is the contract. A source that takes anything else + cannot be built by a caller that only has a name and a mapping.""" + registry = PluginRegistry.discover() + for name in registry.audio_names: + cls = registry.load_audio(name) + instance = cls({"source": name}, AudioFormat()) + assert isinstance(instance, AudioSource), name + assert instance.format.sample_rate == 16000, name + + +# ---------------------------------------------------------------- validation + + +def test_an_absent_source_is_the_common_case(): + """Most manifests say nothing and get a live microphone.""" + report = validate_manifest(load_yaml(EXAMPLES / "bodiless.yaml")) + assert report.ok, report.errors + assert "source" not in (load_yaml(EXAMPLES / "bodiless.yaml")["audio"]["input"]) + + +def test_an_unresolvable_source_is_an_error(): + """Like `kinematics` and `wake.engine`, unlike `driver.plugin`. A body + whose audio source does not resolve produces no frames at all, which takes + the wake engine and every voice rung down with it.""" + doc = load_yaml(EXAMPLES / "bodiless.yaml") + doc["audio"]["input"]["source"] = "nobody_ships_this" + report = validate_manifest(doc) + assert not report.ok + assert "missing_plugin" in codes(report) + + +def test_a_shipped_source_validates_clean(): + doc = load_yaml(EXAMPLES / "bodiless.yaml") + doc["audio"]["input"]["source"] = "microphone" + assert validate_manifest(doc).ok From 7022c53ca5c197028b37e83a6eafc9a57d8d53c8 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:20:50 -0700 Subject: [PATCH 08/21] Add emet-engine and the listen loop Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- .github/workflows/ci.yml | 12 +- emet-engine/README.md | 14 ++ emet-engine/emet_engine/__init__.py | 18 +++ emet-engine/emet_engine/cli.py | 134 ++++++++++++++++ emet-engine/emet_engine/session.py | 182 ++++++++++++++++++++++ emet-engine/pyproject.toml | 31 ++++ emet-engine/tests/test_session.py | 231 ++++++++++++++++++++++++++++ emet-sdk/emet_sdk/validate.py | 12 ++ 8 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 emet-engine/README.md create mode 100644 emet-engine/emet_engine/__init__.py create mode 100644 emet-engine/emet_engine/cli.py create mode 100644 emet-engine/emet_engine/session.py create mode 100644 emet-engine/pyproject.toml create mode 100644 emet-engine/tests/test_session.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3372e96..1d063f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,7 @@ jobs: python -m pip install --upgrade build python -m build --wheel emet-sdk python -m build --wheel emet-hal + python -m build --wheel emet-engine - name: Data files are actually inside the wheel run: | @@ -118,7 +119,7 @@ jobs: - name: Install the wheels, not the source run: | - python -m pip install emet-sdk/dist/*.whl emet-hal/dist/*.whl + python -m pip install emet-sdk/dist/*.whl emet-hal/dist/*.whl emet-engine/dist/*.whl # `cd /tmp` is the point of this step: from here the source checkout is # not on the path, so anything that resolves must be coming out of the @@ -127,6 +128,7 @@ jobs: run: | cd /tmp emet --version + emet-listen --help > /dev/null emet validate "$GITHUB_WORKSPACE/emet-sdk/examples/bodiless.yaml" emet validate "$GITHUB_WORKSPACE/emet-sdk/examples/mock-scout.yaml" --verify-drivers emet explain "$GITHUB_WORKSPACE/emet-sdk/examples/mock-scout.yaml" --why @@ -164,6 +166,7 @@ jobs: run: | python -m pip install -e "emet-sdk[dev]" python -m pip install -e "emet-hal[dev]" + python -m pip install -e "emet-engine[dev]" # The shipped wake engine, which is an optional extra rather than a hard # dependency. pocketsphinx publishes cp311 and cp313 wheels and no cp312 @@ -183,6 +186,13 @@ jobs: working-directory: emet-hal run: python -m pytest -q + # Runs without a microphone or an acoustic model: the loop is exercised + # through a wav file and the mock detector, both reached by the same + # entry-point discovery the real ones use. + - name: Test emet-engine + working-directory: emet-engine + run: python -m pytest -q + # Exercises the CLI against the editable install. This does NOT prove the # packaged layout works — see the `wheel` job for that. - name: CLI accepts the valid examples diff --git a/emet-engine/README.md b/emet-engine/README.md new file mode 100644 index 0000000..c885a88 --- /dev/null +++ b/emet-engine/README.md @@ -0,0 +1,14 @@ +# emet-engine + +The listen loop and the runtime that drives a body. + +Imports `emet_sdk` only. Hardware reaches it by name through entry-point +discovery, never by import — see the layering check in `tools/check_layering.py`. + +```sh +emet-listen path/to/manifest.yaml path/to/soul.yaml +emet-listen path/to/manifest.yaml path/to/soul.yaml --replay recording.wav +``` + +Needs `emet-hal[audio,wake]` installed alongside for a microphone and a +detector to exist. diff --git a/emet-engine/emet_engine/__init__.py b/emet-engine/emet_engine/__init__.py new file mode 100644 index 0000000..b9bd2e8 --- /dev/null +++ b/emet-engine/emet_engine/__init__.py @@ -0,0 +1,18 @@ +"""Emet engine — the part that runs, as opposed to the part that describes. + +Layering, enforced in CI: this package imports `emet_sdk` and nothing else. +Not for tidiness. The engine is where somebody would reach for a concrete +servo or a concrete microphone, and one `from emet_hal.differential import ...` +would quietly end the claim that the engine holds no hardware knowledge. Every +driver, detector and audio source arrives by name through entry-point +discovery instead. + +`emet-hal` is therefore not a dependency of this package. It is a dependency of +a working robot, which is a different thing: install it alongside. +""" + +from emet_engine.session import EngineError, ListenSession + +__version__ = "0.2.0" + +__all__ = ["EngineError", "ListenSession", "__version__"] diff --git a/emet-engine/emet_engine/cli.py b/emet-engine/emet_engine/cli.py new file mode 100644 index 0000000..dc57ede --- /dev/null +++ b/emet-engine/emet_engine/cli.py @@ -0,0 +1,134 @@ +"""`emet-listen` — bring a body up and print what it hears. + +The 0.3 milestone in one command. It does not understand anything yet: it +brings up a microphone and a wake detector, and says so each time the robot +hears its name. Speech recognition arrives in 0.4. + +Useful before that, though. `--replay` points the same path at a recording +instead of a microphone, so a wake failure somebody reports can be reproduced +exactly, on a machine that was never in their room. +""" + +from __future__ import annotations + +import argparse +import asyncio +import copy +import sys +from pathlib import Path +from typing import Any + +from emet_sdk.discovery import PluginRegistry +from emet_sdk.validate import ( + MissingPluginError, + ValidationError, + load_yaml, + validate_manifest, + validate_soul, +) + +from emet_engine.session import EngineError, ListenSession + +__all__ = ["main"] + + +def _load(path: Path, kind: str, registry: PluginRegistry) -> dict[str, Any]: + try: + doc = load_yaml(path) + except Exception as exc: + raise EngineError(f"could not read {path}: {exc}") from exc + report = ( + validate_manifest(doc, registry=registry) if kind == "manifest" else validate_soul(doc) + ) + for finding in report.warnings: + print(f" ! {finding.code} {finding.path}", file=sys.stderr) + if not report.ok: + for finding in report.errors: + print(f" x {finding.code} {finding.path}\n {finding.message}", file=sys.stderr) + raise EngineError(f"{path.name} is not a valid {kind}") + return doc + + +def _replay(manifest: dict[str, Any], wav: str) -> dict[str, Any]: + """Point the body at a recording instead of its microphone.""" + manifest = copy.deepcopy(manifest) + manifest.setdefault("audio", {})["input"] = { + "source": "wav", + "params": {"path": wav}, + # `device` is required by the schema and meaningless for a file. The + # session never reads it; it is here so the document still validates. + "device": "file", + } + return manifest + + +async def _run(args: argparse.Namespace) -> int: + registry = PluginRegistry.discover() + if not registry: + print( + "no plugins are installed, so there is no microphone and no wake\n" + "engine to use. Install the hardware layer:\n" + " pip install 'emet-hal[audio,wake]'", + file=sys.stderr, + ) + return 2 + + manifest = _load(Path(args.manifest), "manifest", registry) + soul = _load(Path(args.soul), "soul", registry) + if args.replay: + manifest = _replay(manifest, args.replay) + + session = ListenSession(manifest, soul, registry=registry) + async with session: + assert session.descriptor is not None and session.format is not None + print( + f"listening for {session.phrase!r}\n" + f" engine {session.engine_name}\n" + f" source {session.source_name}\n" + f" audio {session.format.sample_rate} Hz, " + f"{session.format.frame_ms:.0f} ms frames" + ) + print(" (ctrl-c to stop)\n" if not args.replay else "") + + heard = 0 + async for event in session.wakes(): + heard += 1 + print(f" heard {event.phrase!r} (confidence {event.confidence:.2f})") + + # Only reached when the source ends, which means a replay finished. + print(f"\nsource ended. heard it {heard} time(s).") + if session.dropped: + print( + f"warning: {session.dropped} frame(s) were dropped, so wake words " + f"may have been missed." + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="emet-listen", + description="Bring up a body and print each time it hears its name.", + ) + parser.add_argument("manifest", help="body manifest (yaml)") + parser.add_argument("soul", help="soul bundle (yaml)") + parser.add_argument( + "--replay", + metavar="WAV", + help="read this 16-bit mono wav instead of the microphone, so a wake " + "failure can be reproduced away from the room it happened in", + ) + args = parser.parse_args(argv) + + try: + return asyncio.run(_run(args)) + except KeyboardInterrupt: + print("\nstopped.") + return 0 + except (EngineError, MissingPluginError, ValidationError) as exc: + print(f"\n{exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/emet-engine/emet_engine/session.py b/emet-engine/emet_engine/session.py new file mode 100644 index 0000000..f16e9e4 --- /dev/null +++ b/emet-engine/emet_engine/session.py @@ -0,0 +1,182 @@ +"""The listen loop: audio in, wake events out. + +This is the first thing in Emet that is neither a contract nor a driver. It +takes a body manifest and a soul bundle, brings up the two pieces of hardware +the floor guarantees, and produces an event each time the robot hears its name. + +**It imports `emet_sdk` and nothing else.** Microphones and wake detectors live +in `emet_hal`, and this module never names that package. Both arrive by name +through entry-point discovery, which is the only reason a layering rule that +forbids the import and an engine that needs a microphone can both be true. + +**Order matters at start-up, and not the obvious way round.** The detector is +brought up first and asked what audio it needs, and the source is then +configured to match. Doing it the other way — opening the microphone at +whatever rate the manifest mentions and hoping the detector agrees — is how a +robot ends up running perfectly and hearing nothing, because feeding 48 kHz +audio to a 16 kHz model does not raise anything. It just stops working. + +**The boot check that has no fallback.** Every other missing piece degrades: a +chain that cannot find a head falls through to a light ring, and one that finds +nothing still speaks. A robot that cannot hear its own name has no next rung, +so `start()` refuses rather than running deaf. That check is the entire reason +`WakeDescriptor.can_detect` exists. +""" + +from __future__ import annotations + +import logging +from typing import Any, AsyncIterator, Mapping + +from emet_sdk.discovery import PluginRegistry +from emet_sdk.types import AudioFormat, AudioSource, WakeDescriptor, WakeEvent +from emet_sdk.validate import DEFAULT_AUDIO_SOURCE, DEFAULT_WAKE_ENGINE + +__all__ = ["EngineError", "ListenSession"] + +log = logging.getLogger("emet_engine.session") + + +class EngineError(RuntimeError): + """The engine cannot run, with a message naming what to fix.""" + + +class ListenSession: + """One run of the listen loop, for one body and one soul. + + Usage is two lines once it is started: + + async for event in session.wakes(): + ... + + The iterator ends when the source does, which a file always does and a + microphone never should. + """ + + def __init__( + self, + manifest: Mapping[str, Any], + soul: Mapping[str, Any], + *, + registry: PluginRegistry | None = None, + ) -> None: + self.manifest = manifest + self.soul = soul + self.registry = registry or PluginRegistry.discover() + + audio = manifest.get("audio") or {} + self._input_block: Mapping[str, Any] = audio.get("input") or {} + self._wake_block: Mapping[str, Any] = audio.get("wake") or {} + self.phrase = str((soul.get("identity") or {}).get("wake_word") or "") + + self.engine_name = str(self._wake_block.get("engine") or DEFAULT_WAKE_ENGINE) + self.source_name = str(self._input_block.get("source") or DEFAULT_AUDIO_SOURCE) + + self._wake: Any = None + self._audio: AudioSource | None = None + self.descriptor: WakeDescriptor | None = None + self.format: AudioFormat | None = None + + # ------------------------------------------------------------ lifecycle + + async def start(self) -> None: + if not self.phrase: + raise EngineError( + "the soul bundle has no `identity.wake_word`, so there is " + "nothing to listen for." + ) + + self._wake = self._build_wake() + await self._wake.start() + + self.descriptor = self._wake.describe() + if not self.descriptor.can_detect(self.phrase): + raise EngineError(self._deaf_message()) + + # The detector states the contract; the source is made to fit it. + self.format = AudioFormat( + sample_rate=self.descriptor.sample_rate, + frame_samples=self.descriptor.frame_samples, + ) + + source_cls = self.registry.load_audio(self.source_name) + self._audio = source_cls(self._input_block, self.format) + await self._audio.start() + + log.info( + "listening for %r via %s on %s at %d Hz", + self.phrase, + self.engine_name, + self.source_name, + self.format.sample_rate, + ) + + def _build_wake(self) -> Any: + wake_cls = self.registry.load_wake(self.engine_name) + return wake_cls(self._wake_block, self.phrase) + + def _deaf_message(self) -> str: + """Say what is wrong in the terms the person can act on. + + A health detail from the plugin is more specific than anything this + layer could invent, so prefer it and fall back to naming the phrase. + """ + detail = "" + health = self._wake.health() + if not health.ok and health.detail: + detail = f" {health.detail}" + return ( + f"the wake engine {self.engine_name!r} cannot hear {self.phrase!r}, so this " + f"robot would never answer to its own name. Unlike a missing head there is " + f"nothing to fall back to, so it will not start.{detail}" + ) + + async def stop(self) -> None: + """Safe to call twice, and safe if `start()` raised part way through.""" + if self._audio is not None: + await self._audio.stop() + self._audio = None + if self._wake is not None: + await self._wake.shutdown() + self._wake = None + + async def __aenter__(self) -> "ListenSession": + await self.start() + return self + + async def __aexit__(self, *exc: object) -> None: + await self.stop() + + # ----------------------------------------------------------------- loop + + async def wakes(self) -> AsyncIterator[WakeEvent]: + """Yield an event each time the phrase is heard. + + The detector is reset after every event rather than before the next + read: its hypothesis persists until the utterance is closed, so without + this one wake becomes one event per frame for the rest of the session. + """ + if self._audio is None or self._wake is None: + raise EngineError("session was not started") + + while True: + frame = await self._audio.read() + if frame is None: + return + event = await self._wake.process(frame) + if event is not None: + yield event + await self._wake.reset() + + # -------------------------------------------------------------- honesty + + @property + def dropped(self) -> int: + """Frames the source discarded because this loop fell behind. + + Read defensively: `AudioSource` does not require it, and a file cannot + drop anything. Non-zero means wake words were missed, and a robot that + knows it missed something should be able to say so rather than let it + pass as bad luck. + """ + return int(getattr(self._audio, "dropped", 0) or 0) diff --git a/emet-engine/pyproject.toml b/emet-engine/pyproject.toml new file mode 100644 index 0000000..fac8ba9 --- /dev/null +++ b/emet-engine/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "emet-engine" +version = "0.2.0" +description = "Emet engine — the listen loop and the runtime that drives a body." +readme = "README.md" +requires-python = ">=3.11" +license = "Apache-2.0" +authors = [{ name = "The Emet Authors" }] +keywords = ["emet", "robotics", "engine"] + +# emet-sdk only, and deliberately. Drivers, wake engines and audio sources +# reach this package through entry points, never through an import, so +# depending on `emet-hal` here would create exactly the coupling the layering +# check exists to prevent. A working robot installs both. +dependencies = ["emet-sdk>=0.1"] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[project.scripts] +emet-listen = "emet_engine.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["emet_engine"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/emet-engine/tests/test_session.py b/emet-engine/tests/test_session.py new file mode 100644 index 0000000..9ec633c --- /dev/null +++ b/emet-engine/tests/test_session.py @@ -0,0 +1,231 @@ +"""The listen loop. + +Runs entirely offline: a wav file standing in for a microphone and the mock +detector standing in for pocketsphinx. No hardware, no acoustic model, and +nothing recorded from whoever runs the suite. + +Both of those stand-ins arrive through entry-point discovery, so these tests +exercise the same path the real thing uses. Only the names differ. +""" + +from __future__ import annotations + +import asyncio +import wave +from pathlib import Path + +import pytest + +from emet_engine.session import EngineError, ListenSession +from emet_sdk.validate import MissingPluginError + +PHRASE = "hey emet" + + +def run(coro): + return asyncio.run(coro) + + +def write_wav(path: Path, pcm: bytes) -> str: + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(pcm) + return str(path) + + +def silence(frames: int) -> bytes: + return b"\x00\x00" * frames + + +def saying(times: int) -> bytes: + """PCM whose bytes literally spell the phrase, which is what the mock + detector listens for. Not audio anybody could hear; it exercises the loop.""" + return (silence(1280) + PHRASE.encode() + silence(1280)) * times + + +def body(path: str, *, engine: str = "mock", source: str = "wav", **wake_params) -> dict: + return { + "audio": { + "input": {"source": source, "device": "file", "params": {"path": path}}, + "wake": {"engine": engine, "params": wake_params}, + } + } + + +def soul(wake_word: str | None = PHRASE) -> dict: + identity = {"name": "Emet"} + if wake_word is not None: + identity["wake_word"] = wake_word + return {"identity": identity} + + +# ------------------------------------------------------------------ startup + + +def test_a_session_brings_up_both_halves_of_the_floor(tmp_path): + session = ListenSession(body(write_wav(tmp_path / "a.wav", silence(1280))), soul()) + + async def scenario(): + async with session: + return session.descriptor, session.format + + descriptor, fmt = run(scenario()) + assert descriptor is not None and descriptor.healthy + assert fmt is not None + assert session.engine_name == "mock" + assert session.source_name == "wav" + + +def test_the_detector_states_the_format_and_the_source_is_made_to_fit(tmp_path): + """The ordering that matters. Opening the microphone first and hoping the + detector agrees is how a robot runs perfectly and hears nothing: feeding + 48 kHz audio to a 16 kHz model raises nothing at all.""" + session = ListenSession(body(write_wav(tmp_path / "b.wav", silence(1280))), soul()) + + async def scenario(): + async with session: + return session.descriptor, session.format + + descriptor, fmt = run(scenario()) + assert fmt.sample_rate == descriptor.sample_rate + assert fmt.frame_samples == descriptor.frame_samples + + +def test_defaults_apply_when_the_manifest_says_nothing(tmp_path): + """Most manifests will mention neither, so the defaults are what actually + runs most of the time.""" + session = ListenSession({"audio": {"input": {"device": "x"}}}, soul()) + assert session.engine_name == "pocketsphinx" + assert session.source_name == "microphone" + + +# ------------------------------------------------- the check with no fallback + + +def test_a_detector_that_cannot_hear_the_name_refuses_to_start(tmp_path): + """Every other missing piece degrades. This one has no next rung, so the + session refuses rather than running deaf.""" + manifest = body(write_wav(tmp_path / "c.wav", silence(1280)), phrases=["hey jarvis"]) + session = ListenSession(manifest, soul()) + + with pytest.raises(EngineError) as exc: + run(session.start()) + message = str(exc.value) + assert PHRASE in message + assert "own name" in message + run(session.stop()) + + +def test_a_soul_with_no_wake_word_is_refused(tmp_path): + session = ListenSession(body(write_wav(tmp_path / "d.wav", silence(1280))), soul(None)) + with pytest.raises(EngineError, match="wake_word"): + run(session.start()) + + +def test_an_uninstalled_engine_is_a_missing_plugin(tmp_path): + manifest = body(write_wav(tmp_path / "e.wav", silence(1280)), engine="porcupine") + with pytest.raises(MissingPluginError): + run(ListenSession(manifest, soul()).start()) + + +def test_an_uninstalled_source_is_a_missing_plugin(tmp_path): + manifest = body(write_wav(tmp_path / "f.wav", silence(1280)), source="carrier_pigeon") + session = ListenSession(manifest, soul()) + with pytest.raises(MissingPluginError): + run(session.start()) + run(session.stop()) + + +# --------------------------------------------------------------------- loop + + +def test_silence_yields_nothing(tmp_path): + session = ListenSession(body(write_wav(tmp_path / "g.wav", silence(1280 * 10))), soul()) + + async def scenario(): + async with session: + return [e async for e in session.wakes()] + + assert run(scenario()) == [] + + +def test_each_utterance_yields_exactly_one_event(tmp_path): + """Once per wake, not once per frame. The detector's hypothesis persists + until the utterance is reset, so the loop resets after every event.""" + session = ListenSession(body(write_wav(tmp_path / "h.wav", saying(3))), soul()) + + async def scenario(): + async with session: + return [e async for e in session.wakes()] + + events = run(scenario()) + assert len(events) == 3 + assert {e.phrase for e in events} == {PHRASE} + + +def test_the_loop_ends_when_the_source_does(tmp_path): + """A file ends; a microphone never should. That the loop terminates at all + is what makes `--replay` finish rather than hang.""" + session = ListenSession(body(write_wav(tmp_path / "i.wav", saying(1))), soul()) + + async def scenario(): + async with session: + n = 0 + async for _ in session.wakes(): + n += 1 + return n + + assert run(scenario()) == 1 + + +def test_iterating_before_starting_is_an_error(tmp_path): + session = ListenSession(body(write_wav(tmp_path / "j.wav", silence(1280))), soul()) + + async def scenario(): + with pytest.raises(EngineError, match="not started"): + async for _ in session.wakes(): + pass + + run(scenario()) + + +# ------------------------------------------------------------------ teardown + + +def test_stopping_twice_is_safe(tmp_path): + session = ListenSession(body(write_wav(tmp_path / "k.wav", silence(1280))), soul()) + + async def scenario(): + await session.start() + await session.stop() + await session.stop() + + run(scenario()) + + +def test_stopping_after_a_failed_start_is_safe(tmp_path): + """`start()` raises part way through, having brought up the detector but + not the source. Teardown must cope with that.""" + manifest = body(write_wav(tmp_path / "l.wav", silence(1280)), phrases=["hey jarvis"]) + session = ListenSession(manifest, soul()) + + async def scenario(): + with pytest.raises(EngineError): + await session.start() + await session.stop() + + run(scenario()) + + +def test_a_file_drops_nothing(tmp_path): + """`dropped` is read defensively: the Protocol does not require it, and a + file cannot fall behind.""" + session = ListenSession(body(write_wav(tmp_path / "m.wav", silence(1280))), soul()) + + async def scenario(): + async with session: + return session.dropped + + assert run(scenario()) == 0 diff --git a/emet-sdk/emet_sdk/validate.py b/emet-sdk/emet_sdk/validate.py index bc11712..7e1c5ee 100644 --- a/emet-sdk/emet_sdk/validate.py +++ b/emet-sdk/emet_sdk/validate.py @@ -52,6 +52,8 @@ "BUILTIN_LOCOMOTION", "BUILTIN_WAKE", "BUILTIN_AUDIO", + "DEFAULT_WAKE_ENGINE", + "DEFAULT_AUDIO_SOURCE", "load_yaml", "validate_manifest", "validate_soul", @@ -94,6 +96,16 @@ #: by somebody else gets reproduced without their room. BUILTIN_AUDIO: frozenset[str] = frozenset({"microphone", "wav"}) +#: What an omitted field means. Most manifests will mention neither, so these +#: are the values the engine actually runs with most of the time. +#: +#: They live here rather than in the engine because "what an absent field +#: means" is part of the document's meaning, and a validator and an engine +#: quietly disagreeing about a default is the kind of bug that only ever +#: appears on somebody else's robot. +DEFAULT_WAKE_ENGINE = "pocketsphinx" +DEFAULT_AUDIO_SOURCE = "microphone" + # -------------------------------------------------------------------------- # Findings From 210f06f3e7dbdc6698a496a317c2478834116121 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:57:20 -0700 Subject: [PATCH 09/21] Document the patience default against measured human timing Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-engine/emet_engine/__init__.py | 14 +- emet-engine/emet_engine/cli.py | 23 ++- emet-engine/emet_engine/session.py | 47 ++++++ emet-engine/emet_engine/turn.py | 164 +++++++++++++++++++ emet-engine/emet_engine/vad.py | 165 +++++++++++++++++++ emet-engine/tests/test_session.py | 76 +++++++++ emet-engine/tests/test_turn.py | 235 ++++++++++++++++++++++++++++ 7 files changed, 718 insertions(+), 6 deletions(-) create mode 100644 emet-engine/emet_engine/turn.py create mode 100644 emet-engine/emet_engine/vad.py create mode 100644 emet-engine/tests/test_turn.py diff --git a/emet-engine/emet_engine/__init__.py b/emet-engine/emet_engine/__init__.py index b9bd2e8..3640108 100644 --- a/emet-engine/emet_engine/__init__.py +++ b/emet-engine/emet_engine/__init__.py @@ -12,7 +12,19 @@ """ from emet_engine.session import EngineError, ListenSession +from emet_engine.turn import DEFAULT_PATIENCE_MS, EndReason, Endpointer, Utterance +from emet_engine.vad import EnergyVad, VadTuning __version__ = "0.2.0" -__all__ = ["EngineError", "ListenSession", "__version__"] +__all__ = [ + "EngineError", + "ListenSession", + "Endpointer", + "Utterance", + "EndReason", + "EnergyVad", + "VadTuning", + "DEFAULT_PATIENCE_MS", + "__version__", +] diff --git a/emet-engine/emet_engine/cli.py b/emet-engine/emet_engine/cli.py index dc57ede..5552305 100644 --- a/emet-engine/emet_engine/cli.py +++ b/emet-engine/emet_engine/cli.py @@ -28,6 +28,7 @@ ) from emet_engine.session import EngineError, ListenSession +from emet_engine.turn import EndReason __all__ = ["main"] @@ -83,17 +84,29 @@ async def _run(args: argparse.Namespace) -> int: assert session.descriptor is not None and session.format is not None print( f"listening for {session.phrase!r}\n" - f" engine {session.engine_name}\n" - f" source {session.source_name}\n" - f" audio {session.format.sample_rate} Hz, " - f"{session.format.frame_ms:.0f} ms frames" + f" engine {session.engine_name}\n" + f" source {session.source_name}\n" + f" audio {session.format.sample_rate} Hz, " + f"{session.format.frame_ms:.0f} ms frames\n" + f" patience {session.patience_ms} ms" ) print(" (ctrl-c to stop)\n" if not args.replay else "") heard = 0 - async for event in session.wakes(): + async for event, utterance in session.turns(): heard += 1 print(f" heard {event.phrase!r} (confidence {event.confidence:.2f})") + if utterance.had_speech: + # 0.4 hands this audio to speech recognition. Until then the + # useful thing to show is that the turn was bounded correctly. + print( + f" then {utterance.duration_ms / 1000:.1f}s of speech, " + f"ended on {utterance.reason.value}" + ) + elif utterance.reason is EndReason.SOURCE_ENDED: + print(" the recording ended before anything followed.") + else: + print(" then nothing. probably a false wake.") # Only reached when the source ends, which means a replay finished. print(f"\nsource ended. heard it {heard} time(s).") diff --git a/emet-engine/emet_engine/session.py b/emet-engine/emet_engine/session.py index f16e9e4..6643cfb 100644 --- a/emet-engine/emet_engine/session.py +++ b/emet-engine/emet_engine/session.py @@ -32,6 +32,8 @@ from emet_sdk.types import AudioFormat, AudioSource, WakeDescriptor, WakeEvent from emet_sdk.validate import DEFAULT_AUDIO_SOURCE, DEFAULT_WAKE_ENGINE +from emet_engine.turn import DEFAULT_PATIENCE_MS, Endpointer, Utterance + __all__ = ["EngineError", "ListenSession"] log = logging.getLogger("emet_engine.session") @@ -72,6 +74,12 @@ def __init__( self.engine_name = str(self._wake_block.get("engine") or DEFAULT_WAKE_ENGINE) self.source_name = str(self._input_block.get("source") or DEFAULT_AUDIO_SOURCE) + # Turn-taking is a persona trait, not engine tuning: a reflective soul + # waits longer than an eager one, and that difference is the whole + # reason the number lives on the soul rather than in this file. + interaction = soul.get("interaction") or {} + self.patience_ms = int(interaction.get("patience_ms") or DEFAULT_PATIENCE_MS) + self._wake: Any = None self._audio: AudioSource | None = None self.descriptor: WakeDescriptor | None = None @@ -168,6 +176,45 @@ async def wakes(self) -> AsyncIterator[WakeEvent]: yield event await self._wake.reset() + async def turns(self) -> AsyncIterator[tuple[WakeEvent, Utterance]]: + """Yield the wake and the speech that followed it, once per turn. + + While an utterance is being captured the wake detector is not fed. That + is deliberate: the phrase often appears inside what somebody then says + ("hey emet, what did you mean, hey emet is a silly name"), and a + detector still listening would start a second turn inside the first. + """ + if self._audio is None or self._wake is None or self.format is None: + raise EngineError("session was not started") + + endpointer: Endpointer | None = None + pending: WakeEvent | None = None + + while True: + frame = await self._audio.read() + if frame is None: + if endpointer is not None and pending is not None: + # The source ran out mid-turn. Hand over what was caught + # rather than dropping it: a truncated question is still + # more useful than silence. + yield pending, endpointer.close() + return + + if endpointer is None: + event = await self._wake.process(frame) + if event is not None: + await self._wake.reset() + pending = event + endpointer = Endpointer(self.format, patience_ms=self.patience_ms) + continue + + utterance = endpointer.feed(frame) + if utterance is not None: + assert pending is not None + yield pending, utterance + endpointer = None + pending = None + # -------------------------------------------------------------- honesty @property diff --git a/emet-engine/emet_engine/turn.py b/emet-engine/emet_engine/turn.py new file mode 100644 index 0000000..a1dfccd --- /dev/null +++ b/emet-engine/emet_engine/turn.py @@ -0,0 +1,164 @@ +"""Turn-taking: deciding when someone has finished speaking. + +Turn-taking is what separates charming from infuriating, and no amount of +personality prompting fixes it. A robot that cuts you off mid-sentence reads as +rude however warmly it is written, and one that waits three seconds reads as +slow however clever the answer is. + +`patience_ms` is therefore a **persona trait, not engine tuning**. It lives on +the soul, so Neuma — reflective, sparing with words — waits longer than Hugr, +who is opinionated and interrupts. That is the whole point of putting it there: +the same engine produces two different conversational temperaments from two +data files. + +**Scope.** `extend_on_incomplete`, the trailing-clause heuristic, is not here. +The specification is precise that it triggers when *the transcript* looks +unfinished, and there is no transcript until speech recognition exists. Trying +to guess incompleteness from audio alone would be a different and much worse +heuristic wearing the same name. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from enum import StrEnum + +from emet_sdk.types import AudioFormat + +from emet_engine.vad import EnergyVad, VadTuning + +__all__ = ["Endpointer", "Utterance", "EndReason", "DEFAULT_PATIENCE_MS"] + +#: `interaction.patience_ms` when a soul does not say. Matches the documented +#: default so that a bundle written against the spec behaves as it reads. +DEFAULT_PATIENCE_MS = 900 + + +class EndReason(StrEnum): + """Why the turn ended. The engine above should not treat these alike: a + person who trailed off is not a person who said nothing.""" + + SILENCE = "silence" # they stopped talking. The normal case. + NO_SPEECH = "no_speech" # woken, then nothing. A false wake, probably. + TOO_LONG = "too_long" # still going at the cap. Take what we have. + SOURCE_ENDED = "source_ended" # the file ran out mid-turn. + + +@dataclass(frozen=True, slots=True) +class Utterance: + """One turn of speech, captured between a wake and an endpoint.""" + + audio: bytes + duration_ms: float + reason: EndReason + + @property + def had_speech(self) -> bool: + """Whether anything was actually captured. + + Read from the audio rather than inferred from the reason: a turn can + end because the source ran out having caught plenty, or having caught + nothing, and those are not the same event. + """ + return bool(self.audio) + + +class Endpointer: + """Feed frames after a wake; get an `Utterance` when the turn is over. + + Returns None while the turn is still in progress, so a caller can pump it + from the same loop that reads audio without needing a second thread. + """ + + def __init__( + self, + fmt: AudioFormat, + *, + patience_ms: int = DEFAULT_PATIENCE_MS, + lead_in_ms: int = 2500, + max_utterance_ms: int = 15000, + preroll_frames: int = 4, + vad: EnergyVad | None = None, + tuning: VadTuning | None = None, + ) -> None: + self.format = fmt + self.patience_ms = patience_ms + self.lead_in_ms = lead_in_ms + self.max_utterance_ms = max_utterance_ms + # Detection lags onset by `onset_frames`, so without a pre-roll the + # first consonant of the reply is clipped and the transcript starts + # mid-word. Cheap to keep, expensive to lose. + self._preroll: deque[bytes] = deque(maxlen=max(0, preroll_frames)) + self.vad = vad or EnergyVad(fmt, tuning) + + self._frames: list[bytes] = [] + self._started = False + self._waited_ms = 0.0 + self._speech_ms = 0.0 + + @property + def frame_ms(self) -> float: + return self.format.frame_ms + + @property + def started(self) -> bool: + """Whether speech has begun. Drives `signal.listening` later on.""" + return self._started + + def feed(self, frame: bytes) -> Utterance | None: + speaking = self.vad.feed(frame) + + if not self._started: + self._preroll.append(frame) + if speaking: + self._started = True + # The pre-roll already contains this frame. + self._frames.extend(self._preroll) + self._speech_ms = len(self._frames) * self.frame_ms + return None + self._waited_ms += self.frame_ms + if self._waited_ms >= self.lead_in_ms: + return self._finish(EndReason.NO_SPEECH) + return None + + self._frames.append(frame) + self._speech_ms += self.frame_ms + + if self._speech_ms >= self.max_utterance_ms: + return self._finish(EndReason.TOO_LONG) + + # Measured from the last frame that was actually loud, not from when + # the debounced flag flipped: the VAD's hangover would otherwise be + # charged to the persona's patience, making every soul slower than the + # number in its own bundle. + if not speaking and self.vad.quiet_ms >= self.patience_ms: + return self._finish(EndReason.SILENCE) + + return None + + def close(self) -> Utterance: + """The source ended mid-turn. Return whatever was captured. + + Always `SOURCE_ENDED`, even with nothing captured. `NO_SPEECH` means + something specific — the robot waited the full lead-in and nobody + spoke, which is evidence of a false wake. A recording that stopped + early is not evidence of anything, and labelling it the same way would + make a replay look like a detector fault. + """ + return self._finish(EndReason.SOURCE_ENDED) + + def _finish(self, reason: EndReason) -> Utterance: + audio = b"".join(self._frames) + utterance = Utterance( + audio=audio, + duration_ms=len(self._frames) * self.frame_ms, + reason=reason, + ) + self._frames = [] + self._started = False + self._waited_ms = 0.0 + self._speech_ms = 0.0 + self._preroll.clear() + self.vad.reset() + return utterance diff --git a/emet-engine/emet_engine/vad.py b/emet-engine/emet_engine/vad.py new file mode 100644 index 0000000..0b1d47f --- /dev/null +++ b/emet-engine/emet_engine/vad.py @@ -0,0 +1,165 @@ +"""Voice activity detection: is anybody talking right now. + +Deliberately not a plugin category. There is effectively one good answer in the +world (Silero), it is MIT so it carries none of the rug-pull risk that made +wake a category, and its output feeds turn-taking — `patience_ms`, the trailing +clause — which is personality rather than hardware. A seam there would decouple +nothing. Adding a category later is a minor version bump and removing one is a +major bump, so under uncertainty this stays a component. + +**Why arithmetic rather than Silero, for now.** Silero was the intended choice +and did not survive being checked: + +* `silero-vad` on PyPI hard-depends on `torch` and `torchaudio`. Putting + PyTorch on a Raspberry Pi to decide whether someone is speaking is not a + trade worth making. +* `silero-vad-lite`, the dependency-free wrapper, publishes no aarch64 Linux + wheel — x86_64 and macOS only — and declares no licence. +* Running the ONNX model directly means `onnxruntime` (20.8 MB on ARM) plus a + vendored model file. + +So this ships with no new dependencies at all, and is honest about being worse: +it is a floor, not a ceiling. It hears energy, not speech, and a fan or a +washing machine will fool it in a way a neural model would not. When Silero is +worth its weight, it replaces the guts of this file and nothing above changes, +which is exactly what "not a swap point" was supposed to buy. + +**The design is the standard one** and its parts are all load-bearing: + +* an adaptive noise floor, because a quiet study and a kitchen differ by more + than any fixed threshold can span; +* hysteresis, so one loud frame is not speech and one quiet frame is not + silence; +* asymmetric adaptation, because the floor should rise slowly and fall fast — + a fridge switching on must not be learned as speech, and a fridge switching + off must not deafen the robot for a minute. +""" + +from __future__ import annotations + +import math +from array import array +from dataclasses import dataclass + +from emet_sdk.types import AudioFormat + +__all__ = ["EnergyVad", "VadTuning", "rms"] + + +def rms(frame: bytes) -> float: + """Root mean square of one frame of mono int16.""" + samples = array("h") + samples.frombytes(frame) + if not samples: + return 0.0 + return math.sqrt(sum(s * s for s in samples) / len(samples)) + + +@dataclass(frozen=True, slots=True) +class VadTuning: + """Knobs, with defaults that work in a normal room. + + These are engine tuning rather than persona: how *loud* speech has to be is + a property of the room and the microphone, not of the character. How *long* + the robot waits afterwards is the persona's business, and lives in + `patience_ms` on the soul instead. + """ + + #: Speech must exceed the noise floor by this factor. + ratio: float = 3.0 + #: ...and this absolute level, so a silent room with a floor near zero does + #: not treat its own dither as conversation. + absolute_floor: float = 180.0 + #: Consecutive loud frames before speech is declared. Rejects a door. + onset_frames: int = 2 + #: Consecutive quiet frames before speech is over. At 80 ms frames this is + #: about a third of a second, which is roughly the gap inside a sentence — + #: shorter and the robot interrupts you mid-thought. + hangover_frames: int = 4 + #: How fast the floor rises toward a louder room, per frame. + adapt_up: float = 0.02 + #: How fast it falls toward a quieter one. Deliberately faster: a machine + #: switching off should not leave the robot deaf. + adapt_down: float = 0.25 + + +class EnergyVad: + """Per-frame speech decision with hysteresis and an adapting noise floor. + + Stateful and cheap: one pass over each frame, no allocation beyond the + sample view. Feed it every frame in order. + """ + + def __init__(self, fmt: AudioFormat, tuning: VadTuning | None = None) -> None: + self.format = fmt + self.tuning = tuning or VadTuning() + self.noise_floor: float = self.tuning.absolute_floor + self._speaking = False + self._loud_run = 0 + self._quiet_run = 0 + #: Frames seen. Useful to callers deciding whether the floor has had + #: time to settle. + self.frames = 0 + + @property + def speaking(self) -> bool: + return self._speaking + + @property + def quiet_frames(self) -> int: + """Consecutive frames below the threshold, right now. + + Exposed because the endpointer measures trailing silence from the last + genuinely loud frame, not from when the debounced flag flipped. The + difference is `hangover_frames` — a third of a second that would + otherwise be silently charged to the persona's `patience_ms`, making + every soul slower than the number written in its own bundle. + """ + return self._quiet_run + + @property + def quiet_ms(self) -> float: + return self._quiet_run * self.format.frame_ms + + @property + def threshold(self) -> float: + t = self.tuning + return max(self.noise_floor * t.ratio, t.absolute_floor) + + def feed(self, frame: bytes) -> bool: + """Consume one frame; return whether speech is in progress. + + The return value is the *debounced* state, not whether this particular + frame was loud. A caller asking "is someone talking" wants the former. + """ + t = self.tuning + self.frames += 1 + level = rms(frame) + + loud = level > self.threshold + + # Adapt only while quiet. Learning the floor during speech would + # teach the detector that the person talking is the room. + if not loud: + rate = t.adapt_up if level > self.noise_floor else t.adapt_down + self.noise_floor += (level - self.noise_floor) * rate + + if loud: + self._loud_run += 1 + self._quiet_run = 0 + else: + self._quiet_run += 1 + self._loud_run = 0 + + if not self._speaking and self._loud_run >= t.onset_frames: + self._speaking = True + elif self._speaking and self._quiet_run >= t.hangover_frames: + self._speaking = False + + return self._speaking + + def reset(self) -> None: + """Forget the utterance, keep what has been learned about the room.""" + self._speaking = False + self._loud_run = 0 + self._quiet_run = 0 diff --git a/emet-engine/tests/test_session.py b/emet-engine/tests/test_session.py index 9ec633c..cda31ff 100644 --- a/emet-engine/tests/test_session.py +++ b/emet-engine/tests/test_session.py @@ -180,6 +180,82 @@ async def scenario(): assert run(scenario()) == 1 +# ---------------------------------------------------------------- turns + + +FRAME_BYTES = 2560 + + +def frame(payload: bytes = b"") -> bytes: + # bytes(n) rather than an escape: zero bytes without a backslash in sight. + return payload + bytes(FRAME_BYTES - len(payload)) + + +def loud_frame(amplitude: int = 4000) -> bytes: + from array import array + + return array("h", [amplitude, -amplitude] * (FRAME_BYTES // 4)).tobytes() + + +def test_a_turn_is_a_wake_and_the_speech_after_it(tmp_path): + """The 0.3 loop end to end: hear the name, capture what follows, stop when + they stop.""" + pcm = frame() + frame(PHRASE.encode()) + loud_frame() * 5 + frame() * 20 + manifest = body(write_wav(tmp_path / "turn.wav", pcm)) + session = ListenSession(manifest, soul()) + + async def scenario(): + async with session: + return [(e, u) async for e, u in session.turns()] + + turns = run(scenario()) + assert len(turns) == 1 + event, utterance = turns[0] + assert event.phrase == PHRASE + assert utterance.had_speech + assert utterance.reason.value == "silence" + assert utterance.audio + + +def test_a_wake_with_silence_after_it_is_reported_as_such(tmp_path): + """A false wake is not the same as a question, and the engine above has to + be able to tell them apart.""" + pcm = frame() + frame(PHRASE.encode()) + frame() * 60 + session = ListenSession(body(write_wav(tmp_path / "empty.wav", pcm)), soul()) + + async def scenario(): + async with session: + return [(e, u) async for e, u in session.turns()] + + turns = run(scenario()) + assert len(turns) == 1 + assert not turns[0][1].had_speech + + +def test_patience_comes_from_the_soul(tmp_path): + session = ListenSession( + body(write_wav(tmp_path / "p.wav", frame())), + {"identity": {"wake_word": PHRASE}, "interaction": {"patience_ms": 2500}}, + ) + assert session.patience_ms == 2500 + + +def test_patience_defaults_when_the_soul_is_silent_about_it(tmp_path): + session = ListenSession(body(write_wav(tmp_path / "q.wav", frame())), soul()) + assert session.patience_ms == 900 + + +def test_iterating_turns_before_starting_is_an_error(tmp_path): + session = ListenSession(body(write_wav(tmp_path / "r.wav", frame())), soul()) + + async def scenario(): + with pytest.raises(EngineError, match="not started"): + async for _ in session.turns(): + pass + + run(scenario()) + + def test_iterating_before_starting_is_an_error(tmp_path): session = ListenSession(body(write_wav(tmp_path / "j.wav", silence(1280))), soul()) diff --git a/emet-engine/tests/test_turn.py b/emet-engine/tests/test_turn.py new file mode 100644 index 0000000..775efbd --- /dev/null +++ b/emet-engine/tests/test_turn.py @@ -0,0 +1,235 @@ +"""Voice activity detection and endpointing. + +Synthetic audio throughout: silence is zeroes and speech is a square wave at a +chosen amplitude, so every threshold in these tests is an exact number rather +than a recording somebody has to trust. That makes the failures legible — when +one breaks it says which parameter moved, not "the audio sounds different now". + +The behaviours worth protecting here are the ones that read as rudeness when +they go wrong: cutting somebody off, or sitting silently after they finish. +""" + +from __future__ import annotations + +from array import array + +import pytest + +from emet_engine.turn import DEFAULT_PATIENCE_MS, Endpointer, EndReason +from emet_engine.vad import EnergyVad, VadTuning, rms +from emet_sdk.types import AudioFormat + +FMT = AudioFormat() # 16 kHz, 1280 samples, 80 ms + + +def quiet() -> bytes: + return b"\x00\x00" * FMT.frame_samples + + +def loud(amplitude: int = 4000) -> bytes: + """A square wave, so RMS is exactly `amplitude`.""" + return array("h", [amplitude, -amplitude] * (FMT.frame_samples // 2)).tobytes() + + +# ------------------------------------------------------------------- energy + + +def test_rms_of_silence_is_zero(): + assert rms(quiet()) == 0.0 + + +def test_rms_of_a_square_wave_is_its_amplitude(): + assert rms(loud(4000)) == pytest.approx(4000.0) + + +def test_rms_of_an_empty_frame_does_not_divide_by_zero(): + assert rms(b"") == 0.0 + + +# ---------------------------------------------------------------- hysteresis + + +def test_one_loud_frame_is_not_speech(): + """A door closing, a cup on a table. Onset needs corroboration.""" + vad = EnergyVad(FMT) + assert vad.feed(loud()) is False + + +def test_two_loud_frames_are_speech(): + vad = EnergyVad(FMT) + vad.feed(loud()) + assert vad.feed(loud()) is True + + +def test_speech_survives_a_gap_between_words(): + """The pause inside a sentence must not end the turn, or the robot talks + over the second half of everything you say.""" + vad = EnergyVad(FMT) + for _ in range(4): + vad.feed(loud()) + assert vad.feed(quiet()) is True + assert vad.feed(quiet()) is True + + +def test_speech_ends_after_the_hangover(): + vad = EnergyVad(FMT, VadTuning(hangover_frames=3)) + for _ in range(4): + vad.feed(loud()) + assert vad.feed(quiet()) is True + assert vad.feed(quiet()) is True + assert vad.feed(quiet()) is False + + +def test_quiet_time_is_measured_from_the_last_loud_frame(): + """Not from when the debounced flag flipped. The difference is the + hangover, and charging it to the persona's patience would make every soul + slower than the number in its own bundle.""" + vad = EnergyVad(FMT) + for _ in range(3): + vad.feed(loud()) + for i in range(1, 4): + vad.feed(quiet()) + assert vad.quiet_frames == i + assert vad.quiet_ms == pytest.approx(i * 80.0) + + +# -------------------------------------------------------------- noise floor + + +def test_the_floor_rises_in_a_noisy_room(): + """A kitchen and a study differ by more than any fixed threshold spans.""" + vad = EnergyVad(FMT) + start = vad.noise_floor + hum = loud(300) # below the initial threshold, so it counts as room noise + for _ in range(200): + vad.feed(hum) + assert vad.noise_floor > start + + +def test_the_floor_does_not_learn_the_person_talking(): + """Adapting during speech would teach the detector that the speaker is + the room, and it would go deaf to them.""" + vad = EnergyVad(FMT) + settled = vad.noise_floor + for _ in range(50): + vad.feed(loud(8000)) + assert vad.noise_floor == pytest.approx(settled) + + +def test_the_floor_falls_faster_than_it_rises(): + """A fridge switching off must not deafen the robot for a minute.""" + t = VadTuning() + assert t.adapt_down > t.adapt_up + + +# ------------------------------------------------------------- endpointing + + +def feed_all(ep: Endpointer, frames: list[bytes]): + for frame in frames: + result = ep.feed(frame) + if result is not None: + return result + return None + + +def test_a_wake_with_nothing_after_it_ends_as_no_speech(): + """Probably a false wake. The engine above must be able to tell that from + someone who spoke, so it is a distinct reason rather than an empty turn.""" + ep = Endpointer(FMT, lead_in_ms=400) + utterance = feed_all(ep, [quiet()] * 20) + assert utterance is not None + assert utterance.reason is EndReason.NO_SPEECH + assert not utterance.had_speech + assert utterance.audio == b"" + + +def test_speech_then_silence_ends_the_turn(): + ep = Endpointer(FMT, patience_ms=240) # three frames + utterance = feed_all(ep, [loud()] * 5 + [quiet()] * 10) + assert utterance is not None + assert utterance.reason is EndReason.SILENCE + assert utterance.had_speech + assert utterance.duration_ms > 0 + + +def test_patience_is_honoured_rather_than_approximated(): + """The persona asked for a specific silence. Ending early is an interruption + and ending late is a robot that seems slow, so the number has to mean what + it says.""" + ep = Endpointer(FMT, patience_ms=800) # exactly ten 80 ms frames + for _ in range(4): + assert ep.feed(loud()) is None + for i in range(1, 10): + assert ep.feed(quiet()) is None, f"ended early after {i} quiet frames" + assert ep.feed(quiet()) is not None + + +def test_a_patient_soul_waits_longer_than_an_eager_one(): + """The point of putting `patience_ms` on the soul: one engine, two + conversational temperaments, from two data files.""" + + def quiet_frames_until_end(patience: int) -> int: + ep = Endpointer(FMT, patience_ms=patience) + for _ in range(4): + ep.feed(loud()) + n = 0 + while ep.feed(quiet()) is None: + n += 1 + assert n < 100 + return n + + assert quiet_frames_until_end(1600) > quiet_frames_until_end(400) + + +def test_the_start_of_the_first_word_is_not_clipped(): + """Detection lags onset by `onset_frames`, so without a pre-roll every + transcript would begin mid-word.""" + ep = Endpointer(FMT, patience_ms=240, preroll_frames=4) + utterance = feed_all(ep, [loud()] * 3 + [quiet()] * 10) + assert utterance is not None + # Three loud frames, but the captured audio starts before detection did. + assert len(utterance.audio) > 3 * FMT.frame_bytes + + +def test_somebody_who_will_not_stop_is_cut_off_and_told_so(): + ep = Endpointer(FMT, max_utterance_ms=400) + utterance = feed_all(ep, [loud()] * 30) + assert utterance is not None + assert utterance.reason is EndReason.TOO_LONG + assert utterance.had_speech + + +def test_a_source_ending_mid_turn_returns_what_was_caught(): + """A truncated question is more useful than silence.""" + ep = Endpointer(FMT) + for _ in range(5): + ep.feed(loud()) + utterance = ep.close() + assert utterance.reason is EndReason.SOURCE_ENDED + assert utterance.audio + + +def test_a_source_ending_early_is_not_reported_as_a_false_wake(): + """`NO_SPEECH` means the robot waited its full lead-in and nobody spoke, + which is evidence about the detector. A recording that simply stopped is + evidence about the recording.""" + ep = Endpointer(FMT) + ep.feed(quiet()) + utterance = ep.close() + assert utterance.reason is EndReason.SOURCE_ENDED + assert not utterance.had_speech + + +def test_an_endpointer_can_be_reused_for_the_next_turn(): + ep = Endpointer(FMT, patience_ms=160) + first = feed_all(ep, [loud()] * 4 + [quiet()] * 8) + assert first is not None and first.had_speech + second = feed_all(ep, [loud()] * 4 + [quiet()] * 8) + assert second is not None and second.had_speech + + +def test_the_documented_default_is_what_the_spec_says(): + """A bundle written against the specification must behave as it reads.""" + assert DEFAULT_PATIENCE_MS == 900 + assert Endpointer(FMT).patience_ms == 900 From 177fcb414ada046740dd4085bbc6e974598f5ae7 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:16:16 -0700 Subject: [PATCH 10/21] Add CITATIONS.md and require attribution for outside work Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- CITATIONS.md | 102 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 27 +++++++++ README.md | 2 + emet-engine/emet_engine/turn.py | 17 ++++++ 4 files changed, 148 insertions(+) create mode 100644 CITATIONS.md diff --git a/CITATIONS.md b/CITATIONS.md new file mode 100644 index 0000000..1172ce8 --- /dev/null +++ b/CITATIONS.md @@ -0,0 +1,102 @@ +# Citations + +Outside work whose **ideas, findings, or data** shaped Emet, and what was taken +from each. + +This is not a dependency list — installed packages are declared in each +`pyproject.toml`, and copyright notices live in [NOTICE](NOTICE). This file +exists for the harder-to-track case: a measured result that justifies a +default, a taxonomy that shaped a schema, a phoneme set a data file is written +in. Those leave no trace in a lockfile, and by the time somebody asks where a +constant came from, the reasoning is usually gone. + +Each entry records the source, what Emet took, and **the licence of the thing +taken** — because a non-commercially licensed corpus or a differently licensed +repository is a constraint the project has to carry forward. + +--- + +## TurnBench (2026) + +**Freeman Jiang, Ramon Sanabria, Soham Deshmukh, Bandhav Veluri, Simon Michael +Vuch Williams, Elliott K. Suen, Garreth Lee, Kevin Yoonho Choi, Takuya Umeki, +Riku Kubo, Sathvik Udupa, Chien-yu Huang, Shih-Yun Shan Kuan, Zhuoyan Tao, +Satyapriya Krishna, Sefik Emre Eskimez, Yu Tsao, Hung-yi Lee, Shinji +Watanabe.** *TurnBench: A Multi-Domain Benchmark for Turn-Taking Dynamics in +Spoken Dialogue.* arXiv:2608.25218 [eess.AS], 25 August 2026. + +Sesame AI · Mundo AI · Carnegie Mellon University · National Taiwan University +· Academia Sinica · Oto · Brno University of Technology + +- Project: +- Scorer: — **MIT** +- Corpus: **non-commercial licence, prohibits voice cloning** + +**What Emet took.** Three measured medians from the corpus analysis (§IV-B), +used in [`emet_engine/turn.py`](emet-engine/emet_engine/turn.py) to justify the +default `patience_ms`: + +| | | +|---|---| +| Floor transfer offset | −151 ms (listeners begin before the turn ends) | +| Inter-speaker gap | 380 ms | +| Pause within one speaker's turn | 510 ms | + +The third against the second is the argument that a silence threshold cannot +separate "still thinking" from "finished", because the two distributions +overlap. Emet's endpointer is deliberately conservative for that reason, and +that reasoning is theirs, not ours. + +The paper also supplies the honest grade for what Emet currently ships: an +RMS-energy detector is the benchmark's explicit floor. Recording that is part +of the attribution — the finding was inconvenient, and taking the numbers while +omitting the verdict would be quoting selectively. + +**No corpus data, model weights, or code from this work is redistributed +here.** Only findings are cited. If Emet ever scores itself with their scorer, +the MIT terms apply to the scorer and the non-commercial terms apply to the +corpus, and the distinction has to be respected. + +**Their taxonomy is grounded in conversation analysis**, principally Sacks, +Schegloff and Jefferson (1974) on transition-relevance places, and Yngve +(1970) on backchannels. Emet has those concepts second-hand through this paper +rather than from the primary sources, and says so rather than citing work it +has not read. + +--- + +## CMU PocketSphinx and CMUdict + +**Carnegie Mellon University Speech Group.** PocketSphinx. + — **BSD-2-Clause (CMU)** + +The shipped wake word engine +([`emet_hal/pocketsphinx_wake.py`](emet-hal/emet_hal/pocketsphinx_wake.py)), +used as a dependency rather than copied. + +**What is worth naming beyond the dependency**: `SHIPPED_LEXICON` — the +pronunciations that let `emet`, `hugr` and `neuma` be heard — is written in +**ARPAbet**, and is meaningful only against CMU's pronouncing dictionary, which +supplies every other word in a wake phrase. "hey barnaby" needs no lexicon +entry at all because CMUdict already knows the name. That property is the +practical argument for Emet's phonetic-wake design, and it is CMU's work +underneath. + +The pronunciations themselves were derived for this project by decoding +reference audio with PocketSphinx's own allphone search, then checked for false +firing. + +--- + +## Adding to this file + +If a change takes an idea, a finding, a number, or a data format from outside +work, add it here and cite it at the point of use. See +[CONTRIBUTING.md](CONTRIBUTING.md#citing-outside-work). Two rules that are easy +to get wrong: + +- **Ideas count, not only code.** Using a paper's measurement to pick a + constant is taking something, even though nothing was copied. +- **Record the licence of what was taken**, not just of the repository it came + from. A project can ship an MIT tool alongside a corpus you may not use + commercially, and only one of those is safe to build on. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9d71a5..328a091 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,6 +120,33 @@ See [DESIGN.md](DESIGN.md) §2 for the full set and the reasoning behind each. If a change seems to require breaking one of these, open an issue rather than a pull request: that conversation is usually more interesting than the patch. +## Citing outside work + +If a change takes something from a paper, a repository, a dataset, or anyone +else's writing, credit it **in this repository** — a citation at the point of +use and an entry in [CITATIONS.md](CITATIONS.md). + +**Ideas count, not only copied code.** Using a paper's measurement to choose a +default, or a project's data format for a file Emet writes, is taking +something even when not a line is copied. The default `patience_ms` is a live +example: the constant is ours, the evidence that it is the right shape of +compromise is somebody else's, and the docstring says so. + +Two things the entry must record: + +- **What was taken**, specifically enough that a reader can tell whether a + later change still depends on it. +- **The licence of the thing taken**, which is not always the licence of the + repository it came from. A project can publish an MIT tool beside a corpus + you may not use commercially, and only one of those is safe to build on. + +This is partly courtesy and partly self-defence. Emet is Apache 2.0 and wants +to stay cleanly licensed, and an uncredited borrowing is much harder to +untangle a year later when nobody remembers where the number came from. + +Ordinary dependencies do not belong here — declare those in the relevant +`pyproject.toml`. This file is for what a lockfile cannot record. + ## Running things ```sh diff --git a/README.md b/README.md index 0e0c2e1..ec90854 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ tells you which rungs were skipped and what was wrong with each: it did. - **[CONTRIBUTING.md](CONTRIBUTING.md)**: what is open to contribution, the design rules that are not negotiable, and how to run what CI runs. +- **[CITATIONS.md](CITATIONS.md)**: outside work whose ideas, findings, or data + shaped Emet, and the licence attached to each. - **[TRADEMARK.md](TRADEMARK.md)**: the code is yours to fork; the name is not. ## Licence diff --git a/emet-engine/emet_engine/turn.py b/emet-engine/emet_engine/turn.py index a1dfccd..aeeec3f 100644 --- a/emet-engine/emet_engine/turn.py +++ b/emet-engine/emet_engine/turn.py @@ -11,6 +11,23 @@ the same engine produces two different conversational temperaments from two data files. +**On the default of 900 ms.** The numbers it trades against are measured rather +than guessed, and they are not ours: they come from the TurnBench corpus +analysis (Jiang et al., *TurnBench: A Multi-Domain Benchmark for Turn-Taking +Dynamics in Spoken Dialogue*, arXiv:2608.25218, 2026 — see `CITATIONS.md`). + + floor transfer offset, median -151 ms (before the turn ends) + inter-speaker gap, median 380 ms + pause *within* one speaker's turn 510 ms + +The third number is why a silence threshold cannot be made good. A pause inside +somebody's own turn and a gap between speakers overlap heavily, so no threshold +separates "still thinking" from "finished". 900 ms sits above both: it will +rarely cut anybody off, and it will always feel slower than a person, who +starts speaking before you have finished. That is a deliberate trade, not a +tuned value, and it is the right way round while the only evidence available is +energy. + **Scope.** `extend_on_incomplete`, the trailing-clause heuristic, is not here. The specification is precise that it triggers when *the transcript* looks unfinished, and there is no transcript until speech recognition exists. Trying From 541cf1bd023a83da5802e890a5863d769645fc1b Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:16:31 -0700 Subject: [PATCH 11/21] Measure whether the listen loop keeps up Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-engine/README.md | 40 +++++++ emet-engine/emet_engine/cli.py | 27 ++++- emet-engine/emet_engine/metrics.py | 179 +++++++++++++++++++++++++++ emet-engine/emet_engine/session.py | 30 ++++- emet-engine/tests/test_metrics.py | 186 +++++++++++++++++++++++++++++ 5 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 emet-engine/emet_engine/metrics.py create mode 100644 emet-engine/tests/test_metrics.py diff --git a/emet-engine/README.md b/emet-engine/README.md index c885a88..f42e89d 100644 --- a/emet-engine/README.md +++ b/emet-engine/README.md @@ -12,3 +12,43 @@ emet-listen path/to/manifest.yaml path/to/soul.yaml --replay recording.wav Needs `emet-hal[audio,wake]` installed alongside for a microphone and a detector to exist. + +## Checking that it keeps up + +The 0.3 acceptance bar is ten minutes without drift, which is a number rather +than a feeling. `--stats` produces it: + +```sh +emet-listen manifest.yaml soul.yaml --replay ten-minutes.wav --stats +``` + +The figure that matters is **realtime**: processing time divided by the audio +processed. Audio arrives at a fixed rate whatever the CPU is doing, so 1.0 is +exactly keeping up with no margin and anything above it is falling behind. +`frames over budget` matters separately — a loop with a fine average that +overruns once a minute is dropping a word once a minute, and the average hides +it. + +A run is only evidence if nothing was dropped, nothing went over budget, and +there is real margin. A bare pass on an idle machine is not a pass on a busy +one. + +Recorded baseline, x86 laptop, 10.1 minutes of real speech through pocketsphinx +and the full turn loop: + +``` + frames 7583 (606.6s of audio) + wakes 58 turns 58 + per frame mean 1.67 ms p50 1.08 p95 5.87 max 25.54 + budget 80 ms/frame, 0 frame(s) over + realtime 0.0209 (48x faster than realtime) + dropped 0 + verdict kept up +``` + +**This is not the number that decides the project.** The reference body is a +Raspberry Pi, and an x86 laptop says nothing about ARM except by comparison. +The same command on a Pi is the measurement that matters, and it has not been +taken. A file replay also exercises everything except the sound card, so +genuine clock drift — where the card's second and the system's slowly diverge — +is still unmeasured and needs a live microphone. diff --git a/emet-engine/emet_engine/cli.py b/emet-engine/emet_engine/cli.py index 5552305..f326fcf 100644 --- a/emet-engine/emet_engine/cli.py +++ b/emet-engine/emet_engine/cli.py @@ -110,14 +110,24 @@ async def _run(args: argparse.Namespace) -> int: # Only reached when the source ends, which means a replay finished. print(f"\nsource ended. heard it {heard} time(s).") - if session.dropped: - print( - f"warning: {session.dropped} frame(s) were dropped, so wake words " - f"may have been missed." - ) + _finish(session, args) return 0 +def _finish(session: ListenSession, args: argparse.Namespace) -> None: + """Report what the run cost, if asked, and always report what it lost.""" + if args.stats: + # `live` from the source that actually ran, not from the flag: only a + # real sound card can drift, and claiming otherwise for a file would + # be inventing a measurement. + print("\n" + session.stats.report(live=session.source_name != "wav")) + if session.dropped: + print( + f"\nwarning: {session.dropped} frame(s) were dropped, so wake words " + f"may have been missed. The loop is not keeping up with the audio." + ) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="emet-listen", @@ -131,6 +141,13 @@ def main(argv: list[str] | None = None) -> int: help="read this 16-bit mono wav instead of the microphone, so a wake " "failure can be reproduced away from the room it happened in", ) + parser.add_argument( + "--stats", + action="store_true", + help="report whether the loop kept up: per-frame timings, frames over " + "budget, and the real-time factor. This is how the ten-minute soak in " + "the 0.3 acceptance criteria is actually checked", + ) args = parser.parse_args(argv) try: diff --git a/emet-engine/emet_engine/metrics.py b/emet-engine/emet_engine/metrics.py new file mode 100644 index 0000000..5cee1e9 --- /dev/null +++ b/emet-engine/emet_engine/metrics.py @@ -0,0 +1,179 @@ +"""Measuring whether the loop keeps up. + +0.3 is not done when it works once. The acceptance bar is ten minutes without +drift, and "without drift" is not something you can watch for — it is a number +or it is a feeling. This module makes it a number. + +**The one that decides everything is the real-time factor**: processing time +divided by the audio it processed. Audio arrives at a fixed rate whatever the +CPU is doing, so at an RTF of 1.0 the loop is exactly keeping up and has no +margin; above 1.0 it is falling behind and frames are being dropped somewhere. +This is the number that will decide whether a Raspberry Pi can run Emet at all, +and it cannot be guessed from a laptop — but it can be *compared*, which is why +it is worth recording on both. + +**Frames over budget matters more than the average.** A loop averaging 20 ms +per 80 ms frame is comfortable; the same loop is not comfortable if one frame +in fifty takes 200 ms, because that frame is a dropped word. Averages hide +exactly the failure that people notice, so this counts the overruns +separately. + +**Drift means two different things and only one is measurable offline.** +Processing drift — the loop failing to keep pace — shows up in the RTF and can +be measured against a file. Clock drift, where the sound card's idea of a +second and the system's slowly diverge, only appears with real hardware. This +module reports the first honestly and refuses to invent the second: for a file +source, wall-clock comparisons are meaningless and are labelled as such. +""" + +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass, field + +__all__ = ["SessionStats", "Stopwatch"] + +#: How many per-frame timings to keep for percentiles. Ten minutes at 80 ms +#: frames is 7500, so this holds a whole soak run; a robot left on for a week +#: keeps the most recent hour and a half and its running totals stay exact. +SAMPLE_CAP = 8192 + + +class Stopwatch: + """Times a block, in milliseconds, without pretending to be precise about + anything the operating system will not promise.""" + + __slots__ = ("_start", "elapsed_ms") + + def __init__(self) -> None: + self._start = 0.0 + self.elapsed_ms = 0.0 + + def __enter__(self) -> "Stopwatch": + self._start = time.perf_counter() + return self + + def __exit__(self, *exc: object) -> None: + self.elapsed_ms = (time.perf_counter() - self._start) * 1000.0 + + +@dataclass +class SessionStats: + """What one run of the listen loop did, and whether it kept up.""" + + #: Milliseconds of audio each frame represents. Set from the format so the + #: budget is not hardcoded to one frame size. + frame_ms: float = 80.0 + + frames: int = 0 + wakes: int = 0 + turns: int = 0 + #: Frames the source discarded because the loop fell behind. Not the same + #: as being slow: this is audio that was never seen at all. + dropped: int = 0 + + process_ms_total: float = 0.0 + process_ms_max: float = 0.0 + over_budget: int = 0 + + _samples: deque[float] = field(default_factory=lambda: deque(maxlen=SAMPLE_CAP)) + _started: float = field(default_factory=time.perf_counter) + + # ----------------------------------------------------------- recording + + def record_frame(self, process_ms: float) -> None: + self.frames += 1 + self.process_ms_total += process_ms + self.process_ms_max = max(self.process_ms_max, process_ms) + if process_ms > self.frame_ms: + self.over_budget += 1 + self._samples.append(process_ms) + + # ------------------------------------------------------------ readings + + @property + def audio_ms(self) -> float: + """How much audio was seen. The denominator for everything below.""" + return self.frames * self.frame_ms + + @property + def wall_ms(self) -> float: + return (time.perf_counter() - self._started) * 1000.0 + + @property + def realtime_factor(self) -> float: + """Processing time over audio duration. Below 1.0 keeps up. + + Deliberately excludes time spent waiting for audio to arrive: on a live + microphone that wait is most of the wall clock and is not work. Timing + it would flatter every measurement to about 1.0 and hide the thing this + number exists to reveal. + """ + if not self.audio_ms: + return 0.0 + return self.process_ms_total / self.audio_ms + + @property + def headroom(self) -> float: + """How many times faster than real time. 12x is comfortable, 1x is not.""" + rtf = self.realtime_factor + return (1.0 / rtf) if rtf else float("inf") + + @property + def mean_ms(self) -> float: + return self.process_ms_total / self.frames if self.frames else 0.0 + + def percentile(self, p: float) -> float: + """Nearest-rank over the retained samples. `p` in 0..100.""" + if not self._samples: + return 0.0 + ordered = sorted(self._samples) + i = min(len(ordered) - 1, max(0, round(p / 100.0 * len(ordered)) - 1)) + return ordered[i] + + @property + def kept_up(self) -> bool: + """Whether this run is evidence the loop is viable here. + + Three conditions, and all of them matter. Nothing was dropped, no frame + blew the budget, and there is real margin rather than a bare pass — a + run at 0.99 kept up on a quiet machine and will not on a busy one. + """ + return self.dropped == 0 and self.over_budget == 0 and self.realtime_factor < 0.5 + + # -------------------------------------------------------------- report + + def report(self, *, live: bool) -> str: + """A human-readable summary. + + `live` says whether the audio came from hardware, because it changes + what can honestly be claimed: clock drift is only meaningful against a + real sound card, and printing a wall-clock comparison for a file would + be inventing a measurement. + """ + lines = [ + f" frames {self.frames} ({self.audio_ms / 1000:.1f}s of audio)", + f" wakes {self.wakes} turns {self.turns}", + f" per frame mean {self.mean_ms:.2f} ms " + f"p50 {self.percentile(50):.2f} p95 {self.percentile(95):.2f} " + f"max {self.process_ms_max:.2f}", + f" budget {self.frame_ms:.0f} ms/frame, " + f"{self.over_budget} frame(s) over", + f" realtime {self.realtime_factor:.4f} " + f"({self.headroom:.0f}x faster than realtime)", + f" dropped {self.dropped}", + ] + if live: + skew = self.wall_ms - self.audio_ms + lines.append( + f" clock wall {self.wall_ms / 1000:.1f}s vs audio " + f"{self.audio_ms / 1000:.1f}s skew {skew / 1000:+.2f}s" + ) + else: + lines.append( + " clock not measured: a file has no sound card, so " + "wall time says nothing about drift" + ) + lines.append(f" verdict {'kept up' if self.kept_up else 'DID NOT KEEP UP'}") + return "\n".join(lines) diff --git a/emet-engine/emet_engine/session.py b/emet-engine/emet_engine/session.py index 6643cfb..9a9abba 100644 --- a/emet-engine/emet_engine/session.py +++ b/emet-engine/emet_engine/session.py @@ -32,6 +32,7 @@ from emet_sdk.types import AudioFormat, AudioSource, WakeDescriptor, WakeEvent from emet_sdk.validate import DEFAULT_AUDIO_SOURCE, DEFAULT_WAKE_ENGINE +from emet_engine.metrics import SessionStats, Stopwatch from emet_engine.turn import DEFAULT_PATIENCE_MS, Endpointer, Utterance __all__ = ["EngineError", "ListenSession"] @@ -84,6 +85,10 @@ def __init__( self._audio: AudioSource | None = None self.descriptor: WakeDescriptor | None = None self.format: AudioFormat | None = None + #: Whether the loop is keeping up. Populated as it runs; see + #: `emet_engine.metrics` for why the real-time factor is the number + #: that decides whether a body can run Emet at all. + self.stats = SessionStats() # ------------------------------------------------------------ lifecycle @@ -107,6 +112,8 @@ async def start(self) -> None: frame_samples=self.descriptor.frame_samples, ) + self.stats.frame_ms = self.format.frame_ms + source_cls = self.registry.load_audio(self.source_name) self._audio = source_cls(self._input_block, self.format) await self._audio.start() @@ -171,8 +178,13 @@ async def wakes(self) -> AsyncIterator[WakeEvent]: frame = await self._audio.read() if frame is None: return - event = await self._wake.process(frame) + # Timed around the work only. The wait for audio above is not + # work, and counting it would flatter every measurement. + with Stopwatch() as watch: + event = await self._wake.process(frame) + self._record(watch.elapsed_ms) if event is not None: + self.stats.wakes += 1 yield event await self._wake.reset() @@ -201,22 +213,34 @@ async def turns(self) -> AsyncIterator[tuple[WakeEvent, Utterance]]: return if endpointer is None: - event = await self._wake.process(frame) + with Stopwatch() as watch: + event = await self._wake.process(frame) + self._record(watch.elapsed_ms) if event is not None: + self.stats.wakes += 1 await self._wake.reset() pending = event endpointer = Endpointer(self.format, patience_ms=self.patience_ms) continue - utterance = endpointer.feed(frame) + with Stopwatch() as watch: + utterance = endpointer.feed(frame) + self._record(watch.elapsed_ms) if utterance is not None: assert pending is not None + self.stats.turns += 1 yield pending, utterance endpointer = None pending = None # -------------------------------------------------------------- honesty + def _record(self, process_ms: float) -> None: + self.stats.record_frame(process_ms) + # Pulled from the source each frame rather than read once at the end: + # a run that is killed part way through should still say what it lost. + self.stats.dropped = self.dropped + @property def dropped(self) -> int: """Frames the source discarded because this loop fell behind. diff --git a/emet-engine/tests/test_metrics.py b/emet-engine/tests/test_metrics.py new file mode 100644 index 0000000..a1eef6c --- /dev/null +++ b/emet-engine/tests/test_metrics.py @@ -0,0 +1,186 @@ +"""The instrument. + +Worth testing carefully, because a measuring tool that is quietly wrong is +worse than no measurement: it produces a number, the number gets believed, and +the belief survives long after anyone remembers where it came from. These tests +feed known timings in and assert the arithmetic comes back out. +""" + +from __future__ import annotations + +import time + +from emet_engine.metrics import SAMPLE_CAP, SessionStats, Stopwatch + + +def stats(frame_ms: float = 80.0) -> SessionStats: + return SessionStats(frame_ms=frame_ms) + + +# ------------------------------------------------------------------ counting + + +def test_a_fresh_run_claims_nothing(): + s = stats() + assert s.frames == 0 + assert s.realtime_factor == 0.0 + assert s.mean_ms == 0.0 + assert s.percentile(50) == 0.0 + + +def test_frames_and_audio_duration_track_each_other(): + s = stats(frame_ms=80.0) + for _ in range(50): + s.record_frame(1.0) + assert s.frames == 50 + assert s.audio_ms == 4000.0 + + +# -------------------------------------------------------------- the budget + + +def test_a_frame_inside_the_budget_is_not_an_overrun(): + s = stats(frame_ms=80.0) + s.record_frame(79.9) + assert s.over_budget == 0 + + +def test_a_frame_over_the_budget_is_counted(): + s = stats(frame_ms=80.0) + s.record_frame(80.1) + assert s.over_budget == 1 + + +def test_the_budget_follows_the_frame_size(): + """A body using shorter frames has less time per frame, and the budget has + to move with it rather than being hardcoded to 80 ms.""" + s = stats(frame_ms=20.0) + s.record_frame(30.0) + assert s.over_budget == 1 + + +def test_one_bad_frame_survives_a_good_average(): + """The failure people actually notice. A loop averaging 2 ms is + comfortable; the same loop dropping a word once a minute is not, and an + average will not show it.""" + s = stats(frame_ms=80.0) + for _ in range(999): + s.record_frame(2.0) + s.record_frame(500.0) + assert s.mean_ms < 3.0 + assert s.over_budget == 1 + assert s.process_ms_max == 500.0 + assert not s.kept_up + + +# --------------------------------------------------------- the real number + + +def test_the_realtime_factor_is_work_over_audio(): + s = stats(frame_ms=80.0) + for _ in range(10): + s.record_frame(8.0) # 80 ms of work for 800 ms of audio + assert s.realtime_factor == 0.1 + assert s.headroom == 10.0 + + +def test_exactly_keeping_up_is_a_factor_of_one(): + s = stats(frame_ms=80.0) + for _ in range(10): + s.record_frame(80.0) + assert s.realtime_factor == 1.0 + assert not s.kept_up, "no margin is not the same as keeping up" + + +def test_a_bare_pass_is_not_treated_as_success(): + """0.99 kept up on an idle machine and will not on a busy one. The verdict + wants margin, not a photo finish.""" + s = stats(frame_ms=80.0) + for _ in range(100): + s.record_frame(79.0) + assert s.realtime_factor < 1.0 + assert not s.kept_up + + +def test_comfortable_work_keeps_up(): + s = stats(frame_ms=80.0) + for _ in range(100): + s.record_frame(4.0) + assert s.kept_up + + +def test_dropped_frames_fail_the_verdict_however_fast_the_loop_is(): + """Dropped audio is not slowness, it is audio nobody ever saw. A fast loop + that lost a wake word did not keep up.""" + s = stats() + for _ in range(100): + s.record_frame(1.0) + assert s.kept_up + s.dropped = 1 + assert not s.kept_up + + +# ------------------------------------------------------------- percentiles + + +def test_percentiles_over_a_known_distribution(): + s = stats() + for v in range(1, 101): + s.record_frame(float(v)) + assert s.percentile(50) == 50.0 + assert s.percentile(95) == 95.0 + assert s.percentile(100) == 100.0 + + +def test_percentiles_survive_the_sample_cap(): + """Totals stay exact forever; percentiles cover the recent past. A robot + left on for a week must not grow a list until it dies.""" + s = stats() + for _ in range(SAMPLE_CAP + 500): + s.record_frame(5.0) + assert s.frames == SAMPLE_CAP + 500 + assert len(s._samples) == SAMPLE_CAP + assert s.percentile(50) == 5.0 + assert s.mean_ms == 5.0 + + +# ----------------------------------------------------------------- honesty + + +def test_a_file_run_refuses_to_report_clock_drift(): + """A file has no sound card. Comparing wall time to audio time would + produce a number that looks like drift and means nothing.""" + s = stats() + s.record_frame(1.0) + report = s.report(live=False) + assert "not measured" in report + assert "skew" not in report + + +def test_a_live_run_reports_the_clock(): + s = stats() + s.record_frame(1.0) + report = s.report(live=True) + assert "skew" in report + + +def test_the_verdict_appears_in_the_report(): + s = stats() + for _ in range(10): + s.record_frame(1.0) + assert "kept up" in s.report(live=False) + s.dropped = 3 + assert "DID NOT KEEP UP" in s.report(live=False) + + +# ---------------------------------------------------------------- stopwatch + + +def test_the_stopwatch_measures_elapsed_time(): + with Stopwatch() as watch: + time.sleep(0.02) + assert 10.0 < watch.elapsed_ms < 500.0 + + +def test_the_stopwatch_reports_zero_before_it_is_used(): + assert Stopwatch().elapsed_ms == 0.0 From 318dc4f19389380f1e7490c187b3d37b073ed8ae Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:31:37 -0700 Subject: [PATCH 12/21] Add the emet.audio_out sink seam and wire playback Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-engine/emet_engine/cli.py | 20 ++++ emet-engine/emet_engine/session.py | 48 ++++++++- emet-engine/tests/test_session.py | 22 +++- emet-hal/emet_hal/audio.py | 112 ++++++++++++++++++--- emet-hal/pyproject.toml | 5 + emet-hal/tests/test_audio.py | 60 +++++++++++ emet-sdk/emet_sdk/discovery.py | 30 +++++- emet-sdk/emet_sdk/types.py | 40 ++++++++ emet-sdk/emet_sdk/validate.py | 35 +++++++ emet-sdk/examples/mock-scout.yaml | 4 + emet-sdk/schemas/body-manifest.schema.json | 14 +++ emet-sdk/tests/test_audio_source.py | 63 +++++++++++- 12 files changed, 433 insertions(+), 20 deletions(-) diff --git a/emet-engine/emet_engine/cli.py b/emet-engine/emet_engine/cli.py index f326fcf..32b7e40 100644 --- a/emet-engine/emet_engine/cli.py +++ b/emet-engine/emet_engine/cli.py @@ -78,6 +78,16 @@ async def _run(args: argparse.Namespace) -> int: soul = _load(Path(args.soul), "soul", registry) if args.replay: manifest = _replay(manifest, args.replay) + if args.echo: + # Echo replays captured *input* audio, so the sink has to run at the + # input's rate rather than the synthesis rate it would normally use. + # Once there is speech synthesis this goes away: the sink will run at + # whatever the voice produces and nothing will need to match. + manifest = copy.deepcopy(manifest) + output = manifest.setdefault("audio", {}).setdefault("output", {}) + output["sample_rate"] = int( + (manifest["audio"].get("input") or {}).get("sample_rate") or 16000 + ) session = ListenSession(manifest, soul, registry=registry) async with session: @@ -103,6 +113,9 @@ async def _run(args: argparse.Namespace) -> int: f" then {utterance.duration_ms / 1000:.1f}s of speech, " f"ended on {utterance.reason.value}" ) + if args.echo: + await session.say(utterance.audio) + print(" played it back") elif utterance.reason is EndReason.SOURCE_ENDED: print(" the recording ended before anything followed.") else: @@ -141,6 +154,13 @@ def main(argv: list[str] | None = None) -> int: help="read this 16-bit mono wav instead of the microphone, so a wake " "failure can be reproduced away from the room it happened in", ) + parser.add_argument( + "--echo", + action="store_true", + help="play each captured utterance back through the output. There is " + "no speech synthesis yet, so this is what proves the whole duplex path " + "works: audio in, wake, endpoint, audio out", + ) parser.add_argument( "--stats", action="store_true", diff --git a/emet-engine/emet_engine/session.py b/emet-engine/emet_engine/session.py index 9a9abba..1d04ffa 100644 --- a/emet-engine/emet_engine/session.py +++ b/emet-engine/emet_engine/session.py @@ -29,8 +29,12 @@ from typing import Any, AsyncIterator, Mapping from emet_sdk.discovery import PluginRegistry -from emet_sdk.types import AudioFormat, AudioSource, WakeDescriptor, WakeEvent -from emet_sdk.validate import DEFAULT_AUDIO_SOURCE, DEFAULT_WAKE_ENGINE +from emet_sdk.types import AudioFormat, AudioSink, AudioSource, WakeDescriptor, WakeEvent +from emet_sdk.validate import ( + DEFAULT_AUDIO_SINK, + DEFAULT_AUDIO_SOURCE, + DEFAULT_WAKE_ENGINE, +) from emet_engine.metrics import SessionStats, Stopwatch from emet_engine.turn import DEFAULT_PATIENCE_MS, Endpointer, Utterance @@ -70,10 +74,12 @@ def __init__( audio = manifest.get("audio") or {} self._input_block: Mapping[str, Any] = audio.get("input") or {} self._wake_block: Mapping[str, Any] = audio.get("wake") or {} + self._output_block: Mapping[str, Any] = audio.get("output") or {} self.phrase = str((soul.get("identity") or {}).get("wake_word") or "") self.engine_name = str(self._wake_block.get("engine") or DEFAULT_WAKE_ENGINE) self.source_name = str(self._input_block.get("source") or DEFAULT_AUDIO_SOURCE) + self.sink_name = str(self._output_block.get("sink") or DEFAULT_AUDIO_SINK) # Turn-taking is a persona trait, not engine tuning: a reflective soul # waits longer than an eager one, and that difference is the whole @@ -83,6 +89,7 @@ def __init__( self._wake: Any = None self._audio: AudioSource | None = None + self._sink: AudioSink | None = None self.descriptor: WakeDescriptor | None = None self.format: AudioFormat | None = None #: Whether the loop is keeping up. Populated as it runs; see @@ -118,6 +125,15 @@ async def start(self) -> None: self._audio = source_cls(self._input_block, self.format) await self._audio.start() + # The output rate is not the input rate and has no reason to be: one is + # what the detector needs, the other is what synthesis produces. + self.sink_format = AudioFormat( + sample_rate=int(self._output_block.get("sample_rate") or 22050) + ) + sink_cls = self.registry.load_audio_out(self.sink_name) + self._sink = sink_cls(self._output_block, self.sink_format) + await self._sink.start() + log.info( "listening for %r via %s on %s at %d Hz", self.phrase, @@ -148,6 +164,9 @@ def _deaf_message(self) -> str: async def stop(self) -> None: """Safe to call twice, and safe if `start()` raised part way through.""" + if self._sink is not None: + await self._sink.stop() + self._sink = None if self._audio is not None: await self._audio.stop() self._audio = None @@ -162,6 +181,31 @@ async def __aenter__(self) -> "ListenSession": async def __aexit__(self, *exc: object) -> None: await self.stop() + # ---------------------------------------------------------------- speech + + async def say(self, pcm: bytes) -> None: + """Play mono int16 audio at the sink's rate. + + The whole of Emet's output side for now. 0.4 puts speech synthesis in + front of it; nothing below this line needs to change when it does, + which is the point of the sink being a discovered contract rather than + a speaker this module imported. + """ + if self._sink is None: + raise EngineError("session was not started") + await self._sink.play(pcm) + + async def hush(self) -> None: + """Stop talking immediately, mid-word. + + Barge-in is built on this: `DESIGN.md` §13 requires that speech during + playback interrupts rather than queues. Nothing calls it yet, and the + sink contract carries it from the start so that adding barge-in later + is engine work rather than a breaking change to every sink. + """ + if self._sink is not None: + await self._sink.cancel() + # ----------------------------------------------------------------- loop async def wakes(self) -> AsyncIterator[WakeEvent]: diff --git a/emet-engine/tests/test_session.py b/emet-engine/tests/test_session.py index cda31ff..de6e475 100644 --- a/emet-engine/tests/test_session.py +++ b/emet-engine/tests/test_session.py @@ -45,10 +45,25 @@ def saying(times: int) -> bytes: return (silence(1280) + PHRASE.encode() + silence(1280)) * times -def body(path: str, *, engine: str = "mock", source: str = "wav", **wake_params) -> dict: +def body( + path: str, + *, + engine: str = "mock", + source: str = "wav", + sink: str = "null", + **wake_params, +) -> dict: + """A body whose ears are a file and whose mouth is a bin. + + `sink="null"` is not incidental. The default is a real speaker, so a test + body that said nothing about output would open whatever is plugged into the + machine running the suite — which fails on a headless CI runner and is rude + on a laptop. Tests state where their audio goes. + """ return { "audio": { "input": {"source": source, "device": "file", "params": {"path": path}}, + "output": {"sink": sink, "device": "none"}, "wake": {"engine": engine, "params": wake_params}, } } @@ -94,11 +109,12 @@ async def scenario(): def test_defaults_apply_when_the_manifest_says_nothing(tmp_path): - """Most manifests will mention neither, so the defaults are what actually - runs most of the time.""" + """Most manifests will mention none of these, so the defaults are what + actually runs most of the time. Constructing does not open anything.""" session = ListenSession({"audio": {"input": {"device": "x"}}}, soul()) assert session.engine_name == "pocketsphinx" assert session.source_name == "microphone" + assert session.sink_name == "speaker" # ------------------------------------------------- the check with no fallback diff --git a/emet-hal/emet_hal/audio.py b/emet-hal/emet_hal/audio.py index 74384ac..aa9ac02 100644 --- a/emet-hal/emet_hal/audio.py +++ b/emet-hal/emet_hal/audio.py @@ -38,15 +38,18 @@ from dataclasses import dataclass from typing import Any -from emet_sdk.types import SAMPLE_BYTES, AudioFormat, AudioSource +from emet_sdk.types import SAMPLE_BYTES, AudioFormat, AudioSink, AudioSource __all__ = [ "AudioError", "AudioFormat", "AudioSource", + "AudioSink", "Device", "MicrophoneSource", + "NullSink", "Speaker", + "WavSink", "WavSource", "devices", "resolve_device", @@ -387,27 +390,35 @@ async def stop(self) -> None: class Speaker: - """Playback, so that a voice rung is a sound rather than a promise. + """Playback through a real sound card, so a voice rung is a sound rather + than a promise. - Deliberately small. It takes int16 PCM at a stated rate and plays it; how - that audio came to exist is the business of whatever synthesises speech, - which does not exist yet. + Deliberately small. It takes int16 PCM at the format it was given and plays + it; how that audio came to exist is the business of whatever synthesises + speech. """ - def __init__(self, config: dict[str, Any] | None = None, *, sample_rate: int = 22050) -> None: + def __init__(self, config: dict[str, Any] | None = None, fmt: AudioFormat | None = None) -> None: """`config` is the manifest's `audio.output` block.""" self.config = dict(config or {}) - self.sample_rate = sample_rate + # 22.05 kHz rather than the 16 kHz of the input side: this is a + # synthesis rate, and speech generated at 16 kHz sounds like a + # telephone. The two directions are unrelated and need not agree. + self.format = fmt or AudioFormat(sample_rate=22050) self._device: int | None = None self._sd: Any = None + @property + def sample_rate(self) -> int: + return self.format.sample_rate + async def start(self) -> None: self._sd = _sd() self._device = resolve_device(self.config.get("device"), want_input=False) _check_rate(self._device, self.sample_rate, 1, want_input=False) - async def play(self, pcm: bytes, *, blocking: bool = True) -> None: - """Play mono int16 PCM. Returns when it has finished, unless told not to.""" + async def play(self, pcm: bytes) -> None: + """Play mono int16 PCM, returning when it has finished.""" if self._sd is None: raise AudioError("speaker was not started") samples = array("h") @@ -418,9 +429,86 @@ async def play(self, pcm: bytes, *, blocking: bool = True) -> None: device=self._device, blocking=False, ) - if blocking: - await asyncio.to_thread(self._sd.wait) + await asyncio.to_thread(self._sd.wait) - async def stop(self) -> None: + async def cancel(self) -> None: + """Stop mid-word. This is what barge-in is made of.""" if self._sd is not None: self._sd.stop() + + async def stop(self) -> None: + await self.cancel() + self._sd = None + + +class WavSink: + """Writes what the robot said to a file instead of playing it. + + The counterpart to `WavSource`, and useful for the same reason: a bug + report about what the robot *said* is reproducible if the audio still + exists. It is also the only sink that can be asserted on in a test. + """ + + def __init__(self, config: dict[str, Any] | None = None, fmt: AudioFormat | None = None) -> None: + self.config = dict(config or {}) + self.format = fmt or AudioFormat(sample_rate=22050) + self.path = str((self.config.get("params") or {}).get("path") or "") + self._wav: wave.Wave_write | None = None + + async def start(self) -> None: + if not self.path: + raise AudioError( + "the wav sink needs a destination: set `audio.output.params.path`." + ) + try: + wav = wave.open(self.path, "wb") + except Exception as exc: + raise AudioError(f"could not open {self.path!r} for writing: {exc}") from exc + wav.setnchannels(1) + wav.setsampwidth(SAMPLE_BYTES) + wav.setframerate(self.format.sample_rate) + self._wav = wav + + async def play(self, pcm: bytes) -> None: + if self._wav is None: + raise AudioError("wav sink was not started") + self._wav.writeframes(pcm) + + async def cancel(self) -> None: + """Nothing to interrupt: a file is written as fast as it is handed + over, so there is never playback in progress to stop.""" + + async def stop(self) -> None: + if self._wav is not None: + self._wav.close() + self._wav = None + + +class NullSink: + """Accepts audio and discards it, counting what it was given. + + Not only for tests, though it is essential there — a suite that plays sound + through whatever happens to be plugged in is as rude as one that records. + It is also how you run the loop on a laptop at midnight, and how a body + with no speaker attached still satisfies the contract that every chain + terminates in a voice rung. + """ + + def __init__(self, config: dict[str, Any] | None = None, fmt: AudioFormat | None = None) -> None: + self.config = dict(config or {}) + self.format = fmt or AudioFormat(sample_rate=22050) + #: Bytes discarded. The only evidence this sink leaves behind. + self.written = 0 + self.cancelled = 0 + + async def start(self) -> None: + pass + + async def play(self, pcm: bytes) -> None: + self.written += len(pcm) + + async def cancel(self) -> None: + self.cancelled += 1 + + async def stop(self) -> None: + pass diff --git a/emet-hal/pyproject.toml b/emet-hal/pyproject.toml index 781429b..dd40336 100644 --- a/emet-hal/pyproject.toml +++ b/emet-hal/pyproject.toml @@ -41,6 +41,11 @@ dev = ["pytest>=8.0"] "microphone" = "emet_hal.audio:MicrophoneSource" "wav" = "emet_hal.audio:WavSource" +[project.entry-points."emet.audio_out"] +"speaker" = "emet_hal.audio:Speaker" +"wav" = "emet_hal.audio:WavSink" +"null" = "emet_hal.audio:NullSink" + [project.entry-points."emet.wake"] "pocketsphinx" = "emet_hal.pocketsphinx_wake:PocketSphinxWake" "mock" = "emet_hal.mock:MockWake" diff --git a/emet-hal/tests/test_audio.py b/emet-hal/tests/test_audio.py index 6413b04..2920c08 100644 --- a/emet-hal/tests/test_audio.py +++ b/emet-hal/tests/test_audio.py @@ -23,7 +23,11 @@ from emet_hal.audio import ( AudioError, AudioFormat, + AudioSink, AudioSource, + NullSink, + Speaker, + WavSink, MicrophoneSource, WavSource, _downmix, @@ -361,3 +365,59 @@ async def scenario(): src = run(scenario()) assert src.dropped == 3 assert src._queue.qsize() == 2 + + +# ------------------------------------------------------------------- sinks + + +def test_the_null_sink_swallows_audio_and_counts_it(): + """How the loop runs at midnight, and how a test avoids playing sound + through whatever is plugged into the machine running it.""" + sink = NullSink() + + async def scenario(): + await sink.start() + await sink.play(silence(100)) + await sink.play(silence(50)) + await sink.cancel() + await sink.stop() + + run(scenario()) + assert sink.written == 300 + assert sink.cancelled == 1 + + +def test_the_wav_sink_writes_a_readable_file(tmp_path): + path = tmp_path / "said.wav" + sink = WavSink({"sink": "wav", "params": {"path": str(path)}}, AudioFormat(sample_rate=22050)) + + async def scenario(): + await sink.start() + await sink.play(silence(1000)) + await sink.stop() + + run(scenario()) + with wave.open(str(path)) as w: + assert w.getframerate() == 22050 + assert w.getnchannels() == 1 + assert w.getnframes() == 1000 + + +def test_the_wav_sink_needs_a_destination(): + with pytest.raises(AudioError, match="params.path"): + run(WavSink({"sink": "wav"}).start()) + + +def test_a_speaker_can_be_built_without_touching_hardware(): + """Construction must be inert; only `start()` may open a device. A test + that constructs one must not make a sound.""" + speaker = Speaker({"device": "plughw:1,0"}, AudioFormat(sample_rate=22050)) + assert speaker.sample_rate == 22050 + assert isinstance(speaker, AudioSink) + + +def test_output_defaults_to_a_synthesis_rate_not_the_input_rate(): + """16 kHz is what the detector needs. Speech generated at 16 kHz sounds + like a telephone, and the two directions have no reason to agree.""" + assert NullSink().format.sample_rate == 22050 + assert AudioFormat().sample_rate == 16000 diff --git a/emet-sdk/emet_sdk/discovery.py b/emet-sdk/emet_sdk/discovery.py index 2d695d1..ad640e4 100644 --- a/emet-sdk/emet_sdk/discovery.py +++ b/emet-sdk/emet_sdk/discovery.py @@ -5,13 +5,14 @@ engine has no list of known drivers compiled into it — installing a package is what makes a driver exist. -Five groups: +Six groups: emet.actuators name = the string used in `driver.plugin` emet.sensors name = the string used in `driver.plugin` emet.locomotion name = the string used in `drive.kinematics` emet.wake name = the string used in `audio.wake.engine` emet.audio name = the string used in `audio.input.source` + emet.audio_out name = the string used in `audio.output.sink` `emet.audio` exists for a reason the others do not share. The engine may import `emet_sdk` and nothing else, so it cannot reach into `emet_hal` for a @@ -53,6 +54,7 @@ "GROUP_LOCOMOTION", "GROUP_WAKE", "GROUP_AUDIO", + "GROUP_AUDIO_OUT", "PluginRegistry", "discover", ] @@ -62,6 +64,7 @@ GROUP_LOCOMOTION = "emet.locomotion" GROUP_WAKE = "emet.wake" GROUP_AUDIO = "emet.audio" +GROUP_AUDIO_OUT = "emet.audio_out" def _entry_points(group: str) -> dict[str, EntryPoint]: @@ -83,6 +86,7 @@ def __init__( locomotion: Mapping[str, EntryPoint] | None = None, wake: Mapping[str, EntryPoint] | None = None, audio: Mapping[str, EntryPoint] | None = None, + audio_out: Mapping[str, EntryPoint] | None = None, *, verify_drivers: bool = False, ) -> None: @@ -91,6 +95,7 @@ def __init__( self._locomotion = dict(locomotion or {}) self._wake = dict(wake or {}) self._audio = dict(audio or {}) + self._audio_out = dict(audio_out or {}) #: When False, an unrecognised *driver* name is reported as a warning #: rather than an error. See `validate` for why the two callers differ: #: linting a manifest for hardware you have not wired yet is a normal @@ -107,6 +112,7 @@ def discover(cls) -> "PluginRegistry": locomotion=_entry_points(GROUP_LOCOMOTION), wake=_entry_points(GROUP_WAKE), audio=_entry_points(GROUP_AUDIO), + audio_out=_entry_points(GROUP_AUDIO_OUT), ) def with_verification(self, verify_drivers: bool) -> "PluginRegistry": @@ -121,6 +127,7 @@ def with_verification(self, verify_drivers: bool) -> "PluginRegistry": locomotion=self._locomotion, wake=self._wake, audio=self._audio, + audio_out=self._audio_out, verify_drivers=verify_drivers, ) @@ -138,6 +145,9 @@ def has_wake(self, engine: str) -> bool: def has_audio(self, source: str) -> bool: return source in self._audio + def has_audio_out(self, sink: str) -> bool: + return sink in self._audio_out + @property def driver_names(self) -> list[str]: return sorted({*self._actuators, *self._sensors}) @@ -154,6 +164,10 @@ def wake_names(self) -> list[str]: def audio_names(self) -> list[str]: return sorted(self._audio) + @property + def audio_out_names(self) -> list[str]: + return sorted(self._audio_out) + def __bool__(self) -> bool: return bool( self._actuators @@ -161,6 +175,7 @@ def __bool__(self) -> bool: or self._locomotion or self._wake or self._audio + or self._audio_out ) def __iter__(self) -> Iterator[tuple[str, str]]: @@ -175,6 +190,8 @@ def __iter__(self) -> Iterator[tuple[str, str]]: yield ("wake", name) for name in sorted(self._audio): yield ("audio", name) + for name in sorted(self._audio_out): + yield ("audio_out", name) # ----------------------------------------------------------------- load @@ -213,6 +230,17 @@ def load_audio(self, source: str) -> type: raise _missing(source, self.audio_names, "audio source") return ep.load() + def load_audio_out(self, sink: str) -> type: + """Import and return the class for an `audio.output.sink` name. + + Constructed as `cls(config, fmt)` with the manifest's `audio.output` + block, the same convention sources follow. + """ + ep = self._audio_out.get(sink) + if ep is None: + raise _missing(sink, self.audio_out_names, "audio sink") + return ep.load() + def _missing(name: str, available: Iterable[str], what: str) -> Exception: # Imported lazily: validate imports discovery, so discovery must not diff --git a/emet-sdk/emet_sdk/types.py b/emet-sdk/emet_sdk/types.py index f0632e7..bb035d5 100644 --- a/emet-sdk/emet_sdk/types.py +++ b/emet-sdk/emet_sdk/types.py @@ -29,6 +29,7 @@ "WakeEvent", "AudioFormat", "AudioSource", + "AudioSink", "SAMPLE_BYTES", "Health", "Reading", @@ -289,6 +290,45 @@ async def read(self) -> bytes | None: ... async def stop(self) -> None: ... +@runtime_checkable +class AudioSink(Protocol): + """Somewhere mono int16 audio goes. The other half of the hardware floor. + + Deliberately shaped the opposite way round from `AudioSource`, and kept a + separate group for that reason: audio is *pushed* into a sink and *pulled* + from a source. Forcing both into one contract would give every microphone a + `play` it cannot honour. + + Splitting them also lets the two be chosen independently, which is how a + wake failure gets debugged: read from a recording, play to a real speaker, + or read from a microphone and write what was said to a file. + + **Construction** matches sources exactly. Implementations discovered + through the `emet.audio_out` entry-point group are built as + `cls(config, fmt)`, where `config` is the manifest's `audio.output` block. + """ + + format: AudioFormat + + async def start(self) -> None: ... + + async def play(self, pcm: bytes) -> None: + """Play a whole buffer, returning when it has finished.""" + ... + + async def cancel(self) -> None: + """Stop immediately, mid-word if need be. + + Barge-in depends on this: `DESIGN.md` §13 requires that speech during + playback stops the audio rather than queueing behind it. A sink that + cannot be interrupted makes the robot talk over the person correcting + it, which is the single rudest thing it could do. + """ + ... + + async def stop(self) -> None: ... + + @dataclass(frozen=True, slots=True) class Health: """RSV. Polled by the engine; feeds proprioceptive self-model updates so diff --git a/emet-sdk/emet_sdk/validate.py b/emet-sdk/emet_sdk/validate.py index 7e1c5ee..85ac713 100644 --- a/emet-sdk/emet_sdk/validate.py +++ b/emet-sdk/emet_sdk/validate.py @@ -54,6 +54,8 @@ "BUILTIN_AUDIO", "DEFAULT_WAKE_ENGINE", "DEFAULT_AUDIO_SOURCE", + "BUILTIN_AUDIO_OUT", + "DEFAULT_AUDIO_SINK", "load_yaml", "validate_manifest", "validate_soul", @@ -106,6 +108,12 @@ DEFAULT_WAKE_ENGINE = "pocketsphinx" DEFAULT_AUDIO_SOURCE = "microphone" +#: Audio sinks `emet-hal` ships, and the default. `null` exists so the loop can +#: run without making a sound, which is what you want on a laptop at midnight +#: and in any test that would otherwise play through whatever is plugged in. +BUILTIN_AUDIO_OUT: frozenset[str] = frozenset({"speaker", "wav", "null"}) +DEFAULT_AUDIO_SINK = "speaker" + # -------------------------------------------------------------------------- # Findings @@ -270,6 +278,7 @@ def validate_manifest( _check_plugins(capabilities, registry, report) _check_wake_engine(doc, registry, report) _check_audio_source(doc, registry, report) + _check_audio_sink(doc, registry, report) return report @@ -344,6 +353,32 @@ def _check_audio_source( ) +def _check_audio_sink( + doc: Mapping[str, Any], + registry: PluginRegistry, + report: ValidationReport, +) -> None: + """Resolve `audio.output.sink`, on the same terms as the input source. + + Unconditional, for the same reason: it names an implementation that has to + exist. A body that cannot play audio has no voice rung, and every fallback + chain in Emet terminates in one — so this failing quietly would hollow out + the guarantee the whole abstraction rests on. + """ + sink = ((doc.get("audio") or {}).get("output") or {}).get("sink") + if not isinstance(sink, str) or registry.has_audio_out(sink): + return + installed = ", ".join(registry.audio_out_names) or "(none)" + report.error( + "missing_plugin", + f"no audio sink provides {sink!r}. Installed: {installed}. " + f"`audio.output.sink` is an open enum — this value is legal, the " + f"plugin simply is not installed. Omit it entirely for a real speaker, " + f"which is the default.", + "/audio/output/sink", + ) + + def _check_unique_ids(caps: Sequence[Mapping[str, Any]], report: ValidationReport) -> None: seen: dict[str, int] = {} for i, cap in enumerate(caps): diff --git a/emet-sdk/examples/mock-scout.yaml b/emet-sdk/examples/mock-scout.yaml index c1b8753..8c733d4 100644 --- a/emet-sdk/examples/mock-scout.yaml +++ b/emet-sdk/examples/mock-scout.yaml @@ -46,6 +46,10 @@ audio: output: device: "plughw:1,0" gain_db: -6.0 + # Everything else on this body is mocked, so its mouth is too: `null` + # accepts audio and discards it, which is how the whole loop runs on a + # laptop without playing sound through whatever is plugged in. + sink: "null" wake: # Everything else on this body is mocked, so its ears are too. Fires when # a frame literally contains the phrase, which makes the whole wake path diff --git a/emet-sdk/schemas/body-manifest.schema.json b/emet-sdk/schemas/body-manifest.schema.json index ff8173f..667b7ee 100644 --- a/emet-sdk/schemas/body-manifest.schema.json +++ b/emet-sdk/schemas/body-manifest.schema.json @@ -99,7 +99,21 @@ "additionalProperties": false, "required": ["device"], "properties": { + "sink": { + "description": "P0, optional. Where audio goes on this body. Resolves against installed emet.audio_out plugins, so this is an open enum: an unknown value is a missing plugin, never a schema error. Omit for the default, a real speaker. `wav` writes what the robot said to a file; `null` discards it, which is how the loop runs without making a sound.", + "type": "string", + "minLength": 1 + }, + "params": { + "description": "Passed to the sink untouched. Emet never inspects these.", + "type": "object" + }, "device": { "type": "string", "minLength": 1 }, + "sample_rate": { + "description": "P0, optional. The rate audio is played at, which is a property of whatever synthesises it and need not match audio.input.sample_rate. Speech generated at 16 kHz sounds like a telephone, so this defaults higher.", + "type": "integer", + "exclusiveMinimum": 0 + }, "gain_db": { "type": "number" } } }, diff --git a/emet-sdk/tests/test_audio_source.py b/emet-sdk/tests/test_audio_source.py index a519cba..7a7bf82 100644 --- a/emet-sdk/tests/test_audio_source.py +++ b/emet-sdk/tests/test_audio_source.py @@ -19,8 +19,8 @@ import pytest -from emet_sdk.discovery import GROUP_AUDIO, PluginRegistry -from emet_sdk.types import AudioFormat, AudioSource +from emet_sdk.discovery import GROUP_AUDIO, GROUP_AUDIO_OUT, PluginRegistry +from emet_sdk.types import AudioFormat, AudioSink, AudioSource from emet_sdk.validate import MissingPluginError, load_yaml, validate_manifest EXAMPLES = Path(__file__).resolve().parent.parent / "examples" @@ -143,3 +143,62 @@ def test_a_shipped_source_validates_clean(): doc = load_yaml(EXAMPLES / "bodiless.yaml") doc["audio"]["input"]["source"] = "microphone" assert validate_manifest(doc).ok + + +# ------------------------------------------------------- the other direction + + +def test_audio_out_is_its_own_entry_point_group(): + """Separate from `emet.audio` because audio is pushed into a sink and + pulled from a source, and because the two are chosen independently: read a + recording, play to a real speaker.""" + registry = PluginRegistry.discover() + assert GROUP_AUDIO_OUT == "emet.audio_out" + assert set(registry.audio_out_names) >= {"speaker", "wav", "null"} + assert registry.has_audio_out("null") + + +def test_a_name_can_mean_different_things_in_each_direction(): + """`wav` reads in one group and writes in the other. Sharing a namespace + would have made that impossible to express.""" + registry = PluginRegistry.discover() + assert registry.load_audio("wav") is not registry.load_audio_out("wav") + + +def test_both_directions_appear_in_the_registry_listing(): + listed = {group for group, _ in PluginRegistry.discover()} + assert {"audio", "audio_out"} <= listed + + +def test_an_uninstalled_sink_is_a_missing_plugin(): + with pytest.raises(MissingPluginError): + PluginRegistry.discover().load_audio_out("a_tin_can_and_string") + + +def test_the_engine_can_build_a_sink_it_never_imported(): + registry = PluginRegistry.discover() + cls = registry.load_audio_out("null") + sink = cls({"sink": "null"}, AudioFormat(sample_rate=22050)) + assert isinstance(sink, AudioSink) + assert cls.__module__.startswith("emet_hal") + + +def test_every_shipped_sink_takes_the_documented_constructor(): + registry = PluginRegistry.discover() + for name in registry.audio_out_names: + cls = registry.load_audio_out(name) + assert isinstance(cls({"sink": name}, AudioFormat()), AudioSink), name + + +def test_an_unresolvable_sink_is_an_error(): + """A body that cannot play audio has no voice rung, and every chain in + Emet terminates in one.""" + doc = load_yaml(EXAMPLES / "bodiless.yaml") + doc["audio"]["output"]["sink"] = "nobody_ships_this" + report = validate_manifest(doc) + assert not report.ok + assert "missing_plugin" in codes(report) + + +def test_an_absent_sink_is_the_common_case(): + assert validate_manifest(load_yaml(EXAMPLES / "bodiless.yaml")).ok From e96b4613d15e1b72e1c286a3becef3a8d98f99b9 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:17:08 -0700 Subject: [PATCH 13/21] Single-source the version and bump to 0.3.0 Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- .github/workflows/ci.yml | 15 +++++++++++++++ emet-engine/emet_engine/__init__.py | 13 ++++++++++++- emet-engine/pyproject.toml | 2 +- emet-engine/tests/test_session.py | 20 ++++++++++++++++++++ emet-hal/emet_hal/__init__.py | 25 ++++++++++++++++++++----- emet-hal/pyproject.toml | 2 +- emet-hal/tests/test_hal.py | 20 ++++++++++++++++++++ emet-sdk/emet_sdk/__init__.py | 13 ++++++++++++- emet-sdk/pyproject.toml | 2 +- emet-sdk/tests/test_acceptance.py | 20 ++++++++++++++++++++ 10 files changed, 122 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d063f0..ddf4785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,12 +137,27 @@ jobs: run: | cd /tmp python - <<'PY' + from importlib.metadata import version from emet_sdk.discovery import PluginRegistry + r = PluginRegistry.discover() assert r.has_locomotion("differential"), "entry points did not survive packaging" assert r.has_locomotion("tracked") assert r.has_driver("emet_hal.mock") + assert r.has_wake("pocketsphinx"), "wake group did not survive packaging" + assert r.has_audio("microphone"), "audio group did not survive packaging" + assert r.has_audio_out("speaker"), "audio_out group did not survive packaging" print("discovered:", [f"{g}:{n}" for g, n in r]) + + # The version is declared once, in pyproject.toml, and read back + # through importlib.metadata. From a wheel that resolution path is + # different from an editable install, so it is worth checking here + # rather than only in the test suite. + import emet_sdk, emet_hal, emet_engine + for dist, mod in (("emet-sdk", emet_sdk), ("emet-hal", emet_hal), ("emet-engine", emet_engine)): + assert mod.__version__ == version(dist), f"{dist} version drifted" + assert mod.__version__ != "0+unknown", f"{dist} reports no version" + print("versions:", emet_sdk.__version__) PY test: diff --git a/emet-engine/emet_engine/__init__.py b/emet-engine/emet_engine/__init__.py index 3640108..26ad96e 100644 --- a/emet-engine/emet_engine/__init__.py +++ b/emet-engine/emet_engine/__init__.py @@ -15,7 +15,18 @@ from emet_engine.turn import DEFAULT_PATIENCE_MS, EndReason, Endpointer, Utterance from emet_engine.vad import EnergyVad, VadTuning -__version__ = "0.2.0" +from importlib import metadata as _metadata + +#: Read from the installed distribution rather than written here, so that +#: `pyproject.toml` is the single place this number appears. Two declarations +#: drift silently: before this change the metadata said one version and the +#: source said another, and nothing noticed because nothing compared them. +try: + __version__ = _metadata.version("emet-engine") +except _metadata.PackageNotFoundError: # pragma: no cover - source checkout + # Imported from a tree that was never installed. Say so rather than + # inventing a number that would later be reported as fact. + __version__ = "0+unknown" __all__ = [ "EngineError", diff --git a/emet-engine/pyproject.toml b/emet-engine/pyproject.toml index fac8ba9..a60b508 100644 --- a/emet-engine/pyproject.toml +++ b/emet-engine/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "emet-engine" -version = "0.2.0" +version = "0.3.0" description = "Emet engine — the listen loop and the runtime that drives a body." readme = "README.md" requires-python = ">=3.11" diff --git a/emet-engine/tests/test_session.py b/emet-engine/tests/test_session.py index de6e475..709b07f 100644 --- a/emet-engine/tests/test_session.py +++ b/emet-engine/tests/test_session.py @@ -321,3 +321,23 @@ async def scenario(): return session.dropped assert run(scenario()) == 0 + + +# ----------------------------------------------------------------- version + + +def test_the_reported_version_matches_the_installed_distribution(): + """One declaration, in `pyproject.toml`, read back at import time. + + This test exists because the two used to be written separately and drifted: + the installed metadata said 0.1.0 while the source said 0.2.0, and + nothing noticed because nothing compared them. A version that is quietly + wrong is worse than no version, because it gets reported in bug reports as + fact. + """ + from importlib.metadata import version + + import emet_engine + + assert emet_engine.__version__ == version("emet-engine") + assert emet_engine.__version__ != "0+unknown", "package is not installed" diff --git a/emet-hal/emet_hal/__init__.py b/emet-hal/emet_hal/__init__.py index 5f0ba96..e028bb6 100644 --- a/emet-hal/emet_hal/__init__.py +++ b/emet-hal/emet_hal/__init__.py @@ -7,14 +7,29 @@ Nothing here is imported by name. Plugins advertise themselves through entry points, so installing a package is what makes a driver exist. -Shipped in 0.2: +Shipped: - emet_hal.mock an actuator and a sensor that pretend + emet_hal.mock an actuator, a sensor, and a wake engine that pretend differential two independently driven wheels tracked the same arithmetic, plus tread scrub + pocketsphinx phonetic wake detection (extra: wake) + microphone / wav audio in, live or from a recording (extra: audio) + speaker / wav / null audio out (extra: audio) -No hardware drivers yet. The locomotion plugins are arithmetic and need none; -the mock exists so that the whole stack runs on a laptop. +Still no hardware *drivers*: nothing here drives a servo or a motor controller. +The locomotion plugins are arithmetic and need none, audio goes through +PortAudio, and the mock exists so the whole stack runs on a laptop. """ -__version__ = "0.2.0" +from importlib import metadata as _metadata + +#: Read from the installed distribution rather than written here, so that +#: `pyproject.toml` is the single place this number appears. Two declarations +#: drift silently: before this change the metadata said one version and the +#: source said another, and nothing noticed because nothing compared them. +try: + __version__ = _metadata.version("emet-hal") +except _metadata.PackageNotFoundError: # pragma: no cover - source checkout + # Imported from a tree that was never installed. Say so rather than + # inventing a number that would later be reported as fact. + __version__ = "0+unknown" diff --git a/emet-hal/pyproject.toml b/emet-hal/pyproject.toml index dd40336..32922d1 100644 --- a/emet-hal/pyproject.toml +++ b/emet-hal/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "emet-hal" -version = "0.2.0" +version = "0.3.0" description = "Emet HAL — hardware abstraction layer: drivers and locomotion plugins." readme = "README.md" requires-python = ">=3.11" diff --git a/emet-hal/tests/test_hal.py b/emet-hal/tests/test_hal.py index 251e89f..43da474 100644 --- a/emet-hal/tests/test_hal.py +++ b/emet-hal/tests/test_hal.py @@ -218,3 +218,23 @@ def test_a_wake_engine_can_start_and_still_not_know_the_name(): descriptor = wake.describe() assert descriptor.healthy assert not descriptor.can_detect("hey emet") + + +# ----------------------------------------------------------------- version + + +def test_the_reported_version_matches_the_installed_distribution(): + """One declaration, in `pyproject.toml`, read back at import time. + + This test exists because the two used to be written separately and drifted: + the installed metadata said 0.1.0 while the source said 0.2.0, and + nothing noticed because nothing compared them. A version that is quietly + wrong is worse than no version, because it gets reported in bug reports as + fact. + """ + from importlib.metadata import version + + import emet_hal + + assert emet_hal.__version__ == version("emet-hal") + assert emet_hal.__version__ != "0+unknown", "package is not installed" diff --git a/emet-sdk/emet_sdk/__init__.py b/emet-sdk/emet_sdk/__init__.py index 3ead2b0..fef6522 100644 --- a/emet-sdk/emet_sdk/__init__.py +++ b/emet-sdk/emet_sdk/__init__.py @@ -10,6 +10,8 @@ imports `emet_sdk` only. `emet_engine` imports `emet_sdk` only. """ +from importlib import metadata as _metadata + from emet_sdk.plugin import ( ActuatorPlugin, CapabilityPlugin, @@ -38,7 +40,16 @@ WakeEvent, ) -__version__ = "0.2.0" +#: Read from the installed distribution rather than written here, so that +#: `pyproject.toml` is the single place this number appears. Two declarations +#: drift silently: before this change the metadata said one version and the +#: source said another, and nothing noticed because nothing compared them. +try: + __version__ = _metadata.version("emet-sdk") +except _metadata.PackageNotFoundError: # pragma: no cover - source checkout + # Imported from a tree that was never installed. Say so rather than + # inventing a number that would later be reported as fact. + __version__ = "0+unknown" #: Bumped when a released schema changes shape. Manifests and bundles record #: the version they were written against; a document from a newer SDK is a diff --git a/emet-sdk/pyproject.toml b/emet-sdk/pyproject.toml index 46e1e20..d620475 100644 --- a/emet-sdk/pyproject.toml +++ b/emet-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "emet-sdk" -version = "0.2.0" +version = "0.3.0" description = "Emet SDK — types, schemas, and contracts shared by the engine and all plugins." readme = "README.md" requires-python = ">=3.11" diff --git a/emet-sdk/tests/test_acceptance.py b/emet-sdk/tests/test_acceptance.py index 9276e1c..d680e1c 100644 --- a/emet-sdk/tests/test_acceptance.py +++ b/emet-sdk/tests/test_acceptance.py @@ -284,3 +284,23 @@ def test_motion_pack_rejects_unknown_intent(): report = validate_motion_pack(pack) assert not report.ok assert "unknown_intent" in codes(report) + + +# ----------------------------------------------------------------- version + + +def test_the_reported_version_matches_the_installed_distribution(): + """One declaration, in `pyproject.toml`, read back at import time. + + This test exists because the two used to be written separately and drifted: + the installed metadata said 0.1.0 while the source said 0.2.0, and + nothing noticed because nothing compared them. A version that is quietly + wrong is worse than no version, because it gets reported in bug reports as + fact. + """ + from importlib.metadata import version + + import emet_sdk + + assert emet_sdk.__version__ == version("emet-sdk") + assert emet_sdk.__version__ != "0+unknown", "package is not installed" From 839cf7937bbf179fc485db35f4fa818b99d77bd0 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:39:09 -0700 Subject: [PATCH 14/21] Add a release checklist and bring the docs to 0.3 Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- .github/workflows/ci.yml | 6 ++ CONTRIBUTING.md | 12 +++ README.md | 29 +++++- RELEASING.md | 127 +++++++++++++++++++++++++ emet-sdk/README.md | 14 +-- tools/release_check.py | 200 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 12 deletions(-) create mode 100644 RELEASING.md create mode 100644 tools/release_check.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddf4785..9288365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,12 @@ jobs: - name: Check package layering run: python tools/check_layering.py . + # Cross-package invariants no single suite can see: the three packages + # agreeing about the version, every discovery group having something + # behind it, and no document advertising a version the code is not. + - name: Check release invariants + run: python tools/release_check.py . + dco: name: dco sign-off runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 328a091..2fa1fc4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -147,6 +147,18 @@ untangle a year later when nobody remembers where the number came from. Ordinary dependencies do not belong here — declare those in the relevant `pyproject.toml`. This file is for what a lockfile cannot record. +## Releasing + +Minor and major releases follow [RELEASING.md](RELEASING.md). Two scripts do +the mechanical half: + +```sh +python tools/check_layering.py . +python tools/release_check.py . +``` + +Both run in CI, so they cannot quietly stop working. + ## Running things ```sh diff --git a/README.md b/README.md index ec90854..b04a9de 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,30 @@ is a promise: the thing you are talking to is honest about what it is. --- -## Status: 0.2 (early) +## Status: 0.3 (early) -Emet does not yet listen, speak, remember, or move. Nothing here drives a servo. +Emet listens. It does not yet understand, remember, or move, and nothing here +drives a servo. -What it does today is answer one question: **given a robot, what would each -intent mean on it?** That question turns out to be the whole product in -miniature. +What it does today is two things. It hears its own name and works out when you +have finished speaking: + +```sh +$ emet-listen examples/scout-01.yaml examples/emet-soul.yaml +listening for 'hey emet' + engine pocketsphinx + source microphone + patience 900 ms + + heard 'hey emet' (confidence 1.00) + then 4.0s of speech, ended on silence +``` + +There is no speech recognition yet, so it cannot tell you *what* you said. That +arrives in 0.4. + +And it answers the question the whole design rests on: **given a robot, what +would each intent mean on it?** ```sh $ emet explain examples/bodiless.yaml @@ -101,6 +118,8 @@ tells you which rungs were skipped and what was wrong with each: it did. - **[CONTRIBUTING.md](CONTRIBUTING.md)**: what is open to contribution, the design rules that are not negotiable, and how to run what CI runs. +- **[RELEASING.md](RELEASING.md)**: the checklist every release goes through, + and what went wrong to put each item on it. - **[CITATIONS.md](CITATIONS.md)**: outside work whose ideas, findings, or data shaped Emet, and the licence attached to each. - **[TRADEMARK.md](TRADEMARK.md)**: the code is yours to fork; the name is not. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2779ceb --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,127 @@ +# Releasing + +A checklist for every minor and major release. + +Each item is here because something went wrong without it, and the note under +each says what. A checklist of plausible-sounding good practice gets skipped; +one where every line has a scar does not. + +Run the mechanical half first — it is fast and it fails loudly: + +```sh +python tools/check_layering.py . +python tools/release_check.py . +``` + +Everything below is what a script cannot check. + +--- + +## 1. Walk the scope line by line + +**Open the roadmap. Copy the release's scope sentence. Tick each noun in it +against a file and a test.** + +Not from memory. Read the written scope and check the items off one at a time, +even the ones you are certain about. + +> **The scar.** 0.3's scope read "Audio **in/out**, wake word, VAD, +> endpointing." Four of those five were built and it was called complete. Audio +> *out* had a class in `emet-hal` with no entry point, unreachable from the +> engine, used by nothing. It survived several rounds of "what is left?" +> because each round asked memory rather than the sentence. + +A noun is done when it has **an implementation, a test, and a caller**. Two out +of three is how audio out passed for finished: it had an implementation and a +test, and nothing used it. + +## 2. Meet the acceptance criterion in its own words + +**Paste the criterion. Underneath it, paste the evidence.** + +The roadmap states a gate per release, phrased as an observable outcome. Answer +it literally, not approximately. + +> **The scar.** 0.3's gate is "...for ten minutes without drift." Drift was +> unmeasurable — nothing timed anything — so the claim would have been a +> feeling. `--stats` exists because a criterion you cannot produce a number for +> is one you cannot honestly sign off. + +If part of the criterion needs hardware you do not have to hand, that is not a +reason to soften the criterion. It is the reason to stop and go get it. + +## 3. Check every deferred decision + +**List every "not now, but when X" from this cycle. For each, has X happened?** + +Deferrals are made in conversation and lost there. Write them down as they are +made, with the trigger, wherever you plan the release. + +> **The scar.** The version number lived in six places across three packages. +> It was deferred twice with the trigger "the 0.3 release PR" — and when that +> PR arrived, the trigger only fired because somebody remembered. By then the +> installed metadata said 0.1.0 while the source said 0.2.0. + +## 4. Look for rules that live in only one place + +**Anything enforced in CI but not in a test will be broken by the next change +and found by the pipeline.** + +> **The scar.** `examples/invalid/` has a contract: plain `emet validate` +> rejects everything in it. That rule existed only in `ci.yml`. A fixture was +> added that merely warned, every local test passed, and CI went red on three +> Python versions afterwards. There is now a test that sweeps the directory. + +Ask the reverse too: anything a test asserts that CI does not run. + +## 5. Run what CI runs, not what you usually run + +The suites are not the pipeline. As of this release the pipeline also builds +wheels, installs them outside the source tree, and checks that entry points and +versions survive packaging. + +> **The scar.** A CLI smoke test was described as catching packaging +> regressions. CI installed with `-e`, so the packaged layout was never +> exercised at all. It took building a real wheel to notice. + +## 6. Verify on hardware, or say plainly that you did not + +Emet is a robot. A release verified only against wav files has been verified +against wav files. + +**Do not describe a file replay as a hardware result.** State the platform +every number came from. If the reference body has not run this release, the +release notes say so. + +> **Why it matters here.** A wav replay exercises everything except the sound +> card, so genuine clock drift cannot appear. And an x86 real-time factor says +> nothing about ARM except by comparison. + +## 7. Refresh the documents that state a version + +`tools/release_check.py` catches the obvious ones. It cannot catch a paragraph +that is merely out of date, so re-read: + +- `README.md` — the status section, and any sample output +- each package `README.md` +- `emet_hal/__init__.py` and friends, which list what ships +- `CITATIONS.md`, if anything was taken from a paper or a repository +- the roadmap row for this release, and the release notes + +## 8. Tag + +- The version is bumped in all three `pyproject.toml` files and **nowhere + else** — `release_check.py` enforces this. +- The tag message is written. Commits stay short; the tag carries the detail. +- Every commit in the release is signed off, or the DCO check fails the PR. + +--- + +## The pattern behind all of it + +Every scar above is the same mistake: **checking work against a memory of the +requirement instead of the requirement.** Memory summarises, and summaries drop +the item you were least involved with — which is reliably the one that is +missing. + +Hence the shape of this list. Open the file. Copy the sentence. Tick the nouns. diff --git a/emet-sdk/README.md b/emet-sdk/README.md index 198c914..bd1b795 100644 --- a/emet-sdk/README.md +++ b/emet-sdk/README.md @@ -11,20 +11,20 @@ term in Android's sense: the abstraction itself lives here in `emet_sdk.plugin`, and `emet-hal` is the collection of per-device implementations that satisfy it. -## What is in 0.2 +## What is in 0.3 -Schema, validation, the plugin contract, and chain resolution. There is still -no audio and no engine — nothing here runs a robot. What it can now do is -answer, for any body you describe, what each intent would mean on it. +Schema, validation, the plugin contracts, and chain resolution. This package +still runs nothing: it is the layer the engine and every plugin agree on, and +it deliberately contains almost no logic. | | | |---|---| | `schemas/` | Body manifest, soul bundle, and motion pack, as JSON Schema. The **full** surface — every P0 field, every RSV field reserved for later releases, and the reserved V1 capability types. | -| `emet_sdk/types.py` | `Intent`, `Action`, `Pose`, `Twist`, `CapabilityDescriptor`, `LocomotionDescriptor`, `Health`, `Priority`, `Sensitivity`. | +| `emet_sdk/types.py` | `Intent`, `Action`, `Pose`, `Twist`, `CapabilityDescriptor`, `LocomotionDescriptor`, `WakeDescriptor`, `AudioFormat`, `AudioSource`, `AudioSink`, `Health`, `Priority`, `Sensitivity`. | | `emet_sdk/intents.py` | The closed intent vocabulary, plus the four names reserved from P0. | | `emet_sdk/chains.py` | Fallback chain format, and the rule that every chain terminates in a voice rung. | -| `emet_sdk/plugin.py` | `ActuatorPlugin`, `SensorPlugin`, `LocomotionPlugin` — the public contract. | -| `emet_sdk/discovery.py` | Entry-point discovery. Installing a package is what makes a driver exist. | +| `emet_sdk/plugin.py` | `ActuatorPlugin`, `SensorPlugin`, `LocomotionPlugin`, `WakePlugin` — the public contract. | +| `emet_sdk/discovery.py` | Entry-point discovery across six groups. Installing a package is what makes a driver exist. | | `emet_sdk/resolve.py` | Chain resolution: `(chains, descriptors) → binding table`. | | `emet_sdk/validate.py` | Semantic rules and the error taxonomy. | | `emet_sdk/cli.py` | `emet validate`, `emet explain`. | diff --git a/tools/release_check.py b/tools/release_check.py new file mode 100644 index 0000000..9f24221 --- /dev/null +++ b/tools/release_check.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Check the things a release gets wrong that no single package can notice. + +Every package has its own tests, and they pass while the *repository* is +inconsistent — because a package cannot see its siblings. `emet-sdk` could sit +at 0.3.0 beside an `emet-hal` still at 0.2.0 and every suite would be green. +This looks across the whole tree instead. + +It is deliberately narrow. It does not re-run the test suites, because CI does +that and duplicating it here would produce a slow script people stop running. +It checks the *cross-cutting* invariants: + + versions all three packages agree + plugins every discovery group has a shipped implementation + docs nothing advertises a version the code no longer is + markers no TODO or FIXME left in shipped source + +**Why this exists.** 0.3's scope was "audio in/out, wake word, VAD, +endpointing". Audio *out* was not wired, and it went unnoticed for days because +the work was checked against a memory of the scope rather than the written +scope. A script cannot read a roadmap, so it cannot catch that one — see +`RELEASING.md` for the human half — but everything it *can* mechanise, it +should, because the checks people skip are the ones that need remembering. + +Dependency-free and short enough to read, like `check_layering.py`. + +Usage: python tools/release_check.py [repo_root] +Exit: 0 clean, 1 problems found. +""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + +PACKAGES = ("emet-sdk", "emet-hal", "emet-engine") + +#: Docs that state a version and will lie if they are not updated. A stale +#: README is the first thing a newcomer reads and the last thing anybody edits. +VERSIONED_DOCS = ("README.md", "emet-sdk/README.md", "emet-hal/README.md", "emet-engine/README.md") + +#: Groups the SDK knows how to discover. Each should have at least one shipped +#: implementation, or the group is a promise nothing keeps. +EXPECTED_GROUPS = ( + "emet.actuators", + "emet.sensors", + "emet.locomotion", + "emet.wake", + "emet.audio", + "emet.audio_out", +) + +problems: list[str] = [] +notes: list[str] = [] + + +def problem(msg: str) -> None: + problems.append(msg) + + +def declared_versions(root: Path) -> dict[str, str]: + out: dict[str, str] = {} + for pkg in PACKAGES: + path = root / pkg / "pyproject.toml" + if not path.exists(): + problem(f"{pkg}: no pyproject.toml") + continue + data = tomllib.loads(path.read_text(encoding="utf-8")) + version = data.get("project", {}).get("version") + if not version: + problem(f"{pkg}: pyproject.toml declares no version") + continue + out[pkg] = version + return out + + +def check_versions_agree(versions: dict[str, str]) -> str | None: + """One repository, one version. Three packages released together that + disagree about which release they are is the kind of thing nobody notices + until a bug report quotes two of them.""" + distinct = set(versions.values()) + if len(distinct) > 1: + detail = ", ".join(f"{k}={v}" for k, v in sorted(versions.items())) + problem(f"packages disagree about the version: {detail}") + return None + version = distinct.pop() if distinct else None + if version: + notes.append(f"version {version} across {len(versions)} packages") + return version + + +def check_single_declaration(root: Path) -> None: + """The version belongs in pyproject.toml and nowhere else. + + A hardcoded `__version__` is how the source and the installed metadata + drifted before: both were written by hand, and nothing compared them. + """ + for pkg in PACKAGES: + module = pkg.replace("-", "_") + init = root / pkg / module / "__init__.py" + if not init.exists(): + continue + text = init.read_text(encoding="utf-8") + if re.search(r'^__version__\s*=\s*["\']', text, re.M): + problem( + f"{pkg}/{module}/__init__.py hardcodes __version__. Read it from " + f"the installed distribution instead, so pyproject.toml stays the " + f"only declaration." + ) + + +def check_groups(root: Path) -> None: + """Every discoverable group should have something shipped behind it.""" + provided: dict[str, list[str]] = {} + for pkg in PACKAGES: + path = root / pkg / "pyproject.toml" + if not path.exists(): + continue + data = tomllib.loads(path.read_text(encoding="utf-8")) + for group, entries in (data.get("project", {}).get("entry-points") or {}).items(): + provided.setdefault(group, []).extend(entries) + + for group in EXPECTED_GROUPS: + if not provided.get(group): + problem( + f"entry-point group {group!r} has no shipped implementation. Either " + f"something is missing or the group should not exist yet." + ) + if provided: + total = sum(len(v) for v in provided.values()) + notes.append(f"{total} entry points across {len(provided)} groups") + + +def check_docs(root: Path, version: str | None) -> None: + """Nothing should advertise a version the code no longer is.""" + if not version: + return + series = ".".join(version.split(".")[:2]) # 0.3.0 -> 0.3 + stale = re.compile(r"\b0\.\d+\b") + for rel in VERSIONED_DOCS: + path = root / rel + if not path.exists(): + continue + for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + # Only lines that are *claiming* a version, not every mention of a + # number: "what is in 0.2", "status: 0.2", "shipped in 0.2". + if not re.search(r"(?i)\b(status|what is in|shipped in|version)\b", line): + continue + found = [m for m in stale.findall(line) if m != series] + if found: + problem(f"{rel}:{n} still says {found[0]}, but this is {series}: {line.strip()}") + + +def check_markers(root: Path) -> None: + """No TODO or FIXME in shipped source. Notes to self are fine in a branch + and are not fine in a tag.""" + hits: list[str] = [] + for pkg in PACKAGES: + module = pkg.replace("-", "_") + for py in (root / pkg / module).rglob("*.py"): + for n, line in enumerate(py.read_text(encoding="utf-8").splitlines(), 1): + if re.search(r"\b(TODO|FIXME|XXX|HACK)\b", line): + hits.append(f"{py.relative_to(root)}:{n}") + for hit in hits: + problem(f"marker left in shipped source: {hit}") + + +def main(argv: list[str]) -> int: + root = Path(argv[1] if len(argv) > 1 else ".").resolve() + if not (root / "emet-sdk").exists(): + print(f"release-check: {root} does not look like the Emet repository", file=sys.stderr) + return 1 + + versions = declared_versions(root) + version = check_versions_agree(versions) + check_single_declaration(root) + check_groups(root) + check_docs(root, version) + check_markers(root) + + for note in notes: + print(f"release-check: {note}") + if not problems: + print("release-check: ok") + print( + "release-check: mechanical checks only. The scope and acceptance " + "criteria in RELEASING.md still need a person." + ) + return 0 + print() + for p in problems: + print(f" x {p}") + print(f"\n{len(problems)} problem(s).") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) From efe1cf53287e41e8aa60c87672b35ba3452e4744 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:01:06 -0700 Subject: [PATCH 15/21] Bring the docs and comments in line with 0.3 and the style rules Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- .github/workflows/ci.yml | 10 +- CITATIONS.md | 18 +-- CONTRIBUTING.md | 40 +++++-- DESIGN.md | 107 ++++++++++++++---- README.md | 23 +++- RELEASING.md | 16 +-- TRADEMARK.md | 2 +- emet-engine/README.md | 6 +- emet-engine/emet_engine/__init__.py | 2 +- emet-engine/emet_engine/cli.py | 2 +- emet-engine/emet_engine/metrics.py | 8 +- emet-engine/emet_engine/session.py | 4 +- emet-engine/emet_engine/turn.py | 6 +- emet-engine/emet_engine/vad.py | 12 +- emet-engine/pyproject.toml | 2 +- emet-engine/tests/test_session.py | 2 +- emet-engine/tests/test_turn.py | 2 +- emet-hal/README.md | 45 +++++--- emet-hal/emet_hal/__init__.py | 2 +- emet-hal/emet_hal/audio.py | 12 +- emet-hal/emet_hal/differential.py | 4 +- emet-hal/emet_hal/mock.py | 6 +- emet-hal/emet_hal/pocketsphinx_wake.py | 6 +- emet-hal/emet_hal/tracked.py | 4 +- emet-hal/pyproject.toml | 8 +- emet-hal/tests/test_hal.py | 2 +- emet-sdk/README.md | 21 ++-- emet-sdk/emet_sdk/__init__.py | 2 +- emet-sdk/emet_sdk/chains.py | 14 +-- emet-sdk/emet_sdk/chains/core.yaml | 4 +- emet-sdk/emet_sdk/chains/express.yaml | 4 +- emet-sdk/emet_sdk/cli.py | 20 ++-- emet-sdk/emet_sdk/discovery.py | 8 +- emet-sdk/emet_sdk/intents.py | 10 +- emet-sdk/emet_sdk/plugin.py | 20 ++-- emet-sdk/emet_sdk/resolve.py | 20 ++-- emet-sdk/emet_sdk/types.py | 14 +-- emet-sdk/emet_sdk/validate.py | 40 +++---- emet-sdk/examples/bodiless.yaml | 4 +- emet-sdk/examples/emet-soul.yaml | 4 +- .../examples/invalid/home-out-of-range.yaml | 2 +- .../examples/invalid/legged-no-plugin.yaml | 6 +- emet-sdk/examples/invalid/two-drives.yaml | 4 +- .../examples/invalid/unknown-wake-engine.yaml | 2 +- .../examples/invalid/unterminated-chain.yaml | 2 +- emet-sdk/examples/mock-scout.yaml | 12 +- emet-sdk/examples/scout-01.yaml | 2 +- emet-sdk/pyproject.toml | 2 +- emet-sdk/schemas/body-manifest.schema.json | 10 +- emet-sdk/schemas/motion-pack.schema.json | 6 +- emet-sdk/schemas/soul-bundle.schema.json | 14 +-- emet-sdk/tests/test_acceptance.py | 8 +- emet-sdk/tests/test_audio_source.py | 2 +- emet-sdk/tests/test_integration.py | 10 +- emet-sdk/tests/test_resolve.py | 4 +- emet-sdk/tests/test_wake.py | 4 +- tools/check_layering.py | 2 +- tools/release_check.py | 6 +- 58 files changed, 373 insertions(+), 261 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9288365..170cec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,8 @@ # # Two things are checked, and the second matters more than it looks. # -# tests — the 0.1 acceptance criteria, executable. -# layering — emet_sdk imports nothing internal; emet_hal and emet_engine +# tests: the 0.1 acceptance criteria, executable. +# layering: emet_sdk imports nothing internal; emet_hal and emet_engine # import emet_sdk only. # # The layering check exists because the closed-engine plan used to enforce that @@ -13,7 +13,7 @@ name: ci # Branch work is covered by `pull_request`; `push` only guards what lands. # Triggering on both for every branch ran the whole suite twice per push, -# doubling Actions minutes — which are metered on private repositories — and +# doubling Actions minutes, which are metered on private repositories, and # filling the checks list with confusing (push)/(pull_request) pairs. on: push: @@ -88,7 +88,7 @@ jobs: # In a source checkout `schemas/` sits BESIDE the package; in a built # wheel `force-include` moves it INSIDE. An editable install keeps the # source layout, so every other job in this file exercises only one of - # those two paths — and it is the path no user of the project ever takes. + # those two paths, and it is the path no user of the project ever takes. # # A typo in the force-include would leave the whole suite green and break # the first person to run `pip install emet-sdk`. So: build a real wheel, @@ -215,7 +215,7 @@ jobs: run: python -m pytest -q # Exercises the CLI against the editable install. This does NOT prove the - # packaged layout works — see the `wheel` job for that. + # packaged layout works; see the `wheel` job for that. - name: CLI accepts the valid examples working-directory: emet-sdk run: emet validate examples/bodiless.yaml examples/scout-01.yaml examples/emet-soul.yaml diff --git a/CITATIONS.md b/CITATIONS.md index 1172ce8..de55b5e 100644 --- a/CITATIONS.md +++ b/CITATIONS.md @@ -3,7 +3,7 @@ Outside work whose **ideas, findings, or data** shaped Emet, and what was taken from each. -This is not a dependency list — installed packages are declared in each +This is not a dependency list. Installed packages are declared in each `pyproject.toml`, and copyright notices live in [NOTICE](NOTICE). This file exists for the harder-to-track case: a measured result that justifies a default, a taxonomy that shaped a schema, a phoneme set a data file is written @@ -11,7 +11,7 @@ in. Those leave no trace in a lockfile, and by the time somebody asks where a constant came from, the reasoning is usually gone. Each entry records the source, what Emet took, and **the licence of the thing -taken** — because a non-commercially licensed corpus or a differently licensed +taken**, because a non-commercially licensed corpus or a differently licensed repository is a constraint the project has to carry forward. --- @@ -29,7 +29,7 @@ Sesame AI · Mundo AI · Carnegie Mellon University · National Taiwan Universit · Academia Sinica · Oto · Brno University of Technology - Project: -- Scorer: — **MIT** +- Scorer: , **MIT** - Corpus: **non-commercial licence, prohibits voice cloning** **What Emet took.** Three measured medians from the corpus analysis (§IV-B), @@ -38,7 +38,7 @@ default `patience_ms`: | | | |---|---| -| Floor transfer offset | −151 ms (listeners begin before the turn ends) | +| Floor transfer offset, excluding interruptions | −151 ms (listeners begin before the turn ends) | | Inter-speaker gap | 380 ms | | Pause within one speaker's turn | 510 ms | @@ -49,7 +49,7 @@ that reasoning is theirs, not ours. The paper also supplies the honest grade for what Emet currently ships: an RMS-energy detector is the benchmark's explicit floor. Recording that is part -of the attribution — the finding was inconvenient, and taking the numbers while +of the attribution: the finding was inconvenient, and taking the numbers while omitting the verdict would be quoting selectively. **No corpus data, model weights, or code from this work is redistributed @@ -68,14 +68,16 @@ has not read. ## CMU PocketSphinx and CMUdict **Carnegie Mellon University Speech Group.** PocketSphinx. - — **BSD-2-Clause (CMU)** +, **BSD-2-Clause (CMU)**. The +published wheel also carries BSD-3-Clause WebRTC VAD code (Google) and MIT +pieces (a JSON parser, the Python VAD bindings); all permissive. The shipped wake word engine ([`emet_hal/pocketsphinx_wake.py`](emet-hal/emet_hal/pocketsphinx_wake.py)), used as a dependency rather than copied. -**What is worth naming beyond the dependency**: `SHIPPED_LEXICON` — the -pronunciations that let `emet`, `hugr` and `neuma` be heard — is written in +**What is worth naming beyond the dependency**: `SHIPPED_LEXICON`, the +pronunciations that let `emet`, `hugr` and `neuma` be heard, is written in **ARPAbet**, and is meaningful only against CMU's pronouncing dictionary, which supplies every other word in a wake phrase. "hey barnaby" needs no lexicon entry at all because CMUdict already knows the name. That property is the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fa1fc4..29a3239 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,10 +26,10 @@ under Apache 2.0. Most contributors should never have to remember the flag: -- **Editing on github.com** — nothing to do. Web commits are signed off for you. -- **VS Code** — nothing to do. This repository ships a `.vscode/settings.json` +- **Editing on github.com**: nothing to do. Web commits are signed off for you. +- **VS Code**: nothing to do. This repository ships a `.vscode/settings.json` that turns on `git.alwaysSignOff`, and the built-in Git UI honours it. -- **Command line** — use `git commit -s`. If you forget, fix it afterwards +- **Command line**: use `git commit -s`. If you forget, fix it afterwards rather than redoing the work: ```sh @@ -69,8 +69,8 @@ is the load-bearing one, and it is worth knowing which mechanism provides it. ## What is open to contribution, and what is not -Not everything in this repository is equally safe to change, because not -everything is equally cheap to get wrong. +Some parts of this repository are safer to change than others, because some +are cheaper to get wrong. **Welcome, and the reason the project is open:** @@ -99,6 +99,14 @@ The ordering is by *reversibility*, not importance. A driver that turns out to be wrong is reverted in a minute. A schema field that turns out to be wrong is with us for years. +## Installing plugins + +A plugin is ordinary Python loaded in-process. Installing a third-party plugin +runs that person's code on your robot with full access to everything the robot +has: the microphone, the memory file, the motors. There is no sandbox yet, and +[SECURITY.md](SECURITY.md) says so. Until a reviewed, signed registry exists, +install plugins from people you trust. + ## Ground rules that are not negotiable These are design invariants, not preferences. A change that breaks one is @@ -110,7 +118,7 @@ wrong even if it works: this. It is what makes "every intent is always satisfiable" mechanical rather than aspirational. 3. **`emet_sdk` imports nothing internal.** `emet_hal` and `emet_engine` import - `emet_sdk` only. CI checks this on every push. + `emet_sdk` only. CI checks this on every pull request. 4. **Memory is never namespaced by body.** Experiences travel with the soul; hardware conditions stay with the body. 5. **A missing plugin is not a schema error.** Keep the two failure modes @@ -123,7 +131,7 @@ a pull request: that conversation is usually more interesting than the patch. ## Citing outside work If a change takes something from a paper, a repository, a dataset, or anyone -else's writing, credit it **in this repository** — a citation at the point of +else's writing, credit it **in this repository**: a citation at the point of use and an entry in [CITATIONS.md](CITATIONS.md). **Ideas count, not only copied code.** Using a paper's measurement to choose a @@ -144,7 +152,7 @@ This is partly courtesy and partly self-defence. Emet is Apache 2.0 and wants to stay cleanly licensed, and an uncredited borrowing is much harder to untangle a year later when nobody remembers where the number came from. -Ordinary dependencies do not belong here — declare those in the relevant +Ordinary dependencies do not belong here. Declare those in the relevant `pyproject.toml`. This file is for what a lockfile cannot record. ## Releasing @@ -163,21 +171,29 @@ Both run in CI, so they cannot quietly stop working. ```sh python -m venv .venv -.venv/bin/pip install -e "emet-sdk[dev]" -e "emet-hal[dev]" +.venv/bin/pip install -e "emet-sdk[dev]" -e "emet-hal[dev]" -e "emet-engine[dev]" +.venv/bin/pip install -e "emet-hal[audio,wake]" # optional: microphone, speaker, wake engine ``` -Install **both** packages even if you are only touching one. Plugin discovery -reads entry points, so several SDK tests are meaningless unless something is -registered to be discovered. +Install **all three** packages even if you are only touching one. Plugin +discovery reads entry points, so several SDK and engine tests are meaningless +unless something is registered to be discovered. The suites pass with the +optional extras absent; the tests that need them skip. Before opening a PR, run what CI runs: ```sh python tools/check_layering.py . +python tools/release_check.py . cd emet-sdk && python -m pytest -q cd ../emet-hal && python -m pytest -q +cd ../emet-engine && python -m pytest -q ``` +CI also builds the three wheels and installs them outside the source tree, so a +packaging mistake that an editable install hides still fails the pipeline. The +exact steps are in `.github/workflows/ci.yml`. + To see what your driver actually binds to, without a robot: ```sh diff --git a/DESIGN.md b/DESIGN.md index 30c9d6a..4e119f4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,12 +23,13 @@ sparse at the start; internal cross-references depend on it. ## 0. How to read this document -Every schema field and subsystem carries a tag describing **how finished it -is**, not when it will arrive: +Every schema field and subsystem carries a tag describing **how settled it +is**: whether the field exists, and whether the engine is meant to read it. The +tags say nothing about when anything ships; `ROADMAP.md` does that. | Tag | Meaning | |---|---| -| **`P0`** | Implemented. The engine reads this field and acts on it. | +| **`P0`** | In scope for the first product. The engine reads this field, or will by 1.0. Whether it has shipped yet is a question for `ROADMAP.md` and `STATUS.md`, never for this document. | | **`RSV`** | Reserved. The field exists in the schema and validators accept it, but the engine ignores it. | | **`V1`** | End-goal. Not in the schema yet. Listed so that today's design does not foreclose it. | @@ -91,8 +92,9 @@ emet/ one repository, Apache 2.0 throughout emet-sdk/ the contract layer schemas/ manifest, soul bundle, motion pack (JSON Schema) emet_sdk/ - types.py Intent, Action, CapabilityDescriptor, Pose, Twist - plugin.py ActuatorPlugin, SensorPlugin, LocomotionPlugin ABCs + types.py Intent, Action, CapabilityDescriptor, Pose, Twist, + WakeDescriptor, AudioFormat, AudioSource, AudioSink + plugin.py ActuatorPlugin, SensorPlugin, LocomotionPlugin, WakePlugin ABCs intents.py the canonical intent vocabulary chains.py fallback chain format + the voice-rung rule discovery.py entry-point plugin discovery @@ -100,30 +102,33 @@ emet/ one repository, Apache 2.0 throughout validate.py manifest + bundle validators emet-hal/ community-contributed - mock.py an actuator and a sensor that pretend + mock.py an actuator, a sensor and a wake engine that pretend differential.py two independently driven wheels (§12.1) tracked.py the same arithmetic, plus tread scrub - ... pca9685, tb6612, gc9a01, ws2812 — not yet written + pocketsphinx_wake.py the shipped wake engine (§12.2) + audio.py microphone and speaker through PortAudio; wav and null + ... pca9685, tb6612, gc9a01, ws2812: not yet written - emet-engine/ personality synthesis, memory, arbitration, - choreography, prompting, safety, consolidation. + emet-engine/ the listen loop today (wake, VAD, endpointing, §13); + personality synthesis, memory, arbitration, choreography, + prompting, safety and consolidation as releases arrive. ``` **HAL** is *hardware abstraction layer*: the standard embedded and OS term for the layer separating generic upper software from specific silicon. Emet uses it in Android's sense: the abstraction itself is `emet_sdk.plugin` (the ABCs and capability descriptors), and `emet-hal` is the collection of per-device *implementations* that satisfy it. Spell the acronym out on first use in any document a newcomer might read first; not every contributor arrives from embedded work. -The engine imports the SDK. Plugins import the SDK. The SDK is types and contracts and almost no logic, targeting under ~2,000 lines. It is the only thing both sides must agree on. Enforced in CI by `tools/check_layering.py`, because with one open monorepo the layering is a test rather than a property of how the software is distributed. +The engine imports the SDK. Plugins import the SDK. The SDK is types and contracts and almost no logic, targeting under ~3,500 lines. The original target of 2,000 predates the wake and audio contracts, which added about 800 lines; the rest is headroom for the speech-to-text seam. It is the only thing both sides must agree on. Enforced in CI by `tools/check_layering.py`, because with one open monorepo the layering is a test rather than a property of how the software is distributed. **Where the line falls when something is arguably logic:** deterministic pure functions over contract data belong in the SDK; anything stateful, scheduled, or personality-bearing belongs in the engine. Chain resolution (§6) is the former, `(chains, descriptors) → binding table` with no state and no I/O, and it lives in the SDK so that a HAL contributor can check where their driver binds without running an engine. The choreographer, at 50Hz and holding motion state, is the latter. **One license, permissive, everywhere, settled August 2026.** Apache 2.0 for the SDK, the HAL, and the engine alike. There is no licence boundary inside the repository and nothing about it to explain to a contributor. A hobbyist may do anything with it, and so may a company. -The consequence to hold onto: **anyone may fork Emet, close their fork, and ship it.** That is permitted, not accidental. The only thing that stops such a fork calling itself *Emet* is the trademark. See `TRADEMARK.md`. +The consequence to hold onto: **anyone may fork Emet, close their fork, and ship it.** That is permitted and deliberate. The only thing that stops such a fork calling itself *Emet* is the trademark. See `TRADEMARK.md`. **`P0`**: everything public from the first commit, under its final license. Open is a one-way door: once published, it is published, and a permissive release can never be walked back for code already out. **Layering is enforced by CI, not by a license wall.** An import linter asserts that `emet-sdk` imports nothing internal, `emet-hal` imports only `emet-sdk`, and `emet-engine` imports only `emet-sdk`. Previously this invariant was maintained by the engine being a separate closed artifact; that structural guarantee is now a test, and it must actually run in CI or it will rot. -Python import namespace: `emet_sdk`, `emet_hal`. CLI binary: `emet`. Config root: `/etc/emet/`. Soul bundles: `*.emet` directories. +Python import namespaces: `emet_sdk`, `emet_hal`, `emet_engine`. CLI binaries: `emet` and `emet-listen`. Config root: `/etc/emet/`. Soul bundles: `*.emet` directories. --- @@ -154,16 +159,26 @@ compute: ram_gb: 8 # P0 accelerator: none # RSV none | hailo8l | coral -audio: # P0 required — this is the hardware floor +audio: # P0 required: this is the hardware floor input: device: "plughw:1,0" sample_rate: 16000 channels: 4 aec: hardware # P0 hardware | software | none doa: true # P0 direction of arrival available + source: microphone # P0 optional. OPEN ENUM naming an installed + # emet.audio plugin; default microphone. + # `wav` replays a recording (params.path). output: device: "plughw:1,0" gain_db: -6.0 + sink: speaker # P0 optional. OPEN ENUM naming an installed + # emet.audio_out plugin; default speaker. + # `wav` records, `null` discards. + wake: # P0 optional. Omit for the shipped default. + engine: pocketsphinx # OPEN ENUM naming an installed emet.wake + # plugin. The PHRASE is on the soul (§8.1). + params: {} # passed to the engine untouched capabilities: [] # P0 see 4.2 @@ -240,7 +255,7 @@ Declared axes are what let the choreographer generate motion without user code. ```yaml - id: base type: drive - kinematics: differential # P0 OPEN ENUM — any string naming an + kinematics: differential # P0 OPEN ENUM: any string naming an # installed locomotion plugin. # Built-in P0: differential | tracked # Later: omni | ackermann | legged | ... @@ -276,7 +291,7 @@ a legged plugin arrives: - id: base type: drive kinematics: legged # accepted today; plugin comes later - legs: # RSV — reserved, ignored by the P0 engine + legs: # RSV reserved, ignored by the P0 engine count: 2 dof_per_leg: 3 gait: static # RSV static | dynamic @@ -351,6 +366,7 @@ Enforced by `emet_sdk.validate`, run at boot and by the CLI: - `memory.db` contains **no body-identifying column**. Enforced by schema inspection rather than convention. See §4.1, "What `body.id` is and is not". A bundle whose memory database carries a body reference is invalid. - `drive.kinematics` must be a **string that resolves to an installed locomotion plugin**, not a member of a frozen list. An unrecognized value fails boot with "no locomotion plugin provides `legged`; install one or change `kinematics`", which is a *missing plugin* error, not a *schema* error. This is what keeps the enum open. - Every referenced `driver.plugin` resolves to an installed plugin, or boot fails loudly with the missing package name. Never silently degrade because of a typo: that is a *different* failure from missing hardware, and conflating them costs support hours. +- `audio.wake.engine`, `audio.input.source` and `audio.output.sink` resolve to installed plugins, or validation fails. `driver.plugin` may name hardware not yet wired and so only warns outside `--verify-drivers`; these name software that has to exist for the robot to hear or speak at all. --- @@ -429,7 +445,7 @@ express.curiosity: - actuator: {role: ambient, type: light} action: pulse params: {hue: 190, period_ms: 1200} - - actuator: {voice: true} # terminal rung — ALWAYS binds + - actuator: {voice: true} # terminal rung, ALWAYS binds action: inflect params: {preset: rising, filler: ["hm?", "hmm."]} ``` @@ -487,7 +503,7 @@ YOUR BODY You are a small treaded robot, about the size of a coffee mug, sitting on a desk. You can turn your head left and right, and tilt it up and down. You have two round eyes that can change expression. -You can drive forward, back, and turn in place — slowly, about walking pace +You can drive forward, back, and turn in place, slowly, at about walking pace for an ant. You cannot see: you have no camera. You have no arms and cannot pick anything up. @@ -589,7 +605,7 @@ identity: # Whether a body can hear it depends on the # wake engine it installs (§14), which this # document does not know about. - # Deliberately separate from `name` — see 8.1.1. + # Deliberately separate from `name`, see 8.1.1. line: emet # P0 emet | hugr | neuma | custom (soul line, §1.2) pronouns: "it/its" # P0 author: "The Emet Authors" # P0 free text; a handle, a name, anything @@ -664,7 +680,7 @@ Whether a particular body can hear a particular phrase is not knowable from thes ### 8.2 The soul line field -`identity.line` names which reference personality a bundle derives from (§1.2). It is metadata only: the engine does not branch on it, but it lets the community registry group and filter souls, and it lets docs say "based on Hugr" meaningfully. Custom souls set `line: custom`. +`identity.line` names which reference personality a bundle derives from (§1.2). It is metadata only. The engine does not branch on it. It lets the community registry group and filter souls, and it lets docs say "based on Hugr" meaningfully. Custom souls set `line: custom`. ### 8.3 Portability contract @@ -821,7 +837,7 @@ class PCA9685Servos(ActuatorPlugin): """Called on SIGTERM and on fault. Must leave hardware safe.""" def health(self) -> Health: - """RSV — polled; feeds proprioceptive self-model updates.""" + """RSV. Polled; feeds proprioceptive self-model updates.""" ``` Sensors invert the flow: `SensorPlugin.poll()` returns typed readings the engine consumes; sensors never emit intents. @@ -841,7 +857,7 @@ class DifferentialDrive(LocomotionPlugin): async def command(self, twist: Twist) -> None: """Accept a desired linear/angular velocity. The plugin owns everything - below this line — wheel math, gait phase, balance, whatever it takes.""" + below this line: wheel math, gait phase, balance, whatever it takes.""" async def stop(self, hard: bool = False) -> None: ... ``` @@ -866,6 +882,55 @@ so honestly. - **Tier 3, recorded** (`P0`): `emet teach`. No user code. - **Tier 2, user Python** (`V1`): custom intent handlers for exotic bodies, behind the sandbox. +### 12.2 Wake and audio plugins + +Six entry-point groups in total. Three are built from a manifest capability +(`emet.actuators`, `emet.sensors`, `emet.locomotion`). Three are built from the +manifest's `audio` block (`emet.wake`, `emet.audio`, `emet.audio_out`), because +the hardware floor already guarantees a microphone and a speaker, so there is no +capability to declare, only a choice of what runs on them. + +```python +class PocketSphinxWake(WakePlugin): + engine = "pocketsphinx" # the string matched against audio.wake.engine + + def __init__(self, config, phrase): + """The `audio.wake` block from the body and `identity.wake_word` from + the soul. The one place a soul-declared value reaches a plugin + constructor. Principle 1 holds: the soul says which name it + answers to, never which engine hears it or on what device.""" + + def describe(self) -> WakeDescriptor: + """Which phrases this instance actually loaded, and the sample rate and + frame size it needs. Boot fails if the soul's phrase is not among them: + a robot that never answers to its name has nothing to degrade to.""" + + async def process(self, frame: bytes) -> WakeEvent | None: + """One frame in, an event out if the phrase was heard. Must return + promptly; this runs on every frame of the capture path.""" +``` + +**The detector states the audio format and the source conforms to it.** The +reverse silently feeds 48 kHz audio to a 16 kHz model and the robot stops +hearing without an error. + +`emet.audio` sources and `emet.audio_out` sinks are not `Plugin` subclasses. +They satisfy the `AudioSource` and `AudioSink` protocols in `emet_sdk.types` +and are built as `cls(config, fmt)` from `audio.input` or `audio.output`. Audio +is *pulled* from a source and *pushed* to a sink, which is why they are two +groups and not one: `wav` means a recording to replay in the first and a file to +write in the second. + +**Why wake is a category and not a dependency.** Picovoice disabled every free +Porcupine access key on 30 June 2026. Anything that had wired one detector in +directly stopped waking that day, and its owner could not fix it. Behind the +seam, the fix is one line of a manifest. + +**Voice activity detection is deliberately not a category.** One real answer, +permissively licensed, and its output feeds turn-taking (§13), which is +personality rather than hardware. Adding a category later is a minor version +bump; removing one is major. + --- ## 13. Turn-taking diff --git a/README.md b/README.md index b04a9de..7017df2 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ $ emet-listen examples/scout-01.yaml examples/emet-soul.yaml listening for 'hey emet' engine pocketsphinx source microphone + audio 16000 Hz, 80 ms frames patience 900 ms + (ctrl-c to stop) heard 'hey emet' (confidence 1.00) then 4.0s of speech, ended on silence @@ -40,16 +42,16 @@ would each intent mean on it?** ```sh $ emet explain examples/bodiless.yaml -BINDING TABLE — examples/bodiless.yaml (body: bodiless) +BINDING TABLE examples/bodiless.yaml (body: bodiless) 0 capabilities, 31 intents, 0 bound to hardware, 31 to voice - express.curiosity voice inflect preset=rising filler=['hm?', 'hmm.'] + ~ express.curiosity voice inflect filler=['hm?', 'hmm.'] preset=rising $ emet explain examples/mock-scout.yaml -BINDING TABLE — examples/mock-scout.yaml (body: mock_scout) +BINDING TABLE examples/mock-scout.yaml (body: mock_scout) 5 capabilities, 31 intents, 30 bound to hardware, 1 to voice - express.curiosity head tilt angle_deg=12 speed=0.4 hold_ms=700 + express.curiosity head tilt angle_deg=12 hold_ms=700 speed=0.4 ``` Same personality, same configuration, two bodies. Nobody wrote an `if` @@ -88,13 +90,22 @@ from a promise someone has to remember into something the software enforces. ```sh python -m venv .venv -.venv/bin/pip install -e "emet-sdk[dev]" -e "emet-hal[dev]" # Windows: .venv\Scripts\pip +.venv/bin/pip install -e "emet-sdk[dev]" -e "emet-hal[dev]" -e "emet-engine[dev]" # Windows: .venv\Scripts\pip cd emet-sdk emet validate examples/mock-scout.yaml --verify-drivers emet explain examples/scout-01.yaml --why ``` +To hear it wake, add the optional microphone and wake-engine extras and run the +listen loop. On Linux the `audio` extra needs the system PortAudio +(`apt install libportaudio2`). + +```sh +pip install -e "emet-hal[audio,wake]" +emet-listen examples/scout-01.yaml examples/emet-soul.yaml +``` + `--why` is the one to remember. When a robot is not doing what you expected, it tells you which rungs were skipped and what was wrong with each: @@ -109,7 +120,7 @@ tells you which rungs were skipped and what was wrong with each: |---|---| | `emet-sdk/` | Types, schemas, the intent vocabulary, chain resolution. The contract everything agrees on. | | `emet-hal/` | Drivers and locomotion plugins. Where hardware support goes. | -| `emet-engine/` | Personality, memory, arbitration. Does not exist yet. | +| `emet-engine/` | The listen loop: wake, endpointing, audio in and out. Personality, memory and arbitration arrive from 0.4. | ## Documentation diff --git a/RELEASING.md b/RELEASING.md index 2779ceb..152fa58 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,7 +6,7 @@ Each item is here because something went wrong without it, and the note under each says what. A checklist of plausible-sounding good practice gets skipped; one where every line has a scar does not. -Run the mechanical half first — it is fast and it fails loudly: +Run the mechanical half first. It is fast and it fails loudly: ```sh python tools/check_layering.py . @@ -22,7 +22,7 @@ Everything below is what a script cannot check. **Open the roadmap. Copy the release's scope sentence. Tick each noun in it against a file and a test.** -Not from memory. Read the written scope and check the items off one at a time, +Read the written scope from the file and check the items off one at a time, even the ones you are certain about. > **The scar.** 0.3's scope read "Audio **in/out**, wake word, VAD, @@ -43,7 +43,7 @@ The roadmap states a gate per release, phrased as an observable outcome. Answer it literally, not approximately. > **The scar.** 0.3's gate is "...for ten minutes without drift." Drift was -> unmeasurable — nothing timed anything — so the claim would have been a +> unmeasurable, since nothing timed anything, so the claim would have been a > feeling. `--stats` exists because a criterion you cannot produce a number for > is one you cannot honestly sign off. @@ -52,13 +52,13 @@ reason to soften the criterion. It is the reason to stop and go get it. ## 3. Check every deferred decision -**List every "not now, but when X" from this cycle. For each, has X happened?** +**List every "later, when X" from this cycle. For each, has X happened?** Deferrals are made in conversation and lost there. Write them down as they are made, with the trigger, wherever you plan the release. > **The scar.** The version number lived in six places across three packages. -> It was deferred twice with the trigger "the 0.3 release PR" — and when that +> It was deferred twice with the trigger "the 0.3 release PR", and when that > PR arrived, the trigger only fired because somebody remembered. By then the > installed metadata said 0.1.0 while the source said 0.2.0. @@ -102,7 +102,7 @@ release notes say so. `tools/release_check.py` catches the obvious ones. It cannot catch a paragraph that is merely out of date, so re-read: -- `README.md` — the status section, and any sample output +- `README.md`: the status section, and any sample output - each package `README.md` - `emet_hal/__init__.py` and friends, which list what ships - `CITATIONS.md`, if anything was taken from a paper or a repository @@ -111,7 +111,7 @@ that is merely out of date, so re-read: ## 8. Tag - The version is bumped in all three `pyproject.toml` files and **nowhere - else** — `release_check.py` enforces this. + else**. `release_check.py` enforces this. - The tag message is written. Commits stay short; the tag carries the detail. - Every commit in the release is signed off, or the DCO check fails the PR. @@ -121,7 +121,7 @@ that is merely out of date, so re-read: Every scar above is the same mistake: **checking work against a memory of the requirement instead of the requirement.** Memory summarises, and summaries drop -the item you were least involved with — which is reliably the one that is +the item you were least involved with, which is reliably the one that is missing. Hence the shape of this list. Open the file. Copy the sentence. Tick the nouns. diff --git a/TRADEMARK.md b/TRADEMARK.md index 7269457..364310f 100644 --- a/TRADEMARK.md +++ b/TRADEMARK.md @@ -8,7 +8,7 @@ so explicitly: the licence does not give permission to use a project's trade names or marks. **This is deliberate, and it is the project's only point of control.** Because -the licence is permissive, anyone may take Emet, close their fork, and sell it +the licence is permissive, anyone may take Emet, close their fork, and sell it, and that is a permitted outcome. What the name preserves is that "I run Emet" keeps meaning something specific: this engine, these guarantees, this behaviour. Without it, the sentence decays into "I run something derived from diff --git a/emet-engine/README.md b/emet-engine/README.md index f42e89d..cab6373 100644 --- a/emet-engine/README.md +++ b/emet-engine/README.md @@ -3,7 +3,7 @@ The listen loop and the runtime that drives a body. Imports `emet_sdk` only. Hardware reaches it by name through entry-point -discovery, never by import — see the layering check in `tools/check_layering.py`. +discovery, never by import. See the layering check in `tools/check_layering.py`. ```sh emet-listen path/to/manifest.yaml path/to/soul.yaml @@ -25,7 +25,7 @@ emet-listen manifest.yaml soul.yaml --replay ten-minutes.wav --stats The figure that matters is **realtime**: processing time divided by the audio processed. Audio arrives at a fixed rate whatever the CPU is doing, so 1.0 is exactly keeping up with no margin and anything above it is falling behind. -`frames over budget` matters separately — a loop with a fine average that +`frames over budget` matters separately: a loop with a fine average that overruns once a minute is dropping a word once a minute, and the average hides it. @@ -50,5 +50,5 @@ and the full turn loop: Raspberry Pi, and an x86 laptop says nothing about ARM except by comparison. The same command on a Pi is the measurement that matters, and it has not been taken. A file replay also exercises everything except the sound card, so -genuine clock drift — where the card's second and the system's slowly diverge — +genuine clock drift, where the card's second and the system's slowly diverge, is still unmeasured and needs a live microphone. diff --git a/emet-engine/emet_engine/__init__.py b/emet-engine/emet_engine/__init__.py index 26ad96e..633c84a 100644 --- a/emet-engine/emet_engine/__init__.py +++ b/emet-engine/emet_engine/__init__.py @@ -1,4 +1,4 @@ -"""Emet engine — the part that runs, as opposed to the part that describes. +"""Emet engine: the part that runs, as opposed to the part that describes. Layering, enforced in CI: this package imports `emet_sdk` and nothing else. Not for tidiness. The engine is where somebody would reach for a concrete diff --git a/emet-engine/emet_engine/cli.py b/emet-engine/emet_engine/cli.py index 32b7e40..0a8e6f1 100644 --- a/emet-engine/emet_engine/cli.py +++ b/emet-engine/emet_engine/cli.py @@ -1,4 +1,4 @@ -"""`emet-listen` — bring a body up and print what it hears. +"""`emet-listen`: bring a body up and print what it hears. The 0.3 milestone in one command. It does not understand anything yet: it brings up a microphone and a wake detector, and says so each time the robot diff --git a/emet-engine/emet_engine/metrics.py b/emet-engine/emet_engine/metrics.py index 5cee1e9..91d3260 100644 --- a/emet-engine/emet_engine/metrics.py +++ b/emet-engine/emet_engine/metrics.py @@ -1,7 +1,7 @@ """Measuring whether the loop keeps up. 0.3 is not done when it works once. The acceptance bar is ten minutes without -drift, and "without drift" is not something you can watch for — it is a number +drift, and "without drift" is not something you can watch for. It is a number or it is a feeling. This module makes it a number. **The one that decides everything is the real-time factor**: processing time @@ -9,7 +9,7 @@ CPU is doing, so at an RTF of 1.0 the loop is exactly keeping up and has no margin; above 1.0 it is falling behind and frames are being dropped somewhere. This is the number that will decide whether a Raspberry Pi can run Emet at all, -and it cannot be guessed from a laptop — but it can be *compared*, which is why +and it cannot be guessed from a laptop. It can be *compared*, though, which is why it is worth recording on both. **Frames over budget matters more than the average.** A loop averaging 20 ms @@ -19,7 +19,7 @@ separately. **Drift means two different things and only one is measurable offline.** -Processing drift — the loop failing to keep pace — shows up in the RTF and can +Processing drift, the loop failing to keep pace, shows up in the RTF and can be measured against a file. Clock drift, where the sound card's idea of a second and the system's slowly diverge, only appears with real hardware. This module reports the first honestly and refuses to invent the second: for a file @@ -137,7 +137,7 @@ def kept_up(self) -> bool: """Whether this run is evidence the loop is viable here. Three conditions, and all of them matter. Nothing was dropped, no frame - blew the budget, and there is real margin rather than a bare pass — a + blew the budget, and there is real margin rather than a bare pass. A run at 0.99 kept up on a quiet machine and will not on a busy one. """ return self.dropped == 0 and self.over_budget == 0 and self.realtime_factor < 0.5 diff --git a/emet-engine/emet_engine/session.py b/emet-engine/emet_engine/session.py index 1d04ffa..2a086cc 100644 --- a/emet-engine/emet_engine/session.py +++ b/emet-engine/emet_engine/session.py @@ -11,8 +11,8 @@ **Order matters at start-up, and not the obvious way round.** The detector is brought up first and asked what audio it needs, and the source is then -configured to match. Doing it the other way — opening the microphone at -whatever rate the manifest mentions and hoping the detector agrees — is how a +configured to match. Doing it the other way, opening the microphone at +whatever rate the manifest mentions and hoping the detector agrees, is how a robot ends up running perfectly and hearing nothing, because feeding 48 kHz audio to a 16 kHz model does not raise anything. It just stops working. diff --git a/emet-engine/emet_engine/turn.py b/emet-engine/emet_engine/turn.py index aeeec3f..19cfad8 100644 --- a/emet-engine/emet_engine/turn.py +++ b/emet-engine/emet_engine/turn.py @@ -6,7 +6,7 @@ slow however clever the answer is. `patience_ms` is therefore a **persona trait, not engine tuning**. It lives on -the soul, so Neuma — reflective, sparing with words — waits longer than Hugr, +the soul, so Neuma, reflective and sparing with words, waits longer than Hugr, who is opinionated and interrupts. That is the whole point of putting it there: the same engine produces two different conversational temperaments from two data files. @@ -14,7 +14,7 @@ **On the default of 900 ms.** The numbers it trades against are measured rather than guessed, and they are not ours: they come from the TurnBench corpus analysis (Jiang et al., *TurnBench: A Multi-Domain Benchmark for Turn-Taking -Dynamics in Spoken Dialogue*, arXiv:2608.25218, 2026 — see `CITATIONS.md`). +Dynamics in Spoken Dialogue*, arXiv:2608.25218, 2026; see `CITATIONS.md`). floor transfer offset, median -151 ms (before the turn ends) inter-speaker gap, median 380 ms @@ -158,7 +158,7 @@ def close(self) -> Utterance: """The source ended mid-turn. Return whatever was captured. Always `SOURCE_ENDED`, even with nothing captured. `NO_SPEECH` means - something specific — the robot waited the full lead-in and nobody + something specific: the robot waited the full lead-in and nobody spoke, which is evidence of a false wake. A recording that stopped early is not evidence of anything, and labelling it the same way would make a replay look like a detector fault. diff --git a/emet-engine/emet_engine/vad.py b/emet-engine/emet_engine/vad.py index 0b1d47f..9eb2110 100644 --- a/emet-engine/emet_engine/vad.py +++ b/emet-engine/emet_engine/vad.py @@ -2,8 +2,8 @@ Deliberately not a plugin category. There is effectively one good answer in the world (Silero), it is MIT so it carries none of the rug-pull risk that made -wake a category, and its output feeds turn-taking — `patience_ms`, the trailing -clause — which is personality rather than hardware. A seam there would decouple +wake a category, and its output feeds turn-taking (`patience_ms`, the trailing +clause), which is personality rather than hardware. A seam there would decouple nothing. Adding a category later is a minor version bump and removing one is a major bump, so under uncertainty this stays a component. @@ -14,7 +14,7 @@ PyTorch on a Raspberry Pi to decide whether someone is speaking is not a trade worth making. * `silero-vad-lite`, the dependency-free wrapper, publishes no aarch64 Linux - wheel — x86_64 and macOS only — and declares no licence. + wheel (x86_64 Linux, macOS and Windows only) and declares no licence. * Running the ONNX model directly means `onnxruntime` (20.8 MB on ARM) plus a vendored model file. @@ -30,7 +30,7 @@ than any fixed threshold can span; * hysteresis, so one loud frame is not speech and one quiet frame is not silence; -* asymmetric adaptation, because the floor should rise slowly and fall fast — +* asymmetric adaptation, because the floor should rise slowly and fall fast: a fridge switching on must not be learned as speech, and a fridge switching off must not deafen the robot for a minute. """ @@ -73,7 +73,7 @@ class VadTuning: #: Consecutive loud frames before speech is declared. Rejects a door. onset_frames: int = 2 #: Consecutive quiet frames before speech is over. At 80 ms frames this is - #: about a third of a second, which is roughly the gap inside a sentence — + #: about a third of a second, which is roughly the gap inside a sentence; #: shorter and the robot interrupts you mid-thought. hangover_frames: int = 4 #: How fast the floor rises toward a louder room, per frame. @@ -111,7 +111,7 @@ def quiet_frames(self) -> int: Exposed because the endpointer measures trailing silence from the last genuinely loud frame, not from when the debounced flag flipped. The - difference is `hangover_frames` — a third of a second that would + difference is `hangover_frames`, a third of a second that would otherwise be silently charged to the persona's `patience_ms`, making every soul slower than the number written in its own bundle. """ diff --git a/emet-engine/pyproject.toml b/emet-engine/pyproject.toml index a60b508..ea6ba6c 100644 --- a/emet-engine/pyproject.toml +++ b/emet-engine/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "emet-engine" version = "0.3.0" -description = "Emet engine — the listen loop and the runtime that drives a body." +description = "Emet engine: the listen loop and the runtime that drives a body." readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" diff --git a/emet-engine/tests/test_session.py b/emet-engine/tests/test_session.py index 709b07f..27c855f 100644 --- a/emet-engine/tests/test_session.py +++ b/emet-engine/tests/test_session.py @@ -57,7 +57,7 @@ def body( `sink="null"` is not incidental. The default is a real speaker, so a test body that said nothing about output would open whatever is plugged into the - machine running the suite — which fails on a headless CI runner and is rude + machine running the suite, which fails on a headless CI runner and is rude on a laptop. Tests state where their audio goes. """ return { diff --git a/emet-engine/tests/test_turn.py b/emet-engine/tests/test_turn.py index 775efbd..52a8d61 100644 --- a/emet-engine/tests/test_turn.py +++ b/emet-engine/tests/test_turn.py @@ -2,7 +2,7 @@ Synthetic audio throughout: silence is zeroes and speech is a square wave at a chosen amplitude, so every threshold in these tests is an exact number rather -than a recording somebody has to trust. That makes the failures legible — when +than a recording somebody has to trust. That makes the failures legible: when one breaks it says which parameter moved, not "the audio sounds different now". The behaviours worth protecting here are the ones that read as rudeness when diff --git a/emet-hal/README.md b/emet-hal/README.md index e0d3d0e..e4d0607 100644 --- a/emet-hal/README.md +++ b/emet-hal/README.md @@ -1,7 +1,7 @@ # emet-hal -The hardware abstraction layer for [Emet](../DESIGN.md) — drivers and -locomotion plugins. +The hardware abstraction layer for [Emet](../DESIGN.md): drivers, locomotion +plugins, wake engines, and audio in and out. Apache 2.0, community-contributed. This is the package the project most wants pull requests against: a driver that turns out to be wrong is reverted in a @@ -12,14 +12,24 @@ for years. | Entry point | Group | What it is | |---|---|---| -| `emet_hal.mock` | actuator | Logs what it was asked to do and moves nothing | -| `emet_hal.mock_sensor` | sensor | Returns fixed readings | -| `differential` | locomotion | Two independently driven wheels | -| `tracked` | locomotion | The same arithmetic, plus tread scrub | - -**No hardware drivers yet.** The locomotion plugins are arithmetic — they turn -a desired velocity into per-wheel speeds and touch no GPIO — so they need no -robot and are fully testable against the mock. +| `emet_hal.mock` | `emet.actuators` | Logs what it was asked to do and moves nothing | +| `emet_hal.mock_sensor` | `emet.sensors` | Returns fixed readings | +| `differential` | `emet.locomotion` | Two independently driven wheels | +| `tracked` | `emet.locomotion` | The same arithmetic, plus tread scrub | +| `pocketsphinx` | `emet.wake` | Phonetic wake word detection. Extra: `wake` | +| `mock` | `emet.wake` | Fires when a frame literally contains the phrase | +| `microphone` | `emet.audio` | Live capture through PortAudio. Extra: `audio` | +| `wav` | `emet.audio` | Replays a recording through the same path | +| `speaker` | `emet.audio_out` | Playback through PortAudio. Extra: `audio` | +| `wav` | `emet.audio_out` | Writes what the robot said to a file | +| `null` | `emet.audio_out` | Discards audio, so the loop can run silently | + +**No hardware drivers yet.** The locomotion plugins are arithmetic: they turn +a desired velocity into per-wheel speeds and touch no GPIO, so they need no +robot and are fully testable against the mock. Wake and audio are optional +extras, `emet-hal[wake]` and `emet-hal[audio]`, because a body that never +wakes should not pull down an acoustic model. On Linux, `audio` needs the +system PortAudio: `apt install libportaudio2`. ## How a plugin is found @@ -35,21 +45,27 @@ hardware. Plugins advertise themselves through entry points: ``` For actuators and sensors the entry-point name is what a manifest puts in -`driver.plugin`. For locomotion it is the value of `drive.kinematics` — which +`driver.plugin`. For locomotion it is the value of `drive.kinematics`, which is what makes that enum genuinely open. `kinematics: legged` is legal today and resolves the moment somebody publishes a package registering `legged`. +The same rule covers the three audio groups: `audio.wake.engine` names an +`emet.wake` plugin, `audio.input.source` an `emet.audio` source, and +`audio.output.sink` an `emet.audio_out` sink. ## Writing one -Implement `ActuatorPlugin`, `SensorPlugin`, or `LocomotionPlugin` from -`emet_sdk.plugin`, register an entry point, and install the package. +Implement `ActuatorPlugin`, `SensorPlugin`, `LocomotionPlugin` or `WakePlugin` +from `emet_sdk.plugin`, register an entry point, and install the package. +Audio sources and sinks are not plugins in that sense: they satisfy the +`AudioSource` and `AudioSink` protocols in `emet_sdk.types` and are built as +`cls(config, fmt)` from the manifest's `audio.input` or `audio.output` block. Two things worth getting right: **`describe()` reports what the instance can *actually* do, after `start()`.** Chains bind against your descriptor, not against the manifest. Claiming an axis you cannot drive means a chain binds to you and the robot silently does -nothing — worse than falling through to a light ring. +nothing, which is worse than falling through to a light ring. **`apply()` must return promptly.** Long moves are driven by repeated calls from the choreographer at 50Hz. Sleeping for the duration of a gesture blocks @@ -59,5 +75,6 @@ the loop that would otherwise let a higher-priority intent preempt it. ```sh pip install -e ../emet-sdk -e ".[dev]" +pip install -e ".[audio,wake]" # optional; the tests that need them skip otherwise pytest ``` diff --git a/emet-hal/emet_hal/__init__.py b/emet-hal/emet_hal/__init__.py index e028bb6..f578d81 100644 --- a/emet-hal/emet_hal/__init__.py +++ b/emet-hal/emet_hal/__init__.py @@ -1,4 +1,4 @@ -"""Emet HAL — the hardware abstraction layer. +"""Emet HAL: the hardware abstraction layer. *HAL* is **hardware abstraction layer**, used in Android's sense: the abstraction itself lives in `emet_sdk.plugin`, and this package is the diff --git a/emet-hal/emet_hal/audio.py b/emet-hal/emet_hal/audio.py index aa9ac02..f2ea917 100644 --- a/emet-hal/emet_hal/audio.py +++ b/emet-hal/emet_hal/audio.py @@ -5,7 +5,7 @@ piece of hardware a body cannot decline to have. **Why this is not a plugin category.** Wake is one because engines get -discontinued — Picovoice disabled every free Porcupine access key on 30 June +discontinued: Picovoice disabled every free Porcupine access key on 30 June 2026. Audio devices do not work that way: PortAudio already abstracts ALSA, WASAPI, and CoreAudio behind one interface, so the swap point that would justify a category is already inside the dependency. `AudioSource` is a plain @@ -13,8 +13,8 @@ without anything above noticing. **On sample rates, which are the thing that will bite you.** The wake engine -needs 16 kHz mono. Almost no sound card runs at 16 kHz — they run at 44.1 or -48 — so something must convert. Who does the converting is not uniform: +needs 16 kHz mono. Almost no sound card runs at 16 kHz; they run at 44.1 or +48, so something must convert. Who does the converting varies: * On Linux, an ALSA `plughw:` device converts for you. That is precisely what the `plug` layer is for, and it is why the manifests in this repository say @@ -168,7 +168,7 @@ def resolve_device(spec: str | int | None, *, want_input: bool) -> int | None: if ":" in text or text.startswith(("hw", "plughw")): hint = ( f"\n{text!r} is an ALSA name, so this manifest was written for a Linux " - f"body. That is not a mistake in the manifest — it is the wrong machine " + f"body. The manifest is fine; this is the wrong machine " f"for it. Set the device to one below, or run this on the body it " f"describes." ) @@ -309,7 +309,7 @@ class MicrophoneSource: PortAudio calls back on its own thread; frames cross into asyncio through a bounded queue. When the consumer falls behind the oldest frame is dropped and counted, because the alternative is an ever-growing backlog and a robot - that answers a question from a minute ago. `dropped` is not decoration — + that answers a question from a minute ago. `dropped` matters: a non-zero value means wake words were missed, and the engine should say so rather than let it pass as bad luck. """ @@ -487,7 +487,7 @@ async def stop(self) -> None: class NullSink: """Accepts audio and discards it, counting what it was given. - Not only for tests, though it is essential there — a suite that plays sound + Useful beyond tests, though essential there: a suite that plays sound through whatever happens to be plugged in is as rude as one that records. It is also how you run the loop on a laptop at midnight, and how a body with no speaker attached still satisfies the contract that every chain diff --git a/emet-hal/emet_hal/differential.py b/emet-hal/emet_hal/differential.py index 9c0cc6f..bf85d80 100644 --- a/emet-hal/emet_hal/differential.py +++ b/emet-hal/emet_hal/differential.py @@ -1,4 +1,4 @@ -"""Differential drive — two independently driven wheels. +"""Differential drive: two independently driven wheels. The oldest trick in mobile robotics: drive both wheels the same and you go straight; drive them at different speeds and you turn; drive them opposite and @@ -6,7 +6,7 @@ This plugin ships in a release with no hardware drivers at all, which looks inconsistent until you see what it actually is. A locomotion plugin is -*arithmetic* — it turns a desired velocity into per-wheel speeds and hands +*arithmetic*: it turns a desired velocity into per-wheel speeds and hands those to whatever driver owns the motors. It touches no GPIO, needs no robot, and is fully testable against the mock. Shipping it is how the seam from the design spec gets proven rather than asserted: the engine says "go forward at diff --git a/emet-hal/emet_hal/mock.py b/emet-hal/emet_hal/mock.py index be0d613..85189de 100644 --- a/emet-hal/emet_hal/mock.py +++ b/emet-hal/emet_hal/mock.py @@ -2,8 +2,8 @@ `MockActuator` accepts any action and records it instead of moving anything. That sounds like a testing convenience, and it is, but it is also the way most -development on Emet will actually happen: the whole stack — chain resolution, -arbitration, the choreographer, an entire conversation — can be exercised on a +development on Emet will actually happen: the whole stack (chain resolution, +arbitration, the choreographer, an entire conversation) can be exercised on a laptop with no robot attached. It matters for contributors too. Someone writing a driver for a servo board @@ -53,7 +53,7 @@ class MockActuator(ActuatorPlugin): """An actuator that logs what it was asked to do and does nothing. Set `params.fail_on_start: true` to simulate hardware that is wired but - dead. That path is worth exercising deliberately — it is how you check + dead. That path is worth exercising deliberately; it is how you check that a chain falls through to its next rung instead of binding to something that will never move. """ diff --git a/emet-hal/emet_hal/pocketsphinx_wake.py b/emet-hal/emet_hal/pocketsphinx_wake.py index c72e33b..b59f804 100644 --- a/emet-hal/emet_hal/pocketsphinx_wake.py +++ b/emet-hal/emet_hal/pocketsphinx_wake.py @@ -4,15 +4,15 @@ deliberate reversal of how most wake word detection works, and it is what lets `identity.wake_word` be free text. -A trained detector — openWakeWord, and Porcupine before its access keys were -disabled on 30 June 2026 — learns one phrase from thousands of examples. It is +A trained detector (openWakeWord, and Porcupine before its access keys were +disabled on 30 June 2026) learns one phrase from thousands of examples. It is more accurate, and it means a soul can only answer to a name somebody has already trained. Naming your robot Barnaby would make it deaf. A phonetic spotter works the other way round. It knows how English *sounds*, and a phrase is a sequence of phonemes to watch for. "hey barnaby" needs no training at all, because `barnaby` is already in the pronunciation dictionary. -A name that is not — `emet` is not an English word — needs one line of +A name outside the dictionary, and `emet` is one, needs one line of phonemes, which is what `SHIPPED_LEXICON` below is. The tradeoff is real and worth stating plainly: this is less accurate in noise diff --git a/emet-hal/emet_hal/tracked.py b/emet-hal/emet_hal/tracked.py index 911d3c3..a1363a0 100644 --- a/emet-hal/emet_hal/tracked.py +++ b/emet-hal/emet_hal/tracked.py @@ -1,4 +1,4 @@ -"""Tracked drive — treads instead of wheels. +"""Tracked drive: treads instead of wheels. Shares its arithmetic with differential drive entirely, and differs in one physical fact: **tracks scrub when they turn.** A wheel turning follows an arc @@ -42,7 +42,7 @@ def __init__(self, capability: Mapping[str, Any]) -> None: def describe(self) -> LocomotionDescriptor: base = super().describe() - # Tracks turn in place happily — that is what they are good at — but + # Tracks turn in place happily, that is what they are good at, but # the achievable rate is lower than the geometry alone predicts, and # the self-model should not promise what the treads cannot deliver. return LocomotionDescriptor( diff --git a/emet-hal/pyproject.toml b/emet-hal/pyproject.toml index 32922d1..32c4c16 100644 --- a/emet-hal/pyproject.toml +++ b/emet-hal/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "emet-hal" version = "0.3.0" -description = "Emet HAL — hardware abstraction layer: drivers and locomotion plugins." +description = "Emet HAL: hardware abstraction layer. Drivers, locomotion, wake, and audio plugins." readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" @@ -15,8 +15,8 @@ keywords = ["emet", "robotics", "hal", "drivers"] dependencies = ["emet-sdk>=0.1"] [project.optional-dependencies] -# The shipped wake engine. Optional because a body that never wakes — a test -# rig, a CI run resolving chains — should not pull an acoustic model down. +# The shipped wake engine. Optional because a body that never wakes (a test +# rig, a CI run resolving chains) should not pull an acoustic model down. wake = ["pocketsphinx>=5.0"] # Microphone and speaker. Optional for the same reason, and because on Linux # it needs the system PortAudio (`apt install libportaudio2`) that a headless @@ -28,7 +28,7 @@ dev = ["pytest>=8.0"] # and the engine has no compiled-in list of known hardware. # # For actuators and sensors the entry-point name is the string a manifest puts -# in `driver.plugin`. For locomotion it is the value of `drive.kinematics` — +# in `driver.plugin`. For locomotion it is the value of `drive.kinematics`, # which is what keeps that enum genuinely open. [project.entry-points."emet.actuators"] diff --git a/emet-hal/tests/test_hal.py b/emet-hal/tests/test_hal.py index 43da474..f00d16b 100644 --- a/emet-hal/tests/test_hal.py +++ b/emet-hal/tests/test_hal.py @@ -18,7 +18,7 @@ from emet_hal.mock import MockActuator, MockSensor, MockWake from emet_hal.tracked import TrackedDrive -# r = 0.05 m, W = 0.20 m — chosen so the sums come out in round numbers. +# r = 0.05 m, W = 0.20 m, chosen so the sums come out in round numbers. DRIVE_BLOCK = { "id": "base", "type": "drive", diff --git a/emet-sdk/README.md b/emet-sdk/README.md index bd1b795..8dae4df 100644 --- a/emet-sdk/README.md +++ b/emet-sdk/README.md @@ -1,10 +1,10 @@ # emet-sdk -The contract layer for [Emet](../DESIGN.md) — types, schemas, and the intent +The contract layer for [Emet](../DESIGN.md): types, schemas, and the intent vocabulary that the engine and every plugin agree on. -Apache 2.0. Under ~2,000 lines on purpose: this is contracts and almost no -logic, because it is the one thing both sides of the boundary must share. +Apache 2.0. Small on purpose: this is contracts and almost no logic, because it +is the one thing both sides of the boundary must share. **HAL** below and throughout means *hardware abstraction layer*. Emet uses the term in Android's sense: the abstraction itself lives here in @@ -19,11 +19,11 @@ it deliberately contains almost no logic. | | | |---|---| -| `schemas/` | Body manifest, soul bundle, and motion pack, as JSON Schema. The **full** surface — every P0 field, every RSV field reserved for later releases, and the reserved V1 capability types. | +| `schemas/` | Body manifest, soul bundle, and motion pack, as JSON Schema. The **full** surface: every P0 field, every RSV field reserved for later releases, and the reserved V1 capability types. | | `emet_sdk/types.py` | `Intent`, `Action`, `Pose`, `Twist`, `CapabilityDescriptor`, `LocomotionDescriptor`, `WakeDescriptor`, `AudioFormat`, `AudioSource`, `AudioSink`, `Health`, `Priority`, `Sensitivity`. | | `emet_sdk/intents.py` | The closed intent vocabulary, plus the four names reserved from P0. | | `emet_sdk/chains.py` | Fallback chain format, and the rule that every chain terminates in a voice rung. | -| `emet_sdk/plugin.py` | `ActuatorPlugin`, `SensorPlugin`, `LocomotionPlugin`, `WakePlugin` — the public contract. | +| `emet_sdk/plugin.py` | `CapabilityPlugin` and its subclasses `ActuatorPlugin`, `SensorPlugin`, `LocomotionPlugin`, plus `WakePlugin`: the public contract. | | `emet_sdk/discovery.py` | Entry-point discovery across six groups. Installing a package is what makes a driver exist. | | `emet_sdk/resolve.py` | Chain resolution: `(chains, descriptors) → binding table`. | | `emet_sdk/validate.py` | Semantic rules and the error taxonomy. | @@ -48,11 +48,12 @@ emet explain examples/mock-scout.yaml --why # and why anything degraded ``` `explain` is the one to reach for when a robot is not doing what you expected. -It prints, for every intent, which part of *this* body performs it — and for -anything that fell short of its best option, which rungs were skipped and why: +It prints, for every intent, which part of *this* body performs it, and for +anything that fell short of its best option, which rungs were skipped and why. +On `scout-01.yaml`, whose head cannot roll: ``` - ~ express.affection eyes expression hold_ms=1500 preset=soft + ~ express.affection eyes expression hold_ms=1500 preset=soft skipped rung 0 {role: head, axis: roll}: 'head' has no 'roll' axis; it has pitch, yaw ``` @@ -69,12 +70,12 @@ Add `--strict` to fail on warnings, `--json` for machine-readable output. **Every fallback chain must end in a voice rung.** The hardware floor is a microphone and a speaker, so a chain ending in voice can never fail to bind. This is how "every intent is always satisfiable" becomes mechanical rather -than aspirational — no engine code checks it, because an unterminated chain +than aspirational. No engine code checks it, because an unterminated chain cannot get past the validator. **A missing plugin is never a schema error.** `drive.kinematics` is an *open* enum: any string naming an installed locomotion plugin is legal. -`kinematics: legged` is therefore a `MissingPluginError` — the value is +`kinematics: legged` is therefore a `MissingPluginError`: the value is correct, the software just is not written yet. Conflating a typo with absent hardware costs support hours, and closing that enum would make bipeds unexpressible without migrating every robot in the field. diff --git a/emet-sdk/emet_sdk/__init__.py b/emet-sdk/emet_sdk/__init__.py index fef6522..a0b25c4 100644 --- a/emet-sdk/emet_sdk/__init__.py +++ b/emet-sdk/emet_sdk/__init__.py @@ -1,4 +1,4 @@ -"""Emet SDK — the contract layer. +"""Emet SDK: the contract layer. Types and contracts and almost no logic. This is the only thing the engine and every plugin must agree on, which is why it is small on purpose and why diff --git a/emet-sdk/emet_sdk/chains.py b/emet-sdk/emet_sdk/chains.py index ea7690b..7957938 100644 --- a/emet-sdk/emet_sdk/chains.py +++ b/emet-sdk/emet_sdk/chains.py @@ -1,4 +1,4 @@ -"""Fallback chains — the mechanism that makes one soul run any body. +"""Fallback chains: the mechanism that makes one soul run any body. A chain maps an intent to an ordered list of rungs. At boot the engine walks each chain top to bottom and binds the first rung whose actuator selector @@ -6,13 +6,13 @@ body with only eyes squints; a body with neither says "hm?". Chains are data, live in the SDK, and are overridable per-soul. Degradation is -declared, not improvised (principle 5) — there is no `if hasattr(...)` +declared, not improvised (principle 5): there is no `if hasattr(...)` anywhere in the engine. **The rule this module exists to enforce:** every chain's final rung must be a voice rung. Because the hardware floor is a microphone and a speaker, a chain -ending in voice can never fail to bind, which is what makes principle 2 — -every intent is always satisfiable — mechanical rather than aspirational. +ending in voice can never fail to bind, which is what makes principle 2, +every intent is always satisfiable, mechanical rather than aspirational. """ from __future__ import annotations @@ -40,7 +40,7 @@ class ChainError(ValueError): class UnterminatedChainError(ChainError): """A chain whose final rung is not a voice rung. - Its own exception type because this is not a typo — it is a chain that + Its own exception type because this is worse than a typo: a chain that can fail to bind on a sufficiently bare body, which breaks the guarantee the whole abstraction rests on. """ @@ -60,7 +60,7 @@ class ChainMode: class ActuatorSelector: """Which part of a body a rung wants. - Selectors name roles and axes — `head`, `pitch`, `eyes` — never drivers or + Selectors name roles and axes (`head`, `pitch`, `eyes`), never drivers or channels. This is the seam that keeps principle 1 intact while still letting a chain be specific about what it needs. """ @@ -73,7 +73,7 @@ class ActuatorSelector: def __post_init__(self) -> None: if self.voice and (self.role or self.type or self.axis): raise ChainError( - "a voice rung selector takes no role, type, or axis — " + "a voice rung selector takes no role, type, or axis: " "voice is the floor, and it is unconditional" ) if not self.voice and not (self.role or self.type or self.axis): diff --git a/emet-sdk/emet_sdk/chains/core.yaml b/emet-sdk/emet_sdk/chains/core.yaml index d98ef68..5cca41e 100644 --- a/emet-sdk/emet_sdk/chains/core.yaml +++ b/emet-sdk/emet_sdk/chains/core.yaml @@ -2,11 +2,11 @@ # # Two voice actions carry weight here: # -# `silence` — a voice rung that produces no sound. It still *binds*, which +# `silence`: a voice rung that produces no sound. It still *binds*, which # is what the terminal-rung rule requires. An idle timer on a bodiless robot # must be a silent no-op, not a robot that mutters every twenty seconds. # -# `explain` — a voice rung that says why the body cannot do the thing. This +# `explain`: a voice rung that says why the body cannot do the thing. This # is principle 6 at the chain level: asked to come closer, a robot with no # wheels says so rather than failing quietly. diff --git a/emet-sdk/emet_sdk/chains/express.yaml b/emet-sdk/emet_sdk/chains/express.yaml index 3d3eeea..886c083 100644 --- a/emet-sdk/emet_sdk/chains/express.yaml +++ b/emet-sdk/emet_sdk/chains/express.yaml @@ -1,4 +1,4 @@ -# Fallback chains for `express` — the core expressive set. +# Fallback chains for `express`: the core expressive set. # # Ordered most expressive first. The engine binds the first rung whose # selector matches the manifest; the final voice rung always binds, so a @@ -55,7 +55,7 @@ express.confusion: params: {hue: 35, period_ms: 1600} - actuator: {voice: true} action: inflect - params: {preset: rising, filler: ["hm?", "wait —"]} + params: {preset: rising, filler: ["hm?", "wait..."]} express.concern: mode: first diff --git a/emet-sdk/emet_sdk/cli.py b/emet-sdk/emet_sdk/cli.py index da13cc6..81ef4ef 100644 --- a/emet-sdk/emet_sdk/cli.py +++ b/emet-sdk/emet_sdk/cli.py @@ -5,7 +5,7 @@ `explain` is the one to reach for when a robot is not doing what you expected. It prints the binding table: for every intent, which part of *this* body -performs it, and — for anything that fell short of its best option — which +performs it, and, for anything that fell short of its best option, which rungs were skipped and why. Exit codes: 0 clean, 1 validation errors, 2 usage or IO failure. @@ -36,7 +36,7 @@ ) # ASCII on purpose. This runs over SSH on a Pi, under cron, and in CI, where -# the console encoding is not ours to assume — a status line that raises +# the console encoding is not ours to assume; a status line that raises # UnicodeEncodeError is worse than a plain one. _MARKS = {"error": "x", "warning": "!"} @@ -139,7 +139,7 @@ def _cmd_validate(args: argparse.Namespace) -> int: continue try: kind, report = _validate_path(path, registry) - except Exception as exc: # noqa: BLE001 — surfaced to the user, not swallowed + except Exception as exc: # noqa: BLE001 # surfaced to the user, not swallowed report = ValidationReport() report.error("read_error", f"{type(exc).__name__}: {exc}") kind = "unknown" @@ -192,7 +192,7 @@ def _shipped_chain_files() -> list[Path]: def _load_chains(extra: list[str] | None) -> dict: - """SDK defaults first, then any override file — later wins. + """SDK defaults first, then any override file; later wins. That ordering is how per-soul chain overrides are meant to work: a bundle ships only the ladders it wants to change. @@ -222,7 +222,7 @@ def _cmd_explain(args: argparse.Namespace) -> int: # the wrong file should be told which file and why, not shown a stack. try: manifest = load_yaml(manifest_path) - except Exception as exc: # noqa: BLE001 — reported, not swallowed + except Exception as exc: # noqa: BLE001 # reported, not swallowed print(f"FAIL {manifest_path} (unreadable)") print(f" x read_error") for line in f"{type(exc).__name__}: {exc}".splitlines(): @@ -232,7 +232,7 @@ def _cmd_explain(args: argparse.Namespace) -> int: if not isinstance(manifest, Mapping) or "manifest_version" not in manifest: print(f"FAIL {manifest_path} (not a manifest)") print(" x unknown_document") - print(" `emet explain` needs a body manifest — a document with a") + print(" `emet explain` needs a body manifest, a document with a") print(" top-level `manifest_version` key. For soul bundles and") print(" motion packs, use `emet validate`.") return 1 @@ -263,7 +263,7 @@ def _cmd_explain(args: argparse.Namespace) -> int: table = resolve(chains, caps) body = (manifest.get("body") or {}).get("id", "?") - print(f"BINDING TABLE — {manifest_path} (body: {body})") + print(f"BINDING TABLE {manifest_path} (body: {body})") print(f"{len(caps)} capabilities, {len(table)} intents, " f"{len(table.hardware_bound)} bound to hardware, " f"{len(table.voice_bound)} to voice") @@ -291,7 +291,7 @@ def _cmd_explain(args: argparse.Namespace) -> int: print() print(" Actuators no intent binds to:") for cap_id in unused: - print(f" {cap_id} — {reasons.get(cap_id, 'bound by nothing')}") + print(f" {cap_id}: {reasons.get(cap_id, 'bound by nothing')}") if args.why and not degraded: # Silence here would read as "the flag did nothing" rather than as the @@ -310,7 +310,7 @@ def _cmd_explain(args: argparse.Namespace) -> int: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="emet", - description="Emet — validate body manifests, soul bundles, motion packs, and chains.", + description="Emet: validate body manifests, soul bundles, motion packs, and chains.", ) parser.add_argument("--version", action="version", version=f"emet-sdk {__version__}") sub = parser.add_subparsers(dest="command", required=True) @@ -328,7 +328,7 @@ def build_parser() -> argparse.ArgumentParser: validate.add_argument( "--verify-drivers", action="store_true", - help="require every driver plugin to be installed — what the engine does at " + help="require every driver plugin to be installed, which is what the engine does at " "boot. Off by default, because describing hardware you have not wired " "yet is a normal thing to do.", ) diff --git a/emet-sdk/emet_sdk/discovery.py b/emet-sdk/emet_sdk/discovery.py index ad640e4..b64b359 100644 --- a/emet-sdk/emet_sdk/discovery.py +++ b/emet-sdk/emet_sdk/discovery.py @@ -2,7 +2,7 @@ Plugins are ordinary Python packages that advertise themselves through entry points. Nothing scans directories, nothing imports by convention, and the -engine has no list of known drivers compiled into it — installing a package is +engine has no list of known drivers compiled into it; installing a package is what makes a driver exist. Six groups: @@ -17,7 +17,7 @@ `emet.audio` exists for a reason the others do not share. The engine may import `emet_sdk` and nothing else, so it cannot reach into `emet_hal` for a microphone even though that is exactly where microphones live. Discovery is -what carries one across the boundary — the engine asks for `microphone` and +what carries one across the boundary: the engine asks for `microphone` and receives a class it never imported. Without this group the layering rule and a working engine are mutually exclusive. @@ -35,7 +35,7 @@ manifest. **Errors from this module are never schema errors.** A name that resolves to -no installed package is a `MissingPluginError` — the document is well-formed +no installed package is a `MissingPluginError`: the document is well-formed and the value is legal, the software simply is not present. Conflating that with a typo costs support hours, and closing the enum to avoid the ambiguity would make walking robots inexpressible without migrating every manifest in @@ -252,7 +252,7 @@ def _missing(name: str, available: Iterable[str], what: str) -> Exception: report.error( "missing_plugin", f"no {what} provides {name!r}. Installed: {installed}. " - f"The value is legal — install the package that provides it, or " + f"The value is legal. Install the package that provides it, or " f"correct the name.", ) return MissingPluginError(report) diff --git a/emet-sdk/emet_sdk/intents.py b/emet-sdk/emet_sdk/intents.py index 0e5f6d0..61a44c0 100644 --- a/emet-sdk/emet_sdk/intents.py +++ b/emet-sdk/emet_sdk/intents.py @@ -4,7 +4,7 @@ version bump of the SDK. Closed also means an unrecognised intent is a *validation failure*, not a -no-op — which is why the intents that arrive in later releases are reserved +no-op, which is why the intents that arrive in later releases are reserved here from P0. A soul bundle written today that reaches for `manipulate` is merely ineffective; without reservation it would fail to load once the name was finally added. Bundles are exactly the artifact strangers publish, copy, @@ -32,7 +32,7 @@ #: Sentinel for intents whose argument is arbitrary text rather than a -#: member of a fixed set — `speak` carries an utterance, not a keyword. +#: member of a fixed set; `speak` carries an utterance, not a keyword. FREE_ARGUMENT = frozenset({"*"}) @@ -47,7 +47,7 @@ "attend": frozenset({"speaker", "bearing", "person", "none"}), - # The core expressive set. Deliberately small — a body renders a handful + # The core expressive set. Deliberately small: a body renders a handful # of states legibly, and a longer list would mostly collapse onto them. "express": frozenset({ "curiosity", "delight", "confusion", "concern", @@ -111,7 +111,7 @@ class UnknownIntentError(ValueError): def is_reserved(kind: str) -> bool: - """True if `kind` is reserved — accepted, but dropped by the P0 engine.""" + """True if `kind` is reserved: accepted, but dropped by the P0 engine.""" return kind in RESERVED_INTENTS @@ -167,7 +167,7 @@ def make( ) -> Intent: """Construct an Intent, checking it against the closed vocabulary. - Prefer this over calling `Intent(...)` directly — the dataclass stays + Prefer this over calling `Intent(...)` directly. The dataclass stays permissive so that the engine can round-trip intents it did not create, but anything the soul emits should come through here. """ diff --git a/emet-sdk/emet_sdk/plugin.py b/emet-sdk/emet_sdk/plugin.py index c36b86a..b3853fe 100644 --- a/emet-sdk/emet_sdk/plugin.py +++ b/emet-sdk/emet_sdk/plugin.py @@ -4,7 +4,7 @@ * **Actuators** receive `Action`s and do something physical. The engine tells them what should happen; how is theirs. -* **Sensors** invert the flow — the engine polls, they return `Reading`s. A +* **Sensors** invert the flow: the engine polls, they return `Reading`s. A sensor never emits an intent, because deciding what an observation *means* belongs to the engine. Letting a driver push intents would put its author in charge of the personality. @@ -19,8 +19,8 @@ **Why there is no VAD category.** Voice activity detection looks like it belongs beside wake, and does not. It is not a swap point: there is one real answer (Silero), it is MIT licensed so it carries none of the rug-pull risk -that made wake a category, and its output feeds turn-taking — `patience_ms`, -the trailing-clause heuristic — which is personality rather than hardware. A +that made wake a category, and its output feeds turn-taking (`patience_ms`, +the trailing-clause heuristic), which is personality rather than hardware. A seam there would decouple nothing. The asymmetry settles it: adding a category later is a minor version bump, removing one is a major bump, so under uncertainty the cheap direction is to leave it out. @@ -30,7 +30,7 @@ layer ever will. **On `describe()` and `start()`.** A descriptor reports what an instance can -*actually* do, which is only knowable after initialisation — so `start()` +*actually* do, which is only knowable after initialisation, so `start()` exists, and `describe()` is defined to be called after it. A joint group whose servo board failed to answer returns a narrower descriptor (or `healthy=False`), and fallback chains bind past it to the next rung. That is the difference @@ -70,7 +70,7 @@ class PluginError(RuntimeError): Raising this from `start()` is the supported way to say "this hardware is not present or not working". The engine records it, marks the capability - unhealthy, and lets chains fall through — it does not crash, because a + unhealthy, and lets chains fall through rather than crashing, because a robot with a dead servo is still a robot that can talk. """ @@ -115,7 +115,7 @@ class CapabilityPlugin(Plugin): `params` live here rather than on `Plugin`: wake has none of them. """ - #: The manifest `type` this plugin implements — joint_group, drive, + #: The manifest `type` this plugin implements: joint_group, drive, #: display, light, camera, sensor. Locomotion plugins leave this empty and #: set `kinematics` instead. capability_type: ClassVar[str] = "" @@ -125,7 +125,7 @@ def __init__(self, capability: Mapping[str, Any]) -> None: Not just `driver.params`: a plugin needs `role`, `joints`, `form` and the rest to answer `describe()` honestly. What it does with the - wiring-specific `params` is entirely its own business — Emet passes + wiring-specific `params` is entirely its own business; Emet passes them through without looking at them. """ self.capability: Mapping[str, Any] = capability @@ -153,7 +153,7 @@ async def apply(self, action: Action) -> None: """Execute one action. **Must return promptly.** Long moves are driven by repeated `apply()` - calls from the choreographer at 50Hz — a plugin that sleeps for the + calls from the choreographer at 50Hz; a plugin that sleeps for the duration of a gesture blocks the loop that would let a higher-priority intent preempt it. """ @@ -175,7 +175,7 @@ async def poll(self) -> Reading: Called by the engine on its own schedule. If the value is old, say so with `Reading(stale=True)` rather than returning a stale number as if - it were fresh — a robot acting confidently on a dead sensor is worse + it were fresh; a robot acting confidently on a dead sensor is worse than one that knows it cannot see. """ @@ -277,7 +277,7 @@ async def process(self, frame: bytes) -> WakeEvent | None: detector that never competes with speech recognition for the device. Frames arrive at the rate and size the descriptor asked for. **Must - return promptly** — this runs on every frame of the capture path, so + return promptly**: this runs on every frame of the capture path, so blocking here drops audio and delays the wake it is meant to catch. """ diff --git a/emet-sdk/emet_sdk/resolve.py b/emet-sdk/emet_sdk/resolve.py index e288f71..b05781c 100644 --- a/emet-sdk/emet_sdk/resolve.py +++ b/emet-sdk/emet_sdk/resolve.py @@ -1,4 +1,4 @@ -"""Chain resolution — deciding what each intent means on a particular body. +"""Chain resolution: deciding what each intent means on a particular body. (chains, capability descriptors) -> binding table @@ -13,7 +13,7 @@ three different bodies, zero conditionals. This lives in the SDK rather than the engine because it is a pure function over -contract data — no state, no I/O, no personality — and because a HAL +contract data (no state, no I/O, no personality) and because a HAL contributor needs to see where their driver binds without running an engine. The choreographer, which is stateful and runs at 50Hz, does not. @@ -45,8 +45,8 @@ #: Axes a joint may declare, mirrored from the manifest schema. _AXES = frozenset({"yaw", "pitch", "roll", "linear"}) -#: Capability types a chain can bind to. Cameras and sensors are inputs — the -#: engine consumes what they report; no intent drives them — so they are never +#: Capability types a chain can bind to. Cameras and sensors are inputs (the +#: engine consumes what they report; no intent drives them), so they are never #: candidates for a rung and never "unused". _BINDABLE_TYPES = frozenset({"joint_group", "drive", "display", "light"}) @@ -125,7 +125,7 @@ def unused_capabilities(self) -> list[str]: table and their absence here means nothing. An actuator showing up is worth a look but is not automatically a - mistake. It usually means the part is outranked everywhere — a light + mistake. It usually means the part is outranked everywhere: a light ring sits below the head and eyes in every chain that mentions it, so on a body with both it never binds. `mode: all` would light it; P0 binds one rung per intent. @@ -176,8 +176,8 @@ def resolve( ) -> BindingTable: """Bind every chain against a body. - Never fails. Every chain is guaranteed to terminate in a voice rung — the - validator refuses to load one that does not — so the worst case is that + Never fails. Every chain is guaranteed to terminate in a voice rung (the + validator refuses to load one that does not), so the worst case is that everything binds to the speaker, which is exactly what should happen on a body with no actuators. """ @@ -231,7 +231,7 @@ def unused_reasons( builder needs to tell them apart: * **Nothing asks for it.** A light declared `role: decorative` when every - chain wants `ambient` or `status` — a manifest mistake, and a part that + chain wants `ambient` or `status`: a manifest mistake, and a part that will never do anything. * **Something better always wins.** A body whose head has all three axes never reaches the eyes rung of any express chain. Nothing is wrong; the @@ -266,7 +266,7 @@ def unused_reasons( if eligible == 0: reasons[cap.capability_id] = ( f"no chain selects a {cap.capability_type!r} with role " - f"{cap.role!r} — check the role against what the chains ask for" + f"{cap.role!r}; check the role against what the chains ask for" ) elif outranked: winner, count = max(outranked.items(), key=lambda kv: kv[1]) @@ -286,7 +286,7 @@ def descriptors_from_manifest( """Project a manifest into descriptors *without instantiating anything*. This is what a manifest **claims**, not what hardware **reports**. The - engine builds descriptors the real way — start each plugin, ask it — and a + engine builds descriptors the real way (start each plugin, ask it) and a joint group whose servo board did not answer will describe itself more narrowly than the YAML does. diff --git a/emet-sdk/emet_sdk/types.py b/emet-sdk/emet_sdk/types.py index bb035d5..5beaaa5 100644 --- a/emet-sdk/emet_sdk/types.py +++ b/emet-sdk/emet_sdk/types.py @@ -42,8 +42,8 @@ class Priority(IntEnum): """Arbitration order. Highest wins; ties break by recency. P0 is one intent per actuator: a higher-priority intent preempts, and the - lower one is dropped rather than queued. Per-actuator blending — expressing - curiosity with the eyes *while* attending with the head — is V1. + lower one is dropped rather than queued. Per-actuator blending (expressing + curiosity with the eyes *while* attending with the head) is V1. """ SAFETY = 100 # thermal, estop, joint limit, brownout recovery @@ -138,7 +138,7 @@ class Twist: """A desired velocity, handed to a locomotion plugin. The engine says how fast to go and how fast to turn. Everything below this - line — wheel arithmetic, gait phase, balance — belongs to the plugin. + line (wheel arithmetic, gait phase, balance) belongs to the plugin. """ linear_mps: float = 0.0 @@ -189,7 +189,7 @@ class LocomotionDescriptor: class WakeDescriptor: """What a wake word engine can actually hear, reported after `start()`. - No chain binds against this, because there is no wake chain — but the boot + No chain binds against this, because there is no wake chain, but the boot check does. A soul asking for a phrase this instance cannot detect is a robot that will never answer to its own name, and unlike a missing head there is nothing to degrade to. That has to fail at boot, loudly. @@ -267,7 +267,7 @@ class AudioSource(Protocol): Here rather than in `emet_hal` because it is the seam the engine sees. The engine may import `emet_sdk` and nothing else, so a microphone reaches it - as this Protocol and never as a concrete class — which is the same reason + as this Protocol and never as a concrete class, which is the same reason a servo reaches it as `ActuatorPlugin`. Put this type in the HAL and the engine cannot describe its own input. @@ -346,7 +346,7 @@ class Reading: """One typed observation from a sensor. Sensors invert the flow of the rest of the system: the engine polls them - and consumes what comes back. A sensor never emits an intent — deciding + and consumes what comes back. A sensor never emits an intent: deciding what an observation *means* is the engine's job, and letting hardware push intents would put a driver author in charge of the personality. """ @@ -361,7 +361,7 @@ class Sensitivity(IntEnum): """Memory disclosure levels. OPEN and PERSONAL are left to the character's judgement. PRIVATE and - SEALED are enforced in the retrieval query — the soul never receives them, + SEALED are enforced in the retrieval query: the soul never receives them, so it cannot disclose them regardless of what it is talked into. A hard floor on the catastrophic cases, personality everywhere above it. """ diff --git a/emet-sdk/emet_sdk/validate.py b/emet-sdk/emet_sdk/validate.py index 85ac713..28ee7bb 100644 --- a/emet-sdk/emet_sdk/validate.py +++ b/emet-sdk/emet_sdk/validate.py @@ -4,9 +4,9 @@ *Schema* validation answers "is this the right shape?" and is expressed in JSON Schema. *Semantic* validation answers "does this mean anything?" and -lives here, because cross-field and cross-document rules — a home angle +lives here, because cross-field and cross-document rules (a home angle inside its range, a capability id referenced by a camera mount, a chain that -terminates in voice — cannot be said in JSON Schema. +terminates in voice) cannot be said in JSON Schema. **The error taxonomy is the point of this module.** A typo in a plugin name and a robot that genuinely lacks a servo are different failures, and @@ -22,8 +22,8 @@ **Validating and booting are deliberately not the same strictness.** Linting a manifest for hardware you have not wired yet is a normal thing to do, so an unrecognised driver name is a warning here by default. Booting an engine -against that manifest is not, so `verify_drivers=True` — what `emet validate ---verify-drivers` passes, and what the engine will use — makes it an error. +against that manifest is not, so `verify_drivers=True` (what `emet validate +--verify-drivers` passes, and what the engine will use) makes it an error. `kinematics` is always strict: a body that cannot move the way it claims is a different class of problem from one missing an LED driver. """ @@ -70,7 +70,7 @@ #: What `emet-hal` ships. Kept for documentation and for tests that must not -#: depend on what happens to be installed — it is NOT what the validator checks +#: depend on what happens to be installed. It is NOT what the validator checks #: against. `drive.kinematics` is an open enum resolved against real entry #: points, so a third-party `legged` package is as valid as anything here. BUILTIN_LOCOMOTION: frozenset[str] = frozenset({"differential", "tracked"}) @@ -185,7 +185,7 @@ class MissingPluginError(ValidationError): Its own type because it is emphatically *not* a schema error: the document is well-formed and the value is legal, the software just is not present. - Never silently degrade because of this — that is a different failure from + Never silently degrade because of this; it is a different failure from missing hardware. """ @@ -290,7 +290,7 @@ def _check_wake_engine( ) -> None: """Resolve `audio.wake.engine`, the way `drive.kinematics` resolves. - Absent is fine — a manifest that says nothing about wake takes the default, + Absent is fine: a manifest that says nothing about wake takes the default, and most will. Unconditional, unlike the `driver.plugin` check a few lines up, and the @@ -302,8 +302,8 @@ def _check_wake_engine( anything: there is no half-built state in which a body moves by a kinematics nobody wrote, or wakes to a detector nobody installed. - Wake has the stronger claim of the two. Every other missing piece degrades - — a chain that cannot find a head falls through to a light ring. A robot + Wake has the stronger claim of the two. Every other missing piece degrades: + a chain that cannot find a head falls through to a light ring. A robot that cannot hear its own name has no next rung, so this is the last place a soft warning would be a kindness. It is also not hypothetical: Picovoice disabled every free Porcupine access key on 30 June 2026, and a manifest @@ -316,7 +316,7 @@ def _check_wake_engine( report.error( "missing_plugin", f"no wake word plugin provides {engine!r}. Installed: {installed}. " - f"`audio.wake.engine` is an open enum — this value is legal, the " + f"`audio.wake.engine` is an open enum: this value is legal, the " f"plugin simply is not installed. Unlike a missing driver this is an " f"error rather than a warning, because a robot that cannot hear its " f"own name has nothing to fall back to.", @@ -346,7 +346,7 @@ def _check_audio_source( report.error( "missing_plugin", f"no audio source provides {source!r}. Installed: {installed}. " - f"`audio.input.source` is an open enum — this value is legal, the " + f"`audio.input.source` is an open enum: this value is legal, the " f"plugin simply is not installed. Omit it entirely for a live " f"microphone, which is the default.", "/audio/input/source", @@ -362,7 +362,7 @@ def _check_audio_sink( Unconditional, for the same reason: it names an implementation that has to exist. A body that cannot play audio has no voice rung, and every fallback - chain in Emet terminates in one — so this failing quietly would hollow out + chain in Emet terminates in one, so this failing quietly would hollow out the guarantee the whole abstraction rests on. """ sink = ((doc.get("audio") or {}).get("output") or {}).get("sink") @@ -372,7 +372,7 @@ def _check_audio_sink( report.error( "missing_plugin", f"no audio sink provides {sink!r}. Installed: {installed}. " - f"`audio.output.sink` is an open enum — this value is legal, the " + f"`audio.output.sink` is an open enum: this value is legal, the " f"plugin simply is not installed. Omit it entirely for a real speaker, " f"which is the default.", "/audio/output/sink", @@ -471,7 +471,7 @@ def _check_plugins( report.error( "missing_plugin", f"driver plugin {plugin!r} is not installed. Install the " - f"package that provides it, or correct the name — a typo " + f"package that provides it, or correct the name. A typo " f"here is a different failure from missing hardware.", f"/capabilities/{i}/driver/plugin", ) @@ -485,7 +485,7 @@ def _check_plugins( "missing_plugin", f"no locomotion plugin provides {kinematics!r}. " f"Installed: {', '.join(registry.locomotion_names) or '(none)'}. " - f"`kinematics` is an open enum — this value is legal, the " + f"`kinematics` is an open enum: this value is legal, the " f"plugin simply is not installed.", f"/capabilities/{i}/kinematics", ) @@ -496,8 +496,8 @@ def _check_plugins( report.warn( "driver_not_installed", f"{len(unverified)} driver plugin(s) named here are not installed " - f"({', '.join(unverified)}). Legal in a manifest — describing hardware " - f"you have not wired yet is normal — but the engine will refuse to " + f"({', '.join(unverified)}). Legal in a manifest, since describing hardware " + f"you have not wired yet is normal, but the engine will refuse to " f"boot against it. Run with --verify-drivers to treat this as an error.", "/capabilities", ) @@ -513,7 +513,7 @@ def validate_soul(doc: Any) -> ValidationReport: Note what is *not* checked here. `identity.wake_word` is free text, and whether it can actually be heard depends on which engine a given body - installs — which this document does not know and must not care about. + installs, which this document does not know and must not care about. That check is a boot-time one against a live `WakeDescriptor`, and putting it here would have made a soul valid or invalid depending on the machine it was linted on. @@ -599,8 +599,8 @@ def validate_motion_pack(doc: Any) -> ValidationReport: def validate_chain_document(doc: Any) -> ValidationReport: """Validate a chain file, including the terminal-voice-rung rule. - This is the mechanical enforcement of principle 2 — every intent is always - satisfiable — and it is the reason the SDK rejects chains at all. + This is the mechanical enforcement of principle 2 (every intent is always + satisfiable) and it is the reason the SDK rejects chains at all. """ report = ValidationReport() diff --git a/emet-sdk/examples/bodiless.yaml b/emet-sdk/examples/bodiless.yaml index 80460b3..7fe1f86 100644 --- a/emet-sdk/examples/bodiless.yaml +++ b/emet-sdk/examples/bodiless.yaml @@ -6,7 +6,7 @@ # treaded scout without modification. # # This manifest must stay valid forever. It is the proof that the -# abstraction is real — if a plain box that remembers you and tells you the +# abstraction is real: if a plain box that remembers you and tells you the # truth about itself is charming, everything else is addition. manifest_version: "0.1" @@ -17,7 +17,7 @@ body: scale: desk power: plugged_in description: > - A small grey speakerphone. No moving parts, no lights, no screen — + A small grey speakerphone. No moving parts, no lights, no screen, just a microphone, a speaker, and whatever is listening through them. compute: diff --git a/emet-sdk/examples/emet-soul.yaml b/emet-sdk/examples/emet-soul.yaml index 14a6776..d6332d9 100644 --- a/emet-sdk/examples/emet-soul.yaml +++ b/emet-sdk/examples/emet-soul.yaml @@ -42,7 +42,7 @@ persona: verbosity: 0.4 curiosity: 0.9 formality: 0.3 - drift: # RSV — behaviour arrives in Afflatus + drift: # RSV. Behaviour arrives in Afflatus enabled: false rate: 0.0 locked_traits: [] @@ -60,7 +60,7 @@ idle: enabled: true interval_s: [20, 90] weights: {settle: 0.4, look_around: 0.4, fidget: 0.2} - proactivity: 0.0 # RSV — 0.0 = never initiates + proactivity: 0.0 # RSV. 0.0 = never initiates memory: personalization: true diff --git a/emet-sdk/examples/invalid/home-out-of-range.yaml b/emet-sdk/examples/invalid/home-out-of-range.yaml index 4354dcd..53d22aa 100644 --- a/emet-sdk/examples/invalid/home-out-of-range.yaml +++ b/emet-sdk/examples/invalid/home-out-of-range.yaml @@ -1,7 +1,7 @@ # INVALID: home_deg lies outside range_deg. # # The tilt joint travels [-30, 45] but homes to 60. Homing would drive the -# joint into its own limit — a mechanical failure, on first boot, before +# joint into its own limit: a mechanical failure, on first boot, before # anyone has said a word to it. # # JSON Schema cannot express this: it is a comparison between two sibling diff --git a/emet-sdk/examples/invalid/legged-no-plugin.yaml b/emet-sdk/examples/invalid/legged-no-plugin.yaml index 95de061..fb11457 100644 --- a/emet-sdk/examples/invalid/legged-no-plugin.yaml +++ b/emet-sdk/examples/invalid/legged-no-plugin.yaml @@ -3,7 +3,7 @@ # This is the most important fixture in the set. It proves two things at once: # # 1. `kinematics` is an OPEN enum. `legged` is not rejected as a schema -# violation — it is a perfectly legal value naming a plugin that has not +# violation; it is a perfectly legal value naming a plugin that has not # been written yet. If this file produced a schema error, the enum would # be closed and bipeds would be unexpressible. # @@ -15,7 +15,7 @@ # legs, low centre of mass, large feet, static gait. It walks by shifting its # weight fully onto one foot at a time and never enters a falling phase. # -# Expected: exactly one error — missing_plugin, /capabilities/0/kinematics +# Expected: exactly one error, missing_plugin, /capabilities/0/kinematics manifest_version: "0.1" @@ -45,7 +45,7 @@ capabilities: max_angular_rps: 0.5 accel_limit_mps2: 0.2 odometry: none - legs: # RSV — reserved, ignored by the P0 engine + legs: # RSV reserved, ignored by the P0 engine count: 2 dof_per_leg: 3 gait: static diff --git a/emet-sdk/examples/invalid/two-drives.yaml b/emet-sdk/examples/invalid/two-drives.yaml index 39581a0..2f43c98 100644 --- a/emet-sdk/examples/invalid/two-drives.yaml +++ b/emet-sdk/examples/invalid/two-drives.yaml @@ -1,7 +1,7 @@ # INVALID: two drive capabilities. # -# P0 permits at most one. Hybrid locomotion — wheels that retract into legs, -# a tracked base with an omni turret — is V1, and needs arbitration between +# P0 permits at most one. Hybrid locomotion (wheels that retract into legs, +# a tracked base with an omni turret) is V1, and needs arbitration between # drives that does not exist yet. # # Expected: multiple_drives, /capabilities/1 diff --git a/emet-sdk/examples/invalid/unknown-wake-engine.yaml b/emet-sdk/examples/invalid/unknown-wake-engine.yaml index 0a747cb..1d31f2f 100644 --- a/emet-sdk/examples/invalid/unknown-wake-engine.yaml +++ b/emet-sdk/examples/invalid/unknown-wake-engine.yaml @@ -8,7 +8,7 @@ # it guarded is gone. # # What replaces it is the rule `drive.kinematics` already follows. The name -# below is legal — `audio.wake.engine` is an open enum — but it does not +# below is legal, since `audio.wake.engine` is an open enum, but it does not # resolve, and that is an error rather than a warning. # # The distinction is worth holding onto. A missing `driver.plugin` is a warning diff --git a/emet-sdk/examples/invalid/unterminated-chain.yaml b/emet-sdk/examples/invalid/unterminated-chain.yaml index cf2bc80..3872844 100644 --- a/emet-sdk/examples/invalid/unterminated-chain.yaml +++ b/emet-sdk/examples/invalid/unterminated-chain.yaml @@ -1,6 +1,6 @@ # INVALID: a chain that does not end in a voice rung. # -# This is the fixture that guards principle 2 — every intent is always +# This is the fixture that guards principle 2: every intent is always # satisfiable. The chain below is entirely reasonable on a robot with a head # and eyes, and completely unbindable on the bodiless manifest that must keep # working forever. diff --git a/emet-sdk/examples/mock-scout.yaml b/emet-sdk/examples/mock-scout.yaml index 8c733d4..9f10d08 100644 --- a/emet-sdk/examples/mock-scout.yaml +++ b/emet-sdk/examples/mock-scout.yaml @@ -1,6 +1,6 @@ # The same robot as scout-01, wired entirely to mock plugins. # -# scout-01.yaml names real chips — pca9685, gc9a01, ws2812 — none of which +# scout-01.yaml names real chips (pca9685, gc9a01, ws2812), none of which # have drivers yet. It is the aspirational example, and it validates with a # warning saying so. # @@ -9,14 +9,14 @@ # emet validate examples/mock-scout.yaml --verify-drivers # passes # emet explain examples/mock-scout.yaml # -# That is the point of the mock. The whole stack above the metal — chain -# resolution, arbitration, the choreographer, eventually a conversation — can +# That is the point of the mock. The whole stack above the metal (chain +# resolution, arbitration, the choreographer, eventually a conversation) can # be exercised without owning a servo. It is also how a driver author checks # what the choreographer actually sends before wiring anything up. # # Note the `ring` here declares `role: status` rather than scout-01's # `role: ambient`, so the signal.* chains bind to it instead of falling -# through to the eyes. Compare the two binding tables — it is the clearest +# through to the eyes. Compare the two binding tables; it is the clearest # demonstration in the repository that a role, not a part, is what a chain # asks for. @@ -66,8 +66,8 @@ capabilities: joints: - {id: pan, axis: yaw, channel: 0, range_deg: [-90, 90], home_deg: 0, max_speed_dps: 180} - {id: tilt, axis: pitch, channel: 1, range_deg: [-30, 45], home_deg: 0, max_speed_dps: 120} - # A roll axis scout-01 does not have. Several chains ask for one — - # express.affection, express.confusion, idle.fidget — so this body + # A roll axis scout-01 does not have. Several chains ask for one + # (express.affection, express.confusion, idle.fidget), so this body # binds them to the head where scout-01 falls through to the eyes. - {id: roll, axis: roll, channel: 2, range_deg: [-20, 20], home_deg: 0, max_speed_dps: 90} diff --git a/emet-sdk/examples/scout-01.yaml b/emet-sdk/examples/scout-01.yaml index e10aff5..db862a8 100644 --- a/emet-sdk/examples/scout-01.yaml +++ b/emet-sdk/examples/scout-01.yaml @@ -36,7 +36,7 @@ audio: wake: # The shipped engine. Phonetic, so it hears whatever phrase the soul # names without a model trained for it. Omitting this whole block is - # legal — `bodiless.yaml` does, and takes the default. + # legal; `bodiless.yaml` does, and takes the default. engine: pocketsphinx params: # The shipped default, written out so it is visible. Lower is stricter: diff --git a/emet-sdk/pyproject.toml b/emet-sdk/pyproject.toml index d620475..3177c81 100644 --- a/emet-sdk/pyproject.toml +++ b/emet-sdk/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "emet-sdk" version = "0.3.0" -description = "Emet SDK — types, schemas, and contracts shared by the engine and all plugins." +description = "Emet SDK: types, schemas, and contracts shared by the engine and all plugins." readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" diff --git a/emet-sdk/schemas/body-manifest.schema.json b/emet-sdk/schemas/body-manifest.schema.json index 667b7ee..cb91b1a 100644 --- a/emet-sdk/schemas/body-manifest.schema.json +++ b/emet-sdk/schemas/body-manifest.schema.json @@ -25,7 +25,7 @@ "$ref": "#/$defs/identifier" }, "name": { - "description": "P0. Human label for the body. Not the robot's name — that lives in the soul bundle.", + "description": "P0. Human label for the body. The robot's name lives in the soul bundle.", "type": "string" }, "scale": { @@ -118,7 +118,7 @@ } }, "wake": { - "description": "P0, optional. Which wake word engine listens on this body, and how it is tuned. Omit to take the shipped default. The name resolves against installed emet.wake plugins, so this is an open enum: an unknown value is a missing plugin, never a schema error. The wake PHRASE is not here — it belongs to the soul, as identity.wake_word.", + "description": "P0, optional. Which wake word engine listens on this body, and how it is tuned. Omit to take the shipped default. The name resolves against installed emet.wake plugins, so this is an open enum: an unknown value is a missing plugin, never a schema error. The wake PHRASE belongs to the soul, as identity.wake_word.", "type": "object", "additionalProperties": false, "required": ["engine"], @@ -134,7 +134,7 @@ }, "capabilities": { - "description": "P0. May be empty — a Pi with a USB speakerphone and nothing else is a valid body, and is milestone one.", + "description": "P0. May be empty: a Pi with a USB speakerphone and nothing else is a valid body, and is milestone one.", "type": "array", "default": [], "items": { "$ref": "#/$defs/capability" } @@ -159,7 +159,7 @@ }, "driver": { - "description": "Which plugin owns this hardware, and its instantiation parameters. Resolution to an installed package is a semantic rule — a missing plugin is a MissingPluginError, never a schema error.", + "description": "Which plugin owns this hardware, and its instantiation parameters. Resolution to an installed package is a semantic rule: a missing plugin is a MissingPluginError, never a schema error.", "type": "object", "additionalProperties": false, "required": ["plugin"], @@ -263,7 +263,7 @@ "id": true, "type": true, "kinematics": { - "description": "P0. OPEN ENUM — any string naming a locomotion plugin. Built in: differential, tracked. An unknown value is a MissingPluginError, not a schema error, which is what keeps this open.", + "description": "P0. OPEN ENUM: any string naming a locomotion plugin. Built in: differential, tracked. An unknown value is a MissingPluginError, not a schema error, which is what keeps this open.", "type": "string", "minLength": 1, "pattern": "^[a-z][a-z0-9_]*$" diff --git a/emet-sdk/schemas/motion-pack.schema.json b/emet-sdk/schemas/motion-pack.schema.json index 921aa57..78bf762 100644 --- a/emet-sdk/schemas/motion-pack.schema.json +++ b/emet-sdk/schemas/motion-pack.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://emet.sh/schemas/0.1/motion-pack.schema.json", "title": "Emet Motion Pack", - "description": "Recorded clips keyed to intents. Tier 3 of the capability model: produced by `emet teach` — relax the servos, pose the robot by hand, capture keyframes on a keypress. No code, no math, works on bodies nobody has seen. Deliberately declarative data so that cloud motion generation (V1) needs no schema change.", + "description": "Recorded clips keyed to intents. Tier 3 of the capability model: produced by `emet teach`. Relax the servos, pose the robot by hand, capture keyframes on a keypress. No code, no math, works on bodies nobody has seen. Deliberately declarative data so that cloud motion generation (V1) needs no schema change.", "type": "object", "additionalProperties": false, @@ -16,7 +16,7 @@ "name": { "type": "string", "minLength": 1 }, "requires": { - "description": "Matched against the manifest. A pack whose requirements are unmet is skipped, and the fallback chain falls through to its next rung — it is not an error.", + "description": "Matched against the manifest. A pack whose requirements are unmet is skipped, and the fallback chain falls through to its next rung. It is not an error.", "type": "array", "items": { "type": "object", @@ -47,7 +47,7 @@ "required": ["intent", "keyframes"], "properties": { "intent": { - "description": "The intent this clip renders, as 'kind.argument' — e.g. express.curiosity. Membership in the closed vocabulary is a semantic rule.", + "description": "The intent this clip renders, as 'kind.argument', e.g. express.curiosity. Membership in the closed vocabulary is a semantic rule.", "type": "string", "pattern": "^[a-z_]+(\\.[a-z_]+)?$" }, diff --git a/emet-sdk/schemas/soul-bundle.schema.json b/emet-sdk/schemas/soul-bundle.schema.json index e9ab2ac..88a60b6 100644 --- a/emet-sdk/schemas/soul-bundle.schema.json +++ b/emet-sdk/schemas/soul-bundle.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://emet.sh/schemas/0.1/soul-bundle.schema.json", "title": "Emet soul.yaml", - "description": "The portable personality. Valid on any body. The engine MUST NOT write body-specific state here — no joint trims, no calibration, no device paths.", + "description": "The portable personality. Valid on any body. The engine MUST NOT write body-specific state here: no joint trims, no calibration, no device paths.", "type": "object", "additionalProperties": false, @@ -20,24 +20,24 @@ "required": ["name", "wake_word", "line"], "properties": { "name": { - "description": "P0. What the robot is called. Free text — any name at all. Deliberately NOT the wake word.", + "description": "P0. What the robot is called. Free text, any name at all. Deliberately NOT the wake word.", "type": "string", "minLength": 1 }, "wake_word": { - "description": "P0. The phrase that wakes this soul. Free text: the shipped default engine is a phonetic spotter, so any phrase works given a pronunciation. Prefer three or more syllables — a wake phrase is an acoustic target, which is why it is separate from `name`. Whether a given body can hear it depends on the wake engine it installs, and is checked at boot, not here.", + "description": "P0. The phrase that wakes this soul. Free text: the shipped default engine is a phonetic spotter, so any phrase works given a pronunciation. Prefer three or more syllables: a wake phrase is an acoustic target, which is why it is separate from `name`. Whether a given body can hear it depends on the wake engine it installs, and is checked at boot, not here.", "type": "string", "minLength": 1 }, "line": { - "description": "P0. Which reference soul this derives from. Metadata only — the engine does not branch on it.", + "description": "P0. Which reference soul this derives from. Metadata only; the engine does not branch on it.", "enum": ["emet", "hugr", "neuma", "custom"] }, "pronouns": { "type": "string" }, "author": { "type": "string" }, "created": { "type": "string", "format": "date" }, "license": { - "description": "RSV. An SPDX licence identifier for souls published to the community registry — a soul is configuration but also prose, and a registry nobody can rely on legally is a registry nobody builds on. null is legal for a private soul.", + "description": "RSV. An SPDX licence identifier for souls published to the community registry: a soul is configuration but also prose, and a registry nobody can rely on legally is a registry nobody builds on. null is legal for a private soul.", "type": ["string", "null"] } } @@ -79,7 +79,7 @@ } }, "drift": { - "description": "RSV. Behaviour arrives in release Afflatus. Fields exist now so nothing migrates later. Constraint fixed today: drift must be auditable and reversible — deltas live in an append-only table keyed to the episode that caused them, and soul.yaml is never mutated.", + "description": "RSV. Behaviour arrives in release Afflatus. Fields exist now so nothing migrates later. Constraint fixed today: drift must be auditable and reversible. Deltas live in an append-only table keyed to the episode that caused them, and soul.yaml is never mutated.", "type": "object", "additionalProperties": false, "properties": { @@ -170,7 +170,7 @@ }, "models": { - "description": "P0. BYOK — bring your own keys. Online-first: in P0 the robot fails loudly and in character when a key or the network is missing.", + "description": "P0. BYOK, bring your own keys. Online-first: in P0 the robot fails loudly and in character when a key or the network is missing.", "type": "object", "additionalProperties": false, "properties": { diff --git a/emet-sdk/tests/test_acceptance.py b/emet-sdk/tests/test_acceptance.py index d680e1c..3cee313 100644 --- a/emet-sdk/tests/test_acceptance.py +++ b/emet-sdk/tests/test_acceptance.py @@ -5,7 +5,7 @@ `kinematics: legged` with a *missing plugin* error rather than a schema error. The last two tests in this file are the ones that matter most: they are the -evidence for claims the whole design rests on — that principle 2 is enforced +evidence for claims the whole design rests on: that principle 2 is enforced mechanically, and that reserving schema now really is free later. """ @@ -90,7 +90,7 @@ def test_a_soul_may_answer_to_any_phrase(): names could be heard and `wake_word: barnaby` was an error. The shipped default is a phonetic keyword spotter, which takes any phrase and a pronunciation. The field is free text, and whether a particular engine can - hear a particular phrase is answered at boot against a live descriptor — + hear a particular phrase is answered at boot against a live descriptor, not by a list in the validator, which would make a soul valid or invalid depending on which machine linted it. """ @@ -147,7 +147,7 @@ def test_chain_without_voice_rung_is_rejected(): Every intent is always satisfiable because every chain terminates in a rung that cannot fail to bind. Nothing in the engine checks this at - runtime — it cannot get past the validator. + runtime; it cannot get past the validator. """ report = validate_chain_document( load_yaml(EXAMPLES / "invalid" / "unterminated-chain.yaml") @@ -210,7 +210,7 @@ def test_reserved_intents_are_legal_to_emit(): """A soul written today may reach for an intent that lands in 2033. Because the vocabulary is closed, an unreserved name would make the whole - bundle *invalid* rather than merely ineffective — and bundles are the + bundle *invalid* rather than merely ineffective, and bundles are the artifact strangers publish and keep for years. """ for kind in ("manipulate", "navigate", "gesture", "attend_joint"): diff --git a/emet-sdk/tests/test_audio_source.py b/emet-sdk/tests/test_audio_source.py index 7a7bf82..911ef20 100644 --- a/emet-sdk/tests/test_audio_source.py +++ b/emet-sdk/tests/test_audio_source.py @@ -6,7 +6,7 @@ asks the registry for it, and receives a class it never imported. **Note what this file imports.** Only `emet_sdk`. Everything it exercises is -implemented in `emet_hal`, and none of it is named here — which is exactly the +implemented in `emet_hal`, and none of it is named here, which is exactly the constraint the engine works under, so these tests fail the way the engine would. """ diff --git a/emet-sdk/tests/test_integration.py b/emet-sdk/tests/test_integration.py index 39dad08..bef8b4b 100644 --- a/emet-sdk/tests/test_integration.py +++ b/emet-sdk/tests/test_integration.py @@ -6,9 +6,9 @@ manifest → discovery → plugins → describe() → resolve → apply() -Nothing above this exists yet — there is no engine and no choreographer — so -this is the first and only place the whole path runs. Until 0.3 builds a real -loop, it is the test that says the architecture actually fits together. +Nothing above this drives a body yet (the engine is a listen loop, and there +is no choreographer), so this is the only place the whole path runs. It is +the test that says the architecture actually fits together. The other thing checked here is **agreement**. `emet explain` resolves against a static projection of the manifest, because it has to work on a laptop with @@ -123,7 +123,7 @@ def test_live_descriptors_agree_with_the_static_projection(manifest, chains): It resolves against a projection of the manifest so that it works with no hardware attached. The engine resolves against live plugins. For a plugin - that reports honestly, those must produce the same binding table — or the + that reports honestly, those must produce the same binding table, or the tool people reach for when debugging shows them something the robot will not do. """ @@ -152,7 +152,7 @@ async def scenario(): def test_a_broken_part_changes_the_answer_at_boot(manifest, chains): """The one thing the static projection CANNOT know. - `emet explain` is optimistic by construction — it assumes every declared + `emet explain` is optimistic by construction: it assumes every declared part works. A servo board that does not answer is exactly the case where the engine's table diverges, and it must diverge in the safe direction: past the dead part, not into it. diff --git a/emet-sdk/tests/test_resolve.py b/emet-sdk/tests/test_resolve.py index a7f5489..140d81d 100644 --- a/emet-sdk/tests/test_resolve.py +++ b/emet-sdk/tests/test_resolve.py @@ -2,7 +2,7 @@ 0.2 is done when `emet explain` prints a correct binding table for a bodiless manifest and for a fully-loaded one, and `kinematics: legged` still fails with -a missing-plugin error — now from real entry-point discovery rather than a +a missing-plugin error, now from real entry-point discovery rather than a hardcoded set. The tests that carry the most weight here are the ones about *falling @@ -91,7 +91,7 @@ def test_shipped_locomotion_is_discovered(): def test_missing_axis_falls_through_to_the_next_rung(chains): """scout-01's head has yaw and pitch but no roll. - express.affection wants a roll tilt first, so it must bind to the eyes — + express.affection wants a roll tilt first, so it must bind to the eyes, and the table must say why, because "my robot won't tilt affectionately" is otherwise an unanswerable bug report. """ diff --git a/emet-sdk/tests/test_wake.py b/emet-sdk/tests/test_wake.py index 706988d..db91c5f 100644 --- a/emet-sdk/tests/test_wake.py +++ b/emet-sdk/tests/test_wake.py @@ -6,8 +6,8 @@ hear its own name has no next rung. It just never answers, and looks broken rather than limited. -So these tests are mostly about honesty at the boundary — an engine reporting -what it can actually hear, rather than what the manifest hoped it would — and +So these tests are mostly about honesty at the boundary (an engine reporting +what it can actually hear, rather than what the manifest hoped it would) and about keeping the choice of engine swappable. Picovoice disabled every free Porcupine access key on 30 June 2026. The seam tested here is what makes that a one-line manifest change instead of a dead robot. diff --git a/tools/check_layering.py b/tools/check_layering.py index 7717609..b9fb0f3 100644 --- a/tools/check_layering.py +++ b/tools/check_layering.py @@ -9,7 +9,7 @@ contributor had no engine source to couple to. In a monorepo with everything open, the accidental-coupling path is open to everyone, so an invariant that used to be a property of the distribution is now a test. If this does not run -on every push, the layering rots — and the layering is the product. +on every change, the layering rots, and the layering is the product. Deliberately dependency-free and short enough to read in one sitting. It parses each file's AST and inspects static import statements only. It will not diff --git a/tools/release_check.py b/tools/release_check.py index 9f24221..0a99bff 100644 --- a/tools/release_check.py +++ b/tools/release_check.py @@ -2,7 +2,7 @@ """Check the things a release gets wrong that no single package can notice. Every package has its own tests, and they pass while the *repository* is -inconsistent — because a package cannot see its siblings. `emet-sdk` could sit +inconsistent, because a package cannot see its siblings. `emet-sdk` could sit at 0.3.0 beside an `emet-hal` still at 0.2.0 and every suite would be green. This looks across the whole tree instead. @@ -18,8 +18,8 @@ **Why this exists.** 0.3's scope was "audio in/out, wake word, VAD, endpointing". Audio *out* was not wired, and it went unnoticed for days because the work was checked against a memory of the scope rather than the written -scope. A script cannot read a roadmap, so it cannot catch that one — see -`RELEASING.md` for the human half — but everything it *can* mechanise, it +scope. A script cannot read a roadmap, so it cannot catch that one (see +`RELEASING.md` for the human half), but everything it *can* mechanise, it should, because the checks people skip are the ones that need remembering. Dependency-free and short enough to read, like `check_layering.py`. From d880dfdcda74751dd07debd2fb0801d80244b45b Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:50:25 -0700 Subject: [PATCH 16/21] Add the Pi speakerphone body and open ALSA cards through plughw Report emet-listen stats on Ctrl-C Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-engine/emet_engine/cli.py | 56 +++++++----- emet-engine/tests/test_cli.py | 118 +++++++++++++++++++++++++ emet-hal/emet_hal/audio.py | 18 +++- emet-hal/tests/test_audio.py | 54 +++++++++++ emet-sdk/examples/pi-speakerphone.yaml | 69 +++++++++++++++ 5 files changed, 294 insertions(+), 21 deletions(-) create mode 100644 emet-engine/tests/test_cli.py create mode 100644 emet-sdk/examples/pi-speakerphone.yaml diff --git a/emet-engine/emet_engine/cli.py b/emet-engine/emet_engine/cli.py index 0a8e6f1..97d3fc4 100644 --- a/emet-engine/emet_engine/cli.py +++ b/emet-engine/emet_engine/cli.py @@ -103,26 +103,42 @@ async def _run(args: argparse.Namespace) -> int: print(" (ctrl-c to stop)\n" if not args.replay else "") heard = 0 - async for event, utterance in session.turns(): - heard += 1 - print(f" heard {event.phrase!r} (confidence {event.confidence:.2f})") - if utterance.had_speech: - # 0.4 hands this audio to speech recognition. Until then the - # useful thing to show is that the turn was bounded correctly. - print( - f" then {utterance.duration_ms / 1000:.1f}s of speech, " - f"ended on {utterance.reason.value}" - ) - if args.echo: - await session.say(utterance.audio) - print(" played it back") - elif utterance.reason is EndReason.SOURCE_ENDED: - print(" the recording ended before anything followed.") - else: - print(" then nothing. probably a false wake.") - - # Only reached when the source ends, which means a replay finished. - print(f"\nsource ended. heard it {heard} time(s).") + interrupted = False + try: + async for event, utterance in session.turns(): + heard += 1 + print(f" heard {event.phrase!r} (confidence {event.confidence:.2f})") + if utterance.had_speech: + # 0.4 hands this audio to speech recognition. Until then + # the useful thing to show is that the turn was bounded + # correctly. + print( + f" then {utterance.duration_ms / 1000:.1f}s of speech, " + f"ended on {utterance.reason.value}" + ) + if args.echo: + await session.say(utterance.audio) + print(" played it back") + elif utterance.reason is EndReason.SOURCE_ENDED: + print(" the recording ended before anything followed.") + else: + print(" then nothing. probably a false wake.") + except asyncio.CancelledError: + # Ctrl-C. `asyncio.run` answers SIGINT by cancelling this task, and + # a microphone never ends on its own, so this is how every live + # run finishes. The numbers gathered so far are the reason the run + # happened, so they are reported rather than lost. A task that + # handles its own cancellation uncancels itself, which is what + # lets the session shut down cleanly below. + interrupted = True + task = asyncio.current_task() + if task is not None: + task.uncancel() + + if interrupted: + print(f"\nstopped. heard it {heard} time(s).") + else: + print(f"\nsource ended. heard it {heard} time(s).") _finish(session, args) return 0 diff --git a/emet-engine/tests/test_cli.py b/emet-engine/tests/test_cli.py new file mode 100644 index 0000000..ba7125a --- /dev/null +++ b/emet-engine/tests/test_cli.py @@ -0,0 +1,118 @@ +"""`emet-listen`, driven the way a person drives it. + +Offline, like the rest of the suite: a wav file for a microphone, the mock +detector for pocketsphinx, and a sink that discards. The manifest and soul +loaders are stubbed so the loop can be exercised without a full document, +which the session tests already cover from the other side. + +The case that matters is the interrupted one. A microphone never ends, so +Ctrl-C is how every live run finishes, and `asyncio.run` delivers Ctrl-C as a +cancellation of the running task. A run that dropped its numbers at that point +would make the ten-minute live soak in the 0.3 criteria unreportable. +""" + +from __future__ import annotations + +import argparse +import asyncio +import wave +from pathlib import Path + +from emet_engine import cli +from emet_engine.session import ListenSession + +PHRASE = "hey emet" +FRAME = 1280 # samples per frame at 16 kHz, 80 ms + + +def write_wav(path: Path, pcm: bytes) -> str: + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(pcm) + return str(path) + + +def silence(frames: int) -> bytes: + return bytes(2 * frames) + + +def saying(times: int) -> bytes: + """Frames whose bytes spell the phrase, which is what the mock hears. + + Four seconds of silence after each phrase: the endpointer waits a 2.5 s + lead-in for speech before it gives up on a wake, and the next phrase must + arrive after that, or it is swallowed by the turn still in progress. + """ + return (silence(FRAME) + PHRASE.encode() + silence(FRAME * 50)) * times + + +def body(path: str) -> dict: + return { + "audio": { + "input": {"source": "wav", "device": "file", "params": {"path": path}}, + "output": {"sink": "null", "device": "none"}, + "wake": {"engine": "mock", "params": {}}, + } + } + + +def soul() -> dict: + return {"identity": {"name": "Emet", "wake_word": PHRASE}} + + +def stub_loaders(monkeypatch, manifest: dict, soul_doc: dict) -> None: + monkeypatch.setattr( + cli, "_load", lambda path, kind, registry: manifest if kind == "manifest" else soul_doc + ) + + +def args(**overrides) -> argparse.Namespace: + base = {"manifest": "body.yaml", "soul": "soul.yaml", "replay": None, "echo": False, "stats": True} + base.update(overrides) + return argparse.Namespace(**base) + + +def test_a_finished_replay_reports(tmp_path, monkeypatch, capsys): + wav = write_wav(tmp_path / "three.wav", saying(3)) + stub_loaders(monkeypatch, body(wav), soul()) + + rc = asyncio.run(cli._run(args())) + + out = capsys.readouterr().out + assert rc == 0 + assert "source ended. heard it 3 time(s)." in out + assert "realtime" in out and "verdict" in out + + +def test_an_interrupted_live_run_still_reports(tmp_path, monkeypatch, capsys): + """Cancel the task after the first turn, as `asyncio.run` does on SIGINT. + + A real microphone blocks between frames, so the cancellation lands at an + await inside the loop. A file never blocks, so the stand-in yields to the + event loop once after cancelling, which is where the cancellation is + delivered. The run must still print what it measured, and exit cleanly. + """ + wav = write_wav(tmp_path / "long.wav", saying(3)) + stub_loaders(monkeypatch, body(wav), soul()) + + real_turns = ListenSession.turns + + async def turns_then_ctrl_c(self): + async for turn in real_turns(self): + yield turn + task = asyncio.current_task() + assert task is not None + task.cancel() + await asyncio.sleep(0) + + monkeypatch.setattr(ListenSession, "turns", turns_then_ctrl_c) + + rc = asyncio.run(cli._run(args())) + + out = capsys.readouterr().out + assert rc == 0 + assert "stopped. heard it 1 time(s)." in out + assert "source ended" not in out + assert "realtime" in out and "verdict" in out diff --git a/emet-hal/emet_hal/audio.py b/emet-hal/emet_hal/audio.py index f2ea917..becfd5b 100644 --- a/emet-hal/emet_hal/audio.py +++ b/emet-hal/emet_hal/audio.py @@ -18,7 +18,10 @@ * On Linux, an ALSA `plughw:` device converts for you. That is precisely what the `plug` layer is for, and it is why the manifests in this repository say - `plughw:1,0` rather than `hw:1,0`. + `plughw:1,0` rather than `hw:1,0`. PortAudio lists and opens cards as `hw:` + unless `PA_ALSA_PLUGHW=1` is in its environment when it starts, so `_sd()` + below sets that on Linux before the first import. Cards are then listed as + "(plughw:1,0)", which is also what makes those manifest names resolve. * On Windows, shared-mode host APIs (MME, DirectSound, WASAPI) convert, and exclusive-mode WDM-KS refuses anything but the native rate. @@ -33,6 +36,8 @@ import asyncio import logging +import os +import sys import wave from array import array from dataclasses import dataclass @@ -82,6 +87,17 @@ def __str__(self) -> str: def _sd() -> Any: + # PortAudio's ALSA backend lists and opens each card as `hw:`, which + # converts nothing, so a 48 kHz microphone refuses the detector's 16 kHz. + # With PA_ALSA_PLUGHW=1 in its environment at initialisation it uses + # `plughw:` throughout: ALSA's plug layer converts, and the names it + # reports become "(plughw:1,0)", which is what the manifests in this + # repository say. It has to be set before the first import, because + # sounddevice initialises PortAudio on import. An explicit value already + # in the environment wins. (PortAudio, src/hostapi/alsa/pa_linux_alsa.c, + # BuildDeviceList.) + if sys.platform.startswith("linux"): + os.environ.setdefault("PA_ALSA_PLUGHW", "1") try: import sounddevice except ImportError as exc: # pragma: no cover - depends on the environment diff --git a/emet-hal/tests/test_audio.py b/emet-hal/tests/test_audio.py index 2920c08..11b2ba7 100644 --- a/emet-hal/tests/test_audio.py +++ b/emet-hal/tests/test_audio.py @@ -15,6 +15,9 @@ from __future__ import annotations import asyncio +import os +import sys +import types import wave from pathlib import Path @@ -421,3 +424,54 @@ def test_output_defaults_to_a_synthesis_rate_not_the_input_rate(): like a telephone, and the two directions have no reason to agree.""" assert NullSink().format.sample_rate == 22050 assert AudioFormat().sample_rate == 16000 + + +# -------------------------------------------------------------------------- +# PortAudio configuration +# -------------------------------------------------------------------------- + + +def _fake_sounddevice(monkeypatch): + """Stand in for the real module so `_sd()` returns without touching + PortAudio, and so these tests pass with the `audio` extra absent.""" + monkeypatch.setitem(sys.modules, "sounddevice", types.ModuleType("sounddevice")) + + +def _unset(monkeypatch, name): + """Remove `name` from the environment for this test, restoring the + original state afterwards even when it was absent to begin with.""" + monkeypatch.setenv(name, "placeholder") + monkeypatch.delenv(name) + + +def test_on_linux_portaudio_is_told_to_use_plughw(monkeypatch): + """`hw:` devices convert nothing, so a 48 kHz card refuses 16 kHz. The HAL + asks PortAudio for `plughw:` before it starts, which also makes the + `plughw:1,0` names in the manifests match what PortAudio lists.""" + from emet_hal import audio as audio_module + + monkeypatch.setattr(sys, "platform", "linux") + _unset(monkeypatch, "PA_ALSA_PLUGHW") + _fake_sounddevice(monkeypatch) + audio_module._sd() + assert os.environ["PA_ALSA_PLUGHW"] == "1" + + +def test_an_explicit_plughw_setting_is_respected(monkeypatch): + from emet_hal import audio as audio_module + + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("PA_ALSA_PLUGHW", "0") + _fake_sounddevice(monkeypatch) + audio_module._sd() + assert os.environ["PA_ALSA_PLUGHW"] == "0" + + +def test_off_linux_the_environment_is_left_alone(monkeypatch): + from emet_hal import audio as audio_module + + monkeypatch.setattr(sys, "platform", "win32") + _unset(monkeypatch, "PA_ALSA_PLUGHW") + _fake_sounddevice(monkeypatch) + audio_module._sd() + assert "PA_ALSA_PLUGHW" not in os.environ diff --git a/emet-sdk/examples/pi-speakerphone.yaml b/emet-sdk/examples/pi-speakerphone.yaml new file mode 100644 index 0000000..49d9723 --- /dev/null +++ b/emet-sdk/examples/pi-speakerphone.yaml @@ -0,0 +1,69 @@ +# The reference body for the 0.3 hardware run: a Raspberry Pi 5 with a USB +# microphone and a USB speaker, and nothing else. Structurally the same body +# as `bodiless.yaml`. The differences are the audio block, which names real +# devices and the shipped wake engine, and the honest `aec: none`, because a +# separate USB microphone and USB speaker cancel nothing. +# +# `device` is matched against the names PortAudio reports, exactly or as a +# case-insensitive substring. List them on the body with: +# +# PA_ALSA_PLUGHW=1 python -m sounddevice +# +# `emet-hal` sets that variable itself on Linux before PortAudio starts, so +# cards open through ALSA's `plug` layer, which converts sample rates, and are +# listed as "(plughw:1,0)" rather than "(hw:1,0)". Setting it on the command +# line above makes the listing match what `emet-listen` sees. "USB" is enough +# here when one USB device captures and one plays; if it matches more than one +# device, `emet-listen` lists the candidates and stops. A card number from the +# listing, such as `plughw:1,0`, is the precise alternative. +# +# If a card still refuses 16 kHz, define a plug device in `~/.asoundrc`: +# +# pcm.emet_mic { type plug; slave.pcm "hw:1,0" } +# +# PortAudio lists custom pcm entries by name, so `device: emet_mic` then works. +# +# Validating this manifest together with the reference soul (`--pair`) fails on +# purpose: `emet-soul.yaml` enables barge-in, which needs echo cancellation this +# body does not have. Barge-in is 1.0 work and the 0.3 listen loop ignores it. + +manifest_version: "0.1" + +body: + id: pi_speakerphone + name: "a Raspberry Pi with a USB microphone and speaker" + scale: desk + power: plugged_in + description: > + A bare single-board computer on a desk with a microphone and a speaker + plugged into it. No moving parts, no lights, no screen. + +compute: + platform: raspberrypi5 + ram_gb: 8 + accelerator: none + +audio: + input: + device: "USB" + sample_rate: 16000 + channels: 1 + aec: none + doa: false + output: + device: "USB" + gain_db: -6.0 + # Playback runs at this rate except under `--echo`, which plays captured + # 16 kHz audio and switches the sink to 16 kHz for that run. 48 kHz is + # what nearly every USB DAC does natively, so a `--stats` run never waits + # on a rate the card refuses. + sample_rate: 48000 + wake: + engine: pocketsphinx + +capabilities: [] + +safety: + estop_gpio: null + max_continuous_motion_s: 30 + thermal_throttle_c: 75 From 88a74c2c50a1cac0e9d993fa1aa331cefe725be9 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:43:15 -0700 Subject: [PATCH 17/21] Discard microphone frames that arrive after stop Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-hal/emet_hal/audio.py | 10 ++++++++-- emet-hal/tests/test_audio.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/emet-hal/emet_hal/audio.py b/emet-hal/emet_hal/audio.py index becfd5b..20385e4 100644 --- a/emet-hal/emet_hal/audio.py +++ b/emet-hal/emet_hal/audio.py @@ -382,8 +382,14 @@ def callback(indata, frames, time_info, status) -> None: # PortAudio thread raise AudioError(f"could not open the microphone: {exc}") from exc def _offer(self, raw: bytes) -> None: - """On the event loop thread. Never blocks; drops the oldest instead.""" - assert self._queue is not None + """On the event loop thread. Never blocks; drops the oldest instead. + + A frame can arrive after `stop()`: PortAudio's thread schedules this + call before the stream closes and the loop runs it afterwards. That + frame belongs to nobody, so it is discarded rather than asserted on. + """ + if self._queue is None: + return if self._queue.full(): try: self._queue.get_nowait() diff --git a/emet-hal/tests/test_audio.py b/emet-hal/tests/test_audio.py index 11b2ba7..a4b2324 100644 --- a/emet-hal/tests/test_audio.py +++ b/emet-hal/tests/test_audio.py @@ -426,6 +426,18 @@ def test_output_defaults_to_a_synthesis_rate_not_the_input_rate(): assert AudioFormat().sample_rate == 16000 +def test_a_frame_arriving_after_stop_is_discarded(): + """PortAudio's thread can schedule one last `_offer` before the stream + closes, and the loop runs it after `stop()` has cleared the queue. Ctrl-C + on a live run used to end with an AssertionError traceback from exactly + that. Before `start()` the queue is equally absent, so this is the same + branch without a microphone.""" + src = MicrophoneSource({"channels": 1}) + src._offer(bytes(2 * 1280)) + assert src.dropped == 0 + + + # -------------------------------------------------------------------------- # PortAudio configuration # -------------------------------------------------------------------------- From 00093dd7e2f5583e97f94c85c4cb7ea90eef7924 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:00:23 -0700 Subject: [PATCH 18/21] Add the soak recording prompts Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- tools/soak_prompts.sh | 67 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tools/soak_prompts.sh diff --git a/tools/soak_prompts.sh b/tools/soak_prompts.sh new file mode 100644 index 0000000..48380d6 --- /dev/null +++ b/tools/soak_prompts.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Prompts for recording the ten-minute soak file, so that two recordings made +# months apart exercise the detector the same way. Run it beside arecord, in +# the same terminal: +# +# bash tools/soak_prompts.sh & arecord -D plughw:3,0 -f S16_LE -r 16000 -c 1 -d 600 ~/soak-10min.wav +# +# 58 prompts, one every ten seconds, the cadence of the x86 baseline. Every +# sixth prompt is a decoy that must not wake the robot, which fixes the tally: +# 49 wake phrases and 9 decoys. In the replay report, wakes above 49 are false +# fires and wakes below 49 are misses. Speak at a normal distance and volume, +# and leave the room's ordinary noise in. +# +# SOAK_INTERVAL and SOAK_LEAD (seconds) exist so the script can be checked +# quickly without waiting ten minutes. + +set -u + +INTERVAL="${SOAK_INTERVAL:-10}" +LEAD="${SOAK_LEAD:-3}" +COUNT=58 + +SENTENCES=( + "what time is it" + "remind me to call my sister tomorrow morning" + "how far away is the moon" + "I think the kitchen tap is dripping again" + "play something quiet" + "did anyone come to the door while I was out" + "what is the capital of Mongolia" + "I am going to bed early tonight" + "tell me a fact about octopuses" + "the meeting moved to three o'clock on Thursday" + "turn the lights down a bit" + "how do you spell necessary" + "I left my keys somewhere in this room and I cannot find them anywhere, could you keep an eye out for them" + "never mind" +) + +DECOYS=( + "hey emma, are you still there" + "hey everyone, dinner is ready" + "hey, meet me outside in five minutes" + "there is a mess of cables on the desk" + "hey Emmett, how was school" + "the weather is meant to turn tomorrow" + "hey, met any interesting people today" + "I said no, and I meant it" + "hey Emily, pass me the salt" +) + +sleep "$LEAD" +wakes=0 +decoys=0 +for i in $(seq 1 "$COUNT"); do + if (( i % 6 == 0 )); then + decoys=$((decoys + 1)) + line="${DECOYS[$(( (decoys - 1) % ${#DECOYS[@]} ))]}" + printf '%2d DECOY, say only: "%s"\n' "$i" "$line" + else + wakes=$((wakes + 1)) + line="${SENTENCES[$(( (wakes - 1) % ${#SENTENCES[@]} ))]}" + printf '%2d say: "hey emet" (short pause) "%s"\n' "$i" "$line" + fi + sleep "$INTERVAL" +done +printf '\ndone: %d wake phrases, %d decoys. Expect wakes = %d in the replay report.\n' "$wakes" "$decoys" "$wakes" From 66661cade6d306b21dd60e6a67de4f88fc9df259 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:06:15 -0700 Subject: [PATCH 19/21] Set the wake threshold from a real-room recording Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-hal/emet_hal/pocketsphinx_wake.py | 38 +++++++++++++++++------- emet-hal/tests/test_pocketsphinx_wake.py | 6 ++-- emet-sdk/examples/scout-01.yaml | 6 ++-- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/emet-hal/emet_hal/pocketsphinx_wake.py b/emet-hal/emet_hal/pocketsphinx_wake.py index b59f804..6828ae2 100644 --- a/emet-hal/emet_hal/pocketsphinx_wake.py +++ b/emet-hal/emet_hal/pocketsphinx_wake.py @@ -22,13 +22,13 @@ **On warm-up.** The decoder is least sensitive in the seconds after it starts, before its cepstral mean has adapted. That is a real property, not a bug, and -it is why `DEFAULT_THRESHOLD` is set from cold-start measurements rather than -from a decoder that has been running comfortably for a minute. +it is why `DEFAULT_THRESHOLD` is chosen with the cold end in view: a value +that looks stricter on a warm recording costs wakes at boot. **On confidence.** Keyword spotting reports no calibrated score, so every `WakeEvent` from this engine carries `confidence=1.0`. That is honesty about the absence of a number rather than a claim of certainty. Tune -`params.threshold` instead: lower is stricter. +`params.threshold` instead: higher is stricter, lower fires more easily. """ from __future__ import annotations @@ -49,8 +49,10 @@ SAMPLE_RATE = 16000 FRAME_SAMPLES = 1280 -#: Keyword spotting threshold. Lower is stricter: fewer false wakes, more -#: missed ones. +#: Keyword spotting threshold. Higher is stricter: fewer false wakes, more +#: missed ones. Lower fires more easily. pocketsphinx reports a keyphrase +#: when its score beats the background by at least log(threshold), so a +#: smaller number is an easier bar to clear (kws_search.c). #: #: This value was measured rather than guessed, and the measurement had a #: wrinkle worth recording. pocketsphinx adapts its cepstral mean as audio @@ -62,10 +64,26 @@ #: 1e-25 clean clean #: 1e-30 clean false-fires #: -#: 1e-25 is the only value clean at both ends, which is why it is the default. -#: Measured over four phrases against six synthesised clips, so it is a -#: defensible starting point and not a figure from a real room. -DEFAULT_THRESHOLD = 1e-25 +#: 1e-25 was the only value clean at both ends of that table. It was measured +#: over four phrases against six synthesised clips, and a real room then +#: overturned it. A ten-minute recording on the reference body (Raspberry Pi 5, +#: USB microphone, one voice, one room, 2026-09-10) holds 49 wake phrases and +#: 9 decoys: six near-miss names ("hey emma", "hey Emily", "hey, met any"), +#: three plain sentences with no name in them ("a mess of cables", "meant to"). +#: Replayed with the decoder warm: +#: +#: threshold wakes heard decoys that fired +#: 1e-25 49 of 49 8 of 9, including all three plain sentences +#: 1e-22 48 of 49 3 of 9, all near-miss names +#: 1e-20 48 of 49 3 of 9, the same three +#: 1e-15 44 of 49 1 of 9 +#: +#: 1e-22 is the default: it stops a robot waking on ordinary sentences at the +#: cost of one wake in forty-nine, and it sits nearer the value the cold table +#: cleared than 1e-20 does. Near-miss names fire at every threshold that keeps +#: recall; that is the floor of a phonetic spotter with a two-syllable name, +#: and the reason a trained model is the upgrade rather than a tweak here. +DEFAULT_THRESHOLD = 1e-22 #: Pronunciations for names that are not English words, in ARPAbet, which is #: what the bundled dictionary uses. Multiple entries per name are alternate @@ -88,7 +106,7 @@ class PocketSphinxWake(WakePlugin): Params, all optional: - threshold float, default DEFAULT_THRESHOLD. Lower is stricter. + threshold float, default DEFAULT_THRESHOLD. Higher is stricter. lexicon word -> pronunciation, or word -> [pronunciations]. Merged over SHIPPED_LEXICON, so a body can override a shipped name or teach the engine an entirely new one. diff --git a/emet-hal/tests/test_pocketsphinx_wake.py b/emet-hal/tests/test_pocketsphinx_wake.py index 868f009..e9e8fd5 100644 --- a/emet-hal/tests/test_pocketsphinx_wake.py +++ b/emet-hal/tests/test_pocketsphinx_wake.py @@ -127,9 +127,9 @@ def test_silence_does_not_wake_it(): def test_the_threshold_is_tunable_and_has_a_measured_default(): - """1e-25 is the only value that both wakes on a cold decoder and stays - quiet on a warm one. See the table in the plugin.""" - assert DEFAULT_THRESHOLD == 1e-25 + """1e-22 keeps 48 of 49 wakes on a real-room recording and stops the + detector waking on plain sentences. See the tables in the plugin.""" + assert DEFAULT_THRESHOLD == 1e-22 plugin = started("hey emet", threshold=1e-30) assert plugin.describe().healthy diff --git a/emet-sdk/examples/scout-01.yaml b/emet-sdk/examples/scout-01.yaml index db862a8..41aa014 100644 --- a/emet-sdk/examples/scout-01.yaml +++ b/emet-sdk/examples/scout-01.yaml @@ -39,9 +39,9 @@ audio: # legal; `bodiless.yaml` does, and takes the default. engine: pocketsphinx params: - # The shipped default, written out so it is visible. Lower is stricter: - # fewer false wakes, more missed ones. - threshold: 1.0e-25 + # The shipped default, written out so it is visible. Higher is stricter: + # fewer false wakes, more missed ones. Lower fires more easily. + threshold: 1.0e-22 capabilities: - id: head From 8654247d02c7252641d652fc65d3c6cad0d299aa Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:45:33 -0700 Subject: [PATCH 20/21] Play through a raw output stream without numpy Start the live-run clock at the first frame Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-engine/emet_engine/metrics.py | 10 +++- emet-engine/tests/test_metrics.py | 11 ++++ emet-hal/emet_hal/audio.py | 68 +++++++++++++++++++---- emet-hal/tests/test_audio.py | 89 ++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 13 deletions(-) diff --git a/emet-engine/emet_engine/metrics.py b/emet-engine/emet_engine/metrics.py index 91d3260..a9d6892 100644 --- a/emet-engine/emet_engine/metrics.py +++ b/emet-engine/emet_engine/metrics.py @@ -78,11 +78,16 @@ class SessionStats: over_budget: int = 0 _samples: deque[float] = field(default_factory=lambda: deque(maxlen=SAMPLE_CAP)) - _started: float = field(default_factory=time.perf_counter) + #: Set by the first frame, so that loading the acoustic model and opening + #: the card are left out. What remains is the card's clock against the + #: system's, which is what drift means. + _started: float | None = None # ----------------------------------------------------------- recording def record_frame(self, process_ms: float) -> None: + if self._started is None: + self._started = time.perf_counter() self.frames += 1 self.process_ms_total += process_ms self.process_ms_max = max(self.process_ms_max, process_ms) @@ -99,6 +104,9 @@ def audio_ms(self) -> float: @property def wall_ms(self) -> float: + """Time since the first frame. Zero before any frame has arrived.""" + if self._started is None: + return 0.0 return (time.perf_counter() - self._started) * 1000.0 @property diff --git a/emet-engine/tests/test_metrics.py b/emet-engine/tests/test_metrics.py index a1eef6c..860d0d8 100644 --- a/emet-engine/tests/test_metrics.py +++ b/emet-engine/tests/test_metrics.py @@ -164,6 +164,17 @@ def test_a_live_run_reports_the_clock(): assert "skew" in report +def test_the_wall_clock_starts_at_the_first_frame(): + """The first live run on the reference body reported +1.69 s of skew + over eleven minutes. That was the acoustic model loading before the + first frame, not the sound card drifting. Start-up is not drift.""" + s = stats() + assert s.wall_ms == 0.0 + time.sleep(0.05) + s.record_frame(1.0) + assert s.wall_ms < 30.0 + + def test_the_verdict_appears_in_the_report(): s = stats() for _ in range(10): diff --git a/emet-hal/emet_hal/audio.py b/emet-hal/emet_hal/audio.py index 20385e4..5411ea5 100644 --- a/emet-hal/emet_hal/audio.py +++ b/emet-hal/emet_hal/audio.py @@ -38,6 +38,7 @@ import logging import os import sys +import threading import wave from array import array from dataclasses import dataclass @@ -429,6 +430,8 @@ def __init__(self, config: dict[str, Any] | None = None, fmt: AudioFormat | None self.format = fmt or AudioFormat(sample_rate=22050) self._device: int | None = None self._sd: Any = None + self._stream: Any = None + self._cancelled = threading.Event() @property def sample_rate(self) -> int: @@ -440,23 +443,64 @@ async def start(self) -> None: _check_rate(self._device, self.sample_rate, 1, want_input=False) async def play(self, pcm: bytes) -> None: - """Play mono int16 PCM, returning when it has finished.""" + """Play mono int16 PCM, returning when it has finished. + + Through a raw output stream, written in chunks from a worker thread. + `sounddevice.play()` needs numpy, which the HAL does not depend on, + and chunked writes are what let `cancel()` interrupt mid-word. + """ if self._sd is None: raise AudioError("speaker was not started") - samples = array("h") - samples.frombytes(pcm) - self._sd.play( - memoryview(samples).cast("h"), - samplerate=self.sample_rate, - device=self._device, - blocking=False, - ) - await asyncio.to_thread(self._sd.wait) + if len(pcm) % SAMPLE_BYTES: + raise AudioError("int16 audio has an even number of bytes") + self._cancelled.clear() + sd = self._sd + chunk = max(SAMPLE_BYTES, self.format.frame_bytes) + + def blocking() -> None: + try: + stream = sd.RawOutputStream( + samplerate=self.sample_rate, + channels=1, + dtype="int16", + device=self._device, + ) + except Exception as exc: + raise AudioError(f"could not open the speaker: {exc}") from exc + self._stream = stream + try: + stream.start() + for i in range(0, len(pcm), chunk): + if self._cancelled.is_set(): + break + stream.write(pcm[i : i + chunk]) + except Exception as exc: + # An aborted stream makes the pending write raise. That is + # the cancel working, so it is only an error when nobody + # asked for it. + if not self._cancelled.is_set(): + raise AudioError(f"playback failed: {exc}") from exc + finally: + try: + if self._cancelled.is_set(): + stream.abort() + else: + stream.stop() + finally: + stream.close() + self._stream = None + + await asyncio.to_thread(blocking) async def cancel(self) -> None: """Stop mid-word. This is what barge-in is made of.""" - if self._sd is not None: - self._sd.stop() + self._cancelled.set() + stream = self._stream + if stream is not None: + try: + stream.abort() + except Exception: # pragma: no cover - the stream may already be closed + pass async def stop(self) -> None: await self.cancel() diff --git a/emet-hal/tests/test_audio.py b/emet-hal/tests/test_audio.py index a4b2324..1b185dc 100644 --- a/emet-hal/tests/test_audio.py +++ b/emet-hal/tests/test_audio.py @@ -487,3 +487,92 @@ def test_off_linux_the_environment_is_left_alone(monkeypatch): _fake_sounddevice(monkeypatch) audio_module._sd() assert "PA_ALSA_PLUGHW" not in os.environ + + + +# -------------------------------------------------------------------------- +# Speaker playback, against a fake PortAudio +# -------------------------------------------------------------------------- + + +class FakeOutputStream: + """Records what a RawOutputStream would have been asked to do.""" + + instances: list["FakeOutputStream"] = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.writes: list[bytes] = [] + self.started = self.stopped = self.aborted = self.closed = False + FakeOutputStream.instances.append(self) + + def start(self): + self.started = True + + def write(self, data): + self.writes.append(bytes(data)) + + def stop(self, ignore_errors=True): + self.stopped = True + + def abort(self, ignore_errors=True): + self.aborted = True + + def close(self, ignore_errors=True): + self.closed = True + + +def speaker_with_fake_portaudio(rate: int = 16000) -> Speaker: + FakeOutputStream.instances.clear() + spk = Speaker({"device": "USB"}, AudioFormat(sample_rate=rate)) + spk._sd = types.SimpleNamespace(RawOutputStream=FakeOutputStream) + spk._device = 2 + return spk + + +def test_playback_writes_the_whole_buffer_in_order_without_numpy(): + """`--echo` on the Pi was the first machine to reach this code, and it + failed twice over: a memoryview cast that CPython refuses, and + `sounddevice.play()` underneath, which needs numpy the HAL does not + have. A raw stream written in chunks needs neither.""" + spk = speaker_with_fake_portaudio() + pcm = bytes(range(256)) * 40 # 10240 bytes: four 2560-byte chunks at 16 kHz + run(spk.play(pcm)) + (stream,) = FakeOutputStream.instances + assert b"".join(stream.writes) == pcm + assert len(stream.writes) == 4 + assert stream.kwargs == {"samplerate": 16000, "channels": 1, "dtype": "int16", "device": 2} + assert stream.started and stream.stopped and stream.closed + assert not stream.aborted + + +def test_playback_refuses_a_half_sample(): + spk = speaker_with_fake_portaudio() + with pytest.raises(AudioError): + run(spk.play(bytes(3))) + + +def test_cancel_aborts_the_stream_that_is_playing(): + spk = speaker_with_fake_portaudio() + stream = FakeOutputStream() + spk._stream = stream + run(spk.cancel()) + assert stream.aborted + + +def test_a_cancelled_play_ends_early_and_aborts(): + spk = speaker_with_fake_portaudio() + pcm = bytes(2 * 1280 * 4) + + class CancelOnSecondWrite(FakeOutputStream): + def write(self, data): + super().write(data) + if len(self.writes) == 2: + spk._cancelled.set() + + spk._sd = types.SimpleNamespace(RawOutputStream=CancelOnSecondWrite) + run(spk.play(pcm)) + (stream,) = FakeOutputStream.instances + assert len(stream.writes) == 2 + assert stream.aborted and stream.closed + assert not stream.stopped From c327970f5801fdee7f3e2f74bcc35c5b88ac6123 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:19:28 -0700 Subject: [PATCH 21/21] Set the wake threshold from both recordings Count sound-card overflows beside dropped frames Explain frames dropped during echo Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- emet-engine/emet_engine/cli.py | 8 ++++- emet-engine/emet_engine/metrics.py | 6 +++- emet-engine/emet_engine/session.py | 7 +++++ emet-engine/tests/test_cli.py | 24 +++++++++++++++ emet-engine/tests/test_metrics.py | 7 +++++ emet-engine/tests/test_session.py | 11 +++++++ emet-hal/emet_hal/audio.py | 28 +++++++++++++---- emet-hal/emet_hal/pocketsphinx_wake.py | 39 ++++++++++++++---------- emet-hal/tests/test_audio.py | 15 +++++++-- emet-hal/tests/test_pocketsphinx_wake.py | 7 +++-- emet-sdk/examples/scout-01.yaml | 2 +- 11 files changed, 124 insertions(+), 30 deletions(-) diff --git a/emet-engine/emet_engine/cli.py b/emet-engine/emet_engine/cli.py index 97d3fc4..c66e94e 100644 --- a/emet-engine/emet_engine/cli.py +++ b/emet-engine/emet_engine/cli.py @@ -150,7 +150,13 @@ def _finish(session: ListenSession, args: argparse.Namespace) -> None: # real sound card can drift, and claiming otherwise for a file would # be inventing a measurement. print("\n" + session.stats.report(live=session.source_name != "wav")) - if session.dropped: + if session.dropped and args.echo: + print( + f"\nnote: {session.dropped} frame(s) were dropped while the robot was " + f"speaking. The loop does not read the microphone during playback; " + f"barge-in, in 1.0, is what changes that." + ) + elif session.dropped: print( f"\nwarning: {session.dropped} frame(s) were dropped, so wake words " f"may have been missed. The loop is not keeping up with the audio." diff --git a/emet-engine/emet_engine/metrics.py b/emet-engine/emet_engine/metrics.py index a9d6892..bbfb246 100644 --- a/emet-engine/emet_engine/metrics.py +++ b/emet-engine/emet_engine/metrics.py @@ -72,6 +72,10 @@ class SessionStats: #: Frames the source discarded because the loop fell behind. Not the same #: as being slow: this is audio that was never seen at all. dropped: int = 0 + #: Frames the sound card lost before the source saw them: PortAudio + #: reported an input overflow. A different cause, the same loss, and the + #: first thing to check when a live run's clock skew looks like drift. + overflows: int = 0 process_ms_total: float = 0.0 process_ms_max: float = 0.0 @@ -170,7 +174,7 @@ def report(self, *, live: bool) -> str: f"{self.over_budget} frame(s) over", f" realtime {self.realtime_factor:.4f} " f"({self.headroom:.0f}x faster than realtime)", - f" dropped {self.dropped}", + f" dropped {self.dropped} (card overflows {self.overflows})", ] if live: skew = self.wall_ms - self.audio_ms diff --git a/emet-engine/emet_engine/session.py b/emet-engine/emet_engine/session.py index 2a086cc..e75f8a6 100644 --- a/emet-engine/emet_engine/session.py +++ b/emet-engine/emet_engine/session.py @@ -284,6 +284,13 @@ def _record(self, process_ms: float) -> None: # Pulled from the source each frame rather than read once at the end: # a run that is killed part way through should still say what it lost. self.stats.dropped = self.dropped + self.stats.overflows = self.overflows + + @property + def overflows(self) -> int: + """Frames the sound card lost before the loop saw them. Read + defensively, like `dropped`: a file has no card to overflow.""" + return int(getattr(self._audio, "overflows", 0) or 0) @property def dropped(self) -> int: diff --git a/emet-engine/tests/test_cli.py b/emet-engine/tests/test_cli.py index ba7125a..daa7437 100644 --- a/emet-engine/tests/test_cli.py +++ b/emet-engine/tests/test_cli.py @@ -116,3 +116,27 @@ async def turns_then_ctrl_c(self): assert "stopped. heard it 1 time(s)." in out assert "source ended" not in out assert "realtime" in out and "verdict" in out + + +def test_frames_dropped_during_echo_are_explained_not_blamed(tmp_path, monkeypatch, capsys): + """The loop does not read the microphone while it plays audio back, so an + echo run always drops frames during playback. Calling that "not keeping + up" would be wrong; the reference body's first echo run said so.""" + wav = write_wav(tmp_path / "e.wav", saying(1)) + stub_loaders(monkeypatch, body(wav), soul()) + monkeypatch.setattr(ListenSession, "dropped", property(lambda self: 32)) + + rc = asyncio.run(cli._run(args(echo=True))) + out = capsys.readouterr().out + assert rc == 0 + assert "while the robot was speaking" in out + assert "not keeping up" not in out + + +def test_frames_dropped_while_listening_are_a_warning(tmp_path, monkeypatch, capsys): + wav = write_wav(tmp_path / "w.wav", saying(1)) + stub_loaders(monkeypatch, body(wav), soul()) + monkeypatch.setattr(ListenSession, "dropped", property(lambda self: 32)) + + asyncio.run(cli._run(args())) + assert "not keeping up" in capsys.readouterr().out diff --git a/emet-engine/tests/test_metrics.py b/emet-engine/tests/test_metrics.py index 860d0d8..54c837d 100644 --- a/emet-engine/tests/test_metrics.py +++ b/emet-engine/tests/test_metrics.py @@ -195,3 +195,10 @@ def test_the_stopwatch_measures_elapsed_time(): def test_the_stopwatch_reports_zero_before_it_is_used(): assert Stopwatch().elapsed_ms == 0.0 + + +def test_card_overflows_appear_beside_dropped_frames(): + s = stats() + s.record_frame(1.0) + s.overflows = 4 + assert "card overflows 4" in s.report(live=True) diff --git a/emet-engine/tests/test_session.py b/emet-engine/tests/test_session.py index 27c855f..d303b21 100644 --- a/emet-engine/tests/test_session.py +++ b/emet-engine/tests/test_session.py @@ -341,3 +341,14 @@ def test_the_reported_version_matches_the_installed_distribution(): assert emet_engine.__version__ == version("emet-engine") assert emet_engine.__version__ != "0+unknown", "package is not installed" + + +def test_a_file_overflows_nothing(tmp_path): + """`overflows` is read defensively like `dropped`: a file has no card.""" + session = ListenSession(body(write_wav(tmp_path / "o.wav", silence(1280))), soul()) + + async def scenario(): + async with session: + return session.overflows + + assert run(scenario()) == 0 diff --git a/emet-hal/emet_hal/audio.py b/emet-hal/emet_hal/audio.py index 5411ea5..5ddddbd 100644 --- a/emet-hal/emet_hal/audio.py +++ b/emet-hal/emet_hal/audio.py @@ -346,6 +346,9 @@ def __init__( self.config = dict(config or {}) self.format = fmt or AudioFormat() self.dropped = 0 + #: Frames the card lost before this code saw them: PortAudio reported + #: an input overflow. A different cause from `dropped`, the same loss. + self.overflows = 0 # A mic array is downmixed here so nothing downstream counts capsules. # Channel 0 by default rather than an average: on a ReSpeaker-style # array that channel carries the hardware-processed output, and @@ -364,11 +367,6 @@ async def start(self) -> None: self._loop = asyncio.get_running_loop() self._queue = asyncio.Queue(maxsize=self.QUEUE_FRAMES) - def callback(indata, frames, time_info, status) -> None: # PortAudio thread - if status: - log.debug("audio input status: %s", status) - self._loop.call_soon_threadsafe(self._offer, bytes(indata)) - try: self._stream = sd.RawInputStream( samplerate=self.format.sample_rate, @@ -376,12 +374,30 @@ def callback(indata, frames, time_info, status) -> None: # PortAudio thread device=device, channels=self._channels, dtype="int16", - callback=callback, + callback=self._on_audio, ) self._stream.start() except Exception as exc: raise AudioError(f"could not open the microphone: {exc}") from exc + def _on_audio(self, indata, frames, time_info, status) -> None: + """PortAudio's thread. Hands the frame to the event loop and counts + what the card says it lost. + + `input_overflow` means PortAudio's own buffer filled before this + callback ran, so audio was gone before the queue ever saw it. It is + counted apart from `dropped` because the cause differs: a stalled + thread rather than a slow loop. Either way the audio clock falls + behind the wall clock, so a live run's skew has to be read against + this number before it is called drift. + """ + if status: + log.debug("audio input status: %s", status) + if getattr(status, "input_overflow", False): + self.overflows += 1 + if self._loop is not None: + self._loop.call_soon_threadsafe(self._offer, bytes(indata)) + def _offer(self, raw: bytes) -> None: """On the event loop thread. Never blocks; drops the oldest instead. diff --git a/emet-hal/emet_hal/pocketsphinx_wake.py b/emet-hal/emet_hal/pocketsphinx_wake.py index 6828ae2..7fc3c77 100644 --- a/emet-hal/emet_hal/pocketsphinx_wake.py +++ b/emet-hal/emet_hal/pocketsphinx_wake.py @@ -66,24 +66,31 @@ #: #: 1e-25 was the only value clean at both ends of that table. It was measured #: over four phrases against six synthesised clips, and a real room then -#: overturned it. A ten-minute recording on the reference body (Raspberry Pi 5, -#: USB microphone, one voice, one room, 2026-09-10) holds 49 wake phrases and -#: 9 decoys: six near-miss names ("hey emma", "hey Emily", "hey, met any"), -#: three plain sentences with no name in them ("a mess of cables", "meant to"). -#: Replayed with the decoder warm: +#: overturned it. Two recordings on the reference body (Raspberry Pi 5, USB +#: microphone, one voice, one room, 2026-09-10), replayed with the decoder +#: warm: ten minutes holding 49 wake phrases and 9 decoys (six near-miss names +#: such as "hey emma", three plain sentences such as "a mess of cables"), and +#: five minutes of ordinary talk, typing, humming and an air conditioner with +#: no wake phrase in it at all. #: -#: threshold wakes heard decoys that fired -#: 1e-25 49 of 49 8 of 9, including all three plain sentences -#: 1e-22 48 of 49 3 of 9, all near-miss names -#: 1e-20 48 of 49 3 of 9, the same three -#: 1e-15 44 of 49 1 of 9 +#: threshold wakes heard decoys fired false wakes in 5 min of talk +#: 1e-25 49 of 49 8 of 9 15 (3.0 a minute) +#: 1e-22 48 of 49 3 of 9 11 (2.2 a minute) +#: 1e-20 48 of 49 3 of 9 8 (1.6 a minute) +#: 1e-18 46 of 49 3 of 9 7 +#: 1e-15 44 of 49 1 of 9 2 (0.4 a minute) +#: 1e-12 42 of 49 1 of 9 1 +#: 1e-10 40 of 49 1 of 9 0 #: -#: 1e-22 is the default: it stops a robot waking on ordinary sentences at the -#: cost of one wake in forty-nine, and it sits nearer the value the cold table -#: cleared than 1e-20 does. Near-miss names fire at every threshold that keeps -#: recall; that is the floor of a phonetic spotter with a two-syllable name, -#: and the reason a trained model is the upgrade rather than a tweak here. -DEFAULT_THRESHOLD = 1e-22 +#: No value is clean at both ends. 1e-15 is the default: it sits at the knee +#: of the false-wake curve, where the robot stops answering to typing and +#: humming every minute or two, at the cost of one wake in ten. A live run on +#: the reference body at 1e-22 showed the other side of that trade: 54 false +#: wakes in eleven minutes against two real ones. Near-miss names fire at +#: every threshold that keeps recall. That is the floor of a phonetic spotter +#: with a two-syllable name, and the reason a trained model is the upgrade +#: rather than a tweak here. Cold-start behaviour at 1e-15 is unmeasured. +DEFAULT_THRESHOLD = 1e-15 #: Pronunciations for names that are not English words, in ARPAbet, which is #: what the bundled dictionary uses. Multiple entries per name are alternate diff --git a/emet-hal/tests/test_audio.py b/emet-hal/tests/test_audio.py index 1b185dc..ccc93e1 100644 --- a/emet-hal/tests/test_audio.py +++ b/emet-hal/tests/test_audio.py @@ -437,7 +437,6 @@ def test_a_frame_arriving_after_stop_is_discarded(): assert src.dropped == 0 - # -------------------------------------------------------------------------- # PortAudio configuration # -------------------------------------------------------------------------- @@ -489,7 +488,6 @@ def test_off_linux_the_environment_is_left_alone(monkeypatch): assert "PA_ALSA_PLUGHW" not in os.environ - # -------------------------------------------------------------------------- # Speaker playback, against a fake PortAudio # -------------------------------------------------------------------------- @@ -576,3 +574,16 @@ def write(self, data): assert len(stream.writes) == 2 assert stream.aborted and stream.closed assert not stream.stopped + + +def test_card_overflows_are_counted_from_the_callback(): + """PortAudio flags an input overflow when its own buffer filled before the + callback ran. That audio is gone before the queue exists, so it is counted + apart from `dropped`, and it is the first thing to check when a live run's + clock skew looks like drift.""" + src = MicrophoneSource({"channels": 1}) + src._on_audio(bytes(2 * 1280), 1280, None, types.SimpleNamespace(input_overflow=True)) + src._on_audio(bytes(2 * 1280), 1280, None, types.SimpleNamespace(input_overflow=False)) + src._on_audio(bytes(2 * 1280), 1280, None, None) + assert src.overflows == 1 + assert src.dropped == 0 diff --git a/emet-hal/tests/test_pocketsphinx_wake.py b/emet-hal/tests/test_pocketsphinx_wake.py index e9e8fd5..3b64961 100644 --- a/emet-hal/tests/test_pocketsphinx_wake.py +++ b/emet-hal/tests/test_pocketsphinx_wake.py @@ -127,9 +127,10 @@ def test_silence_does_not_wake_it(): def test_the_threshold_is_tunable_and_has_a_measured_default(): - """1e-22 keeps 48 of 49 wakes on a real-room recording and stops the - detector waking on plain sentences. See the tables in the plugin.""" - assert DEFAULT_THRESHOLD == 1e-22 + """1e-15 keeps 44 of 49 wakes on a real-room recording and cuts false + wakes on ordinary talk from three a minute to one every two and a half. + See the tables in the plugin.""" + assert DEFAULT_THRESHOLD == 1e-15 plugin = started("hey emet", threshold=1e-30) assert plugin.describe().healthy diff --git a/emet-sdk/examples/scout-01.yaml b/emet-sdk/examples/scout-01.yaml index 41aa014..8d3612c 100644 --- a/emet-sdk/examples/scout-01.yaml +++ b/emet-sdk/examples/scout-01.yaml @@ -41,7 +41,7 @@ audio: params: # The shipped default, written out so it is visible. Higher is stricter: # fewer false wakes, more missed ones. Lower fires more easily. - threshold: 1.0e-22 + threshold: 1.0e-15 capabilities: - id: head