huri-events 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: huri-events
3
+ Version: 0.1.0
4
+ Summary: The curated HuRI event vocabulary: the huri.* topics and their wire formats
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: huri-core<0.5,>=0.4
8
+ Requires-Dist: numpy<2,>=1.26
9
+
10
+ # huri-events
11
+
12
+ The curated HuRI event vocabulary: one event class per `huri.*` topic, with its
13
+ wire format, registered through the `huri.events` entry point group. The
14
+ `huri.` namespace is reserved for this package; third-party events use their
15
+ own (`acme.*`), see [Modules as plugins](../../README.md#modules-as-plugins).
16
+
17
+ It is deliberately light (huri-core and numpy): a client installs it next to
18
+ `huri-core[client]` to decode every curated topic.
19
+
20
+ ```sh
21
+ # from a huri-modules checkout:
22
+ pip install "huri-core[client] @ git+https://github.com/Lucy-Robotics/huri-core.git@$(. ./versions.env && echo "$HURI_CORE_REF")" \
23
+ ./packages/huri-events
24
+ ```
25
+
26
+ A distribution of
27
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
28
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core`, which is
29
+ not on PyPI: without a checkout, install it from git in the same pip command,
30
+ so pip never looks it up on an index:
31
+
32
+ ```sh
33
+ ref=0.1.0 # a huri-modules release tag
34
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
35
+ pip install \
36
+ "huri-core @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
37
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events"
38
+ ```
@@ -0,0 +1,29 @@
1
+ # huri-events
2
+
3
+ The curated HuRI event vocabulary: one event class per `huri.*` topic, with its
4
+ wire format, registered through the `huri.events` entry point group. The
5
+ `huri.` namespace is reserved for this package; third-party events use their
6
+ own (`acme.*`), see [Modules as plugins](../../README.md#modules-as-plugins).
7
+
8
+ It is deliberately light (huri-core and numpy): a client installs it next to
9
+ `huri-core[client]` to decode every curated topic.
10
+
11
+ ```sh
12
+ # from a huri-modules checkout:
13
+ pip install "huri-core[client] @ git+https://github.com/Lucy-Robotics/huri-core.git@$(. ./versions.env && echo "$HURI_CORE_REF")" \
14
+ ./packages/huri-events
15
+ ```
16
+
17
+ A distribution of
18
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
19
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core`, which is
20
+ not on PyPI: without a checkout, install it from git in the same pip command,
21
+ so pip never looks it up on an index:
22
+
23
+ ```sh
24
+ ref=0.1.0 # a huri-modules release tag
25
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
26
+ pip install \
27
+ "huri-core @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
28
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events"
29
+ ```
@@ -0,0 +1,34 @@
1
+ """The curated HuRI events, one class per `huri.*` topic.
2
+
3
+ | topic | class | wire |
4
+ |-------------------------|-------------------|--------|
5
+ | huri.audio.in | BytesEvent (core) | binary |
6
+ | huri.voice | Voice | json |
7
+ | huri.transcript | Transcript | json |
8
+ | huri.emotion | Emotion | - |
9
+ | huri.partial_question | PartialQuestion | json |
10
+ | huri.question | RAGQuestion | json |
11
+ | huri.token | Token | json |
12
+ | huri.audio.out | Audio | binary |
13
+ | huri.motion | Motion | binary |
14
+
15
+ `Emotion` and `Voice` only travel between modules inside the server.
16
+ """
17
+
18
+ from huri_events.audio import Audio
19
+ from huri_events.emotion import Emotion
20
+ from huri_events.motion import Motion
21
+ from huri_events.question import PartialQuestion, RAGQuestion
22
+ from huri_events.speech import Transcript, Voice
23
+ from huri_events.text import Token
24
+
25
+ __all__ = [
26
+ "Audio",
27
+ "Emotion",
28
+ "Motion",
29
+ "PartialQuestion",
30
+ "RAGQuestion",
31
+ "Token",
32
+ "Transcript",
33
+ "Voice",
34
+ ]
@@ -0,0 +1,47 @@
1
+ import logging
2
+ import struct
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from huri_core.core.events import EventData
8
+
9
+ logger = logging.getLogger("ray.serve")
10
+
11
+
12
+ @dataclass
13
+ class Audio(EventData[bytes]):
14
+ """Synthesized speech: float32 mono samples.
15
+
16
+ Wire (binary): ``>IBd`` header — sample_rate (u32), end (u8), pts (f64,
17
+ seconds from utterance start) — then the float32 samples.
18
+ """
19
+
20
+ data: np.ndarray
21
+ sample_rate: int
22
+ end: bool = False
23
+ pts: float = 0.0 # presentation timestamp in seconds from utterance start
24
+
25
+ def to_wire(self) -> bytes:
26
+ logger.info(
27
+ "Audio samples=%d sr=%d end=%s pts=%.3fs",
28
+ self.data.shape[0],
29
+ self.sample_rate,
30
+ self.end,
31
+ self.pts,
32
+ )
33
+ header = struct.pack(">IBd", self.sample_rate, int(self.end), self.pts)
34
+ return header + bytes(self.data.tobytes())
35
+
36
+ @classmethod
37
+ def from_wire(cls, data: bytes) -> "Audio":
38
+ sample_rate, end, pts = struct.unpack(">IBd", data[:13])
39
+ samples = np.frombuffer(data[13:], dtype=np.float32)
40
+
41
+ return cls(sample_rate=sample_rate, end=end, pts=pts, data=samples)
42
+
43
+ def summarize(self) -> str:
44
+ """Short repr that avoids dumping full numpy arrays into the log."""
45
+
46
+ cls = type(self).__name__
47
+ return f"{cls}(shape={self.data.shape}, dtype={self.data.dtype})"
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+ from typing import Dict
3
+
4
+ from huri_core.core.events import EventData
5
+
6
+
7
+ @dataclass
8
+ class Emotion(EventData):
9
+ """Speech emotion: best `label`, its `confidence`, all `scores`; `end`
10
+ marks the utterance's final estimate. Travels between modules only
11
+ (EMO -> EAG), it has no wire format."""
12
+
13
+ label: str
14
+ confidence: float
15
+ scores: Dict[str, float]
16
+ end: bool
@@ -0,0 +1,81 @@
1
+ import logging
2
+ import struct
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from huri_core.core.events import EventData
8
+
9
+ _EMAGE_FPS = 30
10
+
11
+ # Layout of the per-frame vectors, shared by to_wire/from_wire.
12
+ _POSE_DIM = 165 # 55 SMPL-X joints x 3 axis-angle
13
+ _EXPR_DIM = 100 # facial expression coefficients
14
+ _TRANS_DIM = 3 # global root translation
15
+
16
+ logger = logging.getLogger("ray.serve")
17
+
18
+
19
+ @dataclass
20
+ class Motion(EventData[bytes]):
21
+ """SMPL-X gesture frames paired with the speech (`pts` matches Audio.pts).
22
+
23
+ Wire (binary): ``>dII`` header — pts (f64), fps (u32), frame count (u32) —
24
+ then float32 poses (t x 165), expressions (t x 100) and trans (t x 3).
25
+ """
26
+
27
+ poses: np.ndarray # (t, 165) SMPL-X axis-angle, 55 joints × 3
28
+ expressions: np.ndarray # (t, 100) facial expression coefficients
29
+ trans: np.ndarray # (t, 3) global root translation
30
+ fps: int = _EMAGE_FPS
31
+ pts: float = 0.0 # presentation timestamp in seconds, paired with Audio.pts
32
+
33
+ def to_wire(self) -> bytes:
34
+ n_frames = self.poses.shape[0]
35
+ logger.info(
36
+ "Motion frames=%d fps=%d pts=%.3fs",
37
+ n_frames,
38
+ self.fps,
39
+ self.pts,
40
+ )
41
+ header = struct.pack(">dII", self.pts, self.fps, n_frames)
42
+ body: bytes = (
43
+ self.poses.astype(np.float32).tobytes()
44
+ + self.expressions.astype(np.float32).tobytes()
45
+ + self.trans.astype(np.float32).tobytes()
46
+ )
47
+ return header + body
48
+
49
+ def summarize(self) -> str:
50
+ cls = type(self).__name__
51
+ return (
52
+ f"{cls}(frames={self.poses.shape[0]}, "
53
+ f"fps={self.fps}, pts={self.pts:.3f}s)"
54
+ )
55
+
56
+ @classmethod
57
+ def from_wire(cls, data: bytes) -> "Motion":
58
+ pts, fps, n_frames = struct.unpack(">dII", data[:16])
59
+
60
+ body = np.frombuffer(data[16:], dtype=np.float32)
61
+ expected = n_frames * (_POSE_DIM + _EXPR_DIM + _TRANS_DIM)
62
+ if body.size < expected:
63
+ raise ValueError(
64
+ f"truncated motion frame: {body.size} floats, expected {expected} "
65
+ f"for {n_frames} frames"
66
+ )
67
+
68
+ offset = 0
69
+ out = []
70
+ for dim in (_POSE_DIM, _EXPR_DIM, _TRANS_DIM):
71
+ out.append(body[offset : offset + n_frames * dim].reshape(n_frames, dim))
72
+ offset += n_frames * dim
73
+ poses, expressions, trans = out
74
+
75
+ return cls(
76
+ poses=poses,
77
+ expressions=expressions,
78
+ trans=trans,
79
+ fps=fps,
80
+ pts=pts,
81
+ )
@@ -0,0 +1,58 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Mapping, Optional
3
+
4
+ from huri_events.emotion import Emotion
5
+ from huri_events.speech import Transcript
6
+
7
+ from huri_core.core.events import JsonEvent
8
+
9
+
10
+ @dataclass
11
+ class PartialQuestion(JsonEvent):
12
+ """Partial question used to aggregate a sentence to an emotion: either
13
+ part may be missing (TAG sends the transcript, EAG the emotion)."""
14
+
15
+ transcript: Optional[Transcript]
16
+ emotion: Optional[Emotion]
17
+
18
+
19
+ @dataclass
20
+ class RAGQuestion(JsonEvent):
21
+ """Fully aggregated question to send to the RAG.
22
+
23
+ Wire: JSON ``{"transcript": {"text", "end"}, "emotion": {...} | null}``,
24
+ or the shorthand ``{"text": str}`` a client sends for a typed question.
25
+ """
26
+
27
+ transcript: Transcript
28
+ emotion: Optional[Emotion] = None
29
+
30
+ @classmethod
31
+ def from_wire(cls, data: Mapping[str, Any]) -> "RAGQuestion":
32
+ """Accept a typed question straight off the wire.
33
+
34
+ In-pipeline, QAG builds this from a Transcript + Emotion. External clients
35
+ (the website backend, the CLI text sender) inject a typed question as a
36
+ bare ``{"text": ...}`` — no live emotion — which we wrap into a final
37
+ Transcript. The nested branch also hydrates the dataclasses (JSON gives
38
+ plain dicts) so a fully-shaped payload round-trips too.
39
+ """
40
+ if "text" in data:
41
+ return cls(Transcript(text=data["text"], end=True), None)
42
+
43
+ # Thomas: it's nasty, but ragquestion is a subclass and doesn't work well with
44
+ # ray for my end, i get RAGQuestion.__init__() got an unexpected keyword
45
+ # argument 'text' if there is a agnostic way, we can fix this, but until now
46
+ # it's like this
47
+
48
+ transcript = data.get("transcript")
49
+ if isinstance(transcript, Mapping):
50
+ transcript = Transcript(**transcript)
51
+ if not isinstance(transcript, Transcript):
52
+ raise ValueError(
53
+ "RAGQuestion.from_wire needs a 'text' or 'transcript' field"
54
+ )
55
+ emotion = data.get("emotion")
56
+ if isinstance(emotion, Mapping):
57
+ emotion = Emotion(**emotion)
58
+ return cls(transcript, emotion)
@@ -0,0 +1,34 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+ import numpy as np
5
+
6
+ from huri_core.core.events import JsonEvent
7
+
8
+
9
+ @dataclass
10
+ class Transcript(JsonEvent):
11
+ """Text recognized from speech; `end` closes the utterance.
12
+
13
+ Wire: JSON ``{"text": str, "end": bool}``.
14
+ """
15
+
16
+ text: str
17
+ end: bool
18
+
19
+
20
+ @dataclass
21
+ class Voice(JsonEvent):
22
+ """A frame of detected speech (float32 samples), or `data=None` to mark the
23
+ end of the utterance. Travels between modules only (VAD -> STT, EMO)."""
24
+
25
+ data: Optional[np.ndarray]
26
+
27
+ def summarize(self) -> str:
28
+ """Short repr that avoids dumping full numpy arrays into the log."""
29
+
30
+ if self.data is not None:
31
+ cls = type(self).__name__
32
+ return f"{cls}(shape={self.data.shape}, dtype={self.data.dtype})"
33
+ else:
34
+ return super().summarize()
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass
2
+
3
+ from huri_core.core.events import JsonEvent
4
+
5
+
6
+ @dataclass
7
+ class Token(JsonEvent):
8
+ """A delta of the assistant's streamed reply; `end` marks the end of the
9
+ utterance (with empty text).
10
+
11
+ Wire: JSON ``{"text": str, "end": bool}``.
12
+ """
13
+
14
+ text: str
15
+ end: bool
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: huri-events
3
+ Version: 0.1.0
4
+ Summary: The curated HuRI event vocabulary: the huri.* topics and their wire formats
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: huri-core<0.5,>=0.4
8
+ Requires-Dist: numpy<2,>=1.26
9
+
10
+ # huri-events
11
+
12
+ The curated HuRI event vocabulary: one event class per `huri.*` topic, with its
13
+ wire format, registered through the `huri.events` entry point group. The
14
+ `huri.` namespace is reserved for this package; third-party events use their
15
+ own (`acme.*`), see [Modules as plugins](../../README.md#modules-as-plugins).
16
+
17
+ It is deliberately light (huri-core and numpy): a client installs it next to
18
+ `huri-core[client]` to decode every curated topic.
19
+
20
+ ```sh
21
+ # from a huri-modules checkout:
22
+ pip install "huri-core[client] @ git+https://github.com/Lucy-Robotics/huri-core.git@$(. ./versions.env && echo "$HURI_CORE_REF")" \
23
+ ./packages/huri-events
24
+ ```
25
+
26
+ A distribution of
27
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
28
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core`, which is
29
+ not on PyPI: without a checkout, install it from git in the same pip command,
30
+ so pip never looks it up on an index:
31
+
32
+ ```sh
33
+ ref=0.1.0 # a huri-modules release tag
34
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
35
+ pip install \
36
+ "huri-core @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
37
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events"
38
+ ```
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ huri_events/__init__.py
4
+ huri_events/audio.py
5
+ huri_events/emotion.py
6
+ huri_events/motion.py
7
+ huri_events/question.py
8
+ huri_events/speech.py
9
+ huri_events/text.py
10
+ huri_events.egg-info/PKG-INFO
11
+ huri_events.egg-info/SOURCES.txt
12
+ huri_events.egg-info/dependency_links.txt
13
+ huri_events.egg-info/entry_points.txt
14
+ huri_events.egg-info/requires.txt
15
+ huri_events.egg-info/top_level.txt
16
+ tests/test_wire.py
@@ -0,0 +1,10 @@
1
+ [huri.events]
2
+ huri.audio.in = huri_core.core.events:BytesEvent
3
+ huri.audio.out = huri_events.audio:Audio
4
+ huri.emotion = huri_events.emotion:Emotion
5
+ huri.motion = huri_events.motion:Motion
6
+ huri.partial_question = huri_events.question:PartialQuestion
7
+ huri.question = huri_events.question:RAGQuestion
8
+ huri.token = huri_events.text:Token
9
+ huri.transcript = huri_events.speech:Transcript
10
+ huri.voice = huri_events.speech:Voice
@@ -0,0 +1,2 @@
1
+ huri-core<0.5,>=0.4
2
+ numpy<2,>=1.26
@@ -0,0 +1 @@
1
+ huri_events
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64,<82"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "huri-events"
7
+ version = "0.1.0"
8
+ description = "The curated HuRI event vocabulary: the huri.* topics and their wire formats"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ # Light on purpose: a client decodes every curated topic with this and
12
+ # huri-core[client], no torch, no ray.
13
+ dependencies = [
14
+ "huri-core>=0.4,<0.5",
15
+ "numpy>=1.26,<2",
16
+ ]
17
+
18
+ # Topic -> event class. The huri.* namespace is reserved for this package.
19
+ [project.entry-points."huri.events"]
20
+ "huri.audio.in" = "huri_core.core.events:BytesEvent" # inbound mic frames (raw int16 PCM)
21
+ "huri.audio.out" = "huri_events.audio:Audio"
22
+ "huri.voice" = "huri_events.speech:Voice"
23
+ "huri.transcript" = "huri_events.speech:Transcript"
24
+ "huri.emotion" = "huri_events.emotion:Emotion"
25
+ "huri.partial_question" = "huri_events.question:PartialQuestion"
26
+ "huri.question" = "huri_events.question:RAGQuestion"
27
+ "huri.token" = "huri_events.text:Token"
28
+ "huri.motion" = "huri_events.motion:Motion"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["huri_events*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,112 @@
1
+ """Every curated event survives its wire format, and huri-events owns huri.*."""
2
+
3
+ from importlib.metadata import entry_points
4
+
5
+ import numpy as np
6
+ import pytest
7
+ from huri_events import (
8
+ Audio,
9
+ Emotion,
10
+ Motion,
11
+ PartialQuestion,
12
+ RAGQuestion,
13
+ Token,
14
+ Transcript,
15
+ Voice,
16
+ )
17
+
18
+ from huri_core.core.events import BytesEvent
19
+
20
+
21
+ def roundtrip(event):
22
+ return type(event).from_wire(event.to_wire())
23
+
24
+
25
+ @pytest.mark.parametrize(
26
+ "event",
27
+ [
28
+ Transcript("hello robot", True),
29
+ Token("Hel", False),
30
+ Token("", True),
31
+ BytesEvent(b"\x00\x01\x02\x03"),
32
+ ],
33
+ ids=lambda e: type(e).__name__,
34
+ )
35
+ def test_simple_events_roundtrip(event):
36
+ assert roundtrip(event) == event
37
+
38
+
39
+ def test_question_roundtrips_with_and_without_emotion():
40
+ emotion = Emotion("neu", 0.9, {"neu": 0.9, "ang": 0.1}, True)
41
+ for question in (
42
+ RAGQuestion(Transcript("hi", True), None),
43
+ RAGQuestion(Transcript("hi", True), emotion),
44
+ ):
45
+ assert roundtrip(question) == question
46
+
47
+
48
+ def test_typed_question_shorthand():
49
+ assert RAGQuestion.from_wire({"text": "hi"}) == RAGQuestion(Transcript("hi", True))
50
+
51
+
52
+ def test_audio_roundtrip_keeps_samples_and_timing():
53
+ audio = Audio(np.linspace(-1, 1, 480, dtype=np.float32), 24000, True, 1.25)
54
+ back = roundtrip(audio)
55
+ assert (back.sample_rate, bool(back.end), back.pts) == (24000, True, 1.25)
56
+ np.testing.assert_array_equal(back.data, audio.data)
57
+
58
+
59
+ def test_motion_roundtrip_and_truncation():
60
+ frames = 3
61
+ motion = Motion(
62
+ poses=np.arange(frames * 165, dtype=np.float32).reshape(frames, 165),
63
+ expressions=np.ones((frames, 100), dtype=np.float32),
64
+ trans=np.zeros((frames, 3), dtype=np.float32),
65
+ pts=0.5,
66
+ )
67
+ back = roundtrip(motion)
68
+ np.testing.assert_array_equal(back.poses, motion.poses)
69
+ assert (back.fps, back.pts) == (motion.fps, 0.5)
70
+ with pytest.raises(ValueError, match="truncated"):
71
+ Motion.from_wire(motion.to_wire()[:-4])
72
+
73
+
74
+ def test_internal_events_are_constructible():
75
+ assert Voice(None).data is None
76
+ assert PartialQuestion(Transcript("a", True), None).emotion is None
77
+
78
+
79
+ def test_huri_events_registers_every_curated_topic():
80
+ eps = {ep.name: ep for ep in entry_points(group="huri.events")}
81
+ curated = {name for name in eps if name.startswith("huri.")}
82
+ assert curated == {
83
+ "huri.audio.in",
84
+ "huri.audio.out",
85
+ "huri.voice",
86
+ "huri.transcript",
87
+ "huri.emotion",
88
+ "huri.partial_question",
89
+ "huri.question",
90
+ "huri.token",
91
+ "huri.motion",
92
+ }
93
+ dists = {getattr(eps[name].dist, "name", None) for name in curated}
94
+ assert dists == {"huri-events"}
95
+
96
+
97
+ def test_motion_survives_the_wire():
98
+ """from_wire used to drop the body, yielding a zero-frame Motion."""
99
+ frames = 4
100
+ original = Motion(
101
+ poses=np.arange(frames * 165, dtype=np.float32).reshape(frames, 165),
102
+ expressions=np.arange(frames * 100, dtype=np.float32).reshape(frames, 100),
103
+ trans=np.arange(frames * 3, dtype=np.float32).reshape(frames, 3),
104
+ pts=1.5,
105
+ )
106
+ decoded = roundtrip(original)
107
+
108
+ assert decoded.pts == pytest.approx(original.pts)
109
+ assert decoded.fps == original.fps
110
+ np.testing.assert_array_equal(decoded.poses, original.poses)
111
+ np.testing.assert_array_equal(decoded.expressions, original.expressions)
112
+ np.testing.assert_array_equal(decoded.trans, original.trans)