huri-module-emo 0.1.0__py3-none-any.whl
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.
- huri_emo/__init__.py +0 -0
- huri_emo/aggregator.py +75 -0
- huri_emo/manifests/eag/huri_module.toml +7 -0
- huri_emo/manifests/emo/huri_module.toml +7 -0
- huri_emo/module.py +155 -0
- huri_module_emo-0.1.0.dist-info/METADATA +49 -0
- huri_module_emo-0.1.0.dist-info/RECORD +10 -0
- huri_module_emo-0.1.0.dist-info/WHEEL +5 -0
- huri_module_emo-0.1.0.dist-info/entry_points.txt +3 -0
- huri_module_emo-0.1.0.dist-info/top_level.txt +1 -0
huri_emo/__init__.py
ADDED
|
File without changes
|
huri_emo/aggregator.py
ADDED
|
@@ -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"
|
huri_emo/module.py
ADDED
|
@@ -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,10 @@
|
|
|
1
|
+
huri_emo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
huri_emo/aggregator.py,sha256=0migoA8J-4LIJ4sjOixWxs4E5ytdAK37it4ksJ7OrdA,2123
|
|
3
|
+
huri_emo/module.py,sha256=UEFkGKa8dJtFp5CPqVOWsp9Ey94Pgoni8AynuhXiOIc,5019
|
|
4
|
+
huri_emo/manifests/eag/huri_module.toml,sha256=LZidbO9x9wkjS5cfWuHLDYmLq-jH9euOjDR_f9jEsVs,345
|
|
5
|
+
huri_emo/manifests/emo/huri_module.toml,sha256=Grrk-z2gDZl62hWm2msRgI8epQovt6nyjv0DUJbQzxY,324
|
|
6
|
+
huri_module_emo-0.1.0.dist-info/METADATA,sha256=uqlSCLm2epqWal8JQ8ZokGOpzfu42t1fyuw_7GEw2cw,2193
|
|
7
|
+
huri_module_emo-0.1.0.dist-info/WHEEL,sha256=YLJXdYXQ2FQ0Uqn2J-6iEIC-3iOey8lH3xCtvFLkd8Q,91
|
|
8
|
+
huri_module_emo-0.1.0.dist-info/entry_points.txt,sha256=Chzfw4drNh5-OmZiPonr7IRDD6WZeiIva9lhLvxscGU,71
|
|
9
|
+
huri_module_emo-0.1.0.dist-info/top_level.txt,sha256=fZoBI9AbPjeCF5DOmu8R5hI-11cwU6aHDi9Dm6lXSvk,9
|
|
10
|
+
huri_module_emo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
huri_emo
|