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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (81.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [huri.modules]
2
+ eag = huri_emo.aggregator:EAG
3
+ emo = huri_emo.module:EMO
@@ -0,0 +1 @@
1
+ huri_emo