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.
- huri_module_emo-0.1.0/PKG-INFO +49 -0
- huri_module_emo-0.1.0/README.md +36 -0
- huri_module_emo-0.1.0/huri_emo/__init__.py +0 -0
- huri_module_emo-0.1.0/huri_emo/aggregator.py +75 -0
- huri_module_emo-0.1.0/huri_emo/manifests/eag/huri_module.toml +7 -0
- huri_module_emo-0.1.0/huri_emo/manifests/emo/huri_module.toml +7 -0
- huri_module_emo-0.1.0/huri_emo/module.py +155 -0
- huri_module_emo-0.1.0/huri_module_emo.egg-info/PKG-INFO +49 -0
- huri_module_emo-0.1.0/huri_module_emo.egg-info/SOURCES.txt +14 -0
- huri_module_emo-0.1.0/huri_module_emo.egg-info/dependency_links.txt +1 -0
- huri_module_emo-0.1.0/huri_module_emo.egg-info/entry_points.txt +3 -0
- huri_module_emo-0.1.0/huri_module_emo.egg-info/requires.txt +7 -0
- huri_module_emo-0.1.0/huri_module_emo.egg-info/top_level.txt +1 -0
- huri_module_emo-0.1.0/pyproject.toml +36 -0
- huri_module_emo-0.1.0/setup.cfg +4 -0
- huri_module_emo-0.1.0/tests/test_emo.py +90 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
huri_emo
|
|
@@ -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,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]
|