wren applies all/every commands itself, answers temperatures, reads aliases and number words; tts says units and clock times

wren's model answers "turn off all lights" with one call, and at 0.98 a
living room light switched on. The node now applies such a command to every
wired target of the kind. Number words become digits ("in five minutes"
lost its delay), aliases put the house's words and known mishearings in the
model's, and sensors.temperature is answered from <area>_temperature or
outdoor. tts writes out units and clock times before speaking.
This commit is contained in:
Melvin Strobl
2026-10-22 12:34:40 +02:00
parent bf63b3d6d4
commit 6b575a4989
6 changed files with 362 additions and 23 deletions
+27 -1
View File
@@ -44,6 +44,15 @@ PLAYING_SLACK_S = 1.0
#: The partial for an utterance that came back empty, often a wake word that
#: was not one, or was lost: shown on the caption, never spoken.
NOTHING_HEARD = "Didn't catch that."
#: Units read out after a number, the longer first. Kokoro says "22 °C" as
#: "22 degrees C" and "3.4 kWh" letter by letter.
UNITS = {
"°C": "degrees",
"°": "degrees",
"kWh": "kilowatt hours",
"kW": "kilowatts",
"W": "watts",
}
def _check(specs: list[MessageSpec], ports: dict[str, DType], node: str) -> None:
@@ -70,6 +79,23 @@ def sentences(text: str) -> list[str]:
return [s for s in (p.strip() for p in re.split(r"(?<=[.!?])\s+", text)) if s]
def speakable(text: str) -> str:
"""``text`` as it is said: a unit after a number in words, and a clock
time as its hour and minutes, since "07:30" is read "zero seven thirty"."""
units = "|".join(map(re.escape, UNITS))
text = re.sub(
rf"(\d)\s*({units})(?![A-Za-z])", lambda m: f"{m[1]} {UNITS[m[2]]}", text
)
return re.sub(r"\b([01]?\d|2[0-3]):([0-5]\d)\b", _clock, text)
def _clock(time: re.Match[str]) -> str:
hour, minute = int(time[1]), int(time[2])
if minute == 0:
return f"{hour} o'clock"
return f"{hour} oh {minute}" if minute < 10 else f"{hour} {minute}"
def mp3_seconds(data: bytes) -> float:
"""An MP3's length, from the frame count LAME writes into its first frame."""
header = int.from_bytes(data[:4], "big")
@@ -358,7 +384,7 @@ class TtsNode(_Speech):
if not text.strip() or not self._reachable(url):
return None
with self._lock:
for sentence in sentences(text):
for sentence in sentences(speakable(text)):
reference = self._spoken(url, sentence)
now = time.monotonic()
if now < self._until + PLAYING_SLACK_S:
+218 -19
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import re
import time
from collections.abc import Iterable
from typing import Any
from typing import Any, NamedTuple
from pydantic import BaseModel, ConfigDict, Field
from threadpoolctl import threadpool_limits
@@ -60,6 +60,7 @@ KIND_WORDS = {
}
#: What the node reads, and the types each may be. Only the prompt wakes it.
#: Besides these, ``<area>_temperature`` inputs answer "how warm is it".
INPUTS = {
"prompt": {DType.STR},
"presence": {DType.STR},
@@ -125,6 +126,84 @@ _SAID = {
#: shown, wired or not, or "the bathroom light" lands on a light that is.
_TRAINED = {"living room": ("light", "media", "heat")}
#: wren's areas outside, whose temperature is the ``outdoor`` reading.
_OUTSIDE = {"garden", "patio", "garage", "driveway", "balcony"}
#: Number words, which wren was trained on as digits.
_ONES = (
"zero one two three four five six seven eight nine ten eleven twelve "
"thirteen fourteen fifteen sixteen seventeen eighteen nineteen"
).split()
_TENS = "twenty thirty forty fifty sixty seventy eighty ninety".split()
_NUMBERS = {w: n for n, w in enumerate(_ONES)} | {
w: 20 + 10 * n for n, w in enumerate(_TENS)
}
#: A command for a whole group of devices: "turn off all lights".
_GROUP = re.compile(r"\b(all|every|everything|everywhere)\b")
#: What leaves such a command to the model and the assistant: a question, or
#: an exception to the group.
_NOT_GROUP = re.compile(
r"^(is|are|was|were|do|does|did|can|could|what|which|how|why|when|where|who)\b"
r"|\b(except|but|apart from|other than)\b"
)
#: The kind a group command names, by its word. None named is every kind.
_GROUP_KINDS = {
"light": "light",
"lights": "light",
"lamps": "light",
"music": "media",
"media": "media",
"tv": "media",
"shutters": "shutter",
"blinds": "shutter",
"heating": "heat",
"watering": "water",
"sprinklers": "water",
}
#: The call a group command makes on each kind, by its action word.
_GROUP_TOOLS = {
"on": {"light": "lights.on", "media": "media.play", "water": "watering.start"},
"off": {
"light": "lights.off",
"media": "media.stop",
"heat": "heating.off",
"water": "watering.stop",
},
"open": {"shutter": "shutters.up"},
"up": {"shutter": "shutters.up"},
"raise": {"shutter": "shutters.up"},
"close": {"shutter": "shutters.down"},
"down": {"shutter": "shutters.down"},
"lower": {"shutter": "shutters.down"},
"stop": {
"media": "media.stop",
"shutter": "shutters.stop",
"water": "watering.stop",
},
}
#: A group call, said: "all lights off".
_GROUP_SAID = {
"lights.on": "lights on",
"lights.off": "lights off",
"media.play": "media playing",
"media.stop": "media stopped",
"heating.off": "heating off",
"shutters.up": "shutters up",
"shutters.down": "shutters down",
"shutters.stop": "shutters stopped",
"watering.start": "watering on",
"watering.stop": "watering off",
}
class _Call(NamedTuple):
"""A call the node makes itself, shaped as the model's."""
tool: str
args: dict[str, Any]
delay_s: float | None = None
def _device(port: str, areas: dict[str, str]) -> tuple[str, str] | None:
"""The (area, kind) a port name stands for, or None."""
@@ -197,6 +276,39 @@ def _plain(command: str) -> str:
return re.sub(r"[.,!?;:]+(?=\s|$)", "", command).strip().lower()
def _aliased(prompt: str, aliases: dict[str, str]) -> str:
"""``prompt`` with the house's words in wren's, whole words in any case:
"the traverse" as "the living room light", "page too" as "patio"."""
if not aliases:
return prompt
words = {k.lower(): v for k, v in aliases.items()}
pattern = "|".join(map(re.escape, sorted(words, key=len, reverse=True)))
return re.sub(
rf"\b({pattern})\b",
lambda m: words[m[1].lower()],
prompt,
flags=re.IGNORECASE,
)
def _digits(command: str) -> str:
"""Number words as digits: "in five minutes" kept its delay only as "in 5
minutes", and "twenty two degrees" read as 21."""
tens, ones = "|".join(_TENS), "|".join(_ONES[1:10])
command = re.sub(
rf"\b({tens})[ -]({ones})\b",
lambda m: str(_NUMBERS[m[1]] + _NUMBERS[m[2]]),
command,
)
words = "|".join(_NUMBERS)
return re.sub(rf"\b({words})\b", lambda m: str(_NUMBERS[m[1]]), command)
def _named(areas: Iterable[str], command: str) -> list[str]:
"""The areas ``command`` names, in the house's order."""
return [a for a in areas if re.search(rf"\b{re.escape(a)}\b", command)]
def _asked(house: Any, command: str) -> set[str]:
"""The areas a call may land on: those the command names, or else the
user's and the living room.
@@ -206,8 +318,7 @@ def _asked(house: Any, command: str) -> set[str]:
word the model never learned landing on a neighbour: "switch on the
appliances" turned on the bathroom light at 0.97.
"""
named = {a for a in house.areas if re.search(rf"\b{re.escape(a)}\b", command)}
return named or {house.user_area, "living room"}
return set(_named(house.areas, command)) or {house.user_area, "living room"}
def _text(call: Any) -> str:
@@ -220,8 +331,15 @@ class WrenNode(Node):
"""Turn the command on ``prompt`` into values for the devices wired to it.
Inputs: ``prompt`` (the command, and the only one that wakes the node),
``presence`` (the area the user is in), ``outdoor`` (°C) and ``dark``
(otherwise read off the clock: before 07:00 or from 18:00).
``presence`` (the area the user is in), ``outdoor`` (°C), ``dark``
(otherwise read off the clock: before 07:00 or from 18:00) and any
``<area>_temperature``. A question for a temperature is answered from
that reading, or from ``outdoor`` for an area outside.
The command reaches the model with the ``aliases`` applied and its number
words as digits. A command for all of a kind ("turn off all lights",
"everything off in the office") is not the model's: the node applies it
to every wired device of that kind itself.
Outputs: ``actions``, a list of records, one per call and target,
``reply``, a sentence saying what was applied ("Kitchen light on."), or
@@ -246,8 +364,13 @@ class WrenNode(Node):
le=1,
description="Below this, nothing is written; the calls are still reported.",
)
aliases: dict[str, str] = Field(
default_factory=dict,
description="The house's own words, and words heard wrong, in wren's: "
'{"traverse": "living room light", "page too": "patio"}.',
)
__slots__ = ("cfg", "devices")
__slots__ = ("cfg", "devices", "readings")
# A call is a command to a device; doing it twice is not doing it once.
idempotent = False
@@ -264,12 +387,18 @@ class WrenNode(Node):
inputs = self._normalize_ports(requires)
outputs = self._normalize_ports(provides)
areas = {area.replace(" ", "_"): area for area in wren.AREAS}
#: The area each ``<area>_temperature`` input reads, to its port.
self.readings: dict[str, str] = {}
for spec in inputs:
allowed = INPUTS.get(spec.port)
reading = areas.get(spec.port.removesuffix("_temperature"))
if reading and spec.port.endswith("_temperature"):
self.readings[reading] = spec.port
allowed = INPUTS["outdoor"] if reading else INPUTS.get(spec.port)
if allowed is None:
raise ValueError(
f"'{spec.port}' is not an input of wren. It reads "
f"{', '.join(INPUTS)}."
f"{', '.join(INPUTS)} and <area>_temperature."
)
if spec.dtype not in allowed:
raise ValueError(
@@ -279,7 +408,6 @@ class WrenNode(Node):
if not any(spec.port == "prompt" for spec in inputs):
raise ValueError("wren needs a 'prompt' input: the command to act on.")
areas = {area.replace(" ", "_"): area for area in wren.AREAS}
#: (area, kind) to the ports standing for that device.
self.devices: dict[tuple[str, str], list[str]] = {}
for spec in outputs:
@@ -364,6 +492,7 @@ class WrenNode(Node):
presence: str | None = None,
outdoor: float | None = None,
dark: bool | None = None,
**readings: float | None,
) -> dict[str, Any] | None:
if not prompt.strip():
# An empty command is nothing to do, which is what a flow input
@@ -372,19 +501,33 @@ class WrenNode(Node):
wren = _wren()
specs = {spec.port: spec for spec in self.output_ports}
house = self._house(wren, specs, presence, outdoor, dark)
command = _plain(prompt)
prompt = _aliased(prompt, self.cfg.aliases)
command = _digits(_plain(prompt))
asked = _asked(house, command)
# One BLAS thread answers in about half the time for a model this
# small. Scoped to the call, so numpy elsewhere in the process keeps
# its threads.
with threadpool_limits(1):
calls, confidence = wren.act(command, house)
group = self._group(command, house.areas)
if group is not None:
calls, confidence, asked = group, 1.0, set(house.areas)
else:
# One BLAS thread answers in about half the time for a model
# this small. Scoped to the call, so numpy elsewhere in the
# process keeps its threads.
with threadpool_limits(1):
calls, confidence = wren.act(command, house)
confidence = round(float(confidence), 3)
write = self.cfg.apply and confidence >= self.cfg.min_confidence
writes: dict[str, Any] = {}
actions: list[dict[str, Any]] = []
said: list[str] = []
answers: list[str] = []
for call in calls:
area = str(call.args.get("area", ""))
if (
call.tool == "sensors.temperature"
and area in asked
and confidence >= self.cfg.min_confidence
and (answer := self._temperature(area, outdoor, readings))
):
answers.append(answer)
area, _, kind = str(call.args.get("id", "")).partition(":")
ports = self.devices.get((area, kind), [])
if call.tool.split(".")[0] != _TOOLS.get(kind) or area not in asked:
@@ -419,19 +562,75 @@ class WrenNode(Node):
done = done or applied
if done:
said.append(_said(call, area))
if group is not None and said:
said = self._group_said(group, command, house.areas)
wired = {spec.port for spec in self.output_ports}
if ACTIONS in wired:
writes[ACTIONS] = actions
if said and REPLY in wired:
if said:
reply = ", ".join(said)
writes[REPLY] = reply[0].upper() + reply[1:] + "."
if not said and UNHANDLED in wired:
answers.insert(0, reply[0].upper() + reply[1:] + ".")
if answers and REPLY in wired:
writes[REPLY] = " ".join(answers)
if not answers and UNHANDLED in wired:
writes[UNHANDLED] = prompt
if not said and REPLY in wired and not self._passed_on():
if not answers and REPLY in wired and not self._passed_on():
writes[REPLY] = CANT
return writes or None
def _group(self, command: str, areas: Iterable[str]) -> list[_Call] | None:
"""The calls a command for a whole group means, one per wired device,
or None when it is not one: "turn off all lights" is every light.
Not the model's to answer: it makes one call of it, and "turn off all
lights" switched a living room light on at 0.98.
"""
action = re.search(rf"\b({'|'.join(_GROUP_TOOLS)})\b", command)
if not _GROUP.search(command) or _NOT_GROUP.search(command) or not action:
return None
tools = _GROUP_TOOLS[action[1]]
kinds = {_GROUP_KINDS[w] for w in command.split() if w in _GROUP_KINDS}
named = _named(areas, command)
delay = re.search(r"\bin (\d+) minutes?\b", command)
seconds = int(delay[1]) * 60.0 if delay else None
calls = [
_Call(tools[kind], {"id": f"{area}:{kind}"}, seconds)
for area, kind in self.devices
if kind in tools
and (not kinds or kind in kinds)
and (not named or area in named)
]
return calls or None
@staticmethod
def _group_said(
calls: list[_Call], command: str, areas: Iterable[str]
) -> list[str]:
"""A group command's reply: "all lights off", "lights off in the office"."""
said = list(dict.fromkeys(_GROUP_SAID[call.tool] for call in calls))
named = _named(areas, command)
if named:
said[-1] += " in the " + " and the ".join(named)
else:
said[0] = "all " + said[0]
if calls[0].delay_s:
minutes = round(calls[0].delay_s / 60)
said[-1] += f" in {minutes} minute{'s' * (minutes != 1)}"
return said
def _temperature(
self, area: str, outdoor: float | None, readings: dict[str, float | None]
) -> str | None:
""" "It's 8 degrees outside.", from the reading wired for the area."""
value = readings.get(self.readings.get(area, ""))
where = f"in the {area}"
if value is None and area in _OUTSIDE:
value, where = outdoor, "outside"
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
return f"It's {round(value)} degrees {where}."
def _passed_on(self) -> bool:
"""Whether a node reads ``unhandled``, such as an assistant."""
name = next((s.name for s in self.output_ports if s.port == UNHANDLED), "")
+2
View File
@@ -41,9 +41,11 @@ class FakeWren:
def __init__(self) -> None:
self.answer: tuple[list[Any], float] = ([], 1.0)
self.houses: list[_House] = []
self.commands: list[str] = []
def act(self, command: str, house: _House) -> tuple[list[Any], float]:
self.houses.append(house)
self.commands.append(command)
return self.answer
+10 -1
View File
@@ -6,7 +6,7 @@ from typing import Any
from fluksio.ai import client
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes.speech import SttNode, TtsNode, sentences
from fluksio.flow.nodes.speech import SttNode, TtsNode, sentences, speakable
from fluksio.flow.pipeline import Pipeline
URL = "http://speech:8100"
@@ -40,6 +40,15 @@ def test_sentences_are_cut_after_their_ending():
]
def test_units_and_clock_times_are_said_as_words():
said = speakable("22 °C and 21.5° at 07:30, 3.4 kWh, 505 W, 2 kW, 7:05 or 18:00.")
assert said == (
"22 degrees and 21.5 degrees at 7 30, 3.4 kilowatt hours, 505 watts, "
"2 kilowatts, 7 oh 5 or 18 o'clock."
)
assert speakable("Kitchen light on, 3 Watts.") == "Kitchen light on, 3 Watts."
def test_an_utterance_is_one_session_committed_when_listening_ends(monkeypatch):
heard: list[bytes] = []
+82
View File
@@ -216,3 +216,85 @@ def test_a_call_on_an_area_the_command_does_not_name_is_not_applied(fake_wren):
assert out is not None
assert "house.bathroom_light" not in out
assert [a["applied"] for a in out["house.actions"]] == [False]
def _speaking(*targets: tuple[str, DType], inputs=(), **params: object) -> WrenNode:
node = WrenNode(
requires=[MessageSpec(name="command", port="prompt", dtype=DType.STR), *inputs],
provides=[
*(MessageSpec(name=port, dtype=dtype) for port, dtype in targets),
MessageSpec(name="reply", dtype=DType.STR),
MessageSpec(name="unhandled", dtype=DType.STR),
],
params={"apply": True, **params},
)
node.assign_flow("house", "control")
return node
def test_the_house_words_and_number_words_reach_the_model_as_its_own(fake_wren):
node = _speaking(("patio_light", DType.BOOL), aliases={"Page Too": "patio"})
fake_wren.answer = ([], 0.99)
out = node.execute({"house.command": "Turn on the page too light in five minutes."})
assert fake_wren.commands == ["turn on the patio light in 5 minutes"]
# The assistant reads the house's words too, as they were said otherwise.
assert out == {
"house.unhandled": "Turn on the patio light in five minutes.",
"house.reply": CANT,
}
def test_a_command_for_all_of_a_kind_reaches_every_wired_one(fake_wren):
lights = [(f"{area}_light", DType.BOOL) for area in ("kitchen", "office")]
node = _speaking(*lights, ("office_shutter", DType.STR))
out = node.execute({"house.command": "Turn off all lights."})
assert out == {
"house.kitchen_light": False,
"house.office_light": False,
"house.reply": "All lights off.",
}
out = node.execute({"house.command": "turn everything off in the office"})
assert out == {
"house.office_light": False,
"house.reply": "Lights off in the office.",
}
assert fake_wren.commands == [] # the node's, not the model's
# A question or an exception is the model's again.
fake_wren.answer = ([], 0.99)
node.execute({"house.command": "are all lights off"})
node.execute({"house.command": "turn off all lights but the kitchen"})
assert len(fake_wren.commands) == 2
def test_a_question_for_a_temperature_is_answered_from_its_reading(fake_wren):
node = _speaking(
inputs=[
MessageSpec(name="outside", port="outdoor", dtype=DType.FLOAT),
MessageSpec(
name="kitchen_temp", port="kitchen_temperature", dtype=DType.FLOAT
),
]
)
assert node.readings == {"kitchen": "kitchen_temperature"}
readings = {"house.outside": 7.6, "house.kitchen_temp": 21.2}
for area, said in [("garden", "outside"), ("kitchen", "in the kitchen")]:
fake_wren.answer = ([FakeCall("sensors.temperature", {"area": area})], 0.99)
command = f"what is the temperature in the {area}"
out = node.execute({"house.command": command, **readings})
degrees = 8 if area == "garden" else 21
assert out == {"house.reply": f"It's {degrees} degrees {said}."}
# No reading for the bedroom, or a model unsure of it: the assistant's.
for area, confidence in [("bedroom", 0.99), ("kitchen", 0.5)]:
fake_wren.answer = (
[FakeCall("sensors.temperature", {"area": area})],
confidence,
)
command = f"how warm is it in the {area}"
out = node.execute({"house.command": command, **readings})
assert out == {"house.unhandled": command, "house.reply": CANT}
+23 -2
View File
@@ -354,6 +354,7 @@ reports a missing dependency.
| `presence` | `str` | the area the user is in; anything else reads as away |
| `outdoor` | `float` or `int` | °C; 20 without it, clamped to −5…30 |
| `dark` | `bool` | without it, dark before 07:00 and from 18:00 |
| `<area>_temperature` | `float` or `int` | °C, read when the command asks how warm the area is |
The outputs are `actions`, a list of records, `reply`, `unhandled`, and any
number of **targets**. A
@@ -391,6 +392,7 @@ reads.
|---|---|---|
| `apply` | `false` | write to the targets; off, the node only reports |
| `min_confidence` | `0.93` | below this nothing is written |
| `aliases` | empty | the house's own words, or words the recogniser hears wrong, in the model's: `{"traverse": "living room light", "page too": "patio"}` |
Each record in `actions` contains `message`, `value`, `applied`, `delay_s`,
`call`, and `confidence`. Unwired calls have an empty `message` and are not
@@ -412,7 +414,22 @@ is reported but not applied, and goes on to `unhandled`. So is a call on an
area the command does not name, unless it is the user's area or, with no area
named, the living room: "switch on the appliances" is a word the model never
learned, and it lands on whichever light it was shown. The command is read
in lower case without sentence marks, as the model was trained.
in lower case without sentence marks and with number words as digits ("in
five minutes" as "in 5 minutes"), as the model was trained. `aliases` are
applied before, whole words in any case, and `unhandled` carries the prompt
with them.
A command for all of a kind, such as "turn off all lights", "switch every
light on in 5 minutes" or "everything off in the office", is not the
model's. The node applies it to every wired target of that kind, within the
areas it names, and replies "All lights off." or "Lights off in the
office.". A question ("are all lights off") or an exception ("all but the
kitchen") goes to the model as any other command.
A question for an area's temperature ("how warm is it in the kitchen") is
answered from its `<area>_temperature` input, or from `outdoor` for an area
outside, as "It's 8 degrees outside.". Without a reading it goes on to
`unhandled`.
!!! note "What the current model cannot do"
@@ -519,7 +536,11 @@ such as one that sends whole clips.
| `voice` | `af_heart` | one of Kokoro's voices, such as `bm_george` |
Each sentence is one call to `{url}/v1/audio/speech` and published as soon as
it is spoken. Calls to one server share a connection, which Speech to text opens
it is spoken. Units after a number and clock times are written out first,
since a speech model reads them letter by letter: "22 °C" is said "22
degrees", "3.4 kWh" "3.4 kilowatt hours" and "07:30" "7 30". How a word
itself is pronounced is the server's: `parrot serve --lexicon` gives words
their phonemes. Calls to one server share a connection, which Speech to text opens
when an utterance starts, so a reply's first sentence does not wait for one. A sentence spoken before in the same voice is reused rather than
spoken again, for the last 256 sentences. A reference carries `"append": true`
while the sentences before it are still playing, which queues it on a panel's