huri-module-emo 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,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: huri-module-emo
3
+ Version: 0.1.0
4
+ Summary: HuRI module: Speech emotion from prosody (wav2vec2 classifier), and its aggregator
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: huri-core[server]<0.5,>=0.4
8
+ Requires-Dist: huri-events<0.2,>=0.1
9
+ Requires-Dist: numpy<2,>=1.26
10
+ Provides-Extra: model
11
+ Requires-Dist: torch; extra == "model"
12
+ Requires-Dist: transformers>=4.46; extra == "model"
13
+
14
+ # huri-module-emo
15
+
16
+ Speech emotion from prosody (wav2vec2 classifier), and its aggregator.
17
+
18
+ | Module | Topics | What it does |
19
+ |---|---|---|
20
+ | `emo` | `huri.voice` → `huri.emotion` | Estimates the emotion of speech from its prosody |
21
+ | `eag` | `huri.emotion` → `huri.partial_question` | Aggregates emotion estimates over an utterance |
22
+
23
+ ```sh
24
+ pip install -e 'packages/huri-module-emo[model]' # from a huri-modules checkout, next to huri-core and huri-events
25
+ HURI_MODULES=emo,eag ... # enable it on the server
26
+ ```
27
+
28
+ `emo` needs the `model` extra (torch and transformers, for its wav2vec2
29
+ classifier); `eag` only aggregates events and needs none of it, so a host that
30
+ runs `eag` alone installs the package without the extra
31
+ (`[tool.huri-modules.extras]` in `pyproject.toml` says which module needs which
32
+ extra; `huri hub install` and `scripts/install_local.sh` add it). Hardware pins
33
+ of `emo`: `requirements-<variant>.emo.txt` (`cpu`, `nvidia`, `amd`; install them
34
+ before the package).
35
+
36
+ A distribution of
37
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
38
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
39
+ `huri-events`, which are not on PyPI: without a checkout, install them from git
40
+ in the same pip command, so pip never looks them up on an index:
41
+
42
+ ```sh
43
+ ref=0.1.0 # a huri-modules release tag
44
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
45
+ pip install \
46
+ "huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
47
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
48
+ "huri-module-emo[model] @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-emo"
49
+ ```
@@ -0,0 +1,36 @@
1
+ # huri-module-emo
2
+
3
+ Speech emotion from prosody (wav2vec2 classifier), and its aggregator.
4
+
5
+ | Module | Topics | What it does |
6
+ |---|---|---|
7
+ | `emo` | `huri.voice` → `huri.emotion` | Estimates the emotion of speech from its prosody |
8
+ | `eag` | `huri.emotion` → `huri.partial_question` | Aggregates emotion estimates over an utterance |
9
+
10
+ ```sh
11
+ pip install -e 'packages/huri-module-emo[model]' # from a huri-modules checkout, next to huri-core and huri-events
12
+ HURI_MODULES=emo,eag ... # enable it on the server
13
+ ```
14
+
15
+ `emo` needs the `model` extra (torch and transformers, for its wav2vec2
16
+ classifier); `eag` only aggregates events and needs none of it, so a host that
17
+ runs `eag` alone installs the package without the extra
18
+ (`[tool.huri-modules.extras]` in `pyproject.toml` says which module needs which
19
+ extra; `huri hub install` and `scripts/install_local.sh` add it). Hardware pins
20
+ of `emo`: `requirements-<variant>.emo.txt` (`cpu`, `nvidia`, `amd`; install them
21
+ before the package).
22
+
23
+ A distribution of
24
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
25
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
26
+ `huri-events`, which are not on PyPI: without a checkout, install them from git
27
+ in the same pip command, so pip never looks them up on an index:
28
+
29
+ ```sh
30
+ ref=0.1.0 # a huri-modules release tag
31
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
32
+ pip install \
33
+ "huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
34
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
35
+ "huri-module-emo[model] @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-emo"
36
+ ```
File without changes
@@ -0,0 +1,75 @@
1
+ from collections import defaultdict
2
+ from typing import Dict, Optional
3
+
4
+ from huri_events.emotion import Emotion
5
+ from huri_events.question import PartialQuestion
6
+
7
+ from huri_core.core.module import Module
8
+
9
+
10
+ class EAG(Module):
11
+ """EAG Module
12
+
13
+ Aggregate all emotions and send when voice end.
14
+
15
+ input: huri.emotion,
16
+ output: huri.partial_question
17
+
18
+ :ema_alpha: if not None, the aggragation will use ema computation instead
19
+ of average. Recents emotion will have stronger impact on the final score.
20
+ Lower alpha will make impact lower, and higher alpha will make it higher. \
21
+ Default alpha would be ~0.3.
22
+ """
23
+
24
+ input_type = "huri.emotion"
25
+ output_type = "huri.partial_question"
26
+ input_event = Emotion
27
+ output_event = PartialQuestion
28
+
29
+ def __init__(self, ema_alpha: Optional[float] = None):
30
+ super().__init__()
31
+
32
+ self.scores: Dict[str, float] = defaultdict(float)
33
+ self.count: int = 0
34
+
35
+ self.ema_alpha = ema_alpha
36
+
37
+ def _finalize(self) -> Emotion:
38
+ avg_scores = (
39
+ {label: score / self.count for label, score in self.scores.items()}
40
+ if self.ema_alpha is None
41
+ else self.scores
42
+ )
43
+
44
+ best_label = max(avg_scores, key=lambda label: avg_scores[label])
45
+
46
+ result = Emotion(
47
+ label=best_label,
48
+ confidence=avg_scores[best_label],
49
+ scores=avg_scores,
50
+ end=True,
51
+ )
52
+
53
+ self.scores.clear()
54
+ self.count = 0
55
+
56
+ return result
57
+
58
+ async def process(self, emotion: Emotion) -> Optional[PartialQuestion]:
59
+ if self.ema_alpha is not None:
60
+ for label, score in emotion.scores.items():
61
+ self.scores[label] = (
62
+ self.ema_alpha * score + (1 - self.ema_alpha) * self.scores[label]
63
+ )
64
+ else:
65
+ for label, score in emotion.scores.items():
66
+ self.scores[label] += score
67
+
68
+ self.count += 1
69
+
70
+ if emotion.end:
71
+ emotion = self._finalize()
72
+
73
+ return PartialQuestion(transcript=None, emotion=emotion)
74
+
75
+ return None
@@ -0,0 +1,7 @@
1
+ name = "eag"
2
+ summary = "Aggregates emotion estimates over an utterance"
3
+ input = { topic = "huri.emotion", event = "huri_events.emotion:Emotion" }
4
+ output = { topic = "huri.partial_question", event = "huri_events.question:PartialQuestion" }
5
+ events = ["huri-events>=0.1,<0.2"]
6
+ requires = { gpu = false, actuation = false }
7
+ huri_core = ">=0.4,<0.5"
@@ -0,0 +1,7 @@
1
+ name = "emo"
2
+ summary = "Estimates the emotion of speech from its prosody"
3
+ input = { topic = "huri.voice", event = "huri_events.speech:Voice" }
4
+ output = { topic = "huri.emotion", event = "huri_events.emotion:Emotion" }
5
+ events = ["huri-events>=0.1,<0.2"]
6
+ requires = { gpu = false, actuation = false }
7
+ huri_core = ">=0.4,<0.5"
@@ -0,0 +1,155 @@
1
+ import asyncio
2
+ import os
3
+ from typing import Any, AsyncGenerator, Dict, List
4
+
5
+ import numpy as np
6
+ from huri_events.emotion import Emotion
7
+ from huri_events.speech import Voice
8
+ from ray import serve
9
+ from ray.serve import handle
10
+
11
+ from huri_core.core.module import ModuleWithHandle
12
+
13
+ _MODEL_NAME = os.environ.get("HURI_EMO_MODEL", "superb/hubert-large-superb-er")
14
+
15
+
16
+ @serve.deployment(name="EMOHandle", max_ongoing_requests=8)
17
+ class EMOHandle:
18
+ """Prosody emotion model.
19
+
20
+ :model_name: name of the Emotion Analysis model.
21
+ :sample_rate: sample rate of the audio passed to predict().
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ model_name: str = _MODEL_NAME,
27
+ sample_rate: int = 16000,
28
+ ):
29
+ from transformers import (
30
+ AutoModelForAudioClassification,
31
+ Wav2Vec2FeatureExtractor,
32
+ )
33
+
34
+ self.model = AutoModelForAudioClassification.from_pretrained(model_name)
35
+ self.feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(model_name)
36
+ self.sample_rate = sample_rate
37
+
38
+ async def predict(self, audio: np.ndarray) -> Dict[str, Any]:
39
+ # Blocking torch inference runs off the replica's event loop.
40
+ return await asyncio.to_thread(self._predict_sync, audio)
41
+
42
+ def _predict_sync(self, audio_np: np.ndarray) -> Dict[str, Any]:
43
+ import torch
44
+
45
+ inputs = self.feature_extractor(
46
+ audio_np, sampling_rate=self.sample_rate, return_tensors="pt", padding=True
47
+ )
48
+
49
+ with torch.no_grad():
50
+ logits = self.model(**inputs).logits
51
+ probs = torch.softmax(logits, dim=-1)[0]
52
+
53
+ predicted_id = int(torch.argmax(probs).item())
54
+
55
+ labels = self.model.config.id2label
56
+
57
+ return {
58
+ "label": labels[predicted_id],
59
+ "confidence": float(probs[predicted_id]),
60
+ "scores": {labels[i]: float(probs[i]) for i in range(len(labels))},
61
+ }
62
+
63
+
64
+ class EMO(ModuleWithHandle):
65
+ """EMO Module
66
+
67
+ Prosody Analysis of user voice speech.
68
+
69
+ input: huri.voice,
70
+ output: huri.emotion
71
+
72
+ :sample_rate: size of received voice audio. Usually 8000, 16000 or 48000.
73
+ :block_duration: size of received voice audio (in s).
74
+ :analysis_window: duration of audio per analysis (in s).
75
+ """
76
+
77
+ _handle_cls = EMOHandle
78
+ input_type = "huri.voice"
79
+ output_type = "huri.emotion"
80
+ input_event = Voice
81
+ output_event = Emotion
82
+
83
+ def __init__(
84
+ self,
85
+ _handle: handle.DeploymentHandle,
86
+ sample_rate: int = 16000,
87
+ block_duration: float = 0.020, # s
88
+ analysis_window: float = 4.0, # s
89
+ **kwargs,
90
+ ):
91
+ super().__init__(_handle=_handle, **kwargs)
92
+
93
+ self.sample_rate = sample_rate
94
+ self.window_size = int(analysis_window / block_duration)
95
+
96
+ self.buffer: List[np.ndarray] = []
97
+
98
+ self.running = False
99
+ self.lock: asyncio.Lock = asyncio.Lock()
100
+
101
+ # Set when the end of the voice is received. Remembered if an analysis
102
+ # is in flight, so that call handles it once it finishes.
103
+ self._end_requested: bool = False
104
+
105
+ async def process(self, voice: Voice) -> AsyncGenerator[Emotion, None]:
106
+ async with self.lock:
107
+ if voice.data is None:
108
+ self._end_requested = True
109
+ else:
110
+ self.buffer.append(voice.data)
111
+ if self.running:
112
+ return
113
+ self.running = True
114
+
115
+ try:
116
+ async for emotion in self._analyze_pending():
117
+ yield emotion
118
+ finally:
119
+ self.running = False
120
+
121
+ async def _analyze_pending(self) -> AsyncGenerator[Emotion, None]:
122
+ """Run analyses until there is nothing left to do.
123
+
124
+ Loops so that an end-of-turn that arrives while a window is being
125
+ analyzed is handled right after it, in this same call. Each pass is
126
+ yielded as its own Emotion.
127
+ """
128
+
129
+ while True:
130
+ async with self.lock:
131
+ end = self._end_requested
132
+ if end:
133
+ self._end_requested = False
134
+ chunks = list(self.buffer)
135
+ else:
136
+ if len(self.buffer) < self.window_size:
137
+ return
138
+ chunks = self.buffer[: self.window_size]
139
+
140
+ # On end, analyze the remaining buffer; fall back to a short zero
141
+ # buffer when it is empty so the model still gets valid input.
142
+ audio = (
143
+ np.concatenate(chunks, axis=0)
144
+ if chunks
145
+ else np.zeros(self.sample_rate // 10, dtype=np.float32)
146
+ )
147
+ result = await self._handle.predict.remote(audio)
148
+
149
+ async with self.lock:
150
+ # Frames received during the await are kept for the next pass.
151
+ self.buffer = self.buffer[len(chunks) :]
152
+
153
+ yield Emotion(result["label"], result["confidence"], result["scores"], end)
154
+ if not self._end_requested:
155
+ return
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: huri-module-emo
3
+ Version: 0.1.0
4
+ Summary: HuRI module: Speech emotion from prosody (wav2vec2 classifier), and its aggregator
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: huri-core[server]<0.5,>=0.4
8
+ Requires-Dist: huri-events<0.2,>=0.1
9
+ Requires-Dist: numpy<2,>=1.26
10
+ Provides-Extra: model
11
+ Requires-Dist: torch; extra == "model"
12
+ Requires-Dist: transformers>=4.46; extra == "model"
13
+
14
+ # huri-module-emo
15
+
16
+ Speech emotion from prosody (wav2vec2 classifier), and its aggregator.
17
+
18
+ | Module | Topics | What it does |
19
+ |---|---|---|
20
+ | `emo` | `huri.voice` → `huri.emotion` | Estimates the emotion of speech from its prosody |
21
+ | `eag` | `huri.emotion` → `huri.partial_question` | Aggregates emotion estimates over an utterance |
22
+
23
+ ```sh
24
+ pip install -e 'packages/huri-module-emo[model]' # from a huri-modules checkout, next to huri-core and huri-events
25
+ HURI_MODULES=emo,eag ... # enable it on the server
26
+ ```
27
+
28
+ `emo` needs the `model` extra (torch and transformers, for its wav2vec2
29
+ classifier); `eag` only aggregates events and needs none of it, so a host that
30
+ runs `eag` alone installs the package without the extra
31
+ (`[tool.huri-modules.extras]` in `pyproject.toml` says which module needs which
32
+ extra; `huri hub install` and `scripts/install_local.sh` add it). Hardware pins
33
+ of `emo`: `requirements-<variant>.emo.txt` (`cpu`, `nvidia`, `amd`; install them
34
+ before the package).
35
+
36
+ A distribution of
37
+ [huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
38
+ `pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
39
+ `huri-events`, which are not on PyPI: without a checkout, install them from git
40
+ in the same pip command, so pip never looks them up on an index:
41
+
42
+ ```sh
43
+ ref=0.1.0 # a huri-modules release tag
44
+ core=0.4.0 # HURI_CORE_REF in that release's versions.env
45
+ pip install \
46
+ "huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
47
+ "huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
48
+ "huri-module-emo[model] @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-emo"
49
+ ```
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ huri_emo/__init__.py
4
+ huri_emo/aggregator.py
5
+ huri_emo/module.py
6
+ huri_emo/manifests/eag/huri_module.toml
7
+ huri_emo/manifests/emo/huri_module.toml
8
+ huri_module_emo.egg-info/PKG-INFO
9
+ huri_module_emo.egg-info/SOURCES.txt
10
+ huri_module_emo.egg-info/dependency_links.txt
11
+ huri_module_emo.egg-info/entry_points.txt
12
+ huri_module_emo.egg-info/requires.txt
13
+ huri_module_emo.egg-info/top_level.txt
14
+ tests/test_emo.py
@@ -0,0 +1,3 @@
1
+ [huri.modules]
2
+ eag = huri_emo.aggregator:EAG
3
+ emo = huri_emo.module:EMO
@@ -0,0 +1,7 @@
1
+ huri-core[server]<0.5,>=0.4
2
+ huri-events<0.2,>=0.1
3
+ numpy<2,>=1.26
4
+
5
+ [model]
6
+ torch
7
+ transformers>=4.46
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64,<82"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "huri-module-emo"
7
+ version = "0.1.0"
8
+ description = "HuRI module: Speech emotion from prosody (wav2vec2 classifier), and its aggregator"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "huri-core[server]>=0.4,<0.5",
13
+ "huri-events>=0.1,<0.2",
14
+ "numpy>=1.26,<2",
15
+ ]
16
+
17
+ # The classifier of `emo` (wav2vec2). `eag` only aggregates its events, so a
18
+ # host that plans eag alone installs neither torch nor transformers.
19
+ [project.optional-dependencies]
20
+ model = ["torch", "transformers>=4.46"]
21
+
22
+ [project.entry-points."huri.modules"]
23
+ emo = "huri_emo.module:EMO"
24
+ eag = "huri_emo.aggregator:EAG"
25
+
26
+ # The extra each module needs: read by huri-modules' tooling
27
+ # (scripts/module_requirements.py, check_registry.py against registry.toml's
28
+ # `extra`), not by huri-core.
29
+ [tool.huri-modules.extras]
30
+ emo = "model"
31
+
32
+ [tool.setuptools.packages.find]
33
+ include = ["huri_emo*"]
34
+
35
+ [tool.setuptools.package-data]
36
+ huri_emo = ["huri_module.toml", "manifests/*/huri_module.toml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,90 @@
1
+ import asyncio
2
+
3
+ import numpy as np
4
+ from huri_events.speech import Voice
5
+
6
+ from huri_core.testing import LocalHandle, ModuleHarness
7
+
8
+
9
+ class FakeHandle:
10
+ """Stands in for EMOHandle: a fixed emotion per call; can block the first."""
11
+
12
+ def __init__(self, gate=None):
13
+ self.gate = gate
14
+ self.calls = []
15
+
16
+ async def predict(self, audio):
17
+ self.calls.append(len(audio))
18
+ if self.gate is not None and len(self.calls) == 1:
19
+ await self.gate.wait()
20
+ return {"label": "neu", "confidence": 0.9, "scores": {"neu": 0.9, "ang": 0.1}}
21
+
22
+
23
+ def _emo(handle):
24
+ from huri_emo.module import EMO
25
+
26
+ # 20 ms blocks -> window of 10 frames (fast tests).
27
+ return ModuleHarness(EMO, handle=LocalHandle(handle), analysis_window=0.2)
28
+
29
+
30
+ async def _collect(emo, voice):
31
+ return await emo.feed(voice)
32
+
33
+
34
+ def _voice():
35
+ return Voice(np.zeros(4, dtype=np.float32))
36
+
37
+
38
+ def test_interim_emotion_once_window_is_full():
39
+ asyncio.run(_test_interim_emotion_once_window_is_full_impl())
40
+
41
+
42
+ async def _test_interim_emotion_once_window_is_full_impl():
43
+ handle = FakeHandle()
44
+ emo = _emo(handle)
45
+
46
+ results = []
47
+ for _ in range(10):
48
+ results += await _collect(emo, _voice())
49
+
50
+ assert [e.end for e in results] == [False]
51
+ assert handle.calls == [40]
52
+
53
+
54
+ def test_end_marker_always_yields_final_emotion():
55
+ asyncio.run(_test_end_marker_always_yields_final_emotion_impl())
56
+
57
+
58
+ async def _test_end_marker_always_yields_final_emotion_impl():
59
+ handle = FakeHandle()
60
+ emo = _emo(handle)
61
+
62
+ # Very short turn: no frames at all still produces Emotion(end=True).
63
+ results = await _collect(emo, Voice(None))
64
+
65
+ assert [e.end for e in results] == [True]
66
+ assert results[0].label == "neu"
67
+
68
+
69
+ def test_end_marker_during_inflight_analysis_is_not_dropped():
70
+ asyncio.run(_test_end_marker_during_inflight_analysis_is_not_dropped_impl())
71
+
72
+
73
+ async def _test_end_marker_during_inflight_analysis_is_not_dropped_impl():
74
+ gate = asyncio.Event()
75
+ handle = FakeHandle(gate)
76
+ emo = _emo(handle)
77
+
78
+ for _ in range(9):
79
+ await _collect(emo, _voice())
80
+
81
+ # 10th frame starts an analysis that blocks on the gate.
82
+ inflight = asyncio.create_task(_collect(emo, _voice()))
83
+ await asyncio.sleep(0)
84
+
85
+ # The single end marker lands mid-analysis: it must be remembered.
86
+ assert await _collect(emo, Voice(None)) == []
87
+ gate.set()
88
+
89
+ results = await inflight
90
+ assert [e.end for e in results] == [False, True]