Reachy Mini multimodal emotion app
Browse files- .gitignore +3 -0
- README.md +42 -10
- index.html +40 -19
- pyproject.toml +36 -0
- reachy_mini_multimodal_emotion/__init__.py +0 -0
- reachy_mini_multimodal_emotion/engine/__init__.py +39 -0
- reachy_mini_multimodal_emotion/engine/asr.py +99 -0
- reachy_mini_multimodal_emotion/engine/face.py +112 -0
- reachy_mini_multimodal_emotion/engine/fusion.py +57 -0
- reachy_mini_multimodal_emotion/engine/labels.py +34 -0
- reachy_mini_multimodal_emotion/engine/pipeline.py +120 -0
- reachy_mini_multimodal_emotion/engine/speech.py +144 -0
- reachy_mini_multimodal_emotion/engine/text.py +80 -0
- reachy_mini_multimodal_emotion/main.py +335 -0
- reachy_mini_multimodal_emotion/reactions.py +59 -0
- reachy_mini_multimodal_emotion/static/index.html +75 -0
- reachy_mini_multimodal_emotion/static/main.js +145 -0
- reachy_mini_multimodal_emotion/static/style.css +140 -0
- style.css +411 -28
- tests/conftest.py +14 -0
- tests/test_branches.py +104 -0
- tests/test_fusion.py +65 -0
- tests/test_pipeline.py +68 -0
- tests/test_policy.py +67 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: static
|
| 7 |
-
pinned: false
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
</
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
</p>
|
| 17 |
-
</div>
|
| 18 |
-
</
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 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,)
|