pearlyjam21 commited on
Commit
d626f8b
·
verified ·
1 Parent(s): d39d2a1

Reachy Mini multimodal emotion app

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.egg-info/
3
+ build/
README.md CHANGED
@@ -1,10 +1,42 @@
1
- ---
2
- title: Reachy Mini Multimodal Emotion
3
- emoji: 📉
4
- colorFrom: pink
5
- colorTo: green
6
- sdk: static
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Reachy Mini Multimodal Emotion
3
+ emoji: 🙂
4
+ colorFrom: indigo
5
+ colorTo: pink
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Face + voice + Chinese words emotion, empathetic reactions
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mini_python_app
12
+ models:
13
+ - pearlyjam21/multimodal-emotion-zh
14
+ - csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17
15
+ ---
16
+
17
+ # Reachy Mini Multimodal Emotion
18
+
19
+ This app lets Reachy Mini sense emotion from three sources and react with an empathetic move from
20
+ `pollen-robotics/reachy-mini-emotions-library`:
21
+
22
+ - **Face**: the camera runs at about 5 fps; YuNet finds the face and a FER+ MobileNetV3 classifies it.
23
+ - **Voice**: the microphone feeds a 2 s window every 0.2 s to a compact ECAPA-TDNN student.
24
+ - **Words**: silero VAD splits speech into utterances, SenseVoice-Small transcribes them, and a Traditional
25
+ Chinese BERT classifies the text.
26
+
27
+ The three results are combined by calibrated late fusion (weighted log-linear pooling). A modality with no
28
+ evidence is left out rather than counted as neutral: no face, silence, English speech (the text model is
29
+ Chinese-only), or a transcript that is too old. Everything runs on the Reachy Mini Wireless CPU with ONNX
30
+ Runtime, and no PyTorch is needed.
31
+
32
+ Models download from [`pearlyjam21/multimodal-emotion-zh`](https://huggingface.co/pearlyjam21/multimodal-emotion-zh)
33
+ on first start (about 360 MB). Its model card lists the metrics, licenses and known limitations.
34
+
35
+ **Dashboard:** `http://reachy-mini.local:8042` shows fused and per-modality probabilities, the transcript,
36
+ fusion-weight sliders, modality on/off switches and test reactions.
37
+
38
+ **Simulation:** set `MM_EMOTION_WAV=/path/to/16k.wav` to loop a file instead of the microphone. Set
39
+ `MM_EMOTION_LOCAL_DIR` to load the models from a local folder instead of the Hub.
40
+
41
+ The output is a soft social cue, not a measurement. On natural conversation the individual models are close
42
+ to chance; see the model card.
index.html CHANGED
@@ -1,19 +1,40 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width" />
7
+ <title> Reachy Mini Multimodal Emotion </title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+
11
+ <body>
12
+ <div class="hero">
13
+ <div class="hero-content">
14
+ <div class="app-icon">🙂🎙️💬</div>
15
+ <h1> Reachy Mini Multimodal Emotion </h1>
16
+ <p class="tagline">Reachy Mini reads your face, hears your voice and understands your (Chinese) words, then responds with empathy.</p>
17
+ </div>
18
+ </div>
19
+
20
+ <div class="container">
21
+ <div class="main-card">
22
+ <div class="app-preview">
23
+ <div class="preview-image">
24
+ <div class="camera-feed">😊 😢 😠 😲 😨 🤢</div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="footer">
31
+ <p>
32
+ 🤖 Reachy Mini Multimodal Emotion •
33
+ <a href="https://github.com/pollen-robotics" target="_blank">Pollen Robotics</a> •
34
+ <a href="https://huggingface.co/spaces/pollen-robotics/reachy-mini-landing-page#apps" target="_blank">Browse More
35
+ Apps</a>
36
+ </p>
37
+ </div>
38
+ </body>
39
+
40
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+
6
+ [project]
7
+ name = "reachy_mini_multimodal_emotion"
8
+ version = "0.1.0"
9
+ description = "Reachy Mini senses emotion from face, voice and (Chinese) words, and responds empathetically"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "reachy-mini",
14
+ "numpy",
15
+ "onnxruntime>=1.17",
16
+ "pillow",
17
+ "tokenizers>=0.19",
18
+ "sherpa-onnx>=1.12",
19
+ "chinese-converter>=1.0",
20
+ "huggingface_hub",
21
+ ]
22
+ keywords = ["reachy-mini-app"]
23
+
24
+ [project.entry-points."reachy_mini_apps"]
25
+ reachy_mini_multimodal_emotion = "reachy_mini_multimodal_emotion.main:ReachyMiniMultimodalEmotion"
26
+
27
+ [tool.setuptools]
28
+ package-dir = { "" = "." }
29
+ include-package-data = true
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["."]
33
+ include = ["reachy_mini_multimodal_emotion*"] # keep build/ and tests/ out of the package
34
+
35
+ [tool.setuptools.package-data]
36
+ reachy_mini_multimodal_emotion = ["**/*"] # Also include all non-.py files
reachy_mini_multimodal_emotion/__init__.py ADDED
File without changes
reachy_mini_multimodal_emotion/engine/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multimodal (face + voice + Chinese text) emotion engine. No torch, no Reachy dependency.
2
+
3
+ from reachy_mini_multimodal_emotion.engine import load_config, download_models
4
+ paths = download_models() # model repo + SenseVoice, cached by huggingface_hub
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from .fusion import BranchResult, FusedResult, fuse
14
+ from .labels import EMOTIONS
15
+
16
+ MODEL_REPO = os.environ.get("MM_EMOTION_MODEL_REPO", "pearlyjam21/multimodal-emotion-zh")
17
+ ASR_REPO = "csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"
18
+
19
+ __all__ = ["EMOTIONS", "BranchResult", "FusedResult", "fuse", "download_models", "load_config"]
20
+
21
+
22
+ def download_models(repo_id: str = MODEL_REPO, with_asr: bool = True, local_dir: str | None = None) -> dict:
23
+ """Fetch (or reuse cached) weights. Returns {"root": Path, "asr": Path | None}.
24
+
25
+ Set MM_EMOTION_LOCAL_DIR to a folder with the model repo's layout to run fully offline.
26
+ """
27
+ from huggingface_hub import snapshot_download
28
+
29
+ local = local_dir or os.environ.get("MM_EMOTION_LOCAL_DIR")
30
+ root = Path(local) if local else Path(snapshot_download(repo_id, allow_patterns=["config.json", "face/*",
31
+ "speech/*", "text/*", "asr/*"]))
32
+ asr = None
33
+ if with_asr:
34
+ asr = Path(snapshot_download(ASR_REPO, allow_patterns=["model.int8.onnx", "tokens.txt"]))
35
+ return {"root": root, "asr": asr}
36
+
37
+
38
+ def load_config(root: str | Path) -> dict:
39
+ return json.loads((Path(root) / "config.json").read_text(encoding="utf-8"))
reachy_mini_multimodal_emotion/engine/asr.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utterance segmentation (silero VAD) and transcription (SenseVoice-Small int8) via sherpa-onnx.
2
+
3
+ SenseVoice also emits its own emotion tag; it is shown on the dashboard for comparison only and
4
+ is not part of the fusion (it has not been evaluated in this project).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import queue
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Callable
14
+
15
+ import numpy as np
16
+
17
+ SAMPLE_RATE = 16000
18
+ VAD_WINDOW = 512 # silero VAD frame size at 16 kHz
19
+
20
+
21
+ @dataclass
22
+ class Utterance:
23
+ text: str
24
+ lang: str | None # "zh", "en", "yue", ... (SenseVoice language tag)
25
+ asr_emotion: str | None # SenseVoice's own tag, informational
26
+ seconds: float
27
+
28
+
29
+ def _tag(value: str) -> str | None:
30
+ value = value.strip().strip("<|>").lower()
31
+ return value or None
32
+
33
+
34
+ class Transcriber:
35
+ """Feed mono 16 kHz audio with `push`; finished utterances go to `on_utterance` from a worker."""
36
+
37
+ def __init__(self, sensevoice_model: str | Path, tokens: str | Path, vad_model: str | Path,
38
+ on_utterance: Callable[[Utterance], None], threads: int = 2) -> None:
39
+ import sherpa_onnx
40
+
41
+ self.recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
42
+ model=str(sensevoice_model), tokens=str(tokens), num_threads=threads, use_itn=True)
43
+ cfg = sherpa_onnx.VadModelConfig()
44
+ cfg.silero_vad.model = str(vad_model)
45
+ cfg.silero_vad.threshold = 0.5
46
+ cfg.silero_vad.min_silence_duration = 0.5
47
+ cfg.silero_vad.min_speech_duration = 0.4
48
+ cfg.silero_vad.max_speech_duration = 10.0
49
+ cfg.silero_vad.window_size = VAD_WINDOW
50
+ cfg.sample_rate = SAMPLE_RATE
51
+ cfg.num_threads = 1
52
+ self.vad = sherpa_onnx.VoiceActivityDetector(cfg, buffer_size_in_seconds=30)
53
+ self.on_utterance = on_utterance
54
+ self._pending = np.zeros(0, dtype=np.float32)
55
+ self._segments: "queue.Queue[np.ndarray]" = queue.Queue(maxsize=2)
56
+ self._stop = threading.Event()
57
+ self._worker = threading.Thread(target=self._run, daemon=True)
58
+ self._worker.start()
59
+
60
+ @property
61
+ def speaking(self) -> bool:
62
+ return bool(self.vad.is_speech_detected())
63
+
64
+ def push(self, chunk: np.ndarray) -> None:
65
+ self._pending = np.concatenate([self._pending, np.asarray(chunk, dtype=np.float32).reshape(-1)])
66
+ while len(self._pending) >= VAD_WINDOW:
67
+ self.vad.accept_waveform(self._pending[:VAD_WINDOW])
68
+ self._pending = self._pending[VAD_WINDOW:]
69
+ while not self.vad.empty():
70
+ segment = np.array(self.vad.front.samples, dtype=np.float32)
71
+ self.vad.pop()
72
+ try:
73
+ self._segments.put_nowait(segment)
74
+ except queue.Full: # ASR is behind; drop rather than build latency
75
+ pass
76
+
77
+ def reset(self) -> None:
78
+ """Forget partial speech (e.g. audio recorded while the robot moved)."""
79
+ self.vad.reset()
80
+ self._pending = np.zeros(0, dtype=np.float32)
81
+
82
+ def transcribe(self, samples: np.ndarray) -> Utterance:
83
+ stream = self.recognizer.create_stream()
84
+ stream.accept_waveform(SAMPLE_RATE, samples)
85
+ self.recognizer.decode_stream(stream)
86
+ r = stream.result
87
+ return Utterance(r.text, _tag(r.lang), _tag(r.emotion), len(samples) / SAMPLE_RATE)
88
+
89
+ def _run(self) -> None:
90
+ while not self._stop.is_set():
91
+ try:
92
+ samples = self._segments.get(timeout=0.2)
93
+ except queue.Empty:
94
+ continue
95
+ self.on_utterance(self.transcribe(samples))
96
+
97
+ def close(self) -> None:
98
+ self._stop.set()
99
+ self._worker.join(timeout=2.0)
reachy_mini_multimodal_emotion/engine/face.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Facial-expression branch: YuNet face detection + newEmotion FER+ MobileNetV3 (fp32 ONNX).
2
+
3
+ Preprocessing reproduces newEmotion's `ferplus-gray-rgb-imagenet-v1` contract without torch:
4
+ BGR crop -> cv2 grayscale -> replicated to RGB -> PIL bilinear resize to 112x112 (stretch)
5
+ -> /255 -> ImageNet mean/std. tests/test_face.py checks it against torchvision.
6
+ The int8 export is not used: its saved FER+ macro-F1 is 0.337 versus 0.754 for fp32.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+ from .fusion import OK, BranchResult
17
+ from .labels import softmax, to_canonical
18
+
19
+ IMAGE_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
20
+ IMAGE_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
21
+ DETECT_WIDTH = 320 # detect on a downscaled frame; crop from the full-resolution one
22
+ EMA_ALPHA = 0.5
23
+ STALE_S = 1.0 # a face result older than this is treated as absent
24
+
25
+
26
+ def crop_face(frame_bgr: np.ndarray, box, padding: float) -> np.ndarray:
27
+ """Same semantics as newEmotion emotion_model.webcam.crop_face (symmetric padding, clamped)."""
28
+ height, width = frame_bgr.shape[:2]
29
+ x, y, bw, bh = (int(v) for v in box)
30
+ pad_x, pad_y = int(bw * padding), int(bh * padding)
31
+ left, top = max(0, x - pad_x), max(0, y - pad_y)
32
+ right, bottom = min(width, x + bw + pad_x), min(height, y + bh + pad_y)
33
+ if right <= left or bottom <= top:
34
+ raise ValueError("face box falls outside the frame")
35
+ return frame_bgr[top:bottom, left:right]
36
+
37
+
38
+ def preprocess_face(face_bgr: np.ndarray, size: int = 112) -> np.ndarray:
39
+ """BGR face crop -> [1, 3, size, size] float32, matching the training transform."""
40
+ import cv2
41
+ from PIL import Image
42
+
43
+ gray = cv2.cvtColor(face_bgr, cv2.COLOR_BGR2GRAY)
44
+ rgb = Image.fromarray(np.repeat(gray[..., None], 3, axis=2), mode="RGB")
45
+ x = np.asarray(rgb.resize((size, size), Image.Resampling.BILINEAR), dtype=np.float32) / 255.0
46
+ x = (x - IMAGE_MEAN) / IMAGE_STD
47
+ return np.ascontiguousarray(x.transpose(2, 0, 1)[None])
48
+
49
+
50
+ class FaceBranch:
51
+ def __init__(self, fer_model: str | Path, yunet_model: str | Path, temperature: float = 1.0,
52
+ padding: float = 0.10, score_threshold: float = 0.7, min_face_px: int = 48,
53
+ threads: int = 1) -> None:
54
+ import cv2
55
+ import onnxruntime as ort
56
+
57
+ opts = ort.SessionOptions()
58
+ opts.intra_op_num_threads = threads
59
+ self.session = ort.InferenceSession(str(fer_model), opts, providers=["CPUExecutionProvider"])
60
+ self.input_name = self.session.get_inputs()[0].name
61
+ self.detector = cv2.FaceDetectorYN.create(str(yunet_model), "", (DETECT_WIDTH, 240),
62
+ score_threshold, 0.3, 50)
63
+ self.temperature, self.padding, self.min_face_px = temperature, padding, min_face_px
64
+ self._probs: np.ndarray | None = None
65
+ self._last_seen = float("-inf")
66
+ self.latency_ms = 0.0
67
+ self.box: list[int] | None = None
68
+
69
+ def reset(self) -> None:
70
+ self._probs, self._last_seen, self.box = None, float("-inf"), None
71
+
72
+ def detect(self, frame_bgr: np.ndarray) -> tuple[np.ndarray, float] | None:
73
+ """Largest face as (x, y, w, h) in full-frame pixels plus its detector score."""
74
+ import cv2
75
+
76
+ h, w = frame_bgr.shape[:2]
77
+ scale = DETECT_WIDTH / w
78
+ small = cv2.resize(frame_bgr, (DETECT_WIDTH, int(round(h * scale))), interpolation=cv2.INTER_AREA)
79
+ self.detector.setInputSize((small.shape[1], small.shape[0]))
80
+ _, faces = self.detector.detect(small)
81
+ if faces is None or not len(faces):
82
+ return None
83
+ best = max(faces, key=lambda f: f[2] * f[3])
84
+ return best[:4] / scale, float(best[-1])
85
+
86
+ def logits(self, face_bgr: np.ndarray) -> np.ndarray:
87
+ return self.session.run(None, {self.input_name: preprocess_face(face_bgr)})[0][0]
88
+
89
+ def step(self, frame_bgr: np.ndarray | None, now: float) -> BranchResult:
90
+ if frame_bgr is None:
91
+ return self.current(now, "no_camera")
92
+ t0 = time.perf_counter()
93
+ found = self.detect(frame_bgr)
94
+ if found is None or min(found[0][2:]) < self.min_face_px:
95
+ self.box = None
96
+ return self.current(now, "no_face")
97
+ box, score = found
98
+ probs = softmax(to_canonical("face", self.logits(crop_face(frame_bgr, box, self.padding))),
99
+ self.temperature)
100
+ self.latency_ms = (time.perf_counter() - t0) * 1000.0
101
+ self.box = [int(v) for v in box]
102
+ self._probs = probs if self._probs is None else EMA_ALPHA * probs + (1 - EMA_ALPHA) * self._probs
103
+ self._last_seen = now
104
+ return BranchResult(self._probs.copy(), OK, {"score": score, "box": self.box,
105
+ "latency_ms": self.latency_ms})
106
+
107
+ def current(self, now: float, missing_status: str = "no_face") -> BranchResult:
108
+ """Latest smoothed result while fresh; otherwise report the face as absent."""
109
+ if self._probs is not None and now - self._last_seen <= STALE_S:
110
+ return BranchResult(self._probs.copy(), OK, {"box": self.box, "latency_ms": self.latency_ms})
111
+ self._probs = None
112
+ return BranchResult(status=missing_status)
reachy_mini_multimodal_emotion/engine/fusion.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calibrated late fusion: weighted log-linear pooling over the branches that are present.
2
+
3
+ A branch that has no evidence (no face, silence, no transcript, unsupported language, stale
4
+ result) is left out and the remaining weights are renormalised. Absence is never encoded as an
5
+ observed "neutral".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ import numpy as np
13
+
14
+ from .labels import EMOTIONS
15
+
16
+ OK = "ok"
17
+
18
+
19
+ @dataclass
20
+ class BranchResult:
21
+ """Calibrated probabilities in EMOTIONS order, or None with a status explaining why."""
22
+
23
+ probs: np.ndarray | None = None
24
+ status: str = "waiting"
25
+ detail: dict = field(default_factory=dict)
26
+
27
+ @property
28
+ def present(self) -> bool:
29
+ return self.status == OK and self.probs is not None
30
+
31
+
32
+ @dataclass
33
+ class FusedResult:
34
+ probs: np.ndarray | None
35
+ used: dict[str, float] # branch -> normalised weight actually applied
36
+
37
+ @property
38
+ def top(self) -> str | None:
39
+ return None if self.probs is None else EMOTIONS[int(self.probs.argmax())]
40
+
41
+ @property
42
+ def confidence(self) -> float:
43
+ return 0.0 if self.probs is None else float(self.probs.max())
44
+
45
+
46
+ def fuse(results: dict[str, BranchResult], weights: dict[str, float], eps: float = 1e-6) -> FusedResult:
47
+ present = {b: r.probs for b, r in results.items()
48
+ if r.present and r.probs is not None and weights.get(b, 0.0) > 0.0}
49
+ if not present:
50
+ return FusedResult(None, {})
51
+ total = sum(weights[b] for b in present)
52
+ used = {b: weights[b] / total for b in present}
53
+ log_p = np.zeros(len(EMOTIONS))
54
+ for b, w in used.items():
55
+ log_p += w * np.log(np.clip(present[b], eps, 1.0))
56
+ p = np.exp(log_p - log_p.max())
57
+ return FusedResult(p / p.sum(), used)
reachy_mini_multimodal_emotion/engine/labels.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One label order for fusion, and the index maps from each branch's own training order.
2
+
3
+ Each branch keeps the taxonomy it was trained with; logits are reordered into EMOTIONS before
4
+ calibration and fusion. EMOTIONS[i] == BRANCH_LABELS[b][TO_CANONICAL[b][i]].
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ EMOTIONS = ["neutral", "happy", "sad", "angry", "surprise", "fear", "disgust"]
12
+
13
+ BRANCH_LABELS = {
14
+ # SER ECAPA student (speech/v3/distill_config_v3.yaml)
15
+ "speech": ["neutral", "happy", "sad", "angry", "surprise", "fear", "disgust"],
16
+ # newEmotion FER+ MobileNetV3 (model_fp32_metadata.json)
17
+ "face": ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"],
18
+ # Traditional Chinese BERT (config.json id2label)
19
+ "text": ["happy", "sad", "angry", "fear", "disgust", "neutral", "surprise"],
20
+ }
21
+
22
+ TO_CANONICAL = {b: np.array([labels.index(e) for e in EMOTIONS]) for b, labels in BRANCH_LABELS.items()}
23
+
24
+
25
+ def to_canonical(branch: str, logits: np.ndarray) -> np.ndarray:
26
+ """Reorder a branch's logits (last axis) into EMOTIONS order."""
27
+ return np.asarray(logits, dtype=np.float64)[..., TO_CANONICAL[branch]]
28
+
29
+
30
+ def softmax(x: np.ndarray, temperature: float = 1.0) -> np.ndarray:
31
+ z = np.asarray(x, dtype=np.float64) / temperature
32
+ z = z - z.max(axis=-1, keepdims=True)
33
+ e = np.exp(z)
34
+ return e / e.sum(axis=-1, keepdims=True)
reachy_mini_multimodal_emotion/engine/pipeline.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wires the branches together from the model repo's config.json.
2
+
3
+ Usage (robot or PC):
4
+ pipe = MultimodalPipeline.from_pretrained()
5
+ pipe.push_audio(mono_16k_chunk) # as audio arrives
6
+ pipe.push_frame(bgr_frame) # a few times per second
7
+ snap = pipe.step() # every ~0.2 s -> fused + per-branch results
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import time
14
+ from collections import deque
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ from . import download_models, load_config
21
+ from .asr import Transcriber, Utterance
22
+ from .face import FaceBranch
23
+ from .fusion import BranchResult, FusedResult, fuse
24
+ from .speech import SpeechBranch
25
+ from .text import TextBranch
26
+
27
+
28
+ @dataclass
29
+ class Snapshot:
30
+ fused: FusedResult
31
+ branches: dict[str, BranchResult]
32
+ vad: np.ndarray # speech valence/arousal/dominance (0-1)
33
+ transcript: str = ""
34
+ asr_emotion: str | None = None
35
+ asr_lang: str | None = None
36
+ utterances: list = field(default_factory=list)
37
+
38
+
39
+ class MultimodalPipeline:
40
+ def __init__(self, root: str | Path, asr_dir: str | Path | None, use_face: bool = True,
41
+ use_text: bool = True) -> None:
42
+ root = Path(root)
43
+ cfg = load_config(root)
44
+ files, fusion = cfg["files"], cfg["fusion"]
45
+ self.temperatures, self.weights = dict(fusion["temperatures"]), dict(fusion["weights"])
46
+ self.speech = SpeechBranch(root / files["speech"], self.temperatures["speech"])
47
+ self.face = FaceBranch(root / files["face"], root / files["face_detector"], self.temperatures["face"],
48
+ padding=cfg["face"]["padding"]) if use_face else None
49
+ self.text = None
50
+ self.transcriber = None
51
+ if use_text and asr_dir is not None:
52
+ self.text = TextBranch(root / files["text"], root / files["text_tokenizer"], self.temperatures["text"])
53
+ asr_dir = Path(asr_dir)
54
+ self.transcriber = Transcriber(asr_dir / "model.int8.onnx", asr_dir / "tokens.txt",
55
+ root / files["vad"], self._on_utterance)
56
+ self.lock = threading.Lock()
57
+ self.utterances: deque = deque(maxlen=8)
58
+ self._last_utt: Utterance | None = None
59
+ self._face_result = BranchResult(status="no_camera" if use_face else "disabled")
60
+ self.enabled = {"speech": True, "face": use_face, "text": self.text is not None}
61
+
62
+ @classmethod
63
+ def from_pretrained(cls, repo_id: str | None = None, use_face: bool = True, use_text: bool = True):
64
+ paths = download_models(repo_id, with_asr=use_text) if repo_id else download_models(with_asr=use_text)
65
+ return cls(paths["root"], paths["asr"], use_face, use_text)
66
+
67
+ # ------------------------------------------------------------------ inputs
68
+ def push_audio(self, chunk: np.ndarray) -> None:
69
+ self.speech.ring.push(chunk)
70
+ if self.transcriber is not None:
71
+ self.transcriber.push(chunk)
72
+
73
+ def push_frame(self, frame_bgr: np.ndarray | None, now: float | None = None) -> BranchResult:
74
+ if self.face is None:
75
+ return self._face_result
76
+ result = self.face.step(frame_bgr, time.monotonic() if now is None else now)
77
+ with self.lock:
78
+ self._face_result = result
79
+ return result
80
+
81
+ def _on_utterance(self, utt: Utterance) -> None:
82
+ assert self.text is not None
83
+ result = self.text.classify(utt.text, utt.lang, time.monotonic())
84
+ with self.lock:
85
+ self._last_utt = utt
86
+ self.utterances.appendleft({
87
+ "time": time.strftime("%H:%M:%S"), "text": self.text.transcript, "lang": utt.lang,
88
+ "asr_emotion": utt.asr_emotion, "text_status": result.status,
89
+ "text_top": None if result.probs is None else int(result.probs.argmax()),
90
+ })
91
+
92
+ def reset_audio(self) -> None:
93
+ """Forget audio context (call after the robot moved or made sound)."""
94
+ self.speech.reset()
95
+ if self.transcriber is not None:
96
+ self.transcriber.reset()
97
+
98
+ # ------------------------------------------------------------------ output
99
+ def step(self, now: float | None = None) -> Snapshot:
100
+ now = time.monotonic() if now is None else now
101
+ branches = {"speech": self.speech.step() if self.enabled["speech"] else BranchResult(status="disabled")}
102
+ with self.lock:
103
+ face = self._face_result
104
+ utt = self._last_utt
105
+ utterances = list(self.utterances)
106
+ if self.face is not None and self.enabled["face"]:
107
+ face = self.face.current(now) if face.present else face
108
+ branches["face"] = face if self.enabled["face"] else BranchResult(status="disabled")
109
+ branches["text"] = (self.text.current(now) if self.text is not None and self.enabled["text"]
110
+ else BranchResult(status="disabled"))
111
+ return Snapshot(
112
+ fused=fuse(branches, self.weights), branches=branches, vad=self.speech.vad.copy(),
113
+ transcript=self.text.transcript if self.text is not None else "",
114
+ asr_emotion=utt.asr_emotion if utt else None, asr_lang=utt.lang if utt else None,
115
+ utterances=utterances,
116
+ )
117
+
118
+ def close(self) -> None:
119
+ if self.transcriber is not None:
120
+ self.transcriber.close()
reachy_mini_multimodal_emotion/engine/speech.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Torch-free acoustic emotion branch (compact ECAPA-TDNN student, SER v3).
2
+
3
+ The log-mel front end is the NumPy port verified against torchaudio in SER_Reachy_merli_v2
4
+ (MelSpectrogram n_fft=512, win=400 Hann, hop=160, 80 HTK mel bands 20-8000 Hz, power 2,
5
+ log clamp 1e-5, per-utterance mean/std normalisation). tests/test_speech.py re-checks it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+ from .fusion import OK, BranchResult
17
+ from .labels import softmax, to_canonical
18
+
19
+ SAMPLE_RATE = 16000
20
+ WINDOW_SEC = 2.0
21
+ RMS_THRESHOLD = 0.005
22
+ EMA_ALPHA = 0.35
23
+
24
+
25
+ def _hz_to_mel(f):
26
+ return 2595.0 * np.log10(1.0 + f / 700.0)
27
+
28
+
29
+ def _mel_to_hz(m):
30
+ return 700.0 * (10.0 ** (m / 2595.0) - 1.0)
31
+
32
+
33
+ class LogMel:
34
+ """NumPy port of utils.audio.LogMelExtractor from the SER training repo."""
35
+
36
+ def __init__(self, sample_rate=SAMPLE_RATE, n_fft=512, win_length=400, hop_length=160,
37
+ n_mels=80, f_min=20.0, f_max=8000.0) -> None:
38
+ self.n_fft, self.hop = n_fft, hop_length
39
+ # Periodic Hann window, zero-padded to n_fft and centred (as torch.stft does)
40
+ win = 0.5 - 0.5 * np.cos(2 * np.pi * np.arange(win_length) / win_length)
41
+ left = (n_fft - win_length) // 2
42
+ self.window = np.zeros(n_fft)
43
+ self.window[left : left + win_length] = win
44
+ # HTK mel filterbank without normalisation (torchaudio.functional.melscale_fbanks)
45
+ all_freqs = np.linspace(0, sample_rate // 2, n_fft // 2 + 1)
46
+ f_pts = _mel_to_hz(np.linspace(_hz_to_mel(f_min), _hz_to_mel(f_max), n_mels + 2))
47
+ f_diff = f_pts[1:] - f_pts[:-1]
48
+ slopes = f_pts[None, :] - all_freqs[:, None]
49
+ down = -slopes[:, :-2] / f_diff[:-1]
50
+ up = slopes[:, 2:] / f_diff[1:]
51
+ self.fbank = np.maximum(0.0, np.minimum(down, up)) # [n_freqs, n_mels]
52
+
53
+ def __call__(self, wave: np.ndarray) -> np.ndarray:
54
+ """wave: [T] float -> normalised log-mel [80, frames] float32."""
55
+ x = np.pad(np.asarray(wave, dtype=np.float64), self.n_fft // 2, mode="reflect")
56
+ n_frames = 1 + (len(x) - self.n_fft) // self.hop
57
+ idx = np.arange(self.n_fft)[None, :] + self.hop * np.arange(n_frames)[:, None]
58
+ spec = np.abs(np.fft.rfft(x[idx] * self.window, axis=1)) ** 2 # [frames, n_freqs]
59
+ log_mel = np.log(np.maximum(spec @ self.fbank, 1e-5)).T # [n_mels, frames]
60
+ mean = log_mel.mean(axis=1, keepdims=True)
61
+ std = log_mel.std(axis=1, ddof=1, keepdims=True) + 1e-5
62
+ return ((log_mel - mean) / std).astype(np.float32)
63
+
64
+
65
+ class RingBuffer:
66
+ """Thread-safe buffer holding the latest window of mono samples."""
67
+
68
+ def __init__(self, n: int = int(WINDOW_SEC * SAMPLE_RATE)) -> None:
69
+ self._buf = np.zeros(n, dtype=np.float32)
70
+ self._filled = 0
71
+ self._lock = threading.Lock()
72
+
73
+ def push(self, x: np.ndarray) -> None:
74
+ x = np.asarray(x, dtype=np.float32).reshape(-1)[-len(self._buf):]
75
+ with self._lock:
76
+ self._buf = np.roll(self._buf, -len(x))
77
+ self._buf[-len(x):] = x
78
+ self._filled = min(len(self._buf), self._filled + len(x))
79
+
80
+ def clear(self) -> None:
81
+ with self._lock:
82
+ self._buf[:] = 0.0
83
+ self._filled = 0
84
+
85
+ def snapshot(self) -> tuple[np.ndarray, bool]:
86
+ """Returns (window, full) where full means a whole window of fresh audio is present."""
87
+ with self._lock:
88
+ return self._buf.copy(), self._filled >= len(self._buf)
89
+
90
+
91
+ class SpeechBranch:
92
+ """Energy gate + log-mel + ONNX + EMA smoothing over calibrated probabilities.
93
+
94
+ Also exposes the student's valence/arousal/dominance head (0-1 each) for continuous cues.
95
+ """
96
+
97
+ def __init__(self, model_path: str | Path, temperature: float = 1.0, threads: int = 2) -> None:
98
+ import onnxruntime as ort
99
+
100
+ opts = ort.SessionOptions()
101
+ opts.intra_op_num_threads = threads # leave CPU for the robot daemon on the CM4
102
+ self.session = ort.InferenceSession(str(model_path), opts, providers=["CPUExecutionProvider"])
103
+ self.temperature = temperature
104
+ self.ring = RingBuffer()
105
+ self.mel = LogMel()
106
+ self.lock = threading.Lock()
107
+ self._probs: np.ndarray | None = None
108
+ self.vad = np.full(3, 0.5)
109
+ self.rms = 0.0
110
+ self.latency_ms = 0.0
111
+
112
+ def reset(self) -> None:
113
+ """Drop buffered audio and smoothing history (after the robot moved or spoke)."""
114
+ self.ring.clear()
115
+ with self.lock:
116
+ self._probs = None
117
+ self.vad = np.full(3, 0.5)
118
+
119
+ def logits(self, wave: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
120
+ """Raw branch-order emotion logits [7] and VAD [3] for one mono 16 kHz window."""
121
+ logits, vad = self.session.run(None, {"log_mel": self.mel(wave)[None]})
122
+ return logits[0], vad[0]
123
+
124
+ def step(self) -> BranchResult:
125
+ wave, full = self.ring.snapshot()
126
+ rms = float(np.sqrt(np.mean(wave**2)))
127
+ self.rms = rms
128
+ if not full:
129
+ return BranchResult(status="buffering")
130
+ if rms < RMS_THRESHOLD: # silence is missing evidence, not "neutral"
131
+ with self.lock:
132
+ self._probs = None
133
+ return BranchResult(status="silence", detail={"rms": rms})
134
+ t0 = time.perf_counter()
135
+ logits, vad = self.logits(wave)
136
+ self.latency_ms = (time.perf_counter() - t0) * 1000.0
137
+ probs = softmax(to_canonical("speech", logits), self.temperature)
138
+ with self.lock:
139
+ if self._probs is None:
140
+ self._probs, self.vad = probs, vad
141
+ else:
142
+ self._probs = EMA_ALPHA * probs + (1 - EMA_ALPHA) * self._probs
143
+ self.vad = EMA_ALPHA * vad + (1 - EMA_ALPHA) * self.vad
144
+ return BranchResult(self._probs.copy(), OK, {"rms": rms, "latency_ms": self.latency_ms})
reachy_mini_multimodal_emotion/engine/text.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Traditional Chinese text-emotion branch (bert-base-chinese fine-tune, dynamic int8 ONNX).
2
+
3
+ Uses the `tokenizers` fast tokenizer (no transformers/torch). ASR output in Simplified Chinese is
4
+ converted to Traditional with chinese-converter, the converter used for the training fear data.
5
+ The model is Chinese-only: English or other transcripts are reported as unsupported, not scored.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ from .fusion import OK, BranchResult
18
+ from .labels import softmax, to_canonical
19
+
20
+ MAX_LENGTH = 128
21
+ TTL_S = 8.0 # how long an utterance's text emotion stays in the fusion
22
+ CHINESE_LANGS = {"zh", "yue"}
23
+ _HAN = re.compile(r"[㐀-鿿]")
24
+
25
+
26
+ class TextBranch:
27
+ def __init__(self, model_path: str | Path, tokenizer_path: str | Path, temperature: float = 1.0,
28
+ threads: int = 2) -> None:
29
+ import onnxruntime as ort
30
+ from tokenizers import Tokenizer
31
+
32
+ opts = ort.SessionOptions()
33
+ opts.intra_op_num_threads = threads
34
+ self.session = ort.InferenceSession(str(model_path), opts, providers=["CPUExecutionProvider"])
35
+ self.tokenizer = Tokenizer.from_file(str(tokenizer_path))
36
+ self.tokenizer.enable_truncation(MAX_LENGTH)
37
+ self.temperature = temperature
38
+ self.lock = threading.Lock()
39
+ self._result = BranchResult(status="waiting")
40
+ self._at = float("-inf")
41
+ self.transcript = ""
42
+ self.latency_ms = 0.0
43
+
44
+ def logits(self, text: str) -> np.ndarray:
45
+ enc = self.tokenizer.encode(text)
46
+ feeds = {
47
+ "input_ids": np.array([enc.ids], dtype=np.int64),
48
+ "attention_mask": np.array([enc.attention_mask], dtype=np.int64),
49
+ "token_type_ids": np.array([enc.type_ids], dtype=np.int64),
50
+ }
51
+ return self.session.run(None, feeds)[0][0]
52
+
53
+ def classify(self, text: str, lang: str | None, now: float) -> BranchResult:
54
+ """Score one finished utterance and keep it as the live text result for TTL_S seconds."""
55
+ text = text.strip()
56
+ if not text:
57
+ result = BranchResult(status="no_transcript")
58
+ elif (lang is not None and lang not in CHINESE_LANGS) or not _HAN.search(text):
59
+ result = BranchResult(status="unsupported_language", detail={"lang": lang})
60
+ else:
61
+ import chinese_converter
62
+
63
+ text = chinese_converter.to_traditional(text)
64
+ t0 = time.perf_counter()
65
+ probs = softmax(to_canonical("text", self.logits(text)), self.temperature)
66
+ self.latency_ms = (time.perf_counter() - t0) * 1000.0
67
+ result = BranchResult(probs, OK, {"latency_ms": self.latency_ms})
68
+ with self.lock:
69
+ self.transcript, self._result, self._at = text, result, now
70
+ return result
71
+
72
+ def current(self, now: float) -> BranchResult:
73
+ with self.lock:
74
+ if self._result.present and now - self._at > TTL_S:
75
+ self._result = BranchResult(status="stale")
76
+ return self._result
77
+
78
+ def reset(self) -> None:
79
+ with self.lock:
80
+ self._result, self._at = BranchResult(status="waiting"), float("-inf")
reachy_mini_multimodal_emotion/main.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reachy Mini multimodal emotion: face + voice + (Chinese) words -> fused emotion -> empathetic move.
2
+
3
+ Pipeline
4
+ camera (BGR) ~5 fps -> YuNet face -> FER+ MobileNetV3 ------------------------\
5
+ microphone 16 kHz -> 2 s window every 0.2 s -> log-mel -> ECAPA student --------> late fusion
6
+ -> silero VAD utterances -> SenseVoice ASR -> zh BERT -----/ |
7
+ reaction policy -> move
8
+
9
+ Branches without evidence (no face, silence, English transcript, stale text) are left out of the
10
+ fusion instead of being counted as "neutral". While a move plays, and briefly after, microphone
11
+ input is ignored so the robot never reacts to its own motors or speaker.
12
+
13
+ Models download from the Hugging Face Hub on first start (about 360 MB with ASR). For simulation
14
+ without a microphone set MM_EMOTION_WAV=/path/to/16k.wav to loop a file instead.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import queue
22
+ import threading
23
+ import time
24
+ import wave
25
+ from collections import deque
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+ from pydantic import BaseModel
30
+
31
+ from reachy_mini import ReachyMini, ReachyMiniApp
32
+ from reachy_mini.reachy_mini import INIT_ANTENNAS_JOINT_POSITIONS, INIT_HEAD_POSE
33
+ from reachy_mini.utils import create_head_pose
34
+
35
+ from reachy_mini_multimodal_emotion.engine.labels import EMOTIONS
36
+ from reachy_mini_multimodal_emotion.engine.speech import SAMPLE_RATE
37
+ from reachy_mini_multimodal_emotion.reactions import DEFAULT_THRESHOLDS, ReactionPolicy, pick_move
38
+
39
+ HOP_SEC = 0.20 # fusion + speech scoring rate
40
+ FACE_PERIOD_S = 0.20 # ~5 fps face analysis
41
+ POST_MOVE_GUARD_S = 0.8 # keep ignoring audio after a move (speaker tail, settling motors)
42
+ CUE_PERIOD_S = 0.05 # subtle antenna/head cues at 20 Hz
43
+ REST_ANTENNAS = np.array(INIT_ANTENNAS_JOINT_POSITIONS)
44
+ logger = logging.getLogger("reachy_mini_multimodal_emotion")
45
+
46
+
47
+ class MicSource:
48
+ """Robot microphone: one chunk per call (a chunk is always ready on the robot), stereo -> mono."""
49
+
50
+ name = "robot microphone"
51
+
52
+ def __init__(self, reachy_mini: ReachyMini) -> None:
53
+ self.media = reachy_mini.media
54
+ self.media.start_recording()
55
+
56
+ def read(self) -> np.ndarray | None:
57
+ sample = self.media.get_audio_sample()
58
+ if sample is None or not len(sample):
59
+ return None
60
+ return (sample.mean(axis=1) if sample.ndim == 2 else sample).astype(np.float32)
61
+
62
+ def close(self) -> None:
63
+ self.media.stop_recording()
64
+
65
+
66
+ class WavSource:
67
+ """Loops a 16 kHz 16-bit wav file in real time (simulation has no microphone)."""
68
+
69
+ def __init__(self, path: str) -> None:
70
+ with wave.open(path, "rb") as w:
71
+ if w.getframerate() != SAMPLE_RATE or w.getsampwidth() != 2:
72
+ raise ValueError(f"{path}: need 16 kHz 16-bit PCM, got {w.getframerate()} Hz")
73
+ pcm = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).reshape(-1, w.getnchannels())
74
+ self.audio = pcm.mean(axis=1).astype(np.float32) / 32768.0
75
+ self.name = f"wav file {Path(path).name}"
76
+ self.pos, self.t_last = 0, time.monotonic()
77
+
78
+ def read(self) -> np.ndarray | None:
79
+ now = time.monotonic()
80
+ n = int((now - self.t_last) * SAMPLE_RATE)
81
+ if n <= 0:
82
+ return None
83
+ self.t_last += n / SAMPLE_RATE
84
+ chunk = np.take(self.audio, range(self.pos, self.pos + n), mode="wrap")
85
+ self.pos = (self.pos + n) % len(self.audio)
86
+ return chunk
87
+
88
+ def close(self) -> None:
89
+ pass
90
+
91
+
92
+ class Settings(BaseModel):
93
+ reactions_enabled: bool = True
94
+ sensitivity: float = 0.5 # 0 = only very confident reactions, 1 = react readily
95
+ move_sound: bool = True
96
+ use_face: bool = True
97
+ use_text: bool = True
98
+ weight_speech: float = 0.35
99
+ weight_face: float = 0.65
100
+ weight_text: float = 0.35
101
+
102
+
103
+ class TestReaction(BaseModel):
104
+ emotion: str
105
+
106
+
107
+ def _round(p) -> dict[str, float] | None:
108
+ return None if p is None else {e: round(float(v), 4) for e, v in zip(EMOTIONS, p)}
109
+
110
+
111
+ class ReachyMiniMultimodalEmotion(ReachyMiniApp):
112
+ custom_app_url: str | None = "http://0.0.0.0:8042"
113
+ request_media_backend: str | None = None
114
+
115
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
116
+ policy = ReactionPolicy()
117
+ settings = Settings()
118
+ wav_path = os.environ.get("MM_EMOTION_WAV")
119
+ source = WavSource(wav_path) if wav_path else MicSource(reachy_mini)
120
+
121
+ pipe = None
122
+ status = {"models": "downloading / loading models", "moves": "loading"}
123
+ moves = None
124
+ reaction_queue: "queue.Queue[tuple[str, str]]" = queue.Queue(maxsize=1)
125
+ log: deque = deque(maxlen=12)
126
+ shared: dict = {"moving": False, "guard_until": 0.0, "snap": None}
127
+ lock = threading.Lock()
128
+
129
+ def apply_settings() -> None:
130
+ offset = (0.5 - settings.sensitivity) * 0.4
131
+ policy.thresholds = {k: float(np.clip(v + offset, 0.3, 0.95)) for k, v in DEFAULT_THRESHOLDS.items()}
132
+ if pipe is not None:
133
+ pipe.weights.update(speech=settings.weight_speech, face=settings.weight_face,
134
+ text=settings.weight_text)
135
+ pipe.enabled["face"] = settings.use_face and pipe.face is not None
136
+ pipe.enabled["text"] = settings.use_text and pipe.text is not None
137
+
138
+ # ---------------------------------------------------------------- web dashboard API
139
+ if self.settings_app is not None:
140
+
141
+ @self.settings_app.get("/state")
142
+ def get_state():
143
+ with lock:
144
+ snap, moving = shared["snap"], shared["moving"]
145
+ entries = list(log)
146
+ out = {"status": status, "moving": moving, "source": source.name, "log": entries,
147
+ "settings": settings.model_dump(), "thresholds": policy.thresholds}
148
+ if snap is None:
149
+ return out
150
+ out.update({
151
+ "top": snap.fused.top, "confidence": round(snap.fused.confidence, 4),
152
+ "fused": _round(snap.fused.probs), "used": {b: round(w, 3) for b, w in snap.fused.used.items()},
153
+ "branches": {b: {"status": r.status, "probs": _round(r.probs),
154
+ "latency_ms": round(float(r.detail.get("latency_ms", 0.0)), 1)}
155
+ for b, r in snap.branches.items()},
156
+ "vad": [round(float(v), 4) for v in snap.vad],
157
+ "transcript": snap.transcript, "asr_lang": snap.asr_lang, "asr_emotion": snap.asr_emotion,
158
+ "utterances": [{**u, "text_top": None if u["text_top"] is None else EMOTIONS[u["text_top"]]}
159
+ for u in snap.utterances],
160
+ })
161
+ return out
162
+
163
+ @self.settings_app.post("/settings")
164
+ def update_settings(new: Settings):
165
+ nonlocal settings
166
+ settings = new
167
+ apply_settings()
168
+ return settings.model_dump()
169
+
170
+ @self.settings_app.post("/test_reaction")
171
+ def test_reaction(req: TestReaction):
172
+ move = pick_move(req.emotion)
173
+ if move is None:
174
+ return {"queued": False, "reason": f"no move for '{req.emotion}'"}
175
+ try:
176
+ reaction_queue.put_nowait((req.emotion, move))
177
+ return {"queued": True, "move": move}
178
+ except queue.Full:
179
+ return {"queued": False, "reason": "a reaction is already queued"}
180
+
181
+ # ---------------------------------------------------------------- background loading
182
+ def load_pipeline() -> None:
183
+ nonlocal pipe
184
+ from reachy_mini_multimodal_emotion.engine.pipeline import MultimodalPipeline
185
+
186
+ try:
187
+ pipe = MultimodalPipeline.from_pretrained()
188
+ apply_settings()
189
+ status["models"] = "ready"
190
+ logger.info("Models ready (face=%s, text=%s)", pipe.face is not None, pipe.text is not None)
191
+ except Exception as e: # e.g. no network on first start; keep the app alive and say why
192
+ logger.exception("Could not load models")
193
+ status["models"] = f"failed: {e}"
194
+
195
+ def load_moves() -> None:
196
+ nonlocal moves
197
+ from reachy_mini.motion.recorded_move import RecordedMoves
198
+
199
+ while not stop_event.is_set():
200
+ try:
201
+ moves = RecordedMoves("pollen-robotics/reachy-mini-emotions-library")
202
+ status["moves"] = "ready"
203
+ return
204
+ except Exception as e:
205
+ status["moves"] = "unavailable (using antenna gestures)"
206
+ logger.warning("Emotion library unavailable, retrying in 30 s: %s", e)
207
+ stop_event.wait(30.0)
208
+
209
+ threading.Thread(target=load_pipeline, daemon=True).start()
210
+ threading.Thread(target=load_moves, daemon=True).start()
211
+
212
+ def fallback_gesture(emotion: str) -> None:
213
+ up = emotion in ("happy", "surprise")
214
+ for a in ((30, -30), (0, 0)) * (2 if up else 1):
215
+ offset = np.deg2rad(a if up else (-a[0], -a[1]))
216
+ reachy_mini.goto_target(antennas=REST_ANTENNAS + offset, duration=0.4)
217
+
218
+ def reaction_worker() -> None:
219
+ while not stop_event.is_set():
220
+ try:
221
+ emotion, move_name = reaction_queue.get(timeout=0.2)
222
+ except queue.Empty:
223
+ continue
224
+ with lock:
225
+ shared["moving"] = True
226
+ snap = shared["snap"]
227
+ log.appendleft({"time": time.strftime("%H:%M:%S"), "emotion": emotion, "move": move_name,
228
+ "used": sorted(snap.fused.used) if snap is not None else []})
229
+ try:
230
+ if moves is not None:
231
+ reachy_mini.play_move(moves.get(move_name), initial_goto_duration=0.6,
232
+ sound=settings.move_sound)
233
+ else:
234
+ fallback_gesture(emotion)
235
+ reachy_mini.goto_target(head=INIT_HEAD_POSE, antennas=REST_ANTENNAS, duration=0.6)
236
+ except Exception:
237
+ logger.exception("Reaction %s failed", move_name)
238
+ finally:
239
+ with lock:
240
+ shared["moving"] = False
241
+ shared["guard_until"] = time.monotonic() + POST_MOVE_GUARD_S
242
+
243
+ def face_worker() -> None:
244
+ """Camera analysis runs on its own thread so detection never delays audio handling."""
245
+ next_t = time.monotonic()
246
+ while not stop_event.is_set():
247
+ next_t += FACE_PERIOD_S
248
+ p = pipe
249
+ if p is not None and p.enabled["face"]:
250
+ try:
251
+ p.push_frame(reachy_mini.media.get_frame())
252
+ except Exception:
253
+ logger.exception("Face step failed")
254
+ stop_event.wait(max(0.0, next_t - time.monotonic()))
255
+ next_t = max(next_t, time.monotonic())
256
+
257
+ # Wake up silently: the daemon leaves the robot limp in its sleep pose between apps
258
+ reachy_mini.enable_motors()
259
+ reachy_mini.goto_target(head=INIT_HEAD_POSE, antennas=REST_ANTENNAS, duration=2.0)
260
+
261
+ workers = [threading.Thread(target=reaction_worker, daemon=True),
262
+ threading.Thread(target=face_worker, daemon=True)]
263
+ for w in workers:
264
+ w.start()
265
+ logger.info("Listening on %s", source.name)
266
+
267
+ # ---------------------------------------------------------------- main loop
268
+ next_hop = next_cue = time.monotonic()
269
+ cue = np.zeros(3) # smoothed [antenna_deg, head_roll_deg, head_pitch_deg]
270
+ was_blocked = False
271
+ try:
272
+ while not stop_event.is_set():
273
+ now = time.monotonic()
274
+ with lock:
275
+ blocked = shared["moving"] or now < shared["guard_until"]
276
+ chunk = source.read()
277
+ p = pipe
278
+
279
+ if blocked:
280
+ was_blocked = True # drop our own motor/speaker sound
281
+ elif p is not None:
282
+ if was_blocked:
283
+ p.reset_audio()
284
+ policy.reset()
285
+ cue[:] = 0.0 # the worker already returned the robot to neutral
286
+ was_blocked = False
287
+ if chunk is not None:
288
+ p.push_audio(chunk)
289
+
290
+ if p is not None and now >= next_hop:
291
+ next_hop = max(next_hop + HOP_SEC, now)
292
+ if not blocked:
293
+ snap = p.step(now)
294
+ with lock:
295
+ shared["snap"] = snap
296
+ probs = snap.fused.probs
297
+ idle = probs is None
298
+ emotion = policy.update(probs if probs is not None else np.zeros(len(EMOTIONS)), idle, now)
299
+ if emotion and settings.reactions_enabled and (move := pick_move(emotion)):
300
+ try:
301
+ reaction_queue.put_nowait((emotion, move))
302
+ except queue.Full:
303
+ pass
304
+
305
+ if not blocked and now >= next_cue:
306
+ # Antennas follow vocal arousal, head tilt follows vocal valence, plus slow breathing.
307
+ next_cue = now + CUE_PERIOD_S
308
+ with lock:
309
+ snap = shared["snap"]
310
+ vad = snap.vad if snap is not None and snap.branches["speech"].present else None
311
+ target = np.array([
312
+ 40.0 * (float(vad[1]) - 0.5) if vad is not None else 0.0,
313
+ 12.0 * (float(vad[0]) - 0.5) if vad is not None else 0.0,
314
+ 1.5 * np.sin(2 * np.pi * 0.25 * now),
315
+ ])
316
+ cue += 0.1 * (target - cue)
317
+ reachy_mini.set_target(
318
+ head=create_head_pose(roll=cue[1], pitch=cue[2], degrees=True),
319
+ antennas=REST_ANTENNAS + np.deg2rad([cue[0], -cue[0]]),
320
+ )
321
+ time.sleep(0.005)
322
+ finally:
323
+ source.close()
324
+ if pipe is not None:
325
+ pipe.close()
326
+ for w in workers:
327
+ w.join(timeout=2.0)
328
+
329
+
330
+ if __name__ == "__main__":
331
+ app = ReachyMiniMultimodalEmotion()
332
+ try:
333
+ app.wrapped_run()
334
+ except KeyboardInterrupt:
335
+ app.stop()
reachy_mini_multimodal_emotion/reactions.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """When to react (decision policy) and how to react (empathetic move mapping)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from dataclasses import dataclass, field
7
+
8
+ from reachy_mini_multimodal_emotion.engine.labels import EMOTIONS as EMOTIONS_7
9
+
10
+ # Empathetic responses from pollen-robotics/reachy-mini-emotions-library (neutral -> no move)
11
+ EMPATHY_MOVES: dict[str, list[str]] = {
12
+ "happy": ["cheerful1", "enthusiastic1", "laughing1"], # join in
13
+ "sad": ["understanding1", "understanding2", "loving1"], # comfort
14
+ "angry": ["calming1", "serenity1"], # calm down
15
+ "surprise": ["surprised1", "amazed1", "curious1"], # share the surprise
16
+ "fear": ["calming1", "welcoming1", "helpful1"], # reassure
17
+ "disgust": ["thoughtful1", "uncomfortable1"], # acknowledge
18
+ }
19
+
20
+ # Calibrated on held-out validation speakers (ESD 0005/0009 + 9 CREMA-D actors, 18,820 smoothed
21
+ # 2 s windows) for roughly 75-80% precision per emotion. Sad is precise on acted data (98% at 0.70)
22
+ # but over-fires on calm, quiet real voices, so it keeps a strict threshold on purpose.
23
+ DEFAULT_THRESHOLDS = {"happy": 0.55, "sad": 0.70, "angry": 0.45, "surprise": 0.45, "fear": 0.50, "disgust": 0.65}
24
+
25
+
26
+ @dataclass
27
+ class ReactionPolicy:
28
+ """Fires once an emotion has been confidently dominant for `hold_s`, then waits `cooldown_s`."""
29
+
30
+ thresholds: dict[str, float] = field(default_factory=lambda: dict(DEFAULT_THRESHOLDS))
31
+ hold_s: float = 1.5
32
+ cooldown_s: float = 6.0
33
+ _candidate: str | None = None
34
+ _since: float = 0.0
35
+ _last_fire: float = float("-inf")
36
+
37
+ def reset(self) -> None:
38
+ self._candidate = None
39
+
40
+ def update(self, probs, idle: bool, now: float) -> str | None:
41
+ """Feed one smoothed prediction; returns the emotion to react to, or None."""
42
+ top = EMOTIONS_7[int(probs.argmax())]
43
+ confident = top in self.thresholds and float(probs.max()) >= self.thresholds[top]
44
+ if idle or not confident:
45
+ self._candidate = None
46
+ return None
47
+ if top != self._candidate:
48
+ self._candidate, self._since = top, now
49
+ return None
50
+ if now - self._since >= self.hold_s and now - self._last_fire >= self.cooldown_s:
51
+ self._last_fire = now
52
+ self._candidate = None
53
+ return top
54
+ return None
55
+
56
+
57
+ def pick_move(emotion: str, rng: random.Random | None = None) -> str | None:
58
+ options = EMPATHY_MOVES.get(emotion)
59
+ return (rng or random).choice(options) if options else None
reachy_mini_multimodal_emotion/static/index.html ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>Multimodal Emotion</title>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
8
+ <link rel="stylesheet" href="/static/style.css">
9
+ </head>
10
+
11
+ <body>
12
+ <header>
13
+ <h1>Multimodal Emotion</h1>
14
+ <span id="status" class="pill idle">connecting</span>
15
+ </header>
16
+
17
+ <main>
18
+ <section class="card hero">
19
+ <div class="label">Fused emotion</div>
20
+ <div id="top" class="top-emotion">-</div>
21
+ <div id="confidence" class="muted">waiting for a face or speech</div>
22
+ <div class="meta"><span id="used">-</span><span id="source">-</span><span id="models">-</span></div>
23
+ </section>
24
+
25
+ <section class="card">
26
+ <h2>Fused probabilities</h2>
27
+ <div id="bars"></div>
28
+ </section>
29
+
30
+ <section class="card">
31
+ <h2>Modalities</h2>
32
+ <div id="branches" class="branches"></div>
33
+ <p class="small muted">A modality without evidence (no face, silence, English speech for the Chinese text
34
+ model) is left out of the fusion; it never counts as "neutral".</p>
35
+ </section>
36
+
37
+ <section class="card">
38
+ <h2>What I heard</h2>
39
+ <div id="transcript" class="transcript muted">-</div>
40
+ <div id="asr" class="small muted"></div>
41
+ <div id="utterances"></div>
42
+ </section>
43
+
44
+ <section class="card">
45
+ <h2>Controls</h2>
46
+ <label class="switch-row"><span>Reactions</span><input type="checkbox" id="reactions_enabled" checked></label>
47
+ <label class="switch-row"><span>Move sounds</span><input type="checkbox" id="move_sound" checked></label>
48
+ <label class="switch-row"><span>Use face (camera)</span><input type="checkbox" id="use_face" checked></label>
49
+ <label class="switch-row"><span>Use words (speech-to-text)</span><input type="checkbox" id="use_text" checked></label>
50
+ <label class="slider-row"><span>Sensitivity <b id="sensitivity-val">0.50</b></span>
51
+ <input type="range" id="sensitivity" min="0" max="1" step="0.05" value="0.5"></label>
52
+ <h3>Fusion weights</h3>
53
+ <label class="slider-row"><span>Voice <b id="weight_speech-val">0.35</b></span>
54
+ <input type="range" id="weight_speech" min="0" max="1" step="0.05" value="0.35"></label>
55
+ <label class="slider-row"><span>Face <b id="weight_face-val">0.65</b></span>
56
+ <input type="range" id="weight_face" min="0" max="1" step="0.05" value="0.65"></label>
57
+ <label class="slider-row"><span>Words <b id="weight_text-val">0.35</b></span>
58
+ <input type="range" id="weight_text" min="0" max="1" step="0.05" value="0.35"></label>
59
+ <p class="small muted">Voice/face weights were tuned on acted CREMA-D video; the words weight is an
60
+ untested default. Weights are renormalised over the modalities present.</p>
61
+ <h3>Try a reaction</h3>
62
+ <div id="tests" class="tests"></div>
63
+ <div id="test-msg" class="small muted"></div>
64
+ </section>
65
+
66
+ <section class="card">
67
+ <h2>Recent reactions</h2>
68
+ <ul id="log" class="log"><li class="muted">none yet</li></ul>
69
+ <div id="moves" class="small muted"></div>
70
+ </section>
71
+ </main>
72
+ <script src="/static/main.js"></script>
73
+ </body>
74
+
75
+ </html>
reachy_mini_multimodal_emotion/static/main.js ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const EMOTIONS = ["neutral", "happy", "sad", "angry", "surprise", "fear", "disgust"];
2
+ const REACTABLE = ["happy", "sad", "angry", "surprise", "fear", "disgust"];
3
+ const BRANCHES = { speech: "voice", face: "face", text: "words" };
4
+ const STATUS_TEXT = {
5
+ ok: "live", silence: "silence", buffering: "listening…", no_face: "no face", no_camera: "no camera",
6
+ no_transcript: "no words", unsupported_language: "not Chinese", stale: "old", waiting: "waiting", disabled: "off",
7
+ };
8
+ const SWITCHES = ["reactions_enabled", "move_sound", "use_face", "use_text"];
9
+ const SLIDERS = ["sensitivity", "weight_speech", "weight_face", "weight_text"];
10
+ const color = (e) => `var(--${e})`;
11
+ const $ = (id) => document.getElementById(id);
12
+ const pct = (p) => `${(p * 100).toFixed(0)}%`;
13
+
14
+ function el(tag, cls, text) {
15
+ const node = document.createElement(tag);
16
+ if (cls) node.className = cls;
17
+ if (text !== undefined) node.textContent = text;
18
+ return node;
19
+ }
20
+
21
+ // Static parts
22
+ $("bars").innerHTML = EMOTIONS.map((e) => `
23
+ <div class="bar-row">
24
+ <span class="name">${e}</span>
25
+ <div class="track"><div class="fill" id="bar-${e}" style="background:${color(e)}"></div></div>
26
+ <span class="value" id="val-${e}">-</span>
27
+ </div>`).join("");
28
+ $("branches").innerHTML = Object.entries(BRANCHES).map(([b, name]) => `
29
+ <div class="branch" id="br-${b}">
30
+ <div class="head"><span class="name">${name}</span><span class="status-chip" id="br-${b}-status">-</span></div>
31
+ <div class="top" id="br-${b}-top">-</div>
32
+ <div class="weight" id="br-${b}-weight">-</div>
33
+ <div class="mini-bars">${EMOTIONS.map((e) => `<div class="track" title="${e}"><div class="fill" id="br-${b}-${e}" style="background:${color(e)}"></div></div>`).join("")}</div>
34
+ </div>`).join("");
35
+ $("tests").innerHTML = REACTABLE.map((e) => `<button data-emotion="${e}">${e}</button>`).join("");
36
+
37
+ let settingsLoaded = false;
38
+
39
+ function renderBranch(b, r, weight) {
40
+ const status = r ? r.status : "waiting";
41
+ const chip = $(`br-${b}-status`);
42
+ chip.textContent = STATUS_TEXT[status] || status;
43
+ chip.className = `status-chip ${status === "ok" ? "ok" : ""}`;
44
+ $(`br-${b}`).className = `branch ${status === "disabled" ? "off" : ""}`;
45
+ const probs = r && r.probs;
46
+ let top = null;
47
+ for (const e of EMOTIONS) {
48
+ const p = probs ? probs[e] : 0;
49
+ $(`br-${b}-${e}`).style.width = `${(p * 100).toFixed(1)}%`;
50
+ if (probs && (top === null || p > probs[top])) top = e;
51
+ }
52
+ $(`br-${b}-top`).textContent = top ? `${top} ${pct(probs[top])}` : "-";
53
+ $(`br-${b}-top`).style.color = top ? color(top) : "var(--muted)";
54
+ const lat = r && r.latency_ms ? ` · ${r.latency_ms} ms` : "";
55
+ $(`br-${b}-weight`).textContent = weight !== undefined ? `fusion weight ${pct(weight)}${lat}` : `not in fusion${lat}`;
56
+ }
57
+
58
+ function render(s) {
59
+ const statusPill = $("status");
60
+ const ready = s.status.models === "ready";
61
+ if (!ready) { statusPill.textContent = s.status.models.startsWith("failed") ? "model error" : "loading models"; statusPill.className = `pill ${s.status.models.startsWith("failed") ? "error" : "idle"}`; }
62
+ else if (s.moving) { statusPill.textContent = "reacting"; statusPill.className = "pill moving"; }
63
+ else if (!s.top) { statusPill.textContent = "watching / listening"; statusPill.className = "pill idle"; }
64
+ else { statusPill.textContent = "sensing"; statusPill.className = "pill listening"; }
65
+
66
+ $("models").textContent = `models: ${s.status.models}`;
67
+ $("source").textContent = s.source;
68
+ $("moves").textContent = `Emotion moves: ${s.status.moves}`;
69
+
70
+ const top = s.top || null;
71
+ $("top").textContent = top || "-";
72
+ $("top").style.color = top ? color(top) : "var(--muted)";
73
+ $("confidence").textContent = top ? `${pct(s.confidence)} confident` : "waiting for a face or speech";
74
+ const used = s.used ? Object.keys(s.used).map((b) => BRANCHES[b]) : [];
75
+ $("used").textContent = used.length ? `from ${used.join(" + ")}` : "no modality present";
76
+
77
+ for (const e of EMOTIONS) {
78
+ const p = s.fused ? s.fused[e] : 0;
79
+ $(`bar-${e}`).style.width = `${(p * 100).toFixed(1)}%`;
80
+ $(`val-${e}`).textContent = s.fused ? pct(p) : "-";
81
+ }
82
+ for (const b of Object.keys(BRANCHES)) renderBranch(b, s.branches && s.branches[b], s.used && s.used[b]);
83
+
84
+ $("transcript").textContent = s.transcript || "-";
85
+ $("asr").textContent = s.asr_lang ? `language: ${s.asr_lang} · SenseVoice's own tag: ${s.asr_emotion || "-"} (not fused)` : "";
86
+ $("utterances").replaceChildren(...(s.utterances || []).slice(0, 5).map((u) => {
87
+ const row = el("div", "utt");
88
+ row.append(el("span", "muted", u.time), el("span", "", u.text || "…"));
89
+ const tag = el("span", "", u.text_top || STATUS_TEXT[u.text_status] || u.text_status);
90
+ if (u.text_top) tag.style.color = color(u.text_top); else tag.className = "muted";
91
+ row.append(tag);
92
+ return row;
93
+ }));
94
+
95
+ $("log").replaceChildren(...(s.log.length ? s.log : [null]).map((r) => {
96
+ if (!r) return el("li", "muted", "none yet");
97
+ const li = el("li");
98
+ const emo = el("span", "emo", r.emotion);
99
+ if (EMOTIONS.includes(r.emotion)) emo.style.color = color(r.emotion);
100
+ li.append(el("span", "time", r.time), emo, el("span", "muted", `→ ${r.move}`));
101
+ if (r.used && r.used.length) li.append(el("span", "muted", `(${r.used.map((b) => BRANCHES[b]).join("+")})`));
102
+ return li;
103
+ }));
104
+
105
+ if (!settingsLoaded) {
106
+ for (const k of SWITCHES) $(k).checked = s.settings[k];
107
+ for (const k of SLIDERS) { $(k).value = s.settings[k]; $(`${k}-val`).textContent = Number(s.settings[k]).toFixed(2); }
108
+ settingsLoaded = true;
109
+ }
110
+ }
111
+
112
+ async function poll() {
113
+ try {
114
+ render(await (await fetch("/state")).json());
115
+ } catch (err) {
116
+ $("status").textContent = "disconnected";
117
+ $("status").className = "pill error";
118
+ } finally {
119
+ setTimeout(poll, 250);
120
+ }
121
+ }
122
+
123
+ async function pushSettings() {
124
+ const settings = {};
125
+ for (const k of SWITCHES) settings[k] = $(k).checked;
126
+ for (const k of SLIDERS) settings[k] = Number($(k).value);
127
+ await fetch("/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(settings) });
128
+ }
129
+
130
+ for (const k of SWITCHES) $(k).addEventListener("change", pushSettings);
131
+ for (const k of SLIDERS) {
132
+ $(k).addEventListener("change", pushSettings);
133
+ $(k).addEventListener("input", () => { $(`${k}-val`).textContent = Number($(k).value).toFixed(2); });
134
+ }
135
+
136
+ $("tests").addEventListener("click", async (ev) => {
137
+ const emotion = ev.target.dataset.emotion;
138
+ if (!emotion) return;
139
+ const r = await (await fetch("/test_reaction", {
140
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ emotion }),
141
+ })).json();
142
+ $("test-msg").textContent = r.queued ? `Playing "${r.move}"` : `Not played: ${r.reason}`;
143
+ });
144
+
145
+ poll();
reachy_mini_multimodal_emotion/static/style.css ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0f1216;
3
+ --card: #181d24;
4
+ --line: #2a313b;
5
+ --ink: #e8ecf1;
6
+ --muted: #93a0ae;
7
+ --accent: #ff9900;
8
+ --neutral: #b8c0c8;
9
+ --happy: #4ade80;
10
+ --sad: #60a5fa;
11
+ --angry: #f43f5e;
12
+ --surprise: #e879f9;
13
+ --fear: #facc15;
14
+ --disgust: #fb923c;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+
19
+ body {
20
+ margin: 0;
21
+ background: var(--bg);
22
+ color: var(--ink);
23
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
24
+ padding: env(safe-area-inset-top) 16px 24px;
25
+ }
26
+
27
+ header {
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: space-between;
31
+ max-width: 720px;
32
+ margin: 16px auto 12px;
33
+ }
34
+
35
+ h1 { font-size: 1.3rem; margin: 0; }
36
+ h2 { font-size: 0.95rem; margin: 0 0 12px; color: var(--muted); font-weight: 600; }
37
+ h3 { font-size: 0.9rem; margin: 18px 0 8px; color: var(--muted); }
38
+
39
+ main { max-width: 720px; margin: 0 auto; display: grid; gap: 12px; }
40
+
41
+ .card {
42
+ background: var(--card);
43
+ border: 1px solid var(--line);
44
+ border-radius: 14px;
45
+ padding: 16px;
46
+ }
47
+
48
+ .muted { color: var(--muted); }
49
+ .small { font-size: 0.8rem; }
50
+ .label { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; }
51
+
52
+ .hero { text-align: center; }
53
+ .top-emotion { font-size: 2.6rem; font-weight: 800; margin: 6px 0 2px; text-transform: capitalize; }
54
+ .meta { display: flex; justify-content: center; gap: 14px; flex-wrap: wrap; margin-top: 10px; color: var(--muted); font-size: 0.8rem; }
55
+
56
+ .pill { padding: 6px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; border: 1px solid var(--line); }
57
+ .pill.listening { color: var(--happy); border-color: var(--happy); }
58
+ .pill.idle { color: var(--muted); }
59
+ .pill.moving { color: var(--accent); border-color: var(--accent); }
60
+ .pill.error { color: var(--angry); border-color: var(--angry); }
61
+
62
+ .bar-row { display: grid; grid-template-columns: 76px 1fr 48px; align-items: center; gap: 10px; margin: 8px 0; }
63
+ .bar-row .name { text-transform: capitalize; font-size: 0.9rem; }
64
+ .bar-row .value { text-align: right; font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); }
65
+ .track { height: 10px; background: #232a33; border-radius: 6px; overflow: hidden; }
66
+ .fill { height: 100%; width: 0; border-radius: 6px; transition: width 0.25s ease; }
67
+ .fill.dom { background: var(--sad); }
68
+
69
+ .circumplex { position: relative; padding: 22px 64px; margin: 0 auto 12px; max-width: 380px; }
70
+ .plane {
71
+ position: relative;
72
+ aspect-ratio: 1;
73
+ max-width: 240px;
74
+ margin: 0 auto;
75
+ border: 1px solid var(--line);
76
+ border-radius: 8px;
77
+ background:
78
+ linear-gradient(var(--line), var(--line)) center / 1px 100% no-repeat,
79
+ linear-gradient(var(--line), var(--line)) center / 100% 1px no-repeat;
80
+ }
81
+ .axis { position: absolute; font-size: 0.75rem; color: var(--muted); }
82
+ .axis.top { top: 0; left: 50%; transform: translateX(-50%); }
83
+ .axis.bottom { bottom: 0; left: 50%; transform: translateX(-50%); }
84
+ .axis.left { left: 0; top: 50%; transform: translateY(-50%); }
85
+ .axis.right { right: 0; top: 50%; transform: translateY(-50%); }
86
+ .dot {
87
+ position: absolute;
88
+ width: 16px;
89
+ height: 16px;
90
+ margin: -8px 0 0 -8px;
91
+ border-radius: 50%;
92
+ background: var(--ink);
93
+ box-shadow: 0 0 0 6px rgba(255, 255, 255, 0.12), 0 0 18px 4px rgba(255, 153, 0, 0.45);
94
+ left: 50%;
95
+ top: 50%;
96
+ transition: left 0.3s ease, top 0.3s ease;
97
+ }
98
+
99
+ .row { display: flex; justify-content: space-between; margin: 6px 0; }
100
+
101
+ .switch-row, .slider-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; }
102
+ .slider-row { flex-wrap: wrap; }
103
+ .slider-row input { flex: 1 1 100%; accent-color: var(--accent); min-height: 32px; }
104
+ input[type="checkbox"] { width: 24px; height: 24px; accent-color: var(--accent); }
105
+
106
+ .tests { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
107
+ .tests button {
108
+ min-height: 44px;
109
+ border-radius: 10px;
110
+ border: 1px solid var(--line);
111
+ background: #20262e;
112
+ color: var(--ink);
113
+ font-size: 0.9rem;
114
+ text-transform: capitalize;
115
+ cursor: pointer;
116
+ }
117
+ .tests button:active { transform: scale(0.97); }
118
+
119
+ .log { list-style: none; margin: 0; padding: 0; }
120
+ .log li { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 0.9rem; }
121
+ .log li:last-child { border-bottom: none; }
122
+ .log .time { color: var(--muted); font-variant-numeric: tabular-nums; }
123
+ .log .emo { text-transform: capitalize; font-weight: 600; }
124
+
125
+ /* multimodal additions */
126
+ .branches { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; }
127
+ .branch { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
128
+ .branch.off { opacity: 0.55; }
129
+ .branch .head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
130
+ .branch .name { font-weight: 700; text-transform: capitalize; }
131
+ .branch .top { font-weight: 700; text-transform: capitalize; margin: 4px 0; }
132
+ .branch .weight { font-size: 0.75rem; color: var(--muted); }
133
+ .mini-bars { display: grid; gap: 3px; margin-top: 6px; }
134
+ .mini-bars .track { height: 6px; }
135
+ .status-chip { font-size: 0.72rem; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
136
+ .status-chip.ok { color: var(--happy); border-color: var(--happy); }
137
+ .transcript { font-size: 1.05rem; min-height: 1.6em; }
138
+ .utt { display: grid; grid-template-columns: 64px 1fr auto; gap: 8px; padding: 6px 0; border-top: 1px solid var(--line); font-size: 0.85rem; }
139
+ .utt:first-child { border-top: 0; }
140
+ .log li .muted { margin-left: 6px; }
style.css CHANGED
@@ -1,28 +1,411 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9
+ line-height: 1.6;
10
+ color: #333;
11
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
12
+ min-height: 100vh;
13
+ }
14
+
15
+ .hero {
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ color: white;
18
+ padding: 4rem 2rem;
19
+ text-align: center;
20
+ }
21
+
22
+ .hero-content {
23
+ max-width: 800px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ .app-icon {
28
+ font-size: 4rem;
29
+ margin-bottom: 1rem;
30
+ display: inline-block;
31
+ }
32
+
33
+ .hero h1 {
34
+ font-size: 3rem;
35
+ font-weight: 700;
36
+ margin-bottom: 1rem;
37
+ background: linear-gradient(45deg, #fff, #f0f9ff);
38
+ background-clip: text;
39
+ -webkit-background-clip: text;
40
+ -webkit-text-fill-color: transparent;
41
+ }
42
+
43
+ .tagline {
44
+ font-size: 1.25rem;
45
+ opacity: 0.9;
46
+ max-width: 600px;
47
+ margin: 0 auto;
48
+ }
49
+
50
+ .container {
51
+ max-width: 1200px;
52
+ margin: 0 auto;
53
+ padding: 0 2rem;
54
+ position: relative;
55
+ z-index: 2;
56
+ }
57
+
58
+ .main-card {
59
+ background: white;
60
+ border-radius: 20px;
61
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
62
+ margin-top: -2rem;
63
+ overflow: hidden;
64
+ margin-bottom: 3rem;
65
+ }
66
+
67
+ .app-preview {
68
+ background: linear-gradient(135deg, #1e3a8a, #3b82f6);
69
+ padding: 3rem;
70
+ color: white;
71
+ text-align: center;
72
+ position: relative;
73
+ }
74
+
75
+ .preview-image {
76
+ background: #000;
77
+ border-radius: 15px;
78
+ padding: 2rem;
79
+ max-width: 500px;
80
+ margin: 0 auto;
81
+ position: relative;
82
+ overflow: hidden;
83
+ }
84
+
85
+ .camera-feed {
86
+ font-size: 4rem;
87
+ margin-bottom: 1rem;
88
+ opacity: 0.7;
89
+ }
90
+
91
+ .detection-overlay {
92
+ position: absolute;
93
+ top: 50%;
94
+ left: 50%;
95
+ transform: translate(-50%, -50%);
96
+ width: 100%;
97
+ }
98
+
99
+ .bbox {
100
+ background: rgba(34, 197, 94, 0.9);
101
+ color: white;
102
+ padding: 0.5rem 1rem;
103
+ border-radius: 8px;
104
+ font-size: 0.9rem;
105
+ font-weight: 600;
106
+ margin: 0.5rem;
107
+ display: inline-block;
108
+ border: 2px solid #22c55e;
109
+ }
110
+
111
+ .app-details {
112
+ padding: 3rem;
113
+ }
114
+
115
+ .app-details h2 {
116
+ font-size: 2rem;
117
+ color: #1e293b;
118
+ margin-bottom: 2rem;
119
+ text-align: center;
120
+ }
121
+
122
+ .template-info {
123
+ display: grid;
124
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
125
+ gap: 2rem;
126
+ margin-bottom: 3rem;
127
+ }
128
+
129
+ .info-box {
130
+ background: #f0f9ff;
131
+ border: 2px solid #e0f2fe;
132
+ border-radius: 12px;
133
+ padding: 2rem;
134
+ }
135
+
136
+ .info-box h3 {
137
+ color: #0c4a6e;
138
+ margin-bottom: 1rem;
139
+ font-size: 1.2rem;
140
+ }
141
+
142
+ .info-box p {
143
+ color: #0369a1;
144
+ line-height: 1.6;
145
+ }
146
+
147
+ .how-to-use {
148
+ background: #fefce8;
149
+ border: 2px solid #fde047;
150
+ border-radius: 12px;
151
+ padding: 2rem;
152
+ margin-top: 3rem;
153
+ }
154
+
155
+ .how-to-use h3 {
156
+ color: #a16207;
157
+ margin-bottom: 1.5rem;
158
+ font-size: 1.3rem;
159
+ text-align: center;
160
+ }
161
+
162
+ .steps {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 1.5rem;
166
+ }
167
+
168
+ .step {
169
+ display: flex;
170
+ align-items: flex-start;
171
+ gap: 1rem;
172
+ }
173
+
174
+ .step-number {
175
+ background: #eab308;
176
+ color: white;
177
+ width: 2rem;
178
+ height: 2rem;
179
+ border-radius: 50%;
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ font-weight: bold;
184
+ flex-shrink: 0;
185
+ }
186
+
187
+ .step h4 {
188
+ color: #a16207;
189
+ margin-bottom: 0.5rem;
190
+ font-size: 1.1rem;
191
+ }
192
+
193
+ .step p {
194
+ color: #ca8a04;
195
+ }
196
+
197
+ .download-card {
198
+ background: white;
199
+ border-radius: 20px;
200
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
201
+ padding: 3rem;
202
+ text-align: center;
203
+ }
204
+
205
+ .download-card h2 {
206
+ font-size: 2rem;
207
+ color: #1e293b;
208
+ margin-bottom: 1rem;
209
+ }
210
+
211
+ .download-card>p {
212
+ color: #64748b;
213
+ font-size: 1.1rem;
214
+ margin-bottom: 2rem;
215
+ }
216
+
217
+ .dashboard-config {
218
+ margin-bottom: 2rem;
219
+ text-align: left;
220
+ max-width: 400px;
221
+ margin-left: auto;
222
+ margin-right: auto;
223
+ }
224
+
225
+ .dashboard-config label {
226
+ display: block;
227
+ color: #374151;
228
+ font-weight: 600;
229
+ margin-bottom: 0.5rem;
230
+ }
231
+
232
+ .dashboard-config input {
233
+ width: 100%;
234
+ padding: 0.75rem 1rem;
235
+ border: 2px solid #e5e7eb;
236
+ border-radius: 8px;
237
+ font-size: 0.95rem;
238
+ transition: border-color 0.2s;
239
+ }
240
+
241
+ .dashboard-config input:focus {
242
+ outline: none;
243
+ border-color: #667eea;
244
+ }
245
+
246
+ .install-btn {
247
+ background: linear-gradient(135deg, #667eea, #764ba2);
248
+ color: white;
249
+ border: none;
250
+ padding: 1.25rem 3rem;
251
+ border-radius: 16px;
252
+ font-size: 1.2rem;
253
+ font-weight: 700;
254
+ cursor: pointer;
255
+ transition: all 0.3s ease;
256
+ display: inline-flex;
257
+ align-items: center;
258
+ gap: 0.75rem;
259
+ margin-bottom: 2rem;
260
+ box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
261
+ }
262
+
263
+ .install-btn:hover:not(:disabled) {
264
+ transform: translateY(-3px);
265
+ box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
266
+ }
267
+
268
+ .install-btn:disabled {
269
+ opacity: 0.7;
270
+ cursor: not-allowed;
271
+ transform: none;
272
+ }
273
+
274
+ .manual-option {
275
+ background: #f8fafc;
276
+ border-radius: 12px;
277
+ padding: 2rem;
278
+ margin-top: 2rem;
279
+ }
280
+
281
+ .manual-option h3 {
282
+ color: #1e293b;
283
+ margin-bottom: 1rem;
284
+ font-size: 1.2rem;
285
+ }
286
+
287
+ .manual-option>p {
288
+ color: #64748b;
289
+ margin-bottom: 1rem;
290
+ }
291
+
292
+ .btn-icon {
293
+ font-size: 1.1rem;
294
+ }
295
+
296
+ .install-status {
297
+ padding: 1rem;
298
+ border-radius: 8px;
299
+ font-size: 0.9rem;
300
+ text-align: center;
301
+ display: none;
302
+ margin-top: 1rem;
303
+ }
304
+
305
+ .install-status.success {
306
+ background: #dcfce7;
307
+ color: #166534;
308
+ border: 1px solid #bbf7d0;
309
+ }
310
+
311
+ .install-status.error {
312
+ background: #fef2f2;
313
+ color: #dc2626;
314
+ border: 1px solid #fecaca;
315
+ }
316
+
317
+ .install-status.loading {
318
+ background: #dbeafe;
319
+ color: #1d4ed8;
320
+ border: 1px solid #bfdbfe;
321
+ }
322
+
323
+ .install-status.info {
324
+ background: #e0f2fe;
325
+ color: #0369a1;
326
+ border: 1px solid #7dd3fc;
327
+ }
328
+
329
+ .manual-install {
330
+ background: #1f2937;
331
+ border-radius: 8px;
332
+ padding: 1rem;
333
+ margin-bottom: 1rem;
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 1rem;
337
+ }
338
+
339
+ .manual-install code {
340
+ color: #10b981;
341
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
342
+ font-size: 0.85rem;
343
+ flex: 1;
344
+ overflow-x: auto;
345
+ }
346
+
347
+ .copy-btn {
348
+ background: #374151;
349
+ color: white;
350
+ border: none;
351
+ padding: 0.5rem 1rem;
352
+ border-radius: 6px;
353
+ font-size: 0.8rem;
354
+ cursor: pointer;
355
+ transition: background-color 0.2s;
356
+ }
357
+
358
+ .copy-btn:hover {
359
+ background: #4b5563;
360
+ }
361
+
362
+ .manual-steps {
363
+ color: #6b7280;
364
+ font-size: 0.9rem;
365
+ line-height: 1.8;
366
+ }
367
+
368
+ .footer {
369
+ text-align: center;
370
+ padding: 2rem;
371
+ color: white;
372
+ opacity: 0.8;
373
+ }
374
+
375
+ .footer a {
376
+ color: white;
377
+ text-decoration: none;
378
+ font-weight: 600;
379
+ }
380
+
381
+ .footer a:hover {
382
+ text-decoration: underline;
383
+ }
384
+
385
+ /* Responsive Design */
386
+ @media (max-width: 768px) {
387
+ .hero {
388
+ padding: 2rem 1rem;
389
+ }
390
+
391
+ .hero h1 {
392
+ font-size: 2rem;
393
+ }
394
+
395
+ .container {
396
+ padding: 0 1rem;
397
+ }
398
+
399
+ .app-details,
400
+ .download-card {
401
+ padding: 2rem;
402
+ }
403
+
404
+ .features-grid {
405
+ grid-template-columns: 1fr;
406
+ }
407
+
408
+ .download-options {
409
+ grid-template-columns: 1fr;
410
+ }
411
+ }
tests/conftest.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model-dependent tests use MM_EMOTION_LOCAL_DIR (a folder with the model repo layout) and skip without it."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+
9
+ @pytest.fixture(scope="session")
10
+ def model_root() -> Path:
11
+ root = os.environ.get("MM_EMOTION_LOCAL_DIR")
12
+ if not root or not (Path(root) / "config.json").exists():
13
+ pytest.skip("set MM_EMOTION_LOCAL_DIR to the model repo folder")
14
+ return Path(root)
tests/test_branches.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Each torch-free branch must reproduce its training-time pipeline."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import numpy as np
7
+ import pytest
8
+
9
+ from reachy_mini_multimodal_emotion.engine import load_config
10
+ from reachy_mini_multimodal_emotion.engine.face import FaceBranch, crop_face, preprocess_face
11
+ from reachy_mini_multimodal_emotion.engine.speech import LogMel
12
+
13
+
14
+ # ------------------------------------------------------------------ speech
15
+ @pytest.mark.parametrize("seconds", [0.5, 2.0, 3.3])
16
+ def test_numpy_log_mel_matches_torchaudio(seconds):
17
+ torch = pytest.importorskip("torch")
18
+ T = pytest.importorskip("torchaudio.transforms")
19
+ rng = np.random.default_rng(0)
20
+ t = np.arange(int(seconds * 16000)) / 16000
21
+ wave = (0.1 * np.sin(2 * np.pi * 180 * t) + 0.02 * rng.standard_normal(len(t))).astype(np.float32)
22
+ mel = T.MelSpectrogram(sample_rate=16000, n_fft=512, win_length=400, hop_length=160, n_mels=80,
23
+ f_min=20.0, f_max=8000.0, center=True, power=2.0)(torch.from_numpy(wave))
24
+ log_mel = torch.log(torch.clamp(mel, min=1e-5))
25
+ ref = ((log_mel - log_mel.mean(-1, keepdim=True)) / (log_mel.std(-1, keepdim=True) + 1e-5)).numpy()
26
+ ours = LogMel()(wave)
27
+ assert ours.shape == ref.shape and np.abs(ours - ref).max() < 1e-3
28
+
29
+
30
+ # ------------------------------------------------------------------ face
31
+ def test_face_preprocess_matches_torchvision_transform():
32
+ pytest.importorskip("torchvision")
33
+ import cv2
34
+ from PIL import Image
35
+ from torchvision import transforms
36
+
37
+ rng = np.random.default_rng(1)
38
+ face = rng.integers(0, 256, (97, 83, 3), dtype=np.uint8)
39
+ gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY)
40
+ tf = transforms.Compose([transforms.Resize((112, 112)), transforms.ToTensor(),
41
+ transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))])
42
+ ref = tf(Image.fromarray(np.repeat(gray[..., None], 3, axis=2), mode="RGB")).unsqueeze(0).numpy()
43
+ np.testing.assert_allclose(preprocess_face(face), ref, atol=1e-5)
44
+
45
+
46
+ def test_crop_face_pads_and_clamps():
47
+ frame = np.zeros((100, 200, 3), np.uint8)
48
+ assert crop_face(frame, (10, 10, 50, 50), 0.10).shape == (60, 60, 3)
49
+ assert crop_face(frame, (0, 0, 50, 50), 0.10).shape == (55, 55, 3)
50
+
51
+
52
+ NEW_EMOTION = os.environ.get("NEW_EMOTION_REPO", "D:/newEmotion")
53
+
54
+
55
+ def test_face_onnx_matches_training_checkpoint(model_root):
56
+ torch = pytest.importorskip("torch")
57
+ ckpt = os.path.join(NEW_EMOTION, "runs", "student_direct", "best.pt")
58
+ if not os.path.exists(ckpt):
59
+ pytest.skip("newEmotion checkpoint not available")
60
+ sys.path.insert(0, NEW_EMOTION)
61
+ from emotion_model.models import create_model
62
+
63
+ payload = torch.load(ckpt, map_location="cpu", weights_only=False)
64
+ model = create_model("mobilenet_v3_large", num_classes=7, pretrained=False)
65
+ model.load_state_dict(payload["model_state"])
66
+ model.eval()
67
+ cfg = load_config(model_root)
68
+ branch = FaceBranch(model_root / cfg["files"]["face"], model_root / cfg["files"]["face_detector"])
69
+ face = np.random.default_rng(2).integers(0, 256, (120, 100, 3), dtype=np.uint8)
70
+ with torch.no_grad():
71
+ ref = model(torch.from_numpy(preprocess_face(face))).numpy()[0]
72
+ np.testing.assert_allclose(branch.logits(face), ref, atol=1e-3)
73
+
74
+
75
+ # ------------------------------------------------------------------ text
76
+ def test_fast_tokenizer_matches_transformers(model_root):
77
+ transformers = pytest.importorskip("transformers")
78
+ from tokenizers import Tokenizer
79
+
80
+ src = os.environ.get("TER_MODEL", "D:/TER/TextEmotionDetection-model-2026-09-23/models/best_model")
81
+ if not os.path.exists(src):
82
+ pytest.skip("original text model not available")
83
+ ref = transformers.AutoTokenizer.from_pretrained(src)
84
+ ours = Tokenizer.from_file(str(model_root / "text" / "tokenizer.json"))
85
+ ours.enable_truncation(128)
86
+ for text in ["我今天終於完成專案了,超開心!", "他說:「我不是不生氣」", "OK啦 123 abc", "很" * 300]:
87
+ enc = ours.encode(text)
88
+ r = ref(text, truncation=True, max_length=128)
89
+ assert enc.ids == r["input_ids"] and enc.type_ids == r["token_type_ids"]
90
+
91
+
92
+ def test_text_branch_statuses(model_root):
93
+ pytest.importorskip("chinese_converter")
94
+ from reachy_mini_multimodal_emotion.engine.text import TTL_S, TextBranch
95
+
96
+ cfg = load_config(model_root)
97
+ tb = TextBranch(model_root / cfg["files"]["text"], model_root / cfg["files"]["text_tokenizer"])
98
+ assert tb.classify("", "zh", 0.0).status == "no_transcript"
99
+ assert tb.classify("I am so happy today", "en", 0.0).status == "unsupported_language"
100
+ r = tb.classify("我今天终于完成项目了,超开心!", "zh", 10.0) # Simplified input is converted
101
+ assert r.present and tb.transcript.startswith("我今天終於")
102
+ assert int(r.probs.argmax()) == 1 # happy
103
+ assert tb.current(10.0 + TTL_S - 0.1).present
104
+ assert tb.current(10.0 + TTL_S + 0.1).status == "stale"
tests/test_fusion.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Label maps and late fusion behaviour, including missing modalities."""
2
+
3
+ import numpy as np
4
+
5
+ from reachy_mini_multimodal_emotion.engine.fusion import OK, BranchResult, fuse
6
+ from reachy_mini_multimodal_emotion.engine.labels import BRANCH_LABELS, EMOTIONS, softmax, to_canonical
7
+
8
+
9
+ def onehot_logits(branch, emotion, high=5.0):
10
+ z = np.zeros(7)
11
+ z[BRANCH_LABELS[branch].index(emotion)] = high
12
+ return z
13
+
14
+
15
+ def test_each_branch_reorders_into_canonical_labels():
16
+ for branch in BRANCH_LABELS:
17
+ for emotion in EMOTIONS:
18
+ assert EMOTIONS[int(to_canonical(branch, onehot_logits(branch, emotion)).argmax())] == emotion
19
+
20
+
21
+ def test_face_reorder_matches_ser_handoff_index_list():
22
+ # SER_models/README.md: reorder face logits into speech order with [6, 3, 4, 0, 5, 2, 1]
23
+ z = np.arange(7.0)
24
+ assert to_canonical("face", z).tolist() == z[[6, 3, 4, 0, 5, 2, 1]].tolist()
25
+
26
+
27
+ def probs_for(emotion, p):
28
+ x = np.full(7, (1 - p) / 6)
29
+ x[EMOTIONS.index(emotion)] = p
30
+ return x
31
+
32
+
33
+ W = {"speech": 0.35, "face": 0.65, "text": 0.35}
34
+
35
+
36
+ def test_missing_branches_are_dropped_not_counted_as_neutral():
37
+ only_speech = fuse({"speech": BranchResult(probs_for("angry", 0.6), OK),
38
+ "face": BranchResult(status="no_face"),
39
+ "text": BranchResult(status="unsupported_language")}, W)
40
+ assert only_speech.used == {"speech": 1.0}
41
+ np.testing.assert_allclose(only_speech.probs, probs_for("angry", 0.6))
42
+
43
+
44
+ def test_nothing_present_gives_no_prediction():
45
+ fused = fuse({"speech": BranchResult(status="silence"), "face": BranchResult(status="no_face")}, W)
46
+ assert fused.probs is None and fused.top is None and fused.used == {}
47
+
48
+
49
+ def test_log_linear_pooling_weights_and_normalisation():
50
+ a, b = probs_for("happy", 0.7), probs_for("sad", 0.7)
51
+ fused = fuse({"speech": BranchResult(a, OK), "face": BranchResult(b, OK)}, W)
52
+ expected = np.exp(0.35 * np.log(a) + 0.65 * np.log(b))
53
+ np.testing.assert_allclose(fused.probs, expected / expected.sum())
54
+ assert fused.top == "sad" and abs(sum(fused.used.values()) - 1) < 1e-12
55
+
56
+
57
+ def test_agreeing_branches_raise_confidence():
58
+ p = probs_for("surprise", 0.5)
59
+ fused = fuse({b: BranchResult(p, OK) for b in W}, W)
60
+ assert fused.top == "surprise" and np.isclose(fused.confidence, 0.5) # identical inputs: unchanged
61
+
62
+
63
+ def test_temperature_flattens():
64
+ z = np.array([3.0, 0, 0, 0, 0, 0, 0])
65
+ assert softmax(z, 6.46).max() < softmax(z, 1.0).max()
tests/test_pipeline.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End to end through MultimodalPipeline with the real models (skipped without MM_EMOTION_LOCAL_DIR)."""
2
+
3
+ import os
4
+ import time
5
+ import wave
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ from reachy_mini_multimodal_emotion.engine.speech import SAMPLE_RATE
12
+
13
+
14
+ @pytest.fixture(scope="module")
15
+ def pipe(model_root):
16
+ from reachy_mini_multimodal_emotion.engine.pipeline import MultimodalPipeline
17
+
18
+ asr = os.environ.get("MM_EMOTION_ASR_DIR")
19
+ p = MultimodalPipeline(model_root, asr if asr and Path(asr).exists() else None)
20
+ yield p
21
+ p.close()
22
+
23
+
24
+ def voiced(seconds=2.0):
25
+ t = np.arange(int(SAMPLE_RATE * seconds)) / SAMPLE_RATE
26
+ return (0.08 * np.sign(np.sin(2 * np.pi * 150 * t)) * (0.6 + 0.4 * np.sin(2 * np.pi * 3 * t))).astype(np.float32)
27
+
28
+
29
+ def test_silence_and_no_face_give_no_emotion(pipe):
30
+ pipe.reset_audio()
31
+ pipe.push_audio(np.zeros(SAMPLE_RATE * 2, np.float32))
32
+ pipe.push_frame(np.full((480, 640, 3), 127, np.uint8))
33
+ snap = pipe.step()
34
+ assert snap.branches["speech"].status == "silence"
35
+ assert snap.branches["face"].status == "no_face"
36
+ assert snap.fused.probs is None
37
+
38
+
39
+ def test_voice_only_fusion_uses_speech_alone(pipe):
40
+ pipe.reset_audio()
41
+ pipe.push_audio(voiced())
42
+ pipe.push_frame(None)
43
+ snap = pipe.step()
44
+ assert snap.branches["speech"].present and snap.fused.used == {"speech": 1.0}
45
+ np.testing.assert_allclose(snap.fused.probs, snap.branches["speech"].probs, atol=1e-9)
46
+
47
+
48
+ def test_asr_utterance_reaches_text_branch(pipe):
49
+ if pipe.transcriber is None:
50
+ pytest.skip("set MM_EMOTION_ASR_DIR to the SenseVoice folder")
51
+ clip = Path(os.environ["MM_EMOTION_ASR_DIR"]) / "test_wavs" / "zh.wav"
52
+ if not clip.exists():
53
+ pytest.skip("SenseVoice zh.wav test clip not available")
54
+ with wave.open(str(clip)) as w:
55
+ audio = np.frombuffer(w.readframes(w.getnframes()), np.int16).astype(np.float32) / 32768
56
+ audio = np.concatenate([audio, np.zeros(SAMPLE_RATE, np.float32)]) # trailing silence ends the utterance
57
+ time.sleep(1.0) # let the worker finish segments left over from earlier tests
58
+ pipe.reset_audio()
59
+ pipe.utterances.clear()
60
+ for i in range(0, len(audio), 1024):
61
+ pipe.push_audio(audio[i:i + 1024])
62
+ deadline = time.monotonic() + 20
63
+ while not any(u["lang"] == "zh" for u in pipe.utterances) and time.monotonic() < deadline:
64
+ time.sleep(0.1)
65
+ zh = [u for u in pipe.utterances if u["lang"] == "zh"]
66
+ assert zh, f"no Mandarin utterance transcribed: {list(pipe.utterances)}"
67
+ assert "時間" in zh[0]["text"] # converted to Traditional
68
+ assert pipe.step().branches["text"].present
tests/test_policy.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reaction policy and SER engine behaviour."""
2
+
3
+ import random
4
+
5
+ import numpy as np
6
+
7
+ from reachy_mini_multimodal_emotion.engine.labels import EMOTIONS
8
+
9
+ from reachy_mini_multimodal_emotion.reactions import EMPATHY_MOVES, ReactionPolicy, pick_move
10
+
11
+
12
+ def probs_for(emotion, p):
13
+ x = np.full(7, (1 - p) / 6)
14
+ x[EMOTIONS.index(emotion)] = p
15
+ return x
16
+
17
+
18
+ def test_policy_needs_sustained_confidence_then_cools_down():
19
+ pol = ReactionPolicy(hold_s=1.5, cooldown_s=6.0)
20
+ happy = probs_for("happy", 0.8)
21
+ assert pol.update(happy, False, 0.0) is None # first sighting starts the hold timer
22
+ assert pol.update(happy, False, 1.0) is None # not held long enough
23
+ assert pol.update(happy, False, 1.6) == "happy" # fires after 1.5 s
24
+ assert pol.update(happy, False, 2.0) is None # restarts hold
25
+ assert pol.update(happy, False, 4.0) is None # held, but inside the 6 s cooldown
26
+ assert pol.update(happy, False, 7.7) == "happy" # cooldown over
27
+
28
+
29
+ def test_policy_ignores_neutral_idle_low_confidence_and_resets_on_switch():
30
+ pol = ReactionPolicy(hold_s=1.0, cooldown_s=0.0)
31
+ assert pol.update(probs_for("neutral", 0.9), False, 0.0) is None
32
+ assert pol.update(probs_for("neutral", 0.9), False, 5.0) is None # neutral never reacts
33
+ assert pol.update(probs_for("angry", 0.4), False, 6.0) is None # below threshold
34
+ pol.update(probs_for("angry", 0.8), False, 7.0)
35
+ assert pol.update(probs_for("angry", 0.8), True, 8.5) is None # idle interrupts
36
+ pol.update(probs_for("angry", 0.8), False, 9.0)
37
+ pol.update(probs_for("fear", 0.8), False, 9.5) # switch restarts hold
38
+ assert pol.update(probs_for("fear", 0.8), False, 10.0) is None
39
+ assert pol.update(probs_for("fear", 0.8), False, 10.6) == "fear"
40
+
41
+
42
+
43
+
44
+ def test_every_reactable_emotion_has_moves():
45
+ rng = random.Random(0)
46
+ for emo in EMOTIONS[1:]:
47
+ assert pick_move(emo, rng) in EMPATHY_MOVES[emo]
48
+ assert pick_move("neutral") is None
49
+
50
+
51
+ def test_mic_source_returns_with_continuous_audio():
52
+ """Regression from SER_Reachy_merli_v2: a chunk is always ready, so read() must not drain 'until None'."""
53
+ import threading
54
+ from types import SimpleNamespace
55
+
56
+ from reachy_mini_multimodal_emotion.main import MicSource
57
+
58
+ class EndlessMic:
59
+ def start_recording(self): pass
60
+ def stop_recording(self): pass
61
+ def get_audio_sample(self): return np.full((256, 2), 0.1, np.float32)
62
+
63
+ src = MicSource(SimpleNamespace(media=EndlessMic()))
64
+ out = []
65
+ t = threading.Thread(target=lambda: out.append(src.read()), daemon=True)
66
+ t.start(); t.join(timeout=1.0)
67
+ assert not t.is_alive() and out[0].shape == (256,)