asdfasdfqrqwer commited on
Commit
c857b85
ยท
0 Parent(s):

Deploy from GitHub 2026-04-23T03:56:31Z

Browse files
This view is limited to 50 files because it contains too many changes. ย  See raw diff
Files changed (50) hide show
  1. .dockerignore +32 -0
  2. .gitattributes +1 -0
  3. Dockerfile +39 -0
  4. README.md +334 -0
  5. VERSION +1 -0
  6. config.yaml +87 -0
  7. data/meld_test/01_angry_fight.wav +3 -0
  8. data/meld_test/02_happy_loving.wav +3 -0
  9. data/meld_test/03_sad_emotional.wav +3 -0
  10. data/meld_test/04_surprise_shock.wav +3 -0
  11. data/meld_test/05_fear_anxiety.wav +3 -0
  12. data/meld_test/06_disgust_annoyance.wav +3 -0
  13. data/meld_test/07_bittersweet.wav +3 -0
  14. data/meld_test/08_calm_daily.wav +3 -0
  15. data/meld_test/09_opposite_emotions.wav +3 -0
  16. data/meld_test/README.md +53 -0
  17. data/meld_test/ground_truth.json +772 -0
  18. railway.toml +9 -0
  19. requirements-deploy.txt +31 -0
  20. requirements.txt +17 -0
  21. scripts/add_ravdess_to_english_manifest.py +76 -0
  22. scripts/asr_savee_disgust_surprise.py +50 -0
  23. scripts/benchmark_emotion2vec.py +513 -0
  24. scripts/benchmark_ser_models.py +799 -0
  25. scripts/build_english_fusion_manifest.py +135 -0
  26. scripts/build_meld_test_sets.py +373 -0
  27. scripts/cache_models.py +128 -0
  28. scripts/convert_to_onnx.py +0 -0
  29. scripts/eval_audio_on_subset.py +138 -0
  30. scripts/evaluate_emotion2vec_english.py +194 -0
  31. scripts/export_lora_onnx.py +269 -0
  32. scripts/optimize_fusion_weights.py +618 -0
  33. scripts/prepare_aihub_test_subset.py +421 -0
  34. scripts/prepare_dataset.py +0 -0
  35. scripts/prepare_lora_dataset.py +875 -0
  36. scripts/prepare_meld_fusion_data.py +153 -0
  37. scripts/prepare_ravdess.py +220 -0
  38. scripts/preprocess_phone_audio.py +175 -0
  39. scripts/quantize_model.py +0 -0
  40. scripts/run_pipeline.py +121 -0
  41. scripts/test_20hours_e2e_server.py +218 -0
  42. scripts/test_english_e2e.py +239 -0
  43. scripts/test_meld_e2e_server.py +253 -0
  44. scripts/train_emotion2vec.py +0 -0
  45. scripts/train_fusion_weights.py +297 -0
  46. scripts/train_kcelectra.py +0 -0
  47. scripts/train_lora_emotion2vec.py +749 -0
  48. scripts/train_lora_kcelectra.py +402 -0
  49. scripts/train_whisper_emotion_head.py +219 -0
  50. scripts/validate_text_emotion_english.py +137 -0
.dockerignore ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Large data directories
2
+ data/
3
+ venv/
4
+ .venv/
5
+
6
+ # Frontend (not needed for API server)
7
+ app/
8
+ node_modules/
9
+
10
+ # Dev files
11
+ notebooks/
12
+ .git/
13
+ .github/
14
+ __pycache__/
15
+ *.pyc
16
+
17
+ # Archives
18
+ *.zip
19
+
20
+ # IDE
21
+ .vscode/
22
+ .idea/
23
+
24
+ # Claude/gstack
25
+ .claude/
26
+ .gstack/
27
+ .superpowers/
28
+
29
+ # Docs (not needed at runtime)
30
+ docs/
31
+ *.md
32
+ !requirements-deploy.txt
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.wav filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # System deps for audio processing (librosa, soundfile)
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ libsndfile1 \
6
+ ffmpeg \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app
10
+
11
+ # Install torch + torchaudio CPU-only (saves ~2.3GB vs full CUDA build)
12
+ RUN pip install --no-cache-dir \
13
+ torch torchaudio --index-url https://download.pytorch.org/whl/cpu
14
+
15
+ # Force cache invalidation
16
+ ARG CACHEBUST=6
17
+ # Install remaining dependencies
18
+ COPY requirements-deploy.txt .
19
+ RUN pip install --no-cache-dir -r requirements-deploy.txt
20
+
21
+ # Copy source code
22
+ COPY src/ src/
23
+ COPY config.yaml .
24
+ COPY scripts/cache_models.py scripts/cache_models.py
25
+
26
+ # Pre-download ML models at build time (avoids cold-start downloads)
27
+ ARG HF_TOKEN
28
+ ENV HF_TOKEN=${HF_TOKEN}
29
+ RUN python scripts/cache_models.py
30
+
31
+ # Create data directory for SQLite + uploads
32
+ RUN mkdir -p data/samples
33
+
34
+ # HF Spaces port
35
+ ENV PORT=7860
36
+
37
+ EXPOSE 7860
38
+
39
+ CMD ["uvicorn", "src.stage4.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: UsTwo API
3
+ emoji: ๐Ÿ’‘
4
+ colorFrom: pink
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # UsTwo โ€” your characters react to your calls
12
+
13
+ > **CMU 11-775 Large Scale Multimedia Analysis** โ€” Team Project
14
+ > 3-person team: Juhyun (PO / Research) ยท Seungjae (ML) ยท Youngkyun (App)
15
+
16
+ UsTwo takes a recorded phone call between two people (couple, friends, family) and runs **multimodal analysis (audio + text)** to understand how each speaker felt. It then produces three things inside a **React Native + FastAPI** mobile app: a **character reaction scene**, a **growing emotion garden**, and an **LLM-written recap card**.
17
+
18
+ The goal isn't just emotion classification. It's to visualize *"what kind of moment did these two share on this call?"*
19
+
20
+ <p align="center">
21
+ <img src="docs/images/app-home.png" width="260" alt="Home" />
22
+ <img src="docs/images/app-recap.png" width="260" alt="Recap card" />
23
+ <img src="docs/images/app-history.png" width="260" alt="History" />
24
+ </p>
25
+
26
+ ---
27
+
28
+ ## At a glance
29
+
30
+ ```
31
+ [call recording .wav/.m4a]
32
+ โ”‚
33
+ โ–ผ
34
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Stage 1 (Seungjae) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
35
+ โ”‚ pyannote 4.x (3.1) โ†’ WhisperX large-v3-turbo INT8 โ†’ ko/en โ”‚
36
+ โ”‚ speaker diarization ASR + forced alignment LID โ”‚
37
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
38
+ โ”‚ segments: [{speaker, start, end, text, lang}]
39
+ โ–ผ
40
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Stage 2 (Seungjae) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
41
+ โ”‚ emotion2vec LoRA (ONNX) + KcELECTRA LoRA (ko) / DistilRoBERTa โ”‚
42
+ โ”‚ audio emotion (7-class) text emotion (7-class ko / 7-class en) โ”‚
43
+ โ”‚ โ”‚
44
+ โ”‚ fusion: per-language, per-class trained weights (v2) โ”‚
45
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
46
+ โ”‚ per-speaker emotion distribution
47
+ โ–ผ
48
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Stage 3 (Youngkyun) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
49
+ โ”‚ character_mapping + garden_logic + recap_generator โ”‚
50
+ โ”‚ 9 pair interactions 5 levels ยท 4 moods Claude LLM โ”‚
51
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
52
+ โ”‚
53
+ โ–ผ
54
+ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Stage 4 (Youngkyun) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
55
+ โ”‚ FastAPI + SQLite โ†” React Native (Expo) โ”‚
56
+ โ”‚ 6 endpoints, async expo-router ยท SVG โ”‚
57
+ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
58
+ ```
59
+
60
+ **End-to-end latency:** a 1-minute call finishes in about 2 minutes on the HF Spaces CPU deployment.
61
+ **Live server:** `https://bbbakery-ustwo-api.hf.space`
62
+
63
+ ---
64
+
65
+ ## Demo โ€” the garden grows
66
+
67
+ The emotion garden levels up as positive-ratio interactions accumulate. Level 1 shows just two seedlings. After many positive calls, Level 5 is a full bloom with flowers, trees, and creatures.
68
+
69
+ <p align="center">
70
+ <img src="docs/images/garden-level-1.png" width="260" alt="Level 1 โ€” seedling" />
71
+ <img src="docs/images/garden-level-5.png" width="260" alt="Level 5 โ€” full bloom" />
72
+ </p>
73
+
74
+ | Level | Threshold (cumulative interactions) | Visual |
75
+ |-------|-------------------------------------|--------|
76
+ | 1 | 0โ€“2 | Two seedlings |
77
+ | 2 | 3โ€“7 | Grass + small flowers |
78
+ | 3 | 8โ€“14 | Trees + many flowers |
79
+ | 4 | 15โ€“24 | Lush garden + creatures |
80
+ | 5 | 25+ | Sunset sky + butterflies, rabbits + full bloom |
81
+
82
+ A **mood overlay** (`happy` ยท `neglected` ยท `recovering` ยท `conflict`) is layered on top, decided by the recent positive/negative ratio and the days since the last interaction. A healthy garden that's been ignored for 5+ days turns `neglected`; a recent call with heavy negative emotion pushes it to `conflict`.
83
+
84
+ ---
85
+
86
+ ## Stage 1โ€“2 โ€” ML Pipeline (Seungjae)
87
+
88
+ ### Stage 1: Diarization + ASR
89
+
90
+ | Component | Model | Config |
91
+ |-----------|-------|--------|
92
+ | VAD | Silero VAD | onset 0.5, min_speech 0.25s |
93
+ | Diarization | pyannote-audio 4.x (3.1) | HF token required |
94
+ | ASR | Faster-Whisper (large-v3-turbo) | INT8 quantized |
95
+ | Forced alignment | WhisperX wav2vec2 | per-word timestamps |
96
+ | Language ID | Whisper auto-detect + SenseVoice-Small | ko / en / unknown |
97
+
98
+ **Output:** `Stage1Output` โ€” `segments: [{speaker, start, end, text, lang}]`, `processing_info`, `models`. Entry point: `src/stage1/process.py`.
99
+
100
+ ### Stage 2: Emotion Recognition (bilingual)
101
+
102
+ | Channel | Model | Classes |
103
+ |---------|-------|---------|
104
+ | Audio emotion | `emotion2vec_plus_base` + **LoRA fine-tuned (ONNX)** | 7: neutral, joy, sadness, anger, surprise, fear, disgust |
105
+ | Text emotion โ€” Korean | **KcELECTRA-base-v2022 + PEFT LoRA (ONNX)** | 7: neutral, joy, sadness, anger, surprise, fear, disgust |
106
+ | Text emotion โ€” English | DistilRoBERTa (j-hartmann/emotion-english, zero-shot) | 7 Ekman + neutral |
107
+ | Fusion | **Per-language, per-class weights trained via gradient descent** | EMOTION_FUSION_WEIGHTS_KO / _EN |
108
+
109
+ ### Evaluation โ€” English (RAVDESS, n=2,880)
110
+
111
+ | Condition | Accuracy | Macro F1 | Latency |
112
+ |-----------|----------|----------|---------|
113
+ | Clean (studio) | **93.2%** | **0.932** | 250 ms |
114
+ | Phone (PSTN simulation, 300โ€“3400 Hz band-limit) | **71.5%** | **0.710** | 242 ms |
115
+ | Degradation | โˆ’21.7 pp | โˆ’0.222 | โ€” |
116
+
117
+ - **Phone-robust emotions (F1 > 0.80):** anger (0.836), surprise (0.856) โ€” high-energy acoustic cues survive the band-limit.
118
+ - **Phone-degraded (largest drop):** joy (0.946 โ†’ 0.585, โˆ’0.361), sadness (0.868 โ†’ 0.559, โˆ’0.309) โ€” subtler pitch/timbre cues degrade hardest.
119
+ - **Text emotion sanity check (DistilRoBERTa on j-hartmann's held-out set):** 95.2% accuracy.
120
+
121
+ Full report: [`docs/stage2/english-evaluation-report.md`](docs/stage2/english-evaluation-report.md)
122
+
123
+ ### Evaluation โ€” Korean (KcELECTRA fine-tuning)
124
+
125
+ - Fine-tuned on the AI Hub *๊ฐ์„ฑ ๋Œ€ํ™” ๋ง๋ญ‰์น˜* (emotion dialogue corpus) on Colab GPU.
126
+ - Macro F1: **0.20 (base) โ†’ 0.65 (fine-tuned)**, a 3.25ร— improvement.
127
+ - Discovered an unlabeled "joy" cluster in the dataset and applied class weighting to recover minority classes.
128
+
129
+ ### Fusion weight training (v2, 2026-04-19)
130
+
131
+ Replaced greedy per-emotion grid search with **PyTorch gradient descent** training `w_a = sigmoid(ฮฑ)` per class, optimized jointly with cross-entropy + L2 regularization on a held-out val split.
132
+
133
+ | Language | Training data | Samples | Val Macro F1 (v1 โ†’ v2) |
134
+ |----------|---------------|---------|------------------------|
135
+ | English | JL-Corpus + SAVEE + MELD + RAVDESS phone | 2,821 | 0.6295 โ†’ **0.7596 (+12.67%p)** |
136
+ | Korean | AI Hub 263 val | 1,294 | 0.8736 โ†’ **0.8748 (tie)** |
137
+
138
+ Biggest single win: English `fear` F1 rose from **0.04 โ†’ 0.67** (greedy's `audio_w=0.00` was a local trap). Per-language weight tables: `src/common/constants.py` (`EMOTION_FUSION_WEIGHTS_KO` / `_EN`). Details: [`docs/stage2/fusion-weights-english-grid-search.md`](docs/stage2/fusion-weights-english-grid-search.md).
139
+
140
+ ### End-to-end test โ€” MELD (Friends TV dialogue)
141
+
142
+ We built 8 scenario WAVs from MELD (anger, joy, sadness, surprise, fear, bittersweet, annoyance, calm) and ran them through the deployed server.
143
+
144
+ | Metric | Result |
145
+ |--------|--------|
146
+ | Pipeline success | **8 / 8** files completed Stage 1 โ†’ 2 โ†’ 3 |
147
+ | Exact top-1 emotion label match | **7 / 7** (one tie) |
148
+ | Average processing time | ~2 min / file (HF Spaces CPU) |
149
+
150
+ ### End-to-end test โ€” 20Hours Korean demo (2026-04-20)
151
+
152
+ Companion Korean E2E set curated from the 20Hours Korean Conversational Speech dataset (M-F pairs only). 7 scenarios @ ~1 min each for live demo.
153
+
154
+ | Metric | Result |
155
+ |--------|--------|
156
+ | Pipeline success | **7 / 7** files completed Stage 1 โ†’ 2 โ†’ 3 |
157
+ | Intended-emotion match (one speaker) | **4 / 7** |
158
+ | Source | `data/20hours_test/` + `scripts/test_20hours_e2e_server.py` |
159
+
160
+ Note: 20Hours source is ASR-training data without emotion labels โ€” `ground_truth.json` lists *intended demo emotions* (for graph visualization), not ground-truth annotations.
161
+
162
+ ---
163
+
164
+ ## Stage 3โ€“4 โ€” App + Server (Youngkyun)
165
+
166
+ ### Stage 3: Reaction ยท Garden ยท Recap
167
+
168
+ `src/stage3/process.py` takes the Stage 2 output and runs three independent modules:
169
+
170
+ | Module | Role | Key logic |
171
+ |--------|------|-----------|
172
+ | `character_mapping.py` | Buckets each speaker's 7-class emotion into 4 moods (up / calm / down / tense) and looks up the pair cell in a 4ร—4 matrix โ†’ one of 9 pair states + a giver role | `joy โ†’ up`, `anger โ†’ tense`, `surprise/fear/disgust` resolved via residual distribution; `up ร— down โ†’ comforting (giver=A)`, `tense ร— tense โ†’ back_turned`, `calm ร— calm โ†’ idle`, ... |
173
+ | `character_mapping.py` (intensity) | Emits a `CharacterReaction.intensity` in `{1, 2, 3}` used by the app for healing-cycle thresholds | Default 2 โ†’ 3 cycles to heal; 1 โ†’ 4 cycles; 3 โ†’ 2 cycles |
174
+ | `garden_logic.py` | Computes growth delta (0โ€“3) from call quality | `positive_ratio โ‰ฅ 0.5` โ†’ +3, `pos โ‰ฅ 0.3 && neg < 0.3` โ†’ +2, `neg โ‰ฅ 0.5` โ†’ 0 |
175
+ | `recap_generator.py` | Generates the narrative recap card via the Claude API | System prompt requires one concrete hook from the transcript (topic, decision, shared joke) + a light garden-voice framing โ€” titles are call-specific, not combo templates. Rule-based template fallback when no API key. |
176
+
177
+ **Mood resolution:** `neg โ‰ฅ 0.5` โ†’ `conflict`; `neg โ‰ฅ 0.3 && pos < 0.3` โ†’ `recovering`; `level โ‰ค 1 && pos < 0.3` โ†’ `neglected`; otherwise `happy`. **Level only goes up** โ€” tough calls shift the tint, never the count.
178
+
179
+ **Confidence gate:** a speaker whose top emotion probability is below 0.5 is demoted to `calm` for pair-state lookup. The 7-class mood chip on the Results screen can therefore differ from the pair-state particle โ€” the chip shows the dominant label, the particle shows the matrix cell after gating.
180
+
181
+ ### Stage 4: FastAPI backend
182
+
183
+ - **Framework:** FastAPI + SQLAlchemy + SQLite (local) ยท 4 tables: `calls`, `analysis_results`, `checkins`, `garden_state`.
184
+ - **Async pipeline:** `POST /api/upload` โ†’ `POST /api/analyze?call_id=X` returns 202 Accepted + a background thread, and the client polls `GET /api/calls/{id}`.
185
+ - **Mock path:** drop `data/samples/{call_id}_stage2.json` and the API skips Stage 1โ€“2, running only Stage 3 โ€” useful for E2E testing without the heavy ML deps.
186
+ - **Endpoints (6):** `/api/upload`, `/api/analyze`, `/api/calls`, `/api/calls/{id}`, `/api/checkins`, `/api/garden`. `/api/calls` extracts `recap_card.title` from the stored Stage 3 JSON so the Home and History feeds can headline each card with a distinct title.
187
+ - **Tests:** 12 API tests on in-memory SQLite via pytest.
188
+
189
+ ### React Native (Expo) app
190
+
191
+ **Router:** `expo-router` โ€” 3 tabs (`Us`, `History`, `Settings`) plus modal routes (`checkin`, `results/[callId]`).
192
+
193
+ | Screen | Description |
194
+ |--------|-------------|
195
+ | <img src="docs/images/app-onboarding.png" width="180" /> | **Onboarding** โ€” paper-deck intro ("A garden for two") that sets the metaphor before the first call lands |
196
+ | <img src="docs/images/app-home.png" width="180" /> | **Home (`Us`)** โ€” live character scene + garden, recent-call feed, mailbox entry point |
197
+ | <img src="docs/images/app-bloom.png" width="180" /> | **Seed bloom alert** โ€” level-up moment; fires when the interaction count crosses a garden threshold, offering `View` / `Later` |
198
+ | <img src="docs/images/app-checkin.png" width="180" /> | **Check-in** โ€” 2-step prompt: my mood, then my guess for the partner's mood (empathic accuracy) |
199
+ | <img src="docs/images/app-results.png" width="180" /> | **Results โ€” emotion analysis** โ€” `My mood` / `Their mood` chips, suggestion line in the garden-voice, `Emotional Landscape` wave (uplifting โ†’ heavy), and `Moments that mattered` per-speaker slices |
200
+ | <img src="docs/images/app-recap.png" width="180" /> | **Results โ€” recap card** โ€” call-specific LLM title + narrative + highlights + per-call `Our Garden` delta, followed by the `Was this accurate?` thumbs-up/down feedback loop |
201
+ | <img src="docs/images/app-history.png" width="180" /> | **History** โ€” `Our Emotional Flow` chart (Me vs Partner), garden delta summary, recent moment cards headlined by the call-specific recap title |
202
+ | <img src="docs/images/app-settings.png" width="180" /> | **Settings** โ€” language toggle (ko/en), developer mode, dev tools |
203
+
204
+ **Character animation layers** (all built on `react-native-reanimated` + `react-native-svg`):
205
+
206
+ 1. **Idle breathing** โ€” per-mood rhythm profile (up / calm / down / tense), uniform `scaleAmp` 1.02, micro-bob, micro-sway (1.4s cycle).
207
+ 2. **Eye blink** โ€” 2.8โ€“5.5s interval, 15% chance of a double-blink.
208
+ 3. **Emotion transition** โ€” joy = bounce + arms **raised**, sadness = sink + drooping arms, anger / disgust = crossed arms (no tilt), surprise = pop, fear = shrink. Body rotation is globally forbidden โ€” emotion reads via face, pupil, body pose, and idle rhythm instead.
209
+ 4. **Pair-state body pose** โ€” `comforting` giver moves 60% closer with no tilt; receiver gets a gentle sag + `pupilOffset` (head-lowering effect on eyes).
210
+ 5. **Pair-state particle signature** โ€” one preset per 4ร—4 matrix cell:
211
+ - `comforting` โ†’ staged echo: 1 heart (giver โ†’ receiver) + 1 bubble dot + 1 translucent heart (bezier engine, `useParticles`).
212
+ - `dancing` โ†’ 1 music note rising above the heads.
213
+ - `cheering` / `listening` / `sitting_together` / `defusing` โ†’ yellow sparkle cluster.
214
+ - `tension` / `back_turned` โ†’ grey sigh cloud.
215
+ - `idle` (calm ร— calm) โ†’ silent; the couple just wanders.
216
+ 6. **Generalised healing** โ€” every giver-present cell (comforting, listening, defusing, cheering) counts interaction cycles; when the intensity-scaled threshold is hit the receiver transitions to neutral. If the giver's solo state was negative-caring (fear / anger / sadness / disgust), they heal too โ€” no one is left behind.
217
+ 7. **Tap interaction** โ€” spring bounce + floating hearts / sparkles (`FloatingHearts` component).
218
+ 8. **Wander** โ€” characters roam between pre-validated waypoints (3โ€“8s move, 2โ€“5s pause), with direction-aware facing (scaleX flip for left/right, back-view for upward movement).
219
+
220
+ **Garden rendering (SVG):** Sky, Ground, Trees (level-specific types and counts), Flowers (distributed across four quadrants), Creatures (butterflies, rabbits, birds โ€” Level 4 and above).
221
+
222
+ **i18n:** a custom `LocaleContext` drives Korean/English switching with `@ustwo/locale` persisted to AsyncStorage.
223
+
224
+ ### State persistence
225
+
226
+ | Store | Data | Key |
227
+ |-------|------|-----|
228
+ | SQLite (server) | calls, analysis_results, checkins, garden_state | โ€” |
229
+ | AsyncStorage (app) | locale, dev_mode, garden (interactionCount + lastPositiveRatio + lastInteractionDate), checkins (local cache) | `@ustwo/*` |
230
+
231
+ ---
232
+
233
+ ## Repository map
234
+
235
+ ```
236
+ src/stage1/ Speaker diarization + ASR (Seungjae)
237
+ src/stage2/ Audio + text emotion + fusion (Seungjae)
238
+ src/stage3/ Character, garden, recap (Youngkyun)
239
+ src/stage4/ FastAPI + SQLite + orchestration (Youngkyun)
240
+ src/common/ Pydantic schemas shared across stages
241
+ app/ React Native (Expo) app (Youngkyun)
242
+ src/routes/ expo-router (tabs, checkin, results, dev)
243
+ src/components/ characters, garden, scenes, layout
244
+ src/contexts/ Locale, Garden, DevMode
245
+ src/hooks/ Animation hooks (idle, blink, emotion, tap, wander)
246
+ notebooks/ KcELECTRA fine-tuning (Colab)
247
+ tests/ pytest (73 Python tests) + jest (71 JS tests)
248
+ docs/stage{1,2,3,4}/ Per-stage technical docs
249
+ docs/images/ README screenshots
250
+ config.yaml Global config (model paths, thresholds)
251
+ Dockerfile HF Spaces deployment (Python 3.12 + torch CPU + ffmpeg)
252
+ ```
253
+
254
+ ---
255
+
256
+ ## Getting started
257
+
258
+ ### 1. Clone
259
+
260
+ ```bash
261
+ git clone https://github.com/boolooppang/UsTwo.git
262
+ cd UsTwo
263
+ ```
264
+
265
+ ### 2. Backend (Python)
266
+
267
+ ```bash
268
+ python -m venv venv && source venv/bin/activate
269
+ pip install -r requirements.txt
270
+ export HF_TOKEN=your_huggingface_token # required by pyannote
271
+
272
+ uvicorn src.stage4.main:app --reload --port 8000
273
+ # โ†’ POST /api/upload, POST /api/analyze?call_id=X, GET /api/calls
274
+ ```
275
+
276
+ ### 3. App (React Native / Expo)
277
+
278
+ ```bash
279
+ cd app && npm install
280
+ npx expo start --dev-client
281
+ # Press 'i' for iOS simulator, or scan the QR code with a real device
282
+ ```
283
+
284
+ To point the app at a different server, edit `API_BASE` in `app/src/api/client.ts` (it currently defaults to the HF Spaces URL).
285
+
286
+ ### 4. Tests
287
+
288
+ ```bash
289
+ python -m pytest tests/ -v # 73 Python tests
290
+ cd app && npx jest # 71 JS tests (144 total)
291
+ ```
292
+
293
+ ---
294
+
295
+ ## Deployment โ€” HuggingFace Spaces (Docker)
296
+
297
+ The API server is deployed to HuggingFace Spaces as a Docker image, running the full pipeline (Stage 1 โ†’ 2 โ†’ 3).
298
+
299
+ - **Live URL:** `https://bbbakery-ustwo-api.hf.space`
300
+ - **Environment variables:** `HF_TOKEN`, `ANTHROPIC_API_KEY` (set in Spaces Settings).
301
+ - **Config files:** [`Dockerfile`](Dockerfile), [`railway.toml`](railway.toml), [`requirements-deploy.txt`](requirements-deploy.txt).
302
+
303
+ ```bash
304
+ # Local Docker test
305
+ docker build -t ustwo .
306
+ docker run -p 7860:7860 -e HF_TOKEN=your_token ustwo
307
+ ```
308
+
309
+ ---
310
+
311
+ ## Team & ownership
312
+
313
+ | Member | Role | Responsibility |
314
+ |--------|------|----------------|
315
+ | **Juhyun** | Product Owner / Researcher | Product design, UX research, empathic-accuracy literature review, emotion โ†’ character mapping spec, evaluation plan, final paper |
316
+ | **Seungjae** | ML Engineer | Stage 1โ€“2 end to end โ€” diarization, ASR, audio emotion, text emotion (ko/en), RAVDESS/MELD evaluation, KcELECTRA fine-tuning |
317
+ | **Youngkyun** | App Engineer | Stage 3โ€“4 end to end โ€” character mapping implementation, garden logic, LLM recap, FastAPI + SQLite server, React Native app, HF Spaces Docker deployment |
318
+
319
+ ---
320
+
321
+ ## Success criteria
322
+
323
+ **MVP โ€” achieved:**
324
+ - โœ… Upload a call recording โ†’ character reaction + recap card within ~2 minutes.
325
+ - โœ… Happy vs tense calls produce visibly different character reactions (9 pair states).
326
+ - โœ… Cumulative positive calls grow the garden through 5 levels.
327
+ - โœ… Bilingual pipeline (Korean + English) with automatic language detection.
328
+ - โœ… MELD-based end-to-end test: 8/8 pipeline success, 7/7 exact emotion-label match.
329
+
330
+ ---
331
+
332
+ ## License
333
+
334
+ MIT
VERSION ADDED
@@ -0,0 +1 @@
 
 
1
+ 2.0.0
config.yaml ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UsTwo Project Configuration
2
+ project:
3
+ name: "UsTwo"
4
+ version: "0.1.0"
5
+
6
+ paths:
7
+ data_dir: "data"
8
+ samples_dir: "data/samples"
9
+ models_dir: "data/models"
10
+ output_dir: "data"
11
+
12
+ # Stage 1: Speaker Diarization + ASR
13
+ stage1:
14
+ output_path: "data/stage1_output.json"
15
+ segments_dir: "data/segments"
16
+
17
+ preprocessing:
18
+ target_sample_rate: 16000
19
+ max_duration_sec: 300
20
+ min_duration_sec: 3
21
+ target_peak: 0.95 # peak normalization โ€” handles volume differences across devices
22
+
23
+ diarization:
24
+ model: "pyannote/speaker-diarization-3.1"
25
+ num_speakers: 2
26
+ merge_gap_sec: 0.15
27
+
28
+ asr:
29
+ model: "large-v3-turbo"
30
+ compute_type: "int8"
31
+ language: null # null = auto-detect
32
+ batch_size: 16
33
+
34
+ alignment:
35
+ enabled: true
36
+
37
+ language_id:
38
+ enabled: true
39
+ # SenseVoice disabled โ€” Whisper language + text heuristic ์‚ฌ์šฉ
40
+ # emotion2vec finetuning ์‹คํŒจ ์‹œ SenseVoice ๊ฐ์ • ํžŒํŠธ ํ™œ์šฉ ์˜ˆ์ •
41
+ # model: "FunAudioLLM/SenseVoiceSmall"
42
+
43
+ # Stage 2: Audio Emotion + Text Emotion
44
+ stage2:
45
+ input_path: "data/stage1_output.json"
46
+ output_path: "data/stage2_output.json"
47
+ audio_emotion:
48
+ model: "iic/emotion2vec_plus_base"
49
+ lora_onnx_path: "data/models/lora_emotion2vec_7class/model.onnx"
50
+ finetuned_checkpoint: null # legacy, use lora_onnx_path instead
51
+ text_emotion:
52
+ korean_model: "searle-j/kote_for_easygoing_people"
53
+ korean_lora_onnx_path: "data/models/lora_kcelectra_7class/model.onnx"
54
+ korean_lora_tokenizer: "data/models/lora_kcelectra_7class/best_model"
55
+ english_model: "j-hartmann/emotion-english-distilroberta-base"
56
+ fusion:
57
+ mode: "emotion_specific" # "emotion_specific" (per-class grid-search optimized) or "fixed" (60/40)
58
+ audio_weight: 0.6 # fallback for mode="fixed"
59
+ text_weight: 0.4
60
+
61
+ # Stage 3: Character Reaction + Garden + Recap
62
+ stage3:
63
+ input_path: "data/stage2_output.json"
64
+ output_path: "data/stage3_output.json"
65
+ recap:
66
+ llm_provider: "anthropic" # anthropic | openai
67
+ model: "claude-sonnet-4-20250514"
68
+ max_tokens: 500
69
+ garden:
70
+ growth_per_call: 5
71
+ max_level: 5
72
+
73
+ # Stage 4: FastAPI Server
74
+ stage4:
75
+ input_path: "data/stage3_output.json"
76
+ host: "0.0.0.0"
77
+ port: 8000
78
+ max_upload_size_mb: 50
79
+ allowed_extensions: [".wav", ".mp3", ".m4a", ".ogg"]
80
+
81
+ # API Keys (ํ™˜๊ฒฝ ๋ณ€์ˆ˜์—์„œ ๋กœ๋“œ โ€” .env ํŒŒ์ผ ์‚ฌ์šฉ)
82
+ api: {}
83
+
84
+ # Logging
85
+ logging:
86
+ level: "INFO"
87
+ format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
data/meld_test/01_angry_fight.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a9e440869de56c29b94e85b12b6145181439011f1c2a24831ee58b17d268bda7
3
+ size 1098486
data/meld_test/02_happy_loving.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:194ce7561f2bb9efa54386938fb2fc0d87713590cbb7e728d7d3185c19f101cd
3
+ size 1400224
data/meld_test/03_sad_emotional.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:23c3e6458334dc0b24940144b9e202c82b7b86be6c0cdea982a76e7934206848
3
+ size 1584548
data/meld_test/04_surprise_shock.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2958c4ad34b23c793995b9357f80e5a3a89cf26d8675eff581402b17a2c01e9f
3
+ size 966734
data/meld_test/05_fear_anxiety.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3fdad57492923cc788a550f75a486950e61d9b851cf69a2e9a694da5c14bca8a
3
+ size 637008
data/meld_test/06_disgust_annoyance.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c5dd1600ef131cfe92ba93f79a5d82396164aade9902d8446ebef475480a2cb6
3
+ size 933964
data/meld_test/07_bittersweet.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:11c445fc5445a7e9c618b09deb351852ac616580ff7d59ac579a0be980a059f3
3
+ size 1384526
data/meld_test/08_calm_daily.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:05b2cc7fdac461bf9e8d6610388891552806a6a1350c55b4624f8209dcc7ca07
3
+ size 1294414
data/meld_test/09_opposite_emotions.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ce64da9783391134b20071c315ff794d9508b14c8c8d639a80e6d09267f1bb4f
3
+ size 796766
data/meld_test/README.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MELD English Test Sets
2
+
3
+ ## Emotion Label Alignment
4
+
5
+ | UsTwo Pipeline (EN) | MELD Label | Match |
6
+ |---|---|---|
7
+ | neutral | neutral | โœ… Exact |
8
+ | joy | joy | โœ… Exact |
9
+ | sadness | sadness | โœ… Exact |
10
+ | anger | anger | โœ… Exact |
11
+ | surprise | surprise | โœ… Exact |
12
+ | fear | fear | โœ… Exact |
13
+ | disgust | disgust | โœ… Exact |
14
+
15
+ **7/7 labels match exactly.** No mapping needed.
16
+
17
+ ## Test Sets
18
+
19
+ | File | Scenario | Speakers | Primary Emotion | Duration | Utterances | Emotion Distribution |
20
+ |---|---|---|---|---|---|---|
21
+ | 01_angry_fight | Couple in a heated argument | Ross, Rachel | anger | 36.9s | 11 utts | anger:7 neutral:2 sadness:1 disgust:1 |
22
+ | 02_happy_loving | Couple being affectionate and playful | Chandler, Monica | joy | 43.8s | 16 utts | joy:6 surprise:5 anger:3 neutral:1 sadness:1 |
23
+ | 03_sad_emotional | Emotional confession โ€” "you still love me?" | Ross, Rachel | sadness | 55.4s | 17 utts | neutral:7 sadness:4 anger:3 surprise:3 |
24
+ | 04_surprise_shock | Drunk voicemail surprise scene | Ross, Rachel | surprise | 30.2s | 8 utts | surprise:5 neutral:2 sadness:1 |
25
+ | 05_fear_anxiety | Anxious and worried conversation | Chandler, Rachel | fear | 19.9s | 12 utts | fear:5 neutral:4 surprise:2 sadness:1 |
26
+ | 06_disgust_annoyance | Annoyed and frustrated bickering | Joey, Rachel | anger | 29.2s | 11 utts | anger:5 neutral:2 sadness:1 surprise:1 fear:1 joy:1 |
27
+ | 07_bittersweet | Saying goodbye with conflicting feelings | Ross, Rachel | sadness | 43.3s | 14 utts | sadness:6 surprise:3 anger:3 fear:1 neutral:1 |
28
+ | 08_calm_daily | Casual everyday chitchat (baseline) | Joey, Monica | neutral | 40.4s | 15 utts | neutral:13 joy:2 |
29
+ | 09_opposite_emotions | Tense speaker + calm listener โ€” triggers `listening` pair animation | 2 spk | surprise | 24.9s | 11 segs | surprise:5 neutral:3 anger:1 fear:1 joy:1 (pipeline fused) |
30
+
31
+ ## Notes
32
+ - All dialogues are 2-speaker (male + female) conversations
33
+ - 03: Ross+Rachel only, utt15 removed (timestamp overlap with utt14)
34
+ - 04: starts from "Rach, I got a message from you", utt3/utt8 removed (timestamp overlaps)
35
+ - 06: Joey+Rachel only, utt16 removed (addresses Ross)
36
+ - 07: utt11 removed (timestamp overlap with utt10)
37
+ - 09: **opposite-emotion demo scene (updated 2026-04-22)** โ€” WAV replaced by user. E2E pipeline output: speaker_0 dominant=surprise (max 0.68, tense via anger residual), speaker_1 dominant=neutral (max 0.54, calm). `(tense, calm)` โ†’ **pair_state=`listening`** triggers (sparkles effect, speaker_1 = giver/listener). Recap captures "one expressing worry through sharp words, the other sharing tender vision." Per-utterance ground-truth labels not available post-update; distribution numbers derived from pipeline fused output.
38
+
39
+ ## Source
40
+ - Dataset: MELD (Multimodal EmotionLines Dataset)
41
+ - Source: Friends TV series
42
+ - Paper: Poria et al., ACL 2019
43
+ - Each WAV is a full dialogue concatenated from per-utterance MP4 clips
44
+ - Audio: 16kHz mono PCM (matches pipeline input format)
45
+
46
+ ## Usage
47
+ ```bash
48
+ # Run pipeline on a single test set
49
+ python scripts/run_pipeline.py data/meld_test/01_angry_fight.wav
50
+
51
+ # Evaluate all test sets
52
+ python scripts/evaluate_meld_test.py
53
+ ```
data/meld_test/ground_truth.json ADDED
@@ -0,0 +1,772 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "01_angry_fight": {
3
+ "description": "Ross-Rachel breakup fight โ€” anger dominant (S3E15)",
4
+ "scenario": "Couple in a heated argument",
5
+ "primary_emotion": "anger",
6
+ "source": "MELD Friends S3E15 Dialogue 51 (utt0 removed, overlap fix)",
7
+ "duration_sec": 34.3,
8
+ "emotion_distribution": {
9
+ "sadness": 1,
10
+ "neutral": 1,
11
+ "disgust": 1,
12
+ "anger": 7
13
+ },
14
+ "total_utterances": 10,
15
+ "utterances": [
16
+ {
17
+ "speaker": "Rachel",
18
+ "emotion": "sadness",
19
+ "sentiment": "negative",
20
+ "text": "Hi. Look um, about what happened earlier..."
21
+ },
22
+ {
23
+ "speaker": "Ross",
24
+ "emotion": "neutral",
25
+ "sentiment": "neutral",
26
+ "text": "No, hey, well, I-I completely understand. You were, you were stressed."
27
+ },
28
+ {
29
+ "speaker": "Rachel",
30
+ "emotion": "disgust",
31
+ "sentiment": "negative",
32
+ "text": "I was gonna give you a chance to apologise to me."
33
+ },
34
+ {
35
+ "speaker": "Ross",
36
+ "emotion": "anger",
37
+ "sentiment": "negative",
38
+ "text": "For what? For letting you throw me out of your office?"
39
+ },
40
+ {
41
+ "speaker": "Rachel",
42
+ "emotion": "anger",
43
+ "sentiment": "negative",
44
+ "text": "You had no"
45
+ },
46
+ {
47
+ "speaker": "Ross",
48
+ "emotion": "anger",
49
+ "sentiment": "negative",
50
+ "text": "Yeah, well excuse me for wanting to be with my girlfriend on our anniversary, boy what an ass am I."
51
+ },
52
+ {
53
+ "speaker": "Rachel",
54
+ "emotion": "anger",
55
+ "sentiment": "negative",
56
+ "text": "But I told you, I didnโ€™t have the time!"
57
+ },
58
+ {
59
+ "speaker": "Ross",
60
+ "emotion": "anger",
61
+ "sentiment": "negative",
62
+ "text": "Yeah, well you never have the time. I mean, I donโ€™t feel like I even have a girlfriend anymore, Rachel."
63
+ },
64
+ {
65
+ "speaker": "Rachel",
66
+ "emotion": "anger",
67
+ "sentiment": "negative",
68
+ "text": "Wh, Ross what do you want from me?"
69
+ },
70
+ {
71
+ "speaker": "Rachel",
72
+ "emotion": "anger",
73
+ "sentiment": "negative",
74
+ "text": "You want me, you want me to quit my job so you can feel like you have a girlfriend?"
75
+ }
76
+ ]
77
+ },
78
+ "02_happy_loving": {
79
+ "description": "Monica-Chandler sweet moment โ€” joy dominant (S5E14)",
80
+ "scenario": "Couple being affectionate and playful",
81
+ "primary_emotion": "joy",
82
+ "source": "MELD Friends S5E14 Dialogue 1026",
83
+ "duration_sec": 43.8,
84
+ "emotion_distribution": {
85
+ "joy": 6,
86
+ "neutral": 1,
87
+ "surprise": 5,
88
+ "anger": 3,
89
+ "sadness": 1
90
+ },
91
+ "total_utterances": 16,
92
+ "utterances": [
93
+ {
94
+ "speaker": "Monica",
95
+ "emotion": "joy",
96
+ "sentiment": "positive",
97
+ "text": "You are so cute! How did you get to be so cute?"
98
+ },
99
+ {
100
+ "speaker": "Chandler",
101
+ "emotion": "joy",
102
+ "sentiment": "positive",
103
+ "text": "Well, my Grandfather was Swedish and my Grandmother was actually a tiny little bunny."
104
+ },
105
+ {
106
+ "speaker": "Monica",
107
+ "emotion": "joy",
108
+ "sentiment": "positive",
109
+ "text": "Okay, now you're even cuter!!"
110
+ },
111
+ {
112
+ "speaker": "Chandler",
113
+ "emotion": "neutral",
114
+ "sentiment": "neutral",
115
+ "text": "Y'know that is a popular opinion today I must say."
116
+ },
117
+ {
118
+ "speaker": "Monica",
119
+ "emotion": "surprise",
120
+ "sentiment": "negative",
121
+ "text": "What?"
122
+ },
123
+ {
124
+ "speaker": "Chandler",
125
+ "emotion": "surprise",
126
+ "sentiment": "negative",
127
+ "text": "The weirdest thing happened at the coffee house, I think, I think Phoebe was hitting on me."
128
+ },
129
+ {
130
+ "speaker": "Monica",
131
+ "emotion": "surprise",
132
+ "sentiment": "negative",
133
+ "text": "What are you talking about?"
134
+ },
135
+ {
136
+ "speaker": "Chandler",
137
+ "emotion": "joy",
138
+ "sentiment": "positive",
139
+ "text": "I'm telling you I think Phoebe thinks I'm foxy."
140
+ },
141
+ {
142
+ "speaker": "Monica",
143
+ "emotion": "joy",
144
+ "sentiment": "positive",
145
+ "text": "That's not possible!"
146
+ },
147
+ {
148
+ "speaker": "Chandler",
149
+ "emotion": "surprise",
150
+ "sentiment": "positive",
151
+ "text": "Ow!"
152
+ },
153
+ {
154
+ "speaker": "Monica",
155
+ "emotion": "joy",
156
+ "sentiment": "positive",
157
+ "text": "I'm sorry it's just, Phoebe just always thought you were, you were charming in a, in a sexless kind of way."
158
+ },
159
+ {
160
+ "speaker": "Chandler",
161
+ "emotion": "anger",
162
+ "sentiment": "negative",
163
+ "text": "Oh, y'know I-I can't hear that enough."
164
+ },
165
+ {
166
+ "speaker": "Monica",
167
+ "emotion": "sadness",
168
+ "sentiment": "negative",
169
+ "text": "I'm sorry, I think that you just misunderstood her."
170
+ },
171
+ {
172
+ "speaker": "Chandler",
173
+ "emotion": "anger",
174
+ "sentiment": "negative",
175
+ "text": "No, I didn't misunderstand, okay? She was all over me! She touched my bicep for crying out loud!"
176
+ },
177
+ {
178
+ "speaker": "Monica",
179
+ "emotion": "surprise",
180
+ "sentiment": "negative",
181
+ "text": "This bicep?"
182
+ },
183
+ {
184
+ "speaker": "Chandler",
185
+ "emotion": "anger",
186
+ "sentiment": "negative",
187
+ "text": "Well it's not flexed right now!"
188
+ }
189
+ ]
190
+ },
191
+ "03_sad_emotional": {
192
+ "description": "Ross-Rachel emotional confession โ€” sadness dominant (S3E25)",
193
+ "scenario": "Emotional conversation with sadness and regret",
194
+ "primary_emotion": "sadness",
195
+ "source": "MELD Friends S3E25 Dialogue 312 (Ross+Rachel only, utt15/19 removed)",
196
+ "duration_sec": 49.5,
197
+ "emotion_distribution": {
198
+ "neutral": 6,
199
+ "anger": 3,
200
+ "sadness": 4,
201
+ "surprise": 3
202
+ },
203
+ "total_utterances": 16,
204
+ "utterances": [
205
+ {
206
+ "speaker": "Ross",
207
+ "emotion": "neutral",
208
+ "sentiment": "neutral",
209
+ "text": "You donโ€™t know?! Rach, you balded my girlfriend!"
210
+ },
211
+ {
212
+ "speaker": "Rachel",
213
+ "emotion": "anger",
214
+ "sentiment": "negative",
215
+ "text": "All right! Ross, do you think itโ€™s easy for me to see you with somebody else?"
216
+ },
217
+ {
218
+ "speaker": "Ross",
219
+ "emotion": "anger",
220
+ "sentiment": "negative",
221
+ "text": "Y'know, hey! Youโ€™re the one who ended it, remember?"
222
+ },
223
+ {
224
+ "speaker": "Rachel",
225
+ "emotion": "neutral",
226
+ "sentiment": "neutral",
227
+ "text": "Yeah, because I was"
228
+ },
229
+ {
230
+ "speaker": "Ross",
231
+ "emotion": "sadness",
232
+ "sentiment": "negative",
233
+ "text": "You still love me?"
234
+ },
235
+ {
236
+ "speaker": "Rachel",
237
+ "emotion": "sadness",
238
+ "sentiment": "negative",
239
+ "text": "Noo."
240
+ },
241
+ {
242
+ "speaker": "Ross",
243
+ "emotion": "neutral",
244
+ "sentiment": "neutral",
245
+ "text": "You still love me."
246
+ },
247
+ {
248
+ "speaker": "Rachel",
249
+ "emotion": "surprise",
250
+ "sentiment": "positive",
251
+ "text": "Oh, y-yeah, so, you-you love me!"
252
+ },
253
+ {
254
+ "speaker": "Ross",
255
+ "emotion": "surprise",
256
+ "sentiment": "positive",
257
+ "text": "Noo, nnnnn. What does this mean? What do you, I mean do you wanna, get back together?"
258
+ },
259
+ {
260
+ "speaker": "Rachel",
261
+ "emotion": "anger",
262
+ "sentiment": "negative",
263
+ "text": "Noo!"
264
+ },
265
+ {
266
+ "speaker": "Rachel",
267
+ "emotion": "surprise",
268
+ "sentiment": "positive",
269
+ "text": "Maybe!"
270
+ },
271
+ {
272
+ "speaker": "Rachel",
273
+ "emotion": "neutral",
274
+ "sentiment": "neutral",
275
+ "text": "I, I donโ€™t know."
276
+ },
277
+ {
278
+ "speaker": "Rachel",
279
+ "emotion": "sadness",
280
+ "sentiment": "negative",
281
+ "text": "Ross, I still canโ€™t forgive you for what you did, I canโ€™t, I just, but sometimes when Iโ€™m with you I just, I feel so..."
282
+ },
283
+ {
284
+ "speaker": "Ross",
285
+ "emotion": "sadness",
286
+ "sentiment": "negative",
287
+ "text": "What?!"
288
+ },
289
+ {
290
+ "speaker": "Rachel",
291
+ "emotion": "neutral",
292
+ "sentiment": "neutral",
293
+ "text": "I just, I feel, I-I just..."
294
+ },
295
+ {
296
+ "speaker": "Rachel",
297
+ "emotion": "neutral",
298
+ "sentiment": "neutral",
299
+ "text": "I feel..."
300
+ }
301
+ ]
302
+ },
303
+ "04_surprise_shock": {
304
+ "description": "Ross-Rachel drunk voicemail surprise (S4E21)",
305
+ "scenario": "Unexpected news and surprising revelations between couple",
306
+ "primary_emotion": "surprise",
307
+ "source": "MELD Friends S4E21 Dialogue 848 (from utt2, overlaps removed)",
308
+ "duration_sec": 30.2,
309
+ "emotion_distribution": {
310
+ "neutral": 2,
311
+ "sadness": 1,
312
+ "surprise": 5
313
+ },
314
+ "total_utterances": 8,
315
+ "utterances": [
316
+ {
317
+ "speaker": "Ross",
318
+ "emotion": "neutral",
319
+ "sentiment": "neutral",
320
+ "text": "Rach, I got a message from you."
321
+ },
322
+ {
323
+ "speaker": "Rachel",
324
+ "emotion": "sadness",
325
+ "sentiment": "negative",
326
+ "text": "Oh my God Ross, no, hang up the phone, give me the phone Ross, give me the phone, give me the phone, give me the."
327
+ },
328
+ {
329
+ "speaker": "Ross",
330
+ "emotion": "surprise",
331
+ "sentiment": "negative",
332
+ "text": "You're over me?"
333
+ },
334
+ {
335
+ "speaker": "Rachel",
336
+ "emotion": "surprise",
337
+ "sentiment": "negative",
338
+ "text": "Ohhhhhhhh God."
339
+ },
340
+ {
341
+ "speaker": "Ross",
342
+ "emotion": "surprise",
343
+ "sentiment": "negative",
344
+ "text": "Wha... you're uh, you're, you're over me?"
345
+ },
346
+ {
347
+ "speaker": "Ross",
348
+ "emotion": "surprise",
349
+ "sentiment": "negative",
350
+ "text": "When, when were you... under me?"
351
+ },
352
+ {
353
+ "speaker": "Rachel",
354
+ "emotion": "neutral",
355
+ "sentiment": "neutral",
356
+ "text": "Well, basically, lately, I've uh, I've uh, sort of had feelings for you."
357
+ },
358
+ {
359
+ "speaker": "Ross",
360
+ "emotion": "surprise",
361
+ "sentiment": "negative",
362
+ "text": "OK, I need to lie down."
363
+ }
364
+ ]
365
+ },
366
+ "05_fear_anxiety": {
367
+ "description": "Chandler-Rachel anxious situation โ€” fear dominant (S7E11)",
368
+ "scenario": "Anxious and worried conversation between two people",
369
+ "primary_emotion": "fear",
370
+ "source": "MELD Friends S7E11 Dialogue 989",
371
+ "duration_sec": 19.9,
372
+ "emotion_distribution": {
373
+ "surprise": 2,
374
+ "neutral": 4,
375
+ "sadness": 1,
376
+ "fear": 5
377
+ },
378
+ "total_utterances": 12,
379
+ "utterances": [
380
+ {
381
+ "speaker": "Rachel",
382
+ "emotion": "surprise",
383
+ "sentiment": "positive",
384
+ "text": "Its still there!"
385
+ },
386
+ {
387
+ "speaker": "Chandler",
388
+ "emotion": "neutral",
389
+ "sentiment": "neutral",
390
+ "text": "Mrs. Braverman must be out."
391
+ },
392
+ {
393
+ "speaker": "Rachel",
394
+ "emotion": "sadness",
395
+ "sentiment": "negative",
396
+ "text": "She could be out of town. Maybe sheโ€™ll be gone for months."
397
+ },
398
+ {
399
+ "speaker": "Chandler",
400
+ "emotion": "fear",
401
+ "sentiment": "negative",
402
+ "text": "By then, the cheesecake may have gone bad. We donโ€™t want her to come back to bad cheesecake."
403
+ },
404
+ {
405
+ "speaker": "Rachel",
406
+ "emotion": "fear",
407
+ "sentiment": "negative",
408
+ "text": "No that could kill her."
409
+ },
410
+ {
411
+ "speaker": "Chandler",
412
+ "emotion": "neutral",
413
+ "sentiment": "neutral",
414
+ "text": "Well, we donโ€™t want that."
415
+ },
416
+ {
417
+ "speaker": "Rachel",
418
+ "emotion": "neutral",
419
+ "sentiment": "neutral",
420
+ "text": "No, so weโ€™re protecting her."
421
+ },
422
+ {
423
+ "speaker": "Chandler",
424
+ "emotion": "neutral",
425
+ "sentiment": "neutral",
426
+ "text": "But we should take it."
427
+ },
428
+ {
429
+ "speaker": "Rachel",
430
+ "emotion": "fear",
431
+ "sentiment": "negative",
432
+ "text": "But we should move quick."
433
+ },
434
+ {
435
+ "speaker": "Chandler",
436
+ "emotion": "surprise",
437
+ "sentiment": "negative",
438
+ "text": "Why?"
439
+ },
440
+ {
441
+ "speaker": "Rachel",
442
+ "emotion": "fear",
443
+ "sentiment": "negative",
444
+ "text": "Because I think I just heard her moving around in there."
445
+ },
446
+ {
447
+ "speaker": "Chandler",
448
+ "emotion": "fear",
449
+ "sentiment": "negative",
450
+ "text": "Go! Go! Go! Go! Go! Go! Go! Go! Go! Go!"
451
+ }
452
+ ]
453
+ },
454
+ "06_disgust_annoyance": {
455
+ "description": "Joey-Rachel annoyance/bickering scene (S6E9)",
456
+ "scenario": "Annoyed and frustrated reactions between couple",
457
+ "primary_emotion": "anger",
458
+ "source": "MELD Friends S6E9 Dialogue 1025 (Joey+Rachel only, utt16 removed)",
459
+ "duration_sec": 29.2,
460
+ "emotion_distribution": {
461
+ "sadness": 1,
462
+ "surprise": 1,
463
+ "anger": 5,
464
+ "neutral": 2,
465
+ "fear": 1,
466
+ "joy": 1
467
+ },
468
+ "total_utterances": 11,
469
+ "utterances": [
470
+ {
471
+ "speaker": "Joey",
472
+ "emotion": "sadness",
473
+ "sentiment": "negative",
474
+ "text": "Will you hurry up?"
475
+ },
476
+ {
477
+ "speaker": "Joey",
478
+ "emotion": "surprise",
479
+ "sentiment": "negative",
480
+ "text": "Did you not hear me before when I told you that all of Janineโ€™s friends are dancers?!"
481
+ },
482
+ {
483
+ "speaker": "Joey",
484
+ "emotion": "anger",
485
+ "sentiment": "negative",
486
+ "text": "And that theyโ€™re going to be drinking alot!"
487
+ },
488
+ {
489
+ "speaker": "Rachel",
490
+ "emotion": "neutral",
491
+ "sentiment": "neutral",
492
+ "text": "No, I did, but tell me again, because itโ€™s so romantic."
493
+ },
494
+ {
495
+ "speaker": "Joey",
496
+ "emotion": "anger",
497
+ "sentiment": "negative",
498
+ "text": "Well youโ€™re whippinโ€™ so slow! Canโ€™t you do it any faster?"
499
+ },
500
+ {
501
+ "speaker": "Rachel",
502
+ "emotion": "anger",
503
+ "sentiment": "negative",
504
+ "text": "Joey!"
505
+ },
506
+ {
507
+ "speaker": "Rachel",
508
+ "emotion": "anger",
509
+ "sentiment": "negative",
510
+ "text": "Come on!"
511
+ },
512
+ {
513
+ "speaker": "Rachel",
514
+ "emotion": "anger",
515
+ "sentiment": "negative",
516
+ "text": "I donโ€™t wanna make any mistakes, alright?"
517
+ },
518
+ {
519
+ "speaker": "Rachel",
520
+ "emotion": "fear",
521
+ "sentiment": "negative",
522
+ "text": "This is the only dessert and if I screw it up everybody's gonna be like โ€œOh, remember that Thanksgiving when Rachel screwed up the trifle?โ€"
523
+ },
524
+ {
525
+ "speaker": "Rachel",
526
+ "emotion": "neutral",
527
+ "sentiment": "neutral",
528
+ "text": "So why donโ€™t you just let me worry about making the trifle and you just worry about eating it, alright?"
529
+ },
530
+ {
531
+ "speaker": "Joey",
532
+ "emotion": "joy",
533
+ "sentiment": "positive",
534
+ "text": "Oh I am!"
535
+ }
536
+ ]
537
+ },
538
+ "07_bittersweet": {
539
+ "description": "Ross-Rachel bittersweet farewell โ€” sadness+surprise (S5E5), overlap fixed",
540
+ "scenario": "Mixed emotions: saying goodbye with conflicting feelings",
541
+ "primary_emotion": "sadness",
542
+ "source": "MELD Friends S5E5 Dialogue 676 (utt11 overlap removed)",
543
+ "duration_sec": 43.3,
544
+ "emotion_distribution": {
545
+ "sadness": 6,
546
+ "surprise": 3,
547
+ "fear": 1,
548
+ "anger": 3,
549
+ "neutral": 1
550
+ },
551
+ "total_utterances": 14,
552
+ "utterances": [
553
+ {
554
+ "speaker": "Ross",
555
+ "emotion": "sadness",
556
+ "sentiment": "negative",
557
+ "text": "is for me not to see you anymore."
558
+ },
559
+ {
560
+ "speaker": "Rachel",
561
+ "emotion": "surprise",
562
+ "sentiment": "positive",
563
+ "text": "That's crazy!"
564
+ },
565
+ {
566
+ "speaker": "Rachel",
567
+ "emotion": "surprise",
568
+ "sentiment": "positive",
569
+ "text": "You can't do that!"
570
+ },
571
+ {
572
+ "speaker": "Rachel",
573
+ "emotion": "sadness",
574
+ "sentiment": "negative",
575
+ "text": "What are you going to tell her?"
576
+ },
577
+ {
578
+ "speaker": "Rachel",
579
+ "emotion": "fear",
580
+ "sentiment": "negative",
581
+ "text": "Oh God."
582
+ },
583
+ {
584
+ "speaker": "Rachel",
585
+ "emotion": "sadness",
586
+ "sentiment": "negative",
587
+ "text": "Ohh, you already agreed to this, haven't you?"
588
+ },
589
+ {
590
+ "speaker": "Ross",
591
+ "emotion": "sadness",
592
+ "sentiment": "negative",
593
+ "text": "It's awful I know, I mean, I feel terrible but I have to do this if I want my marriage to work."
594
+ },
595
+ {
596
+ "speaker": "Ross",
597
+ "emotion": "sadness",
598
+ "sentiment": "negative",
599
+ "text": "And I do, I have to make"
600
+ },
601
+ {
602
+ "speaker": "Rachel",
603
+ "emotion": "surprise",
604
+ "sentiment": "positive",
605
+ "text": "Ohh! Lucky me! Oh my God! That"
606
+ },
607
+ {
608
+ "speaker": "Ross",
609
+ "emotion": "sadness",
610
+ "sentiment": "negative",
611
+ "text": "You have no idea what a nightmare this has been. This is so hard."
612
+ },
613
+ {
614
+ "speaker": "Rachel",
615
+ "emotion": "anger",
616
+ "sentiment": "negative",
617
+ "text": "Oh yeah, really? Is it Ross? Yeah? Okay, well let me make this a just a little bit easier for you."
618
+ },
619
+ {
620
+ "speaker": "Rachel",
621
+ "emotion": "anger",
622
+ "sentiment": "negative",
623
+ "text": "Storming out!"
624
+ },
625
+ {
626
+ "speaker": "Ross",
627
+ "emotion": "neutral",
628
+ "sentiment": "neutral",
629
+ "text": "Rachel, this is your apartment."
630
+ },
631
+ {
632
+ "speaker": "Rachel",
633
+ "emotion": "anger",
634
+ "sentiment": "negative",
635
+ "text": "Yeah, well that's how mad I am!!"
636
+ }
637
+ ]
638
+ },
639
+ "08_calm_daily": {
640
+ "description": "Joey-Monica casual conversation โ€” neutral dominant (S7E19)",
641
+ "scenario": "Normal everyday chitchat between friends (baseline)",
642
+ "primary_emotion": "neutral",
643
+ "source": "MELD Friends S7E19 Dialogue 8 (dev)",
644
+ "duration_sec": 40.4,
645
+ "emotion_distribution": {
646
+ "neutral": 13,
647
+ "joy": 2
648
+ },
649
+ "total_utterances": 15,
650
+ "utterances": [
651
+ {
652
+ "speaker": "Monica",
653
+ "emotion": "neutral",
654
+ "sentiment": "neutral",
655
+ "text": "Hey! What did you decide to do about the movie?"
656
+ },
657
+ {
658
+ "speaker": "Joey",
659
+ "emotion": "neutral",
660
+ "sentiment": "neutral",
661
+ "text": "I donโ€™t know!"
662
+ },
663
+ {
664
+ "speaker": "Joey",
665
+ "emotion": "neutral",
666
+ "sentiment": "neutral",
667
+ "text": "Itโ€™s not like itโ€™s porn!"
668
+ },
669
+ {
670
+ "speaker": "Joey",
671
+ "emotion": "neutral",
672
+ "sentiment": "neutral",
673
+ "text": "This is a serious, legitimate movie."
674
+ },
675
+ {
676
+ "speaker": "Joey",
677
+ "emotion": "neutral",
678
+ "sentiment": "neutral",
679
+ "text": "And the nudity is really important to the story."
680
+ },
681
+ {
682
+ "speaker": "Monica",
683
+ "emotion": "neutral",
684
+ "sentiment": "neutral",
685
+ "text": "Thatโ€™s what you say about porn."
686
+ },
687
+ {
688
+ "speaker": "Joey",
689
+ "emotion": "neutral",
690
+ "sentiment": "neutral",
691
+ "text": "Youโ€™re right. Maybe I shouldnโ€™t even go on the call back."
692
+ },
693
+ {
694
+ "speaker": "Monica",
695
+ "emotion": "joy",
696
+ "sentiment": "positive",
697
+ "text": "No! No you should! A lot of major actors do nude scenes! I mean, the chance to star in a movie? Come on!"
698
+ },
699
+ {
700
+ "speaker": "Joey",
701
+ "emotion": "neutral",
702
+ "sentiment": "neutral",
703
+ "text": "Well thatโ€™s true."
704
+ },
705
+ {
706
+ "speaker": "Joey",
707
+ "emotion": "neutral",
708
+ "sentiment": "neutral",
709
+ "text": "And I am only naked in one scene."
710
+ },
711
+ {
712
+ "speaker": "Joey",
713
+ "emotion": "neutral",
714
+ "sentiment": "neutral",
715
+ "text": "Plus it sounds really great."
716
+ },
717
+ {
718
+ "speaker": "Joey",
719
+ "emotion": "neutral",
720
+ "sentiment": "neutral",
721
+ "text": "My characterโ€™s catholic and he falls in love with this Jewish girl."
722
+ },
723
+ {
724
+ "speaker": "Joey",
725
+ "emotion": "neutral",
726
+ "sentiment": "neutral",
727
+ "text": "Who run away together and they get caught in this big rainstorm."
728
+ },
729
+ {
730
+ "speaker": "Joey",
731
+ "emotion": "neutral",
732
+ "sentiment": "neutral",
733
+ "text": "So we go into this barn and undress each other and hold each other."
734
+ },
735
+ {
736
+ "speaker": "Joey",
737
+ "emotion": "joy",
738
+ "sentiment": "positive",
739
+ "text": "Itโ€™s really sweet and-and tender."
740
+ }
741
+ ]
742
+ },
743
+ "09_opposite_emotions": {
744
+ "description": "Updated clip โ€” pair_state 'listening' triggers (tense speaker + calm listener)",
745
+ "scenario": "One speaker worked-up/tense + one calm listener โ€” triggers listening pair animation (sparkles effect)",
746
+ "primary_emotion": "surprise",
747
+ "source": "MELD Friends clip (user-updated 2026-04-22, duration 24.9s)",
748
+ "duration_sec": 24.9,
749
+ "pair_state_observed": "listening",
750
+ "emotion_distribution": {
751
+ "surprise": 5,
752
+ "anger": 1,
753
+ "neutral": 3,
754
+ "fear": 1,
755
+ "joy": 1
756
+ },
757
+ "per_speaker_fused": {
758
+ "speaker_0": {
759
+ "surprise": 4,
760
+ "anger": 1
761
+ },
762
+ "speaker_1": {
763
+ "surprise": 1,
764
+ "neutral": 3,
765
+ "fear": 1,
766
+ "joy": 1
767
+ }
768
+ },
769
+ "total_segments": 11,
770
+ "notes": "Ground-truth utterance labels not available after WAV update. Distribution derived from pipeline fused output."
771
+ }
772
+ }
railway.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [build]
2
+ builder = "dockerfile"
3
+
4
+ [deploy]
5
+ startCommand = "uvicorn src.stage4.main:app --host 0.0.0.0 --port $PORT"
6
+ healthcheckPath = "/api/health"
7
+ healthcheckTimeout = 300
8
+ restartPolicyType = "on_failure"
9
+ restartPolicyMaxRetries = 3
requirements-deploy.txt ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UsTwo Railway Deployment Dependencies
2
+ # Stage 1 (pyannote, whisperx) ์ œ์™ธ โ€” ์˜์–ด Stage 2 + Stage 3 + Stage 4 only
3
+ # torch๋Š” Dockerfile์—์„œ CPU-only๋กœ ๋ณ„๋„ ์„ค์น˜
4
+
5
+ # Stage 4: FastAPI server
6
+ fastapi>=0.100.0
7
+ uvicorn>=0.20.0
8
+ pydantic>=2.0.0
9
+ sqlalchemy>=2.0.0
10
+ python-multipart>=0.0.6
11
+ pyyaml>=6.0
12
+
13
+ # Stage 3: Recap generation
14
+ anthropic>=0.20.0
15
+
16
+ # Quick Recap: Whisper API transcription
17
+ openai>=1.12.0
18
+
19
+ # Stage 1: Speaker diarization + ASR
20
+ pyannote.audio>=3.1
21
+ faster-whisper>=1.0.0
22
+ whisperx>=3.1.0
23
+ torchaudio>=2.0.0
24
+
25
+ # Stage 2: Emotion analysis (English)
26
+ transformers>=4.38.0
27
+ funasr>=1.0.0
28
+ onnxruntime>=1.17.0
29
+ librosa>=0.10.0
30
+ soundfile>=0.12.0
31
+ scipy>=1.10.0
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.100.0
2
+ uvicorn>=0.20.0
3
+ pydantic>=2.0.0
4
+ sqlalchemy>=2.0.0
5
+ python-multipart>=0.0.6
6
+ pyyaml>=6.0
7
+ anthropic>=0.20.0
8
+
9
+ # ML โ€” Stage 2 emotion analysis
10
+ torch>=2.0.0
11
+ transformers>=4.38.0
12
+ funasr>=1.0.0
13
+ onnxruntime>=1.17.0
14
+ librosa>=0.10.0
15
+ soundfile>=0.12.0
16
+ scikit-learn>=1.3.0
17
+ scipy>=1.10.0
scripts/add_ravdess_to_english_manifest.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Append RAVDESS fear/disgust/sadness (phone) to the English fusion manifest.
3
+
4
+ Input:
5
+ - data/english_fusion/manifest.json (1,669 samples)
6
+ - data/ravdess/manifest.csv (RAVDESS phone/clean paths)
7
+
8
+ Output:
9
+ - data/english_fusion/manifest_v2.json (2,821 samples)
10
+
11
+ RAVDESS statement โ†’ text:
12
+ 1 โ†’ "Kids are talking by the door."
13
+ 2 โ†’ "Dogs are sitting by the door."
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import csv
18
+ import json
19
+ import logging
20
+ from collections import Counter
21
+ from pathlib import Path
22
+
23
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
24
+ logger = logging.getLogger(__name__)
25
+
26
+ RAVDESS_TARGETS = {"fear", "disgust", "sadness"}
27
+ STATEMENT_TEXT = {
28
+ "1": "Kids are talking by the door.",
29
+ "2": "Dogs are sitting by the door.",
30
+ }
31
+
32
+ BASE_MANIFEST = Path("data/english_fusion/manifest.json")
33
+ RAVDESS_CSV = Path("data/ravdess/manifest.csv")
34
+ OUT = Path("data/english_fusion/manifest_v2.json")
35
+
36
+
37
+ def load_ravdess_rows() -> list[dict]:
38
+ rows: list[dict] = []
39
+ with open(RAVDESS_CSV, newline="") as f:
40
+ reader = csv.DictReader(f)
41
+ for r in reader:
42
+ if r["emotion"] not in RAVDESS_TARGETS:
43
+ continue
44
+ text = STATEMENT_TEXT.get(r["statement"])
45
+ if not text:
46
+ continue
47
+ rows.append({
48
+ "path": r["phone_path"],
49
+ "text": text,
50
+ "label": r["emotion"],
51
+ "source": "ravdess_phone",
52
+ "speaker": f"ravdess_actor_{int(r['actor_id']):02d}",
53
+ })
54
+ return rows
55
+
56
+
57
+ def main() -> None:
58
+ base = json.loads(BASE_MANIFEST.read_text())
59
+ logger.info("Base manifest: %d samples", len(base))
60
+
61
+ rav = load_ravdess_rows()
62
+ logger.info("RAVDESS additions: %d samples (fear/disgust/sadness)", len(rav))
63
+
64
+ combined = base + rav
65
+ counts = Counter(r["label"] for r in combined)
66
+ sources = Counter(r["source"] for r in combined)
67
+ logger.info("Total v2: %d", len(combined))
68
+ logger.info("By label: %s", dict(counts))
69
+ logger.info("By source: %s", dict(sources))
70
+
71
+ OUT.write_text(json.dumps(combined, indent=2, ensure_ascii=False))
72
+ logger.info("Saved to %s", OUT)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
scripts/asr_savee_disgust_surprise.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run faster-whisper ASR on SAVEE disgust + surprise wavs (120 files).
3
+
4
+ Output: data/savee/savee_asr.json โ€” {wav_name: transcript}
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from faster_whisper import WhisperModel
14
+
15
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
16
+ logger = logging.getLogger(__name__)
17
+
18
+ SAVEE_DIR = Path("data/savee/ALL")
19
+ OUT = Path("data/savee/savee_asr.json")
20
+ PATTERN = re.compile(r"^[A-Z]{2}_(d|su)\d+\.wav$")
21
+
22
+
23
+ def main() -> None:
24
+ wavs = sorted([p for p in SAVEE_DIR.iterdir() if PATTERN.match(p.name)])
25
+ logger.info("Found %d SAVEE disgust/surprise wavs", len(wavs))
26
+
27
+ logger.info("Loading faster-whisper large-v3-turbo (int8, CPU)...")
28
+ model = WhisperModel("large-v3-turbo", device="cpu", compute_type="int8")
29
+
30
+ results: dict[str, str] = {}
31
+ if OUT.exists():
32
+ results = json.loads(OUT.read_text())
33
+ logger.info("Loaded %d cached transcripts", len(results))
34
+
35
+ for i, wav in enumerate(wavs):
36
+ if wav.name in results:
37
+ continue
38
+ segments, _ = model.transcribe(str(wav), language="en", beam_size=5, vad_filter=False)
39
+ text = " ".join(s.text.strip() for s in segments).strip()
40
+ results[wav.name] = text
41
+ if i % 10 == 0:
42
+ OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
43
+ logger.info("[%d/%d] %s -> %s", i + 1, len(wavs), wav.name, text[:60])
44
+
45
+ OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
46
+ logger.info("Saved %d transcripts to %s", len(results), OUT)
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
scripts/benchmark_emotion2vec.py ADDED
@@ -0,0 +1,513 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """emotion2vec Variant Smoke Test โ€” base vs plus_base vs plus_large ์‹ค์ธก ๋น„๊ต.
3
+
4
+ ๊ธฐ์กด Stage 1 ์ถœ๋ ฅ ์„ธ๊ทธ๋จผํŠธ(88๊ฐœ)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ 3๊ฐœ emotion2vec variant์˜
5
+ latency, RAM, ์˜ˆ์ธก ํ’ˆ์งˆ์„ ์‹ค์ธก ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค.
6
+
7
+ Usage:
8
+ python scripts/benchmark_emotion2vec.py # 3๊ฐœ ์ „๋ถ€
9
+ python scripts/benchmark_emotion2vec.py --variants plus_base # ๋‹จ์ผ
10
+ python scripts/benchmark_emotion2vec.py --device cuda # GPU
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import gc
17
+ import glob
18
+ import json
19
+ import logging
20
+ import os
21
+ import statistics
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+ import psutil
28
+
29
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
33
+ # Constants
34
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
35
+
36
+ VARIANT_CONFIGS = {
37
+ "base": "iic/emotion2vec_base",
38
+ "plus_base": "iic/emotion2vec_plus_base",
39
+ "plus_large": "iic/emotion2vec_plus_large",
40
+ }
41
+
42
+ # emotion2vec 9-class โ†’ project 7-class mapping
43
+ LABEL_MAP = {
44
+ "angry": "anger",
45
+ "disgusted": "disgust",
46
+ "fearful": "fear",
47
+ "happy": "joy",
48
+ "neutral": "neutral",
49
+ "sad": "sadness",
50
+ "surprised": "surprise",
51
+ "other": "neutral",
52
+ "unknown": "neutral",
53
+ }
54
+
55
+ PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
56
+
57
+ # Representative segments for Korean sanity check (indices into sorted segment list)
58
+ # Will be selected dynamically: shortest, longest, and 3 evenly spaced
59
+ SANITY_CHECK_COUNT = 5
60
+
61
+
62
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
63
+ # Segment Discovery
64
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
65
+
66
+ def discover_segments(segments_dir: str) -> list[dict]:
67
+ """Find all segment WAV files and load metadata from stage1_output.json."""
68
+ pattern = os.path.join(segments_dir, "call_*", "seg_*.wav")
69
+ paths = sorted(glob.glob(pattern))
70
+
71
+ if not paths:
72
+ logger.error("No segment files found in %s", segments_dir)
73
+ sys.exit(1)
74
+
75
+ # Try to load metadata from stage1_output.json for text context
76
+ metadata = {}
77
+ stage1_path = Path(segments_dir).parent / "stage1_output.json"
78
+ if stage1_path.exists():
79
+ with open(stage1_path) as f:
80
+ data = json.load(f)
81
+ for seg in data.get("segments", []):
82
+ metadata[seg["audio_path"]] = {
83
+ "text": seg.get("text", ""),
84
+ "speaker_id": seg.get("speaker_id", ""),
85
+ "start": seg.get("start", 0),
86
+ "end": seg.get("end", 0),
87
+ }
88
+
89
+ segments = []
90
+ for p in paths:
91
+ call_id = Path(p).parent.name
92
+ seg_name = Path(p).stem
93
+ meta = metadata.get(p, {})
94
+ segments.append({
95
+ "path": p,
96
+ "call_id": call_id,
97
+ "seg_name": seg_name,
98
+ "text": meta.get("text", ""),
99
+ "speaker_id": meta.get("speaker_id", ""),
100
+ "duration_sec": meta.get("end", 0) - meta.get("start", 0),
101
+ })
102
+
103
+ logger.info("Discovered %d segments across %d calls",
104
+ len(segments), len(set(s["call_id"] for s in segments)))
105
+ return segments
106
+
107
+
108
+ def select_sanity_segments(segments: list[dict], count: int = SANITY_CHECK_COUNT) -> list[dict]:
109
+ """Select representative segments for sanity check: shortest, longest, + evenly spaced."""
110
+ if len(segments) <= count:
111
+ return segments
112
+
113
+ # Sort by duration for selection
114
+ by_dur = sorted(segments, key=lambda s: s["duration_sec"])
115
+ # Filter to segments that have text (from stage1_output.json call)
116
+ with_text = [s for s in by_dur if s["text"]]
117
+ if len(with_text) < count:
118
+ with_text = by_dur
119
+
120
+ selected = [with_text[0], with_text[-1]] # shortest, longest
121
+ remaining = count - 2
122
+ step = max(1, len(with_text) // (remaining + 1))
123
+ for i in range(1, remaining + 1):
124
+ idx = min(i * step, len(with_text) - 1)
125
+ candidate = with_text[idx]
126
+ if candidate not in selected:
127
+ selected.append(candidate)
128
+
129
+ return selected[:count]
130
+
131
+
132
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
133
+ # Benchmarking
134
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
135
+
136
+ def get_process_rss_mb() -> float:
137
+ """Current process RSS in MB."""
138
+ return psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024)
139
+
140
+
141
+ def map_predictions(raw_scores: dict[str, float]) -> dict:
142
+ """Map emotion2vec native labels to project 7-class taxonomy."""
143
+ mapped = {label: 0.0 for label in PROJECT_LABELS}
144
+ for native_label, score in raw_scores.items():
145
+ project_label = LABEL_MAP.get(native_label, "neutral")
146
+ mapped[project_label] += score
147
+
148
+ top_label = max(mapped, key=mapped.get)
149
+ return {
150
+ "label": top_label,
151
+ "confidence": mapped[top_label],
152
+ "scores": mapped,
153
+ "raw_scores": raw_scores,
154
+ }
155
+
156
+
157
+ def benchmark_variant(
158
+ variant_name: str,
159
+ model_id: str,
160
+ segments: list[dict],
161
+ device: str = "cpu",
162
+ warmup: int = 3,
163
+ ) -> dict:
164
+ """Run full benchmark for one emotion2vec variant."""
165
+ from funasr import AutoModel
166
+
167
+ logger.info("=" * 60)
168
+ logger.info("Benchmarking: %s (%s)", variant_name, model_id)
169
+ logger.info("=" * 60)
170
+
171
+ result = {
172
+ "variant": variant_name,
173
+ "model_id": model_id,
174
+ "device": device,
175
+ }
176
+
177
+ # 1. Baseline RAM
178
+ gc.collect()
179
+ baseline_rss = get_process_rss_mb()
180
+
181
+ # 2. Model load + load time
182
+ logger.info("Loading model...")
183
+ load_start = time.perf_counter()
184
+ try:
185
+ model = AutoModel(model=model_id, device=device)
186
+ except Exception as e:
187
+ logger.error("Failed to load %s: %s", model_id, e)
188
+ result["error"] = str(e)
189
+ return result
190
+ load_time = time.perf_counter() - load_start
191
+ result["load_time_sec"] = round(load_time, 2)
192
+ logger.info("Model loaded in %.2fs", load_time)
193
+
194
+ # 3. Peak RAM after load
195
+ post_load_rss = get_process_rss_mb()
196
+ result["model_ram_mb"] = round(post_load_rss - baseline_rss, 1)
197
+ logger.info("Model RAM: %.1f MB", result["model_ram_mb"])
198
+
199
+ # 4. Warmup
200
+ logger.info("Warmup (%d runs)...", warmup)
201
+ warmup_segs = segments[:warmup] if len(segments) >= warmup else segments
202
+ for seg in warmup_segs:
203
+ try:
204
+ model.generate(seg["path"], granularity="utterance", extract_embedding=False)
205
+ except Exception as e:
206
+ logger.warning("Warmup failed on %s: %s", seg["path"], e)
207
+
208
+ # 5. Timed inference on all segments
209
+ logger.info("Running inference on %d segments...", len(segments))
210
+ predictions = []
211
+ latencies = []
212
+ errors = []
213
+ peak_rss = post_load_rss
214
+
215
+ for i, seg in enumerate(segments):
216
+ try:
217
+ t0 = time.perf_counter()
218
+ output = model.generate(
219
+ seg["path"], granularity="utterance", extract_embedding=False,
220
+ )
221
+ t1 = time.perf_counter()
222
+
223
+ latency_ms = (t1 - t0) * 1000
224
+ latencies.append(latency_ms)
225
+
226
+ # Parse emotion2vec output
227
+ raw_scores = {}
228
+ if output and isinstance(output, list) and len(output) > 0:
229
+ rec = output[0]
230
+ labels = rec.get("labels", [])
231
+ scores = rec.get("scores", [])
232
+ for label, score in zip(labels, scores):
233
+ raw_scores[label] = float(score)
234
+
235
+ mapped = map_predictions(raw_scores)
236
+
237
+ predictions.append({
238
+ "seg_name": seg["seg_name"],
239
+ "call_id": seg["call_id"],
240
+ "text": seg["text"],
241
+ "speaker_id": seg["speaker_id"],
242
+ "duration_sec": seg["duration_sec"],
243
+ "latency_ms": round(latency_ms, 1),
244
+ **mapped,
245
+ })
246
+
247
+ except Exception as e:
248
+ errors.append({"seg_name": seg["seg_name"], "error": str(e)})
249
+ logger.warning("Inference error on %s: %s", seg["seg_name"], e)
250
+
251
+ # Track peak RAM
252
+ current_rss = get_process_rss_mb()
253
+ peak_rss = max(peak_rss, current_rss)
254
+
255
+ if (i + 1) % 20 == 0:
256
+ logger.info(" %d/%d segments done", i + 1, len(segments))
257
+
258
+ # 6. Aggregate results
259
+ result["peak_ram_mb"] = round(peak_rss - baseline_rss, 1)
260
+ result["total_segments"] = len(segments)
261
+ result["successful"] = len(predictions)
262
+ result["errors"] = errors
263
+
264
+ if latencies:
265
+ result["latency"] = {
266
+ "mean_ms": round(statistics.mean(latencies), 1),
267
+ "median_ms": round(statistics.median(latencies), 1),
268
+ "std_ms": round(statistics.stdev(latencies), 1) if len(latencies) > 1 else 0,
269
+ "p95_ms": round(sorted(latencies)[int(len(latencies) * 0.95)], 1),
270
+ "min_ms": round(min(latencies), 1),
271
+ "max_ms": round(max(latencies), 1),
272
+ }
273
+ else:
274
+ result["latency"] = {}
275
+
276
+ # Emotion distribution
277
+ dist = {label: 0 for label in PROJECT_LABELS}
278
+ for pred in predictions:
279
+ dist[pred["label"]] += 1
280
+ result["emotion_distribution"] = dist
281
+
282
+ result["predictions"] = predictions
283
+
284
+ # 7. Cleanup
285
+ logger.info("Cleaning up model...")
286
+ del model
287
+ gc.collect()
288
+ try:
289
+ import torch
290
+ if torch.cuda.is_available():
291
+ torch.cuda.empty_cache()
292
+ except ImportError:
293
+ pass
294
+
295
+ logger.info("Done: %s โ€” mean latency %.1fms, peak RAM %.1fMB",
296
+ variant_name,
297
+ result.get("latency", {}).get("mean_ms", 0),
298
+ result.get("peak_ram_mb", 0))
299
+
300
+ return result
301
+
302
+
303
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
304
+ # Output Formatting
305
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
306
+
307
+ def fmt_table(headers: list[str], rows: list[list[str]], col_widths: list[int] | None = None) -> str:
308
+ """Simple table formatter."""
309
+ if not col_widths:
310
+ col_widths = []
311
+ for i, h in enumerate(headers):
312
+ max_w = len(h)
313
+ for row in rows:
314
+ if i < len(row):
315
+ max_w = max(max_w, len(str(row[i])))
316
+ col_widths.append(max_w + 2)
317
+
318
+ def fmt_row(cells):
319
+ return "โ”‚ " + " โ”‚ ".join(str(c).ljust(w) for c, w in zip(cells, col_widths)) + " โ”‚"
320
+
321
+ separator = "โ”œโ”€" + "โ”€โ”ผโ”€".join("โ”€" * w for w in col_widths) + "โ”€โ”ค"
322
+ top = "โ”Œโ”€" + "โ”€โ”ฌโ”€".join("โ”€" * w for w in col_widths) + "โ”€โ”"
323
+ bottom = "โ””โ”€" + "โ”€โ”ดโ”€".join("โ”€" * w for w in col_widths) + "โ”€โ”˜"
324
+
325
+ lines = [top, fmt_row(headers), separator]
326
+ for row in rows:
327
+ lines.append(fmt_row(row))
328
+ lines.append(bottom)
329
+ return "\n".join(lines)
330
+
331
+
332
+ def format_results(all_results: dict[str, dict], segments: list[dict]) -> str:
333
+ """Format benchmark results into readable console output."""
334
+ output_parts = []
335
+
336
+ # โ”€โ”€ Performance Comparison โ”€โ”€
337
+ output_parts.append("\n=== Performance Comparison ===")
338
+ headers = ["Variant", "Load (s)", "RAM (MB)", "Latency mean (ms)", "Latency p95 (ms)", "Errors"]
339
+ rows = []
340
+ for name, res in all_results.items():
341
+ if "error" in res:
342
+ rows.append([name, "FAIL", "-", "-", "-", res["error"][:40]])
343
+ continue
344
+ lat = res.get("latency", {})
345
+ mean_str = f"{lat.get('mean_ms', 0):.1f} ยฑ {lat.get('std_ms', 0):.1f}"
346
+ rows.append([
347
+ name,
348
+ f"{res.get('load_time_sec', 0):.1f}",
349
+ f"{res.get('peak_ram_mb', 0):.0f}",
350
+ mean_str,
351
+ f"{lat.get('p95_ms', 0):.1f}",
352
+ str(len(res.get("errors", []))),
353
+ ])
354
+ output_parts.append(fmt_table(headers, rows))
355
+
356
+ # โ”€โ”€ Knockout Check โ”€โ”€
357
+ output_parts.append("\n=== Knockout Check ===")
358
+ for name, res in all_results.items():
359
+ if "error" in res:
360
+ output_parts.append(f" {name}: โŒ LOAD FAILED")
361
+ continue
362
+ lat_mean = res.get("latency", {}).get("mean_ms", 999)
363
+ ram = res.get("peak_ram_mb", 999)
364
+ lat_ok = "โœ…" if lat_mean <= 500 else "โŒ"
365
+ ram_ok = "โœ…" if ram <= 2048 else "โŒ"
366
+ output_parts.append(f" {name}: Latency {lat_ok} ({lat_mean:.0f}ms โ‰ค 500ms) RAM {ram_ok} ({ram:.0f}MB โ‰ค 2048MB)")
367
+
368
+ # โ”€โ”€ Emotion Distribution โ”€โ”€
369
+ output_parts.append("\n=== Emotion Distribution (across all segments) ===")
370
+ headers = ["Variant"] + PROJECT_LABELS
371
+ rows = []
372
+ for name, res in all_results.items():
373
+ if "error" in res:
374
+ continue
375
+ dist = res.get("emotion_distribution", {})
376
+ rows.append([name] + [str(dist.get(l, 0)) for l in PROJECT_LABELS])
377
+ output_parts.append(fmt_table(headers, rows))
378
+
379
+ # โ”€โ”€ Korean Sanity Check โ”€โ”€
380
+ output_parts.append("\n=== Korean Sanity Check ===")
381
+ sanity_segs = select_sanity_segments(segments)
382
+ for seg in sanity_segs:
383
+ text_preview = seg["text"][:50] + "..." if len(seg["text"]) > 50 else seg["text"]
384
+ output_parts.append(f'\n {seg["seg_name"]} ({seg["duration_sec"]:.1f}s): "{text_preview}"')
385
+ for name, res in all_results.items():
386
+ if "error" in res:
387
+ output_parts.append(f" {name}: FAILED")
388
+ continue
389
+ # Find matching prediction
390
+ preds = res.get("predictions", [])
391
+ match = next((p for p in preds if p["seg_name"] == seg["seg_name"]), None)
392
+ if match:
393
+ output_parts.append(f" {name:12s}: {match['label']:10s} ({match['confidence']:.2f})")
394
+ else:
395
+ output_parts.append(f" {name:12s}: no prediction")
396
+
397
+ # โ”€โ”€ Variant Agreement โ”€โ”€
398
+ output_parts.append("\n=== Variant Agreement ===")
399
+ valid_results = {k: v for k, v in all_results.items() if "error" not in v}
400
+ if len(valid_results) >= 2:
401
+ variant_names = list(valid_results.keys())
402
+ # Build prediction maps: seg_name -> label
403
+ pred_maps = {}
404
+ for name, res in valid_results.items():
405
+ pred_maps[name] = {p["seg_name"]: p["label"] for p in res.get("predictions", [])}
406
+
407
+ # All-agree count
408
+ all_seg_names = set()
409
+ for pm in pred_maps.values():
410
+ all_seg_names.update(pm.keys())
411
+
412
+ agree_count = 0
413
+ total_count = 0
414
+ for seg_name in all_seg_names:
415
+ labels = [pm.get(seg_name) for pm in pred_maps.values() if seg_name in pm]
416
+ if len(labels) == len(valid_results):
417
+ total_count += 1
418
+ if len(set(labels)) == 1:
419
+ agree_count += 1
420
+
421
+ output_parts.append(f" All {len(valid_results)} variants agree: {agree_count}/{total_count} ({agree_count/max(total_count,1)*100:.0f}%)")
422
+
423
+ # Pairwise agreement
424
+ for i in range(len(variant_names)):
425
+ for j in range(i + 1, len(variant_names)):
426
+ a, b = variant_names[i], variant_names[j]
427
+ common = set(pred_maps[a].keys()) & set(pred_maps[b].keys())
428
+ pair_agree = sum(1 for s in common if pred_maps[a][s] == pred_maps[b][s])
429
+ pct = pair_agree / max(len(common), 1) * 100
430
+ output_parts.append(f" {a} vs {b}: {pair_agree}/{len(common)} ({pct:.0f}%)")
431
+
432
+ return "\n".join(output_parts)
433
+
434
+
435
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
436
+ # Main
437
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
438
+
439
+ def main():
440
+ parser = argparse.ArgumentParser(description="emotion2vec variant benchmark")
441
+ parser.add_argument(
442
+ "--variants", nargs="*", default=list(VARIANT_CONFIGS.keys()),
443
+ choices=list(VARIANT_CONFIGS.keys()),
444
+ help="Which variants to benchmark (default: all)",
445
+ )
446
+ parser.add_argument("--segments-dir", default="data/segments", help="Segments directory")
447
+ parser.add_argument("--output-json", default="data/benchmark_results.json", help="Output JSON path")
448
+ parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"], help="Compute device")
449
+ parser.add_argument("--warmup", type=int, default=3, help="Warmup iterations")
450
+ args = parser.parse_args()
451
+
452
+ # Check dependency
453
+ try:
454
+ import funasr # noqa: F401
455
+ except ImportError:
456
+ logger.error("funasr not installed. Run: pip install funasr onnxruntime")
457
+ sys.exit(1)
458
+
459
+ # Discover segments
460
+ segments = discover_segments(args.segments_dir)
461
+ logger.info("Total segments: %d", len(segments))
462
+
463
+ # Run benchmarks
464
+ all_results = {}
465
+ for variant_name in args.variants:
466
+ model_id = VARIANT_CONFIGS[variant_name]
467
+ result = benchmark_variant(
468
+ variant_name, model_id, segments,
469
+ device=args.device, warmup=args.warmup,
470
+ )
471
+ all_results[variant_name] = result
472
+
473
+ # Format and print results
474
+ report = format_results(all_results, segments)
475
+ print(report)
476
+
477
+ # Save JSON
478
+ output_path = Path(args.output_json)
479
+ output_path.parent.mkdir(parents=True, exist_ok=True)
480
+
481
+ # Get system info
482
+ import platform
483
+ try:
484
+ import torch
485
+ torch_version = torch.__version__
486
+ cuda_available = torch.cuda.is_available()
487
+ except ImportError:
488
+ torch_version = "not installed"
489
+ cuda_available = False
490
+
491
+ output_data = {
492
+ "metadata": {
493
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
494
+ "device": args.device,
495
+ "total_segments": len(segments),
496
+ "python_version": platform.python_version(),
497
+ "torch_version": torch_version,
498
+ "cuda_available": cuda_available,
499
+ "cpu": platform.processor() or "unknown",
500
+ "ram_total_gb": round(psutil.virtual_memory().total / (1024**3), 1),
501
+ },
502
+ "results": all_results,
503
+ }
504
+
505
+ with open(output_path, "w", encoding="utf-8") as f:
506
+ json.dump(output_data, f, indent=2, ensure_ascii=False, default=str)
507
+
508
+ logger.info("Results saved to %s", output_path)
509
+ print(f"\nFull results saved to {output_path}")
510
+
511
+
512
+ if __name__ == "__main__":
513
+ main()
scripts/benchmark_ser_models.py ADDED
@@ -0,0 +1,799 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """3-Model SER Benchmark โ€” emotion2vec vs SpeechBrain vs Whisper+Head.
3
+
4
+ AI Hub ํ•œ๊ตญ์–ด ๊ฐ์ • ๋ฐ์ดํ„ฐ์…‹ ํ…Œ์ŠคํŠธ ์„œ๋ธŒ์…‹์„ ์‚ฌ์šฉํ•˜์—ฌ 3๊ฐœ ๋ชจ๋ธ์˜
5
+ ์ •ํ™•๋„, ๋ ˆ์ดํ„ด์‹œ, ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์„ ๊ฐ๊ด€์ ์œผ๋กœ ๋น„๊ตํ•œ๋‹ค.
6
+
7
+ Usage:
8
+ # 2๊ฐœ ๋ชจ๋ธ ๋จผ์ € (Whisper head ์—†์ด)
9
+ python scripts/benchmark_ser_models.py \\
10
+ --test-dir data/evaluation/korean \\
11
+ --models emotion2vec speechbrain
12
+
13
+ # ์ „์ฒด 3๊ฐœ ๋ชจ๋ธ
14
+ python scripts/benchmark_ser_models.py \\
15
+ --test-dir data/evaluation/korean \\
16
+ --models emotion2vec speechbrain whisper \\
17
+ --whisper-head-ckpt data/models/whisper_emotion_head.pt
18
+
19
+ # Quick smoke test
20
+ python scripts/benchmark_ser_models.py \\
21
+ --test-dir data/evaluation/korean \\
22
+ --models emotion2vec --max-samples 10
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import csv
29
+ import gc
30
+ import json
31
+ import logging
32
+ import os
33
+ import statistics
34
+ import sys
35
+ import tempfile
36
+ import time
37
+ from abc import ABC, abstractmethod
38
+ from pathlib import Path
39
+
40
+ import numpy as np
41
+ import psutil
42
+ import soundfile as sf
43
+
44
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
48
+ # Constants
49
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
50
+
51
+ EVAL_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear"]
52
+
53
+ # Knockout criteria (from evaluation-framework.md)
54
+ KNOCKOUT_F1 = 0.70
55
+ KNOCKOUT_LATENCY_MS = 500
56
+ KNOCKOUT_RAM_MB = 2048
57
+
58
+
59
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
60
+ # Model Adapter Interface
61
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
62
+
63
+ class SERModelAdapter(ABC):
64
+ """Abstract base for SER model adapters."""
65
+
66
+ name: str
67
+ model_id: str
68
+ params_m: int # millions
69
+
70
+ @abstractmethod
71
+ def load(self, device: str) -> None:
72
+ ...
73
+
74
+ @abstractmethod
75
+ def predict(self, audio_path: str) -> dict[str, float]:
76
+ """Return {emotion_label: score} in project taxonomy."""
77
+ ...
78
+
79
+ @abstractmethod
80
+ def unload(self) -> None:
81
+ ...
82
+
83
+
84
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
85
+ # Adapter 1: emotion2vec_plus_base
86
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
87
+
88
+ class Emotion2vecAdapter(SERModelAdapter):
89
+ name = "emotion2vec_plus_base"
90
+ model_id = "iic/emotion2vec_plus_base"
91
+ params_m = 90
92
+
93
+ # emotion2vec 9-class โ†’ project 7-class (from src/stage2/audio_emotion.py)
94
+ LABEL_MAP = {
95
+ "angry": "anger", "disgusted": "disgust", "fearful": "fear",
96
+ "happy": "joy", "neutral": "neutral", "sad": "sadness",
97
+ "surprised": "surprise", "other": "neutral", "unknown": "neutral",
98
+ "็”Ÿๆฐ”/angry": "anger", "ๅŽŒๆถ/disgusted": "disgust",
99
+ "ๆๆƒง/fearful": "fear", "ๅผ€ๅฟƒ/happy": "joy",
100
+ "ไธญ็ซ‹/neutral": "neutral", "้šพ่ฟ‡/sad": "sadness",
101
+ "ๅƒๆƒŠ/surprised": "surprise", "ๅ…ถไป–/other": "neutral", "<unk>": "neutral",
102
+ }
103
+
104
+ def __init__(self):
105
+ self._model = None
106
+
107
+ def load(self, device: str) -> None:
108
+ from funasr import AutoModel
109
+ self._model = AutoModel(model=self.model_id, device=device, hub="hf")
110
+
111
+ def predict(self, audio_path: str) -> dict[str, float]:
112
+ output = self._model.generate(
113
+ audio_path, granularity="utterance", extract_embedding=False,
114
+ )
115
+ scores = {label: 0.0 for label in EVAL_LABELS}
116
+ if output and isinstance(output, list) and len(output) > 0:
117
+ rec = output[0]
118
+ for native_label, score in zip(rec.get("labels", []), rec.get("scores", [])):
119
+ mapped = self.LABEL_MAP.get(native_label, "neutral")
120
+ if mapped in scores:
121
+ scores[mapped] += float(score)
122
+ # Normalize
123
+ total = sum(scores.values())
124
+ if total > 0:
125
+ scores = {k: v / total for k, v in scores.items()}
126
+ return scores
127
+
128
+ def unload(self) -> None:
129
+ del self._model
130
+ self._model = None
131
+
132
+
133
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
134
+ # Adapter 2: SpeechBrain wav2vec2-IEMOCAP
135
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
136
+
137
+ class SpeechBrainAdapter(SERModelAdapter):
138
+ name = "speechbrain_wav2vec2"
139
+ model_id = "speechbrain/emotion-recognition-wav2vec2-IEMOCAP"
140
+ params_m = 314
141
+
142
+ # SpeechBrain 4-class โ†’ project taxonomy
143
+ # NOTE: This model CANNOT predict fear or surprise
144
+ LABEL_MAP = {
145
+ "ang": "anger",
146
+ "hap": "joy",
147
+ "sad": "sadness",
148
+ "neu": "neutral",
149
+ }
150
+
151
+ def __init__(self):
152
+ self._classifier = None
153
+ self._label_order = None # populated from label_encoder
154
+
155
+ def load(self, device: str) -> None:
156
+ import torch
157
+ from speechbrain.inference.classifiers import EncoderClassifier
158
+ self._classifier = EncoderClassifier.from_hparams(
159
+ source=self.model_id,
160
+ run_opts={"device": device},
161
+ )
162
+ self._classifier = self._classifier.to(device)
163
+
164
+ # Get label order from label_encoder
165
+ try:
166
+ le = self._classifier.hparams.label_encoder
167
+ # lab2ind: {'neu': 0, 'ang': 1, 'hap': 2, 'sad': 3}
168
+ self._label_order = [None] * len(le.lab2ind)
169
+ for lab, idx in le.lab2ind.items():
170
+ self._label_order[idx] = lab
171
+ logger.info("SpeechBrain labels: %s", self._label_order)
172
+ except Exception:
173
+ self._label_order = ["neu", "ang", "hap", "sad"]
174
+
175
+ def predict(self, audio_path: str) -> dict[str, float]:
176
+ import torch
177
+ import torchaudio
178
+
179
+ signal, sr = torchaudio.load(audio_path)
180
+ if sr != 16000:
181
+ signal = torchaudio.functional.resample(signal, sr, 16000)
182
+ if signal.shape[0] > 1:
183
+ signal = signal.mean(dim=0, keepdim=True)
184
+
185
+ # Use modules directly (classify_batch broken in SpeechBrain 1.1.0)
186
+ with torch.no_grad():
187
+ feats = self._classifier.mods.wav2vec2(signal)
188
+ pooled = self._classifier.mods.avg_pool(feats)
189
+ logits = self._classifier.mods.output_mlp(pooled)
190
+ probs = torch.softmax(logits.squeeze(1), dim=-1).squeeze().tolist()
191
+
192
+ if isinstance(probs, float):
193
+ probs = [probs]
194
+
195
+ scores = {label: 0.0 for label in EVAL_LABELS}
196
+ for sb_label, prob in zip(self._label_order, probs):
197
+ mapped = self.LABEL_MAP.get(sb_label, "neutral")
198
+ if mapped in scores:
199
+ scores[mapped] += prob
200
+
201
+ return scores
202
+
203
+ def unload(self) -> None:
204
+ del self._classifier
205
+ self._classifier = None
206
+
207
+
208
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
209
+ # Adapter 3: Whisper-Medium + Emotion Head
210
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
211
+
212
+ class WhisperMediumAdapter(SERModelAdapter):
213
+ name = "whisper_medium_head"
214
+ model_id = "openai/whisper-medium"
215
+ params_m = 769
216
+
217
+ def __init__(self, head_ckpt: str | None = None):
218
+ self._encoder = None
219
+ self._head = None
220
+ self._processor = None
221
+ self._head_ckpt = head_ckpt
222
+ self._device = "cpu"
223
+
224
+ def load(self, device: str) -> None:
225
+ import torch
226
+ from transformers import WhisperModel, WhisperFeatureExtractor
227
+
228
+ self._device = device
229
+ self._processor = WhisperFeatureExtractor.from_pretrained(self.model_id)
230
+ self._encoder = WhisperModel.from_pretrained(self.model_id).to(device)
231
+ self._encoder.eval()
232
+
233
+ # Classifier head: hidden_dim โ†’ 6 classes
234
+ hidden_dim = self._encoder.config.d_model # 1024 for medium
235
+ self._head = torch.nn.Linear(hidden_dim, len(EVAL_LABELS)).to(device)
236
+
237
+ if self._head_ckpt and Path(self._head_ckpt).exists():
238
+ logger.info("Loading Whisper emotion head from %s", self._head_ckpt)
239
+ state = torch.load(self._head_ckpt, map_location=device, weights_only=True)
240
+ self._head.load_state_dict(state)
241
+ else:
242
+ logger.warning("No trained Whisper head โ€” using random weights (baseline)")
243
+
244
+ self._head.eval()
245
+
246
+ def predict(self, audio_path: str) -> dict[str, float]:
247
+ import torch
248
+ import librosa
249
+
250
+ # Load and preprocess
251
+ audio, sr = librosa.load(audio_path, sr=16000)
252
+ inputs = self._processor(
253
+ audio, sampling_rate=16000, return_tensors="pt",
254
+ )
255
+ input_features = inputs.input_features.to(self._device)
256
+
257
+ with torch.no_grad():
258
+ encoder_out = self._encoder.encoder(input_features)
259
+ hidden = encoder_out.last_hidden_state # (1, T, D)
260
+ pooled = hidden.mean(dim=1) # (1, D)
261
+ logits = self._head(pooled) # (1, 6)
262
+ probs = torch.softmax(logits, dim=-1).squeeze().cpu().tolist()
263
+
264
+ scores = {}
265
+ for label, prob in zip(EVAL_LABELS, probs):
266
+ scores[label] = prob
267
+ return scores
268
+
269
+ def unload(self) -> None:
270
+ del self._encoder, self._head, self._processor
271
+ self._encoder = self._head = self._processor = None
272
+
273
+
274
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
275
+ # Phone Augmentation
276
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
277
+
278
+ def apply_phone_augmentation(audio_path: str) -> str:
279
+ """Apply phone-quality degradation, return path to temp WAV file."""
280
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
281
+ from common.phone_simulator import PhoneSimulator, CompandingType
282
+
283
+ audio, sr = sf.read(audio_path, dtype="float32")
284
+ if audio.ndim == 2:
285
+ audio = audio.mean(axis=1)
286
+
287
+ sim = PhoneSimulator(companding=CompandingType.ALAW)
288
+ degraded, new_sr = sim.process(audio, sr)
289
+
290
+ # Save to temp file
291
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
292
+ sf.write(tmp.name, degraded, new_sr, subtype="PCM_16")
293
+ return tmp.name
294
+
295
+
296
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
297
+ # Test Data Loading
298
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
299
+
300
+ def load_test_data(test_dir: str, max_samples: int | None = None) -> list[dict]:
301
+ """Load test samples from prepared subset."""
302
+ csv_path = Path(test_dir) / "test_labels.csv"
303
+ if not csv_path.exists():
304
+ logger.error("test_labels.csv not found in %s", test_dir)
305
+ sys.exit(1)
306
+
307
+ samples = []
308
+ with open(csv_path, encoding="utf-8") as f:
309
+ reader = csv.DictReader(f)
310
+ for row in reader:
311
+ audio_path = str(Path(test_dir) / row["file_path"])
312
+ if not Path(audio_path).exists():
313
+ logger.warning("Audio file not found: %s", audio_path)
314
+ continue
315
+ samples.append({
316
+ "audio_path": audio_path,
317
+ "emotion": row["emotion"],
318
+ "duration": float(row["duration"]),
319
+ "speaker_id": row.get("speaker_id", ""),
320
+ "intensity": row.get("intensity", ""),
321
+ })
322
+
323
+ if max_samples and len(samples) > max_samples:
324
+ import random
325
+ random.seed(42)
326
+ samples = random.sample(samples, max_samples)
327
+
328
+ logger.info("Loaded %d test samples from %s", len(samples), test_dir)
329
+ return samples
330
+
331
+
332
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
333
+ # Benchmark Runner
334
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
335
+
336
+ def get_process_rss_mb() -> float:
337
+ return psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024)
338
+
339
+
340
+ def benchmark_model(
341
+ adapter: SERModelAdapter,
342
+ samples: list[dict],
343
+ device: str,
344
+ phone_augment: bool,
345
+ warmup: int = 5,
346
+ ) -> dict:
347
+ """Run full benchmark for one model on both clean and optionally phone conditions."""
348
+ logger.info("=" * 60)
349
+ logger.info("Benchmarking: %s (%dM params)", adapter.name, adapter.params_m)
350
+ logger.info("=" * 60)
351
+
352
+ result = {
353
+ "model": adapter.name,
354
+ "model_id": adapter.model_id,
355
+ "params_m": adapter.params_m,
356
+ "device": device,
357
+ }
358
+
359
+ # Baseline RAM
360
+ gc.collect()
361
+ baseline_rss = get_process_rss_mb()
362
+
363
+ # Load model
364
+ logger.info("Loading model...")
365
+ load_start = time.perf_counter()
366
+ try:
367
+ adapter.load(device)
368
+ except Exception as e:
369
+ logger.error("Failed to load %s: %s", adapter.name, e)
370
+ result["error"] = str(e)
371
+ return result
372
+ load_time = time.perf_counter() - load_start
373
+ result["load_time_sec"] = round(load_time, 2)
374
+
375
+ post_load_rss = get_process_rss_mb()
376
+ result["model_ram_mb"] = round(post_load_rss - baseline_rss, 1)
377
+ logger.info("Loaded in %.1fs, RAM: %.0fMB", load_time, result["model_ram_mb"])
378
+
379
+ # Run for each condition
380
+ conditions = ["clean"]
381
+ if phone_augment:
382
+ conditions.append("phone")
383
+
384
+ for condition in conditions:
385
+ logger.info("--- Condition: %s ---", condition)
386
+
387
+ # Warmup
388
+ warmup_samples = samples[:warmup] if len(samples) >= warmup else samples
389
+ for s in warmup_samples:
390
+ try:
391
+ audio_path = s["audio_path"]
392
+ if condition == "phone":
393
+ audio_path = apply_phone_augmentation(audio_path)
394
+ adapter.predict(audio_path)
395
+ if condition == "phone":
396
+ os.unlink(audio_path)
397
+ except Exception:
398
+ pass
399
+
400
+ # Inference
401
+ y_true = []
402
+ y_pred = []
403
+ latencies = []
404
+ errors = []
405
+ peak_rss = get_process_rss_mb()
406
+
407
+ for i, sample in enumerate(samples):
408
+ audio_path = sample["audio_path"]
409
+ tmp_path = None
410
+
411
+ try:
412
+ if condition == "phone":
413
+ tmp_path = apply_phone_augmentation(audio_path)
414
+ audio_path = tmp_path
415
+
416
+ t0 = time.perf_counter()
417
+ scores = adapter.predict(audio_path)
418
+ t1 = time.perf_counter()
419
+
420
+ latency_ms = (t1 - t0) * 1000
421
+ latencies.append(latency_ms)
422
+
423
+ pred_label = max(scores, key=scores.get)
424
+ y_true.append(sample["emotion"])
425
+ y_pred.append(pred_label)
426
+
427
+ except Exception as e:
428
+ errors.append({"index": i, "error": str(e)})
429
+ logger.warning("Error on sample %d: %s", i, e)
430
+ finally:
431
+ if tmp_path and os.path.exists(tmp_path):
432
+ os.unlink(tmp_path)
433
+
434
+ current_rss = get_process_rss_mb()
435
+ peak_rss = max(peak_rss, current_rss)
436
+
437
+ if (i + 1) % 50 == 0:
438
+ logger.info(" %d/%d done (mean lat: %.0fms)", i + 1, len(samples),
439
+ statistics.mean(latencies) if latencies else 0)
440
+
441
+ # Compute metrics
442
+ cond_result = compute_metrics(y_true, y_pred, latencies, peak_rss - baseline_rss, errors)
443
+ result[condition] = cond_result
444
+
445
+ logger.info(" %s: macro_f1=%.3f, accuracy=%.3f, mean_latency=%.0fms, peak_ram=%.0fMB",
446
+ condition,
447
+ cond_result["macro_f1"],
448
+ cond_result["accuracy"],
449
+ cond_result["latency"]["mean_ms"],
450
+ cond_result["peak_ram_mb"])
451
+
452
+ # Unload
453
+ adapter.unload()
454
+ gc.collect()
455
+ try:
456
+ import torch
457
+ if torch.cuda.is_available():
458
+ torch.cuda.empty_cache()
459
+ except ImportError:
460
+ pass
461
+
462
+ return result
463
+
464
+
465
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
466
+ # Metrics
467
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
468
+
469
+ def compute_metrics(
470
+ y_true: list[str],
471
+ y_pred: list[str],
472
+ latencies: list[float],
473
+ peak_ram_mb: float,
474
+ errors: list[dict],
475
+ ) -> dict:
476
+ """Compute accuracy, F1, confusion matrix, latency stats."""
477
+ from sklearn.metrics import (
478
+ accuracy_score,
479
+ precision_recall_fscore_support,
480
+ confusion_matrix,
481
+ )
482
+
483
+ if not y_true or not y_pred:
484
+ return {
485
+ "accuracy": 0.0, "macro_f1": 0.0, "weighted_f1": 0.0,
486
+ "per_class": {l: {"precision": 0, "recall": 0, "f1": 0, "support": 0} for l in EVAL_LABELS},
487
+ "confusion_matrix": [[0] * len(EVAL_LABELS)] * len(EVAL_LABELS),
488
+ "confusion_labels": EVAL_LABELS,
489
+ "latency": {}, "peak_ram_mb": round(peak_ram_mb, 1),
490
+ "total_samples": 0, "errors": errors,
491
+ "note": "All samples failed โ€” no predictions available",
492
+ }
493
+
494
+ accuracy = accuracy_score(y_true, y_pred)
495
+ precision, recall, f1, support = precision_recall_fscore_support(
496
+ y_true, y_pred, labels=EVAL_LABELS, average=None, zero_division=0,
497
+ )
498
+ macro_f1 = float(np.mean(f1))
499
+ weighted_f1 = float(np.average(f1, weights=support)) if sum(support) > 0 else 0.0
500
+
501
+ cm = confusion_matrix(y_true, y_pred, labels=EVAL_LABELS).tolist()
502
+
503
+ per_class = {}
504
+ for i, label in enumerate(EVAL_LABELS):
505
+ per_class[label] = {
506
+ "precision": round(float(precision[i]), 4),
507
+ "recall": round(float(recall[i]), 4),
508
+ "f1": round(float(f1[i]), 4),
509
+ "support": int(support[i]),
510
+ }
511
+
512
+ latency_stats = {}
513
+ if latencies:
514
+ latency_stats = {
515
+ "mean_ms": round(statistics.mean(latencies), 1),
516
+ "median_ms": round(statistics.median(latencies), 1),
517
+ "std_ms": round(statistics.stdev(latencies), 1) if len(latencies) > 1 else 0,
518
+ "p95_ms": round(sorted(latencies)[int(len(latencies) * 0.95)], 1),
519
+ "min_ms": round(min(latencies), 1),
520
+ "max_ms": round(max(latencies), 1),
521
+ }
522
+
523
+ return {
524
+ "accuracy": round(accuracy, 4),
525
+ "macro_f1": round(macro_f1, 4),
526
+ "weighted_f1": round(weighted_f1, 4),
527
+ "per_class": per_class,
528
+ "confusion_matrix": cm,
529
+ "confusion_labels": EVAL_LABELS,
530
+ "latency": latency_stats,
531
+ "peak_ram_mb": round(peak_ram_mb, 1),
532
+ "total_samples": len(y_true),
533
+ "errors": errors,
534
+ }
535
+
536
+
537
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
538
+ # Knockout Check
539
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
540
+
541
+ def knockout_check(result: dict) -> dict:
542
+ """Check if model passes knockout criteria."""
543
+ checks = {}
544
+ for condition in ["clean", "phone"]:
545
+ if condition not in result:
546
+ continue
547
+ cond = result[condition]
548
+ f1_ok = cond["macro_f1"] >= KNOCKOUT_F1
549
+ lat_ok = cond["latency"].get("mean_ms", 999) <= KNOCKOUT_LATENCY_MS
550
+ ram_ok = cond["peak_ram_mb"] <= KNOCKOUT_RAM_MB
551
+ checks[condition] = {
552
+ "korean_f1": f"{'PASS' if f1_ok else 'FAIL'} ({cond['macro_f1']:.3f} {'โ‰ฅ' if f1_ok else '<'} {KNOCKOUT_F1})",
553
+ "latency": f"{'PASS' if lat_ok else 'FAIL'} ({cond['latency'].get('mean_ms', 0):.0f}ms {'โ‰ค' if lat_ok else '>'} {KNOCKOUT_LATENCY_MS}ms)",
554
+ "ram": f"{'PASS' if ram_ok else 'FAIL'} ({cond['peak_ram_mb']:.0f}MB {'โ‰ค' if ram_ok else '>'} {KNOCKOUT_RAM_MB}MB)",
555
+ "overall": "PASS" if (f1_ok and lat_ok and ram_ok) else "FAIL",
556
+ }
557
+ return checks
558
+
559
+
560
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
561
+ # Report Generation
562
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
563
+
564
+ def generate_markdown_report(all_results: dict, output_path: str):
565
+ """Generate a markdown comparison report."""
566
+ lines = [
567
+ "# 3-Model SER Benchmark Report",
568
+ "",
569
+ f"**Generated**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
570
+ f"**Dataset**: AI Hub #71631 (๊ฐ์ •์ด ํƒœ๊น…๋œ ์ž์œ ๋Œ€ํ™” - ์„ฑ์ธ)",
571
+ f"**Evaluation Classes**: {', '.join(EVAL_LABELS)} (6-class, no disgust)",
572
+ "",
573
+ "---",
574
+ "",
575
+ "## Summary Comparison",
576
+ "",
577
+ ]
578
+
579
+ # Summary table
580
+ headers = ["Model", "Params", "Clean F1", "Phone F1", "Latency (mean)", "Latency (p95)", "RAM", "Knockout"]
581
+ rows = []
582
+ for name, res in all_results.items():
583
+ if "error" in res:
584
+ rows.append(f"| {name} | {res.get('params_m', '?')}M | LOAD FAILED | - | - | - | - | FAIL |")
585
+ continue
586
+ clean = res.get("clean", {})
587
+ phone = res.get("phone", {})
588
+ ko = knockout_check(res)
589
+ clean_ko = ko.get("clean", {}).get("overall", "N/A")
590
+ rows.append(
591
+ f"| {name} | {res['params_m']}M "
592
+ f"| {clean.get('macro_f1', 0):.3f} "
593
+ f"| {phone.get('macro_f1', 'N/A') if phone else 'N/A'} "
594
+ f"| {clean.get('latency', {}).get('mean_ms', 0):.0f}ms "
595
+ f"| {clean.get('latency', {}).get('p95_ms', 0):.0f}ms "
596
+ f"| {clean.get('peak_ram_mb', 0):.0f}MB "
597
+ f"| {clean_ko} |"
598
+ )
599
+
600
+ lines.append(f"| {' | '.join(headers)} |")
601
+ lines.append(f"| {'---|' * len(headers)}")
602
+ lines.extend(rows)
603
+ lines.append("")
604
+
605
+ # Knockout details
606
+ lines.extend(["", "## Knockout Check", ""])
607
+ for name, res in all_results.items():
608
+ if "error" in res:
609
+ continue
610
+ ko = knockout_check(res)
611
+ lines.append(f"### {name}")
612
+ for cond, checks in ko.items():
613
+ lines.append(f"**{cond}**: {checks['overall']}")
614
+ lines.append(f" - F1: {checks['korean_f1']}")
615
+ lines.append(f" - Latency: {checks['latency']}")
616
+ lines.append(f" - RAM: {checks['ram']}")
617
+ lines.append("")
618
+
619
+ # Per-model details with confusion matrix
620
+ lines.extend(["## Per-Model Details", ""])
621
+ for name, res in all_results.items():
622
+ if "error" in res:
623
+ continue
624
+ lines.append(f"### {name}")
625
+
626
+ for condition in ["clean", "phone"]:
627
+ if condition not in res:
628
+ continue
629
+ cond = res[condition]
630
+ lines.extend([
631
+ f"",
632
+ f"#### {condition.title()} Condition",
633
+ f"",
634
+ f"- Accuracy: {cond['accuracy']:.3f}",
635
+ f"- Macro F1: {cond['macro_f1']:.3f}",
636
+ f"- Weighted F1: {cond['weighted_f1']:.3f}",
637
+ f"",
638
+ "**Per-class F1:**",
639
+ "",
640
+ "| Emotion | Precision | Recall | F1 | Support |",
641
+ "|---|---|---|---|---|",
642
+ ])
643
+ for label in EVAL_LABELS:
644
+ pc = cond["per_class"].get(label, {})
645
+ lines.append(
646
+ f"| {label} | {pc.get('precision', 0):.3f} "
647
+ f"| {pc.get('recall', 0):.3f} "
648
+ f"| {pc.get('f1', 0):.3f} "
649
+ f"| {pc.get('support', 0)} |"
650
+ )
651
+
652
+ # Confusion matrix
653
+ lines.extend(["", "**Confusion Matrix:**", ""])
654
+ cm = cond.get("confusion_matrix", [])
655
+ if cm:
656
+ lines.append("| | " + " | ".join(EVAL_LABELS) + " |")
657
+ lines.append("| --- | " + " | ".join(["---"] * len(EVAL_LABELS)) + " |")
658
+ for i, row in enumerate(cm):
659
+ lines.append(f"| **{EVAL_LABELS[i]}** | " + " | ".join(str(v) for v in row) + " |")
660
+
661
+ lines.append("")
662
+
663
+ # Limitations
664
+ lines.extend([
665
+ "## Known Limitations",
666
+ "",
667
+ "- **SpeechBrain wav2vec2-IEMOCAP**: Only outputs 4 classes (angry, happy, sad, neutral). "
668
+ "Cannot predict fear or surprise โ†’ structurally penalized in 6-class macro F1.",
669
+ "- **Whisper-Medium + Head**: Requires a separately trained classifier head. "
670
+ "Without training, results reflect random baseline (~16.7%).",
671
+ "- **AI Hub dataset**: No 'disgust' class โ†’ evaluated as 6-class instead of project's 7-class.",
672
+ "",
673
+ ])
674
+
675
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
676
+ with open(output_path, "w", encoding="utf-8") as f:
677
+ f.write("\n".join(lines))
678
+ logger.info("Markdown report saved to %s", output_path)
679
+
680
+
681
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
682
+ # Main
683
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
684
+
685
+ ADAPTER_MAP = {
686
+ "emotion2vec": Emotion2vecAdapter,
687
+ "speechbrain": SpeechBrainAdapter,
688
+ "whisper": WhisperMediumAdapter,
689
+ }
690
+
691
+
692
+ def main():
693
+ parser = argparse.ArgumentParser(description="3-Model SER Benchmark")
694
+ parser.add_argument("--test-dir", required=True, help="ํ…Œ์ŠคํŠธ ์„œ๋ธŒ์…‹ ๋””๋ ‰ํ† ๋ฆฌ (test_labels.csv ํฌํ•จ)")
695
+ parser.add_argument("--models", nargs="+", default=["emotion2vec", "speechbrain"],
696
+ choices=list(ADAPTER_MAP.keys()), help="๋ฒค์น˜๋งˆํฌํ•  ๋ชจ๋ธ (default: emotion2vec speechbrain)")
697
+ parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"], help="Compute device")
698
+ parser.add_argument("--whisper-head-ckpt", default=None, help="Whisper emotion head ์ฒดํฌํฌ์ธํŠธ ๊ฒฝ๋กœ")
699
+ parser.add_argument("--phone-augment", action="store_true", default=False, help="Phone augmentation ํ‰๊ฐ€ ์ถ”๊ฐ€")
700
+ parser.add_argument("--warmup", type=int, default=5, help="Warmup ํšŸ์ˆ˜")
701
+ parser.add_argument("--max-samples", type=int, default=None, help="์ตœ๋Œ€ ์ƒ˜ํ”Œ ์ˆ˜ (smoke test์šฉ)")
702
+ parser.add_argument("--output-json", default="data/evaluation/benchmark_3model_results.json")
703
+ parser.add_argument("--output-md", default="docs/stage2/benchmark-3model-report.md")
704
+ args = parser.parse_args()
705
+
706
+ # Load test data
707
+ samples = load_test_data(args.test_dir, max_samples=args.max_samples)
708
+ if not samples:
709
+ logger.error("No test samples loaded")
710
+ sys.exit(1)
711
+
712
+ # Run benchmarks
713
+ all_results = {}
714
+ for model_name in args.models:
715
+ adapter_cls = ADAPTER_MAP[model_name]
716
+ if model_name == "whisper":
717
+ adapter = adapter_cls(head_ckpt=args.whisper_head_ckpt)
718
+ else:
719
+ adapter = adapter_cls()
720
+
721
+ result = benchmark_model(
722
+ adapter, samples, args.device,
723
+ phone_augment=args.phone_augment,
724
+ warmup=args.warmup,
725
+ )
726
+ result["knockout"] = knockout_check(result)
727
+ all_results[adapter.name] = result
728
+
729
+ # Save JSON
730
+ output_json_path = Path(args.output_json)
731
+ output_json_path.parent.mkdir(parents=True, exist_ok=True)
732
+
733
+ import platform
734
+ try:
735
+ import torch
736
+ torch_version = torch.__version__
737
+ cuda_available = torch.cuda.is_available()
738
+ except ImportError:
739
+ torch_version = "not installed"
740
+ cuda_available = False
741
+
742
+ output_data = {
743
+ "metadata": {
744
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
745
+ "device": args.device,
746
+ "test_samples": len(samples),
747
+ "eval_classes": EVAL_LABELS,
748
+ "conditions": ["clean"] + (["phone"] if args.phone_augment else []),
749
+ "system_info": {
750
+ "cpu": platform.processor() or "unknown",
751
+ "ram_total_gb": round(psutil.virtual_memory().total / (1024**3), 1),
752
+ "python": platform.python_version(),
753
+ "torch": torch_version,
754
+ "cuda": cuda_available,
755
+ },
756
+ },
757
+ "results": all_results,
758
+ }
759
+
760
+ with open(output_json_path, "w", encoding="utf-8") as f:
761
+ json.dump(output_data, f, indent=2, ensure_ascii=False, default=str)
762
+ logger.info("JSON results saved to %s", output_json_path)
763
+
764
+ # Generate markdown report
765
+ generate_markdown_report(all_results, args.output_md)
766
+
767
+ # Console summary
768
+ print("\n" + "=" * 60)
769
+ print("BENCHMARK COMPLETE")
770
+ print("=" * 60)
771
+ for name, res in all_results.items():
772
+ if "error" in res:
773
+ print(f"\n {name}: LOAD FAILED โ€” {res['error']}")
774
+ continue
775
+ clean = res.get("clean", {})
776
+ ko = res.get("knockout", {}).get("clean", {})
777
+ print(f"\n {name} ({res['params_m']}M params):")
778
+ print(f" Clean F1: {clean.get('macro_f1', 0):.3f} Accuracy: {clean.get('accuracy', 0):.3f}")
779
+ print(f" Latency: {clean.get('latency', {}).get('mean_ms', 0):.0f}ms (mean), "
780
+ f"{clean.get('latency', {}).get('p95_ms', 0):.0f}ms (p95)")
781
+ print(f" RAM: {clean.get('peak_ram_mb', 0):.0f}MB")
782
+ print(f" Knockout: {ko.get('overall', 'N/A')}")
783
+
784
+ if args.phone_augment:
785
+ print("\n --- Phone Degradation ---")
786
+ for name, res in all_results.items():
787
+ if "error" in res or "phone" not in res:
788
+ continue
789
+ clean_f1 = res.get("clean", {}).get("macro_f1", 0)
790
+ phone_f1 = res["phone"]["macro_f1"]
791
+ drop = clean_f1 - phone_f1
792
+ print(f" {name}: {clean_f1:.3f} โ†’ {phone_f1:.3f} (ฮ”={drop:+.3f})")
793
+
794
+ print(f"\n Results: {args.output_json}")
795
+ print(f" Report: {args.output_md}")
796
+
797
+
798
+ if __name__ == "__main__":
799
+ main()
scripts/build_english_fusion_manifest.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build unified English fusion evaluation manifest.
3
+
4
+ Combines:
5
+ - JL-Corpus 5-class (angryโ†’anger, happyโ†’joy, sadโ†’sadness, neutral, anxiousโ†’fear)
6
+ - SAVEE 2-class (disgust, surprise) with WhisperX ASR transcripts
7
+ - MELD fusion disgust + surprise samples (natural dialogue)
8
+
9
+ Output: data/english_fusion/manifest.json
10
+ Format: [{"path": ..., "text": ..., "label": ..., "source": ..., "speaker": ...}, ...]
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import re
17
+ from collections import Counter
18
+ from pathlib import Path
19
+
20
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
21
+ logger = logging.getLogger(__name__)
22
+
23
+ PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
24
+
25
+ JL_MAP = {
26
+ "angry": "anger",
27
+ "happy": "joy",
28
+ "sad": "sadness",
29
+ "neutral": "neutral",
30
+ "anxious": "fear",
31
+ }
32
+
33
+ SAVEE_MAP = {"d": "disgust", "su": "surprise"}
34
+ SAVEE_PATTERN = re.compile(r"^(?P<spk>[A-Z]{2})_(?P<emo>d|su)(?P<idx>\d+)\.wav$")
35
+ JL_PATTERN = re.compile(r"^(?P<spk>[a-z]+\d+)_(?P<emo>[a-z]+)_(?P<sid>[^_]+)_(?P<take>\d+)\.wav$")
36
+
37
+ OUT_DIR = Path("data/english_fusion")
38
+ OUT_MANIFEST = OUT_DIR / "manifest.json"
39
+
40
+
41
+ def load_jl_corpus() -> list[dict]:
42
+ jl_dir = Path("data/jl_corpus")
43
+ rows: list[dict] = []
44
+ for wav in sorted(jl_dir.glob("*.wav")):
45
+ m = JL_PATTERN.match(wav.name)
46
+ if not m:
47
+ continue
48
+ emo_jl = m.group("emo")
49
+ if emo_jl not in JL_MAP:
50
+ continue
51
+ label = JL_MAP[emo_jl]
52
+ txt_path = wav.with_suffix(".txt")
53
+ if not txt_path.exists():
54
+ continue
55
+ text = txt_path.read_text(encoding="utf-8", errors="ignore").strip()
56
+ if not text:
57
+ continue
58
+ rows.append({
59
+ "path": str(wav),
60
+ "text": text,
61
+ "label": label,
62
+ "source": "jl_corpus",
63
+ "speaker": m.group("spk"),
64
+ })
65
+ logger.info("JL-Corpus 5-class: %d samples", len(rows))
66
+ return rows
67
+
68
+
69
+ def load_savee() -> list[dict]:
70
+ savee_dir = Path("data/savee/ALL")
71
+ asr_path = Path("data/savee/savee_asr.json")
72
+ if not asr_path.exists():
73
+ logger.warning("SAVEE ASR not found at %s", asr_path)
74
+ return []
75
+ asr = json.loads(asr_path.read_text())
76
+
77
+ rows: list[dict] = []
78
+ for wav in sorted(savee_dir.iterdir()):
79
+ m = SAVEE_PATTERN.match(wav.name)
80
+ if not m:
81
+ continue
82
+ label = SAVEE_MAP[m.group("emo")]
83
+ text = asr.get(wav.name, "").strip()
84
+ if not text:
85
+ continue
86
+ rows.append({
87
+ "path": str(wav),
88
+ "text": text,
89
+ "label": label,
90
+ "source": "savee",
91
+ "speaker": m.group("spk"),
92
+ })
93
+ logger.info("SAVEE disgust+surprise: %d samples", len(rows))
94
+ return rows
95
+
96
+
97
+ def load_meld_disgust_surprise() -> list[dict]:
98
+ meld_path = Path("data/meld_fusion/manifest.json")
99
+ if not meld_path.exists():
100
+ logger.warning("MELD manifest missing: %s", meld_path)
101
+ return []
102
+ data = json.loads(meld_path.read_text())
103
+ rows: list[dict] = []
104
+ for d in data:
105
+ if d["label"] not in ("disgust", "surprise"):
106
+ continue
107
+ if not d.get("text", "").strip():
108
+ continue
109
+ rows.append({
110
+ "path": d["path"],
111
+ "text": d["text"],
112
+ "label": d["label"],
113
+ "source": "meld",
114
+ "speaker": f"dia{d.get('dialogue_id', '?')}",
115
+ })
116
+ logger.info("MELD disgust+surprise: %d samples", len(rows))
117
+ return rows
118
+
119
+
120
+ def main() -> None:
121
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
122
+ all_rows = load_jl_corpus() + load_savee() + load_meld_disgust_surprise()
123
+
124
+ counts = Counter(r["label"] for r in all_rows)
125
+ sources = Counter(r["source"] for r in all_rows)
126
+ logger.info("Total: %d samples", len(all_rows))
127
+ logger.info("By label: %s", dict(counts))
128
+ logger.info("By source: %s", dict(sources))
129
+
130
+ OUT_MANIFEST.write_text(json.dumps(all_rows, indent=2, ensure_ascii=False))
131
+ logger.info("Saved to %s", OUT_MANIFEST)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()
scripts/build_meld_test_sets.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Build English test sets from MELD (Friends) dataset.
4
+
5
+ Extracts 8 scenario-based test sets from MELD MP4 clips,
6
+ converts to WAV, and concatenates into single audio files
7
+ that simulate real phone calls for E2E pipeline testing.
8
+
9
+ Usage:
10
+ python scripts/build_meld_test_sets.py
11
+
12
+ Output:
13
+ data/meld_test/
14
+ โ”œโ”€โ”€ 01_angry_fight.wav
15
+ โ”œโ”€โ”€ 02_happy_loving.wav
16
+ โ”œโ”€โ”€ ...
17
+ โ”œโ”€โ”€ 08_calm_daily.wav
18
+ โ”œโ”€โ”€ ground_truth.json # per-utterance emotion labels
19
+ โ””โ”€โ”€ README.md # test set descriptions
20
+ """
21
+
22
+ import csv
23
+ import json
24
+ import os
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ from collections import Counter
29
+ from pathlib import Path
30
+
31
+ # --- Configuration ---
32
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
33
+ ZIP_PATH = PROJECT_ROOT / "data" / "english_test.zip"
34
+ OUTPUT_DIR = PROJECT_ROOT / "data" / "meld_test"
35
+ SAMPLE_RATE = 16000 # 16kHz mono โ€” matches our pipeline input
36
+
37
+ # 8 test scenarios โ€” each maps to a specific MELD dialogue
38
+ TEST_SETS = [
39
+ {
40
+ "tag": "01_angry_fight",
41
+ "desc": "Ross-Rachel breakup fight โ€” anger dominant (S3E15)",
42
+ "scenario": "Couple in a heated argument",
43
+ "primary_emotion": "anger",
44
+ "split": "train",
45
+ "dia_id": "51",
46
+ },
47
+ {
48
+ "tag": "02_happy_loving",
49
+ "desc": "Monica-Chandler sweet moment โ€” joy dominant (S5E14)",
50
+ "scenario": "Couple being affectionate and playful",
51
+ "primary_emotion": "joy",
52
+ "split": "train",
53
+ "dia_id": "1026",
54
+ },
55
+ {
56
+ "tag": "03_sad_emotional",
57
+ "desc": "Ross-Rachel emotional confession โ€” sadness dominant (S3E25)",
58
+ "scenario": "Emotional conversation with sadness and regret",
59
+ "primary_emotion": "sadness",
60
+ "split": "train",
61
+ "dia_id": "312",
62
+ },
63
+ {
64
+ "tag": "04_surprise_shock",
65
+ "desc": "Ross-Rachel surprise revelations (S7E18)",
66
+ "scenario": "Unexpected news and reactions",
67
+ "primary_emotion": "surprise",
68
+ "split": "train",
69
+ "dia_id": "747",
70
+ },
71
+ {
72
+ "tag": "05_fear_anxiety",
73
+ "desc": "Monica-Chandler anxious situation โ€” fear+mixed (S4E14)",
74
+ "scenario": "Anxious and worried conversation",
75
+ "primary_emotion": "fear",
76
+ "split": "train",
77
+ "dia_id": "109",
78
+ },
79
+ {
80
+ "tag": "06_disgust_annoyance",
81
+ "desc": "Family annoyance scene โ€” disgust+anger (S6E9)",
82
+ "scenario": "Annoyed and disgusted reactions",
83
+ "primary_emotion": "disgust",
84
+ "split": "train",
85
+ "dia_id": "1025",
86
+ },
87
+ {
88
+ "tag": "07_bittersweet",
89
+ "desc": "Ross-Rachel bittersweet farewell โ€” sadness+surprise (S5E5)",
90
+ "scenario": "Mixed emotions: saying goodbye with conflicting feelings",
91
+ "primary_emotion": "sadness",
92
+ "split": "train",
93
+ "dia_id": "676",
94
+ },
95
+ {
96
+ "tag": "08_calm_daily",
97
+ "desc": "Casual daily conversation โ€” neutral baseline (S3E23)",
98
+ "scenario": "Normal everyday chitchat (baseline)",
99
+ "primary_emotion": "neutral",
100
+ "split": "train",
101
+ "dia_id": "450",
102
+ },
103
+ ]
104
+
105
+
106
+ def load_csv_from_zip(zip_path: Path) -> dict[str, list[dict]]:
107
+ """Load all CSV data from zip, grouped by split_diaID."""
108
+ import zipfile
109
+
110
+ dialogues = {}
111
+ with zipfile.ZipFile(zip_path, "r") as zf:
112
+ csv_files = [
113
+ ("train", "JSON files/JSON files/CSV Processed/train_sent_emo_cleaned_processed.csv"),
114
+ ("dev", "JSON files/JSON files/CSV Processed/dev_sent_emo_cleaned_processed.csv"),
115
+ ("test", "JSON files/JSON files/CSV Processed/test_sent_emo_cleaned_processed.csv"),
116
+ ]
117
+ for split, csv_path in csv_files:
118
+ try:
119
+ with zf.open(csv_path) as f:
120
+ import io
121
+ reader = csv.DictReader(io.TextIOWrapper(f, encoding="utf-8"))
122
+ for row in reader:
123
+ key = f"{split}_{row['Dialogue_ID']}"
124
+ dialogues.setdefault(key, []).append(row)
125
+ except KeyError:
126
+ print(f" Warning: {csv_path} not found in zip")
127
+ return dialogues
128
+
129
+
130
+ def find_mp4_path(split: str, dia_id: str, utt_id: str, available_files: set) -> str | None:
131
+ """Find MP4 file path for a specific utterance."""
132
+ patterns = [
133
+ f"MELD.Raw/MELD.Raw/{split}/{split}_splits/dia{dia_id}_utt{utt_id}.mp4",
134
+ f"MELD.Raw/MELD.Raw/{split}/{split}_splits_complete/dia{dia_id}_utt{utt_id}.mp4",
135
+ f"MELD.Raw/MELD.Raw/{split}/output_repeated_splits_{split}/final_videos_{split}dia{dia_id}_utt{utt_id}.mp4",
136
+ ]
137
+ for p in patterns:
138
+ if p in available_files:
139
+ return p
140
+ return None
141
+
142
+
143
+ def get_mp4_list_from_zip(zip_path: Path) -> set:
144
+ """Get set of all MP4 file paths in zip."""
145
+ import zipfile
146
+ with zipfile.ZipFile(zip_path, "r") as zf:
147
+ return {n for n in zf.namelist() if n.endswith(".mp4")}
148
+
149
+
150
+ def extract_and_concat_wav(
151
+ zip_path: Path, mp4_paths: list[str], output_wav: Path, sample_rate: int = 16000
152
+ ) -> float:
153
+ """Extract audio from MP4s in zip and concatenate into single WAV."""
154
+ with tempfile.TemporaryDirectory() as tmpdir:
155
+ tmpdir = Path(tmpdir)
156
+ wav_parts = []
157
+
158
+ # Extract each MP4 and convert to WAV
159
+ import zipfile
160
+ with zipfile.ZipFile(zip_path, "r") as zf:
161
+ for i, mp4_path in enumerate(mp4_paths):
162
+ mp4_local = tmpdir / f"part_{i:03d}.mp4"
163
+ wav_local = tmpdir / f"part_{i:03d}.wav"
164
+
165
+ # Extract MP4
166
+ with zf.open(mp4_path) as src, open(mp4_local, "wb") as dst:
167
+ dst.write(src.read())
168
+
169
+ # Convert to WAV (16kHz mono)
170
+ result = subprocess.run(
171
+ [
172
+ "ffmpeg", "-y", "-i", str(mp4_local),
173
+ "-ar", str(sample_rate),
174
+ "-ac", "1",
175
+ "-acodec", "pcm_s16le",
176
+ str(wav_local),
177
+ ],
178
+ capture_output=True,
179
+ text=True,
180
+ )
181
+ if result.returncode != 0:
182
+ print(f" Warning: ffmpeg failed for {mp4_path}: {result.stderr[:200]}")
183
+ continue
184
+
185
+ if wav_local.exists() and wav_local.stat().st_size > 0:
186
+ wav_parts.append(wav_local)
187
+
188
+ if not wav_parts:
189
+ return 0.0
190
+
191
+ # Concatenate WAVs using ffmpeg concat
192
+ list_file = tmpdir / "concat_list.txt"
193
+ with open(list_file, "w") as f:
194
+ for wp in wav_parts:
195
+ f.write(f"file '{wp}'\n")
196
+
197
+ output_wav.parent.mkdir(parents=True, exist_ok=True)
198
+ result = subprocess.run(
199
+ [
200
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0",
201
+ "-i", str(list_file),
202
+ "-ar", str(sample_rate),
203
+ "-ac", "1",
204
+ "-acodec", "pcm_s16le",
205
+ str(output_wav),
206
+ ],
207
+ capture_output=True,
208
+ text=True,
209
+ )
210
+ if result.returncode != 0:
211
+ print(f" Concat failed: {result.stderr[:300]}")
212
+ return 0.0
213
+
214
+ # Get duration
215
+ probe = subprocess.run(
216
+ ["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
217
+ "-of", "default=noprint_wrappers=1:nokey=1", str(output_wav)],
218
+ capture_output=True, text=True,
219
+ )
220
+ try:
221
+ return float(probe.stdout.strip())
222
+ except ValueError:
223
+ return 0.0
224
+
225
+
226
+ def main():
227
+ print("=" * 60)
228
+ print(" MELD English Test Set Builder")
229
+ print("=" * 60)
230
+
231
+ if not ZIP_PATH.exists():
232
+ print(f"Error: {ZIP_PATH} not found")
233
+ sys.exit(1)
234
+
235
+ # 1. Load CSV data
236
+ print("\n[1/4] Loading CSV data from zip...")
237
+ dialogues = load_csv_from_zip(ZIP_PATH)
238
+ print(f" Loaded {len(dialogues)} dialogues")
239
+
240
+ # 2. Get available MP4 files
241
+ print("[2/4] Scanning MP4 files in zip...")
242
+ mp4_files = get_mp4_list_from_zip(ZIP_PATH)
243
+ print(f" Found {len(mp4_files)} MP4 files")
244
+
245
+ # 3. Process each test set
246
+ print("[3/4] Building test sets...\n")
247
+ ground_truth = {}
248
+ summary_lines = []
249
+
250
+ for ts in TEST_SETS:
251
+ tag = ts["tag"]
252
+ key = f"{ts['split']}_{ts['dia_id']}"
253
+ utts = dialogues.get(key, [])
254
+
255
+ if not utts:
256
+ print(f" โŒ {tag}: dialogue {key} not found")
257
+ continue
258
+
259
+ print(f" ๐Ÿ“ฆ {tag} โ€” {ts['desc']}")
260
+ print(f" {len(utts)} utterances", end="")
261
+
262
+ # Find MP4 paths
263
+ mp4_paths = []
264
+ for u in utts:
265
+ p = find_mp4_path(ts["split"], ts["dia_id"], u["Utterance_ID"], mp4_files)
266
+ if p:
267
+ mp4_paths.append(p)
268
+
269
+ print(f", {len(mp4_paths)}/{len(utts)} MP4s found")
270
+
271
+ if not mp4_paths:
272
+ print(f" โŒ No MP4 files found, skipping")
273
+ continue
274
+
275
+ # Extract and concatenate
276
+ output_wav = OUTPUT_DIR / f"{tag}.wav"
277
+ duration = extract_and_concat_wav(ZIP_PATH, mp4_paths, output_wav, SAMPLE_RATE)
278
+ print(f" โœ… {output_wav.name} โ€” {duration:.1f}s")
279
+
280
+ # Build ground truth
281
+ emo_counts = Counter(u["Emotion"] for u in utts)
282
+ ground_truth[tag] = {
283
+ "description": ts["desc"],
284
+ "scenario": ts["scenario"],
285
+ "primary_emotion": ts["primary_emotion"],
286
+ "source": f"MELD Friends S{utts[0]['Season']}E{utts[0]['Episode']} Dialogue {ts['dia_id']}",
287
+ "duration_sec": round(duration, 1),
288
+ "emotion_distribution": dict(emo_counts),
289
+ "total_utterances": len(utts),
290
+ "utterances": [
291
+ {
292
+ "speaker": u["Speaker"],
293
+ "emotion": u["Emotion"],
294
+ "sentiment": u["Sentiment"],
295
+ "text": u["Utterance"],
296
+ }
297
+ for u in utts
298
+ ],
299
+ }
300
+
301
+ summary_lines.append(
302
+ f"| {tag} | {ts['scenario'][:40]} | {ts['primary_emotion']} | {duration:.1f}s | {len(utts)} utts | {dict(emo_counts)} |"
303
+ )
304
+
305
+ # 4. Save ground truth + README
306
+ print("\n[4/4] Saving metadata...")
307
+
308
+ gt_path = OUTPUT_DIR / "ground_truth.json"
309
+ with open(gt_path, "w", encoding="utf-8") as f:
310
+ json.dump(ground_truth, f, indent=2, ensure_ascii=False)
311
+ print(f" โœ… {gt_path}")
312
+
313
+ # Emotion alignment check
314
+ our_labels = {"neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"}
315
+ meld_labels = set()
316
+ for gt in ground_truth.values():
317
+ meld_labels.update(gt["emotion_distribution"].keys())
318
+
319
+ readme_content = f"""# MELD English Test Sets
320
+
321
+ ## Emotion Label Alignment
322
+
323
+ | UsTwo Pipeline (EN) | MELD Label | Match |
324
+ |---|---|---|
325
+ | neutral | neutral | โœ… Exact |
326
+ | joy | joy | โœ… Exact |
327
+ | sadness | sadness | โœ… Exact |
328
+ | anger | anger | โœ… Exact |
329
+ | surprise | surprise | โœ… Exact |
330
+ | fear | fear | โœ… Exact |
331
+ | disgust | disgust | โœ… Exact |
332
+
333
+ **7/7 labels match exactly.** No mapping needed.
334
+
335
+ ## Test Sets
336
+
337
+ | File | Scenario | Primary Emotion | Duration | Utterances | Emotion Distribution |
338
+ |---|---|---|---|---|---|
339
+ {chr(10).join(summary_lines)}
340
+
341
+ ## Source
342
+ - Dataset: MELD (Multimodal EmotionLines Dataset)
343
+ - Source: Friends TV series
344
+ - Paper: Poria et al., ACL 2019
345
+ - Each WAV is a full dialogue concatenated from per-utterance MP4 clips
346
+ - Audio: 16kHz mono PCM (matches pipeline input format)
347
+
348
+ ## Usage
349
+ ```bash
350
+ # Run pipeline on a single test set
351
+ python scripts/run_pipeline.py data/meld_test/01_angry_fight.wav
352
+
353
+ # Evaluate all test sets
354
+ python scripts/evaluate_meld_test.py
355
+ ```
356
+ """
357
+ readme_path = OUTPUT_DIR / "README.md"
358
+ with open(readme_path, "w", encoding="utf-8") as f:
359
+ f.write(readme_content)
360
+ print(f" โœ… {readme_path}")
361
+
362
+ # Summary
363
+ print("\n" + "=" * 60)
364
+ print(" DONE")
365
+ print("=" * 60)
366
+ total_files = len(list(OUTPUT_DIR.glob("*.wav")))
367
+ print(f" {total_files} WAV files in {OUTPUT_DIR}")
368
+ print(f" Ground truth: {gt_path}")
369
+ print(f" Emotion alignment: {len(our_labels & meld_labels)}/{len(our_labels)} exact match")
370
+
371
+
372
+ if __name__ == "__main__":
373
+ main()
scripts/cache_models.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Pre-download and cache all ML models at Docker build time.
3
+
4
+ This avoids cold-start model downloads on first request in HF Spaces.
5
+ Models are cached to default HuggingFace/torch hub directories.
6
+
7
+ Usage (in Dockerfile):
8
+ RUN python scripts/cache_models.py
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ import sys
14
+
15
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
16
+ logger = logging.getLogger("cache_models")
17
+
18
+
19
+ def cache_pyannote():
20
+ """Cache pyannote speaker-diarization-3.1 (~1.5GB)."""
21
+ hf_token = os.environ.get("HF_TOKEN")
22
+ if not hf_token:
23
+ logger.warning("HF_TOKEN not set, skipping pyannote cache")
24
+ return
25
+ try:
26
+ from pyannote.audio import Pipeline
27
+ logger.info("Caching pyannote/speaker-diarization-3.1...")
28
+ Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", token=hf_token)
29
+ logger.info("pyannote cached OK")
30
+ except Exception as e:
31
+ logger.warning("pyannote cache failed: %s", e)
32
+
33
+
34
+ def cache_whisperx():
35
+ """Cache WhisperX large-v3-turbo INT8 (~1.5GB)."""
36
+ try:
37
+ import whisperx
38
+ logger.info("Caching whisperx large-v3-turbo (int8)...")
39
+ whisperx.load_model("large-v3-turbo", device="cpu", compute_type="int8")
40
+ logger.info("whisperx cached OK")
41
+ except Exception as e:
42
+ logger.warning("whisperx cache failed: %s", e)
43
+
44
+
45
+ def cache_emotion2vec():
46
+ """Cache emotion2vec_plus_base (~300MB)."""
47
+ try:
48
+ from funasr import AutoModel
49
+ logger.info("Caching iic/emotion2vec_plus_base...")
50
+ AutoModel(model="iic/emotion2vec_plus_base", device="cpu", hub="hf")
51
+ logger.info("emotion2vec cached OK")
52
+ except Exception as e:
53
+ logger.warning("emotion2vec cache failed: %s", e)
54
+
55
+
56
+ def cache_text_models():
57
+ """Cache text emotion models (~300MB each)."""
58
+ try:
59
+ from transformers import pipeline
60
+ logger.info("Caching j-hartmann/emotion-english-distilroberta-base...")
61
+ pipeline("text-classification", model="j-hartmann/emotion-english-distilroberta-base", top_k=None)
62
+ logger.info("DistilRoBERTa cached OK")
63
+ except Exception as e:
64
+ logger.warning("DistilRoBERTa cache failed: %s", e)
65
+
66
+ try:
67
+ from transformers import pipeline
68
+ logger.info("Caching searle-j/kote_for_easygoing_people...")
69
+ pipeline("text-classification", model="searle-j/kote_for_easygoing_people", top_k=None)
70
+ logger.info("KcELECTRA cached OK")
71
+ except Exception as e:
72
+ logger.warning("KcELECTRA cache failed: %s", e)
73
+
74
+
75
+ def download_lora_onnx_models():
76
+ """Download LoRA ONNX models from public HF Hub repo into data/models/."""
77
+ from pathlib import Path
78
+ from huggingface_hub import hf_hub_download, snapshot_download
79
+
80
+ repo_id = "BBBAKERY/ustwo-lora-models"
81
+
82
+ # emotion2vec ONNX โ†’ data/models/lora_emotion2vec_7class/model.onnx
83
+ audio_dir = Path("data/models/lora_emotion2vec_7class")
84
+ audio_dir.mkdir(parents=True, exist_ok=True)
85
+ try:
86
+ logger.info("Downloading emotion2vec LoRA ONNX from %s...", repo_id)
87
+ for fname in ["emotion2vec/model.onnx", "emotion2vec/model.json"]:
88
+ path = hf_hub_download(repo_id=repo_id, filename=fname, repo_type="model")
89
+ target = audio_dir / Path(fname).name
90
+ if not target.exists() or target.resolve() != Path(path).resolve():
91
+ import shutil
92
+ shutil.copy(path, target)
93
+ logger.info("emotion2vec LoRA ONNX cached OK")
94
+ except Exception as e:
95
+ logger.warning("emotion2vec LoRA ONNX download failed: %s", e)
96
+
97
+ # KcELECTRA ONNX + tokenizer โ†’ data/models/lora_kcelectra_7class/
98
+ text_dir = Path("data/models/lora_kcelectra_7class")
99
+ text_dir.mkdir(parents=True, exist_ok=True)
100
+ try:
101
+ logger.info("Downloading KcELECTRA LoRA ONNX from %s...", repo_id)
102
+ for fname in ["kcelectra/model.onnx", "kcelectra/model.json"]:
103
+ path = hf_hub_download(repo_id=repo_id, filename=fname, repo_type="model")
104
+ target = text_dir / Path(fname).name
105
+ import shutil
106
+ shutil.copy(path, target)
107
+
108
+ # Tokenizer folder
109
+ tokenizer_target = text_dir / "best_model"
110
+ tokenizer_target.mkdir(parents=True, exist_ok=True)
111
+ for fname in ["tokenizer.json", "tokenizer_config.json", "special_tokens_map.json", "vocab.txt"]:
112
+ path = hf_hub_download(repo_id=repo_id, filename=f"kcelectra/tokenizer/{fname}", repo_type="model")
113
+ import shutil
114
+ shutil.copy(path, tokenizer_target / fname)
115
+
116
+ logger.info("KcELECTRA LoRA ONNX + tokenizer cached OK")
117
+ except Exception as e:
118
+ logger.warning("KcELECTRA LoRA ONNX download failed: %s", e)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ logger.info("=== Pre-caching ML models ===")
123
+ cache_pyannote()
124
+ cache_whisperx()
125
+ cache_emotion2vec()
126
+ cache_text_models()
127
+ download_lora_onnx_models()
128
+ logger.info("=== Model caching complete ===")
scripts/convert_to_onnx.py ADDED
File without changes
scripts/eval_audio_on_subset.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate audio model (LoRA ONNX or base) on a subset of val manifest.
3
+
4
+ Usage:
5
+ python scripts/eval_audio_on_subset.py \
6
+ --val-manifest data/lora_dataset/val_manifest.json \
7
+ --source ravdess \
8
+ --model lora_onnx \
9
+ --onnx data/models/lora_emotion2vec_7class/model.onnx
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import logging
16
+ from collections import Counter
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+
21
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
22
+ logger = logging.getLogger(__name__)
23
+
24
+ PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
25
+
26
+ LORA_LABELS = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
27
+ LORA_TO_PROJECT = {
28
+ "happiness": "joy", "anger": "anger", "disgust": "disgust",
29
+ "fear": "fear", "neutral": "neutral", "sadness": "sadness", "surprise": "surprise",
30
+ }
31
+
32
+ BASE_LABEL_MAP = {
33
+ "angry": "anger", "disgusted": "disgust", "fearful": "fear",
34
+ "happy": "joy", "neutral": "neutral", "sad": "sadness", "surprised": "surprise",
35
+ "other": "neutral", "unknown": "neutral",
36
+ "็”Ÿๆฐ”/angry": "anger", "ๅŽŒๆถ/disgusted": "disgust", "ๆๆƒง/fearful": "fear",
37
+ "ๅผ€ๅฟƒ/happy": "joy", "ไธญ็ซ‹/neutral": "neutral", "้šพ่ฟ‡/sad": "sadness",
38
+ "ๅƒๆƒŠ/surprised": "surprise", "ๅ…ถไป–/other": "neutral", "<unk>": "neutral",
39
+ }
40
+
41
+
42
+ def predict_lora_onnx(audio_path: str, session, max_seconds: float = 15.0):
43
+ import soundfile as sf
44
+ audio, sr = sf.read(audio_path, dtype="float32")
45
+ if audio.ndim == 2:
46
+ audio = audio.mean(axis=1)
47
+ if sr != 16000:
48
+ import librosa
49
+ audio = librosa.resample(audio, orig_sr=sr, target_sr=16000)
50
+ max_samples = int(max_seconds * 16000)
51
+ if len(audio) > max_samples:
52
+ audio = audio[:max_samples]
53
+ waveform = audio.reshape(1, -1).astype(np.float32)
54
+ logits = session.run(None, {"waveform": waveform})[0]
55
+ exp_logits = np.exp(logits - logits.max(axis=-1, keepdims=True))
56
+ probs = (exp_logits / exp_logits.sum(axis=-1, keepdims=True)).squeeze()
57
+ scores = {label: 0.0 for label in PROJECT_LABELS}
58
+ for lora_label, prob in zip(LORA_LABELS, probs):
59
+ scores[LORA_TO_PROJECT[lora_label]] = float(prob)
60
+ return max(scores, key=scores.get)
61
+
62
+
63
+ def predict_base(audio_path: str, funasr_model):
64
+ try:
65
+ output = funasr_model.generate(audio_path, granularity="utterance", extract_embedding=False)
66
+ except Exception:
67
+ return "neutral"
68
+ scores = {label: 0.0 for label in PROJECT_LABELS}
69
+ if output and isinstance(output, list) and len(output) > 0:
70
+ rec = output[0]
71
+ for native_label, score in zip(rec.get("labels", []), rec.get("scores", [])):
72
+ pl = BASE_LABEL_MAP.get(native_label, "neutral")
73
+ scores[pl] += float(score)
74
+ return max(scores, key=scores.get)
75
+
76
+
77
+ def f1_score(y_true, y_pred, label):
78
+ tp = sum(1 for t, p in zip(y_true, y_pred) if t == label and p == label)
79
+ fp = sum(1 for t, p in zip(y_true, y_pred) if t != label and p == label)
80
+ fn = sum(1 for t, p in zip(y_true, y_pred) if t == label and p != label)
81
+ if tp + fp == 0 or tp + fn == 0:
82
+ return 0.0
83
+ p = tp / (tp + fp); r = tp / (tp + fn)
84
+ return 2 * p * r / (p + r) if (p + r) > 0 else 0.0
85
+
86
+
87
+ def main():
88
+ parser = argparse.ArgumentParser()
89
+ parser.add_argument("--val-manifest", default="data/lora_dataset/val_manifest.json")
90
+ parser.add_argument("--source", default="ravdess", help="Filter by source: ravdess, 263, 71631")
91
+ parser.add_argument("--model", choices=["lora_onnx", "base"], default="lora_onnx")
92
+ parser.add_argument("--onnx", default="data/models/lora_emotion2vec_7class/model.onnx")
93
+ args = parser.parse_args()
94
+
95
+ with open(args.val_manifest) as f:
96
+ val = json.load(f)
97
+ samples = [s for s in val if s["source"] == args.source]
98
+ logger.info("Evaluating %s on %d %s samples", args.model, len(samples), args.source)
99
+
100
+ # Normalize labels: happiness โ†’ joy
101
+ for s in samples:
102
+ if s["label"] == "happiness":
103
+ s["label"] = "joy"
104
+
105
+ if args.model == "lora_onnx":
106
+ import onnxruntime as ort
107
+ session = ort.InferenceSession(args.onnx, providers=["CPUExecutionProvider"])
108
+ predict_fn = lambda p: predict_lora_onnx(p, session)
109
+ else:
110
+ from funasr import AutoModel
111
+ model = AutoModel(model="iic/emotion2vec_plus_base", device="cpu", hub="hf")
112
+ predict_fn = lambda p: predict_base(p, model)
113
+
114
+ y_true, y_pred = [], []
115
+ for i, s in enumerate(samples):
116
+ y_true.append(s["label"])
117
+ y_pred.append(predict_fn(s["path"]))
118
+ if (i + 1) % 100 == 0:
119
+ logger.info("Progress: %d / %d", i + 1, len(samples))
120
+
121
+ # Per-class F1
122
+ f1s = {label: f1_score(y_true, y_pred, label) for label in PROJECT_LABELS}
123
+ macro_f1 = np.mean(list(f1s.values()))
124
+ acc = sum(1 for t, p in zip(y_true, y_pred) if t == p) / len(samples)
125
+
126
+ print()
127
+ print(f"=== {args.model} on {args.source} ({len(samples)} samples) ===")
128
+ print(f"Macro F1: {macro_f1:.4f}")
129
+ print(f"Accuracy: {acc:.4f}")
130
+ print("Per-class F1:")
131
+ for label, f1 in f1s.items():
132
+ support = sum(1 for t in y_true if t == label)
133
+ if support > 0:
134
+ print(f" {label:<12} {f1:.4f} (n={support})")
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
scripts/evaluate_emotion2vec_english.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """emotion2vec base ๋ชจ๋ธ ์˜์–ด ํ‰๊ฐ€ (RAVDESS ๋ฐ์ดํ„ฐ์…‹).
3
+
4
+ clean + phone 2๊ฐœ ์กฐ๊ฑด์œผ๋กœ ํ‰๊ฐ€ํ•˜์—ฌ ์‹ค์ „ ํ†ตํ™” ํ™˜๊ฒฝ ์„ฑ๋Šฅ์„ ์ถ”์ •ํ•œ๋‹ค.
5
+
6
+ Usage:
7
+ python scripts/evaluate_emotion2vec_english.py
8
+ python scripts/evaluate_emotion2vec_english.py --condition clean # clean๋งŒ
9
+ python scripts/evaluate_emotion2vec_english.py --condition phone # phone๋งŒ
10
+ python scripts/evaluate_emotion2vec_english.py --max-samples 100 # ๋น ๋ฅธ ํ…Œ์ŠคํŠธ
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import csv
17
+ import json
18
+ import logging
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ PROJECT_ROOT = Path(__file__).parent.parent
24
+ sys.path.insert(0, str(PROJECT_ROOT))
25
+
26
+ logging.basicConfig(
27
+ level=logging.INFO,
28
+ format="%(asctime)s - %(levelname)s - %(message)s",
29
+ )
30
+ logger = logging.getLogger("eval_emotion2vec_en")
31
+
32
+ MANIFEST_PATH = PROJECT_ROOT / "data" / "ravdess" / "manifest.csv"
33
+ OUTPUT_JSON = PROJECT_ROOT / "data" / "ravdess_eval_results.json"
34
+
35
+ PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
36
+
37
+
38
+ def load_manifest(max_samples: int | None = None) -> list[dict]:
39
+ """manifest.csv ๋กœ๋“œ."""
40
+ rows = []
41
+ with open(MANIFEST_PATH) as f:
42
+ reader = csv.DictReader(f)
43
+ for row in reader:
44
+ rows.append(row)
45
+ if max_samples:
46
+ rows = rows[:max_samples]
47
+ logger.info(f"manifest ๋กœ๋“œ: {len(rows)}๊ฐœ ์ƒ˜ํ”Œ")
48
+ return rows
49
+
50
+
51
+ def evaluate_condition(
52
+ samples: list[dict],
53
+ condition: str,
54
+ device: str,
55
+ ) -> dict:
56
+ """ํ•œ ์กฐ๊ฑด(clean/phone)์— ๋Œ€ํ•ด ํ‰๊ฐ€ ์‹คํ–‰."""
57
+ from sklearn.metrics import (
58
+ accuracy_score,
59
+ classification_report,
60
+ confusion_matrix,
61
+ )
62
+ from src.stage2.audio_emotion import predict as audio_predict
63
+
64
+ path_key = "clean_path" if condition == "clean" else "phone_path"
65
+
66
+ y_true = []
67
+ y_pred = []
68
+ latencies = []
69
+ errors = 0
70
+
71
+ total = len(samples)
72
+ for i, sample in enumerate(samples, 1):
73
+ audio_path = sample[path_key]
74
+ if not audio_path or not Path(audio_path).exists():
75
+ errors += 1
76
+ continue
77
+
78
+ ground_truth = sample["emotion"]
79
+
80
+ t0 = time.perf_counter()
81
+ result = audio_predict(audio_path, device=device)
82
+ latency = (time.perf_counter() - t0) * 1000 # ms
83
+
84
+ y_true.append(ground_truth)
85
+ y_pred.append(result["emotion"])
86
+ latencies.append(latency)
87
+
88
+ if i % 200 == 0 or i == total:
89
+ acc_so_far = sum(1 for t, p in zip(y_true, y_pred) if t == p) / len(y_true)
90
+ logger.info(
91
+ f" [{condition}] {i}/{total} โ€” "
92
+ f"acc={acc_so_far:.3f}, "
93
+ f"avg_latency={sum(latencies)/len(latencies):.0f}ms"
94
+ )
95
+
96
+ accuracy = accuracy_score(y_true, y_pred)
97
+ report = classification_report(
98
+ y_true, y_pred, labels=PROJECT_LABELS, output_dict=True, zero_division=0,
99
+ )
100
+ cm = confusion_matrix(y_true, y_pred, labels=PROJECT_LABELS)
101
+
102
+ # per-class metrics ์ •๋ฆฌ
103
+ per_class = {}
104
+ for label in PROJECT_LABELS:
105
+ if label in report:
106
+ per_class[label] = {
107
+ "precision": round(report[label]["precision"], 4),
108
+ "recall": round(report[label]["recall"], 4),
109
+ "f1": round(report[label]["f1-score"], 4),
110
+ "support": report[label]["support"],
111
+ }
112
+
113
+ result = {
114
+ "condition": condition,
115
+ "total_samples": len(y_true),
116
+ "errors": errors,
117
+ "accuracy": round(accuracy, 4),
118
+ "macro_f1": round(report["macro avg"]["f1-score"], 4),
119
+ "weighted_f1": round(report["weighted avg"]["f1-score"], 4),
120
+ "per_class": per_class,
121
+ "confusion_matrix": cm.tolist(),
122
+ "confusion_labels": PROJECT_LABELS,
123
+ "avg_latency_ms": round(sum(latencies) / len(latencies), 1) if latencies else 0,
124
+ }
125
+
126
+ logger.info(f"\n{'='*60}")
127
+ logger.info(f"[{condition.upper()}] ๊ฒฐ๊ณผ:")
128
+ logger.info(f" Accuracy: {accuracy:.4f}")
129
+ logger.info(f" Macro F1: {report['macro avg']['f1-score']:.4f}")
130
+ logger.info(f" Weighted F1: {report['weighted avg']['f1-score']:.4f}")
131
+ logger.info(f" Avg Latency: {result['avg_latency_ms']:.0f}ms")
132
+ logger.info(f"\nPer-class F1:")
133
+ for label in PROJECT_LABELS:
134
+ if label in per_class:
135
+ logger.info(f" {label:10s}: F1={per_class[label]['f1']:.3f} "
136
+ f"(P={per_class[label]['precision']:.3f} R={per_class[label]['recall']:.3f}) "
137
+ f"n={per_class[label]['support']}")
138
+ logger.info(f"\nConfusion Matrix (rows=true, cols=pred):")
139
+ logger.info(f" {'':10s} " + " ".join(f"{l[:4]:>6s}" for l in PROJECT_LABELS))
140
+ for i_row, label in enumerate(PROJECT_LABELS):
141
+ row_str = " ".join(f"{v:6d}" for v in cm[i_row])
142
+ logger.info(f" {label:10s} {row_str}")
143
+ logger.info(f"{'='*60}\n")
144
+
145
+ return result
146
+
147
+
148
+ def main():
149
+ parser = argparse.ArgumentParser(description="emotion2vec ์˜์–ด ํ‰๊ฐ€ (RAVDESS)")
150
+ parser.add_argument("--condition", choices=["clean", "phone", "both"], default="both")
151
+ parser.add_argument("--device", default="cpu")
152
+ parser.add_argument("--max-samples", type=int, default=None, help="ํ‰๊ฐ€ํ•  ์ตœ๋Œ€ ์ƒ˜ํ”Œ ์ˆ˜")
153
+ args = parser.parse_args()
154
+
155
+ if not MANIFEST_PATH.exists():
156
+ logger.error(f"manifest.csv๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋จผ์ € prepare_ravdess.py๋ฅผ ์‹คํ–‰ํ•˜์„ธ์š”.")
157
+ sys.exit(1)
158
+
159
+ samples = load_manifest(args.max_samples)
160
+
161
+ conditions = []
162
+ if args.condition in ("clean", "both"):
163
+ conditions.append("clean")
164
+ if args.condition in ("phone", "both"):
165
+ conditions.append("phone")
166
+
167
+ results = []
168
+ for cond in conditions:
169
+ logger.info(f"\n{'#'*60}")
170
+ logger.info(f"ํ‰๊ฐ€ ์‹œ์ž‘: {cond.upper()} ์กฐ๊ฑด")
171
+ logger.info(f"{'#'*60}")
172
+ result = evaluate_condition(samples, cond, args.device)
173
+ results.append(result)
174
+
175
+ # ๊ฒฐ๊ณผ ์ €์žฅ
176
+ with open(OUTPUT_JSON, "w") as f:
177
+ json.dump(results, f, indent=2, ensure_ascii=False)
178
+ logger.info(f"๊ฒฐ๊ณผ ์ €์žฅ: {OUTPUT_JSON}")
179
+
180
+ # clean vs phone ๋น„๊ต (both์ผ ๋•Œ)
181
+ if len(results) == 2:
182
+ clean_acc = results[0]["accuracy"]
183
+ phone_acc = results[1]["accuracy"]
184
+ degradation = clean_acc - phone_acc
185
+ logger.info(f"\n{'='*60}")
186
+ logger.info(f"Clean vs Phone ๋น„๊ต:")
187
+ logger.info(f" Clean accuracy: {clean_acc:.4f}")
188
+ logger.info(f" Phone accuracy: {phone_acc:.4f}")
189
+ logger.info(f" Degradation: {degradation:+.4f}")
190
+ logger.info(f"{'='*60}")
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
scripts/export_lora_onnx.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """ONNX export for LoRA-finetuned emotion2vec 7-class model.
3
+
4
+ Merges LoRA weights into base model, wraps as a single waveform-to-logits
5
+ module, and exports to ONNX with dynamic batch/time axes.
6
+
7
+ Usage:
8
+ python scripts/export_lora_onnx.py \
9
+ --checkpoint data/models/lora_emotion2vec_7class/best_lora.pt \
10
+ --output data/models/lora_emotion2vec_7class/emotion2vec_lora.onnx \
11
+ --device cpu
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import logging
19
+ from pathlib import Path
20
+
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+
25
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Import LoRA components
29
+ import sys
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+
32
+ from train_lora_emotion2vec import (
33
+ LoRALinear,
34
+ MLPHead,
35
+ inject_lora,
36
+ merge_lora_linear,
37
+ LABELS_7CLASS,
38
+ NUM_CLASSES,
39
+ )
40
+
41
+
42
+ def merge_all_lora(encoder: nn.Module) -> None:
43
+ """Walk encoder.blocks, replace each LoRALinear with merged nn.Linear.
44
+
45
+ Modifies the encoder in-place.
46
+ """
47
+ for block in encoder.blocks:
48
+ if isinstance(block.attn.qkv, LoRALinear):
49
+ block.attn.qkv = merge_lora_linear(block.attn.qkv)
50
+ if isinstance(block.attn.proj, LoRALinear):
51
+ block.attn.proj = merge_lora_linear(block.attn.proj)
52
+
53
+
54
+ class Emotion2vecONNXWrapper(nn.Module):
55
+ """Wraps emotion2vec encoder for ONNX export.
56
+
57
+ forward(waveform: (B, T)) -> logits: (B, 7)
58
+ Includes layer_norm + extract_features + mean pool + proj.
59
+ """
60
+
61
+ def __init__(self, encoder):
62
+ super().__init__()
63
+ self.encoder = encoder
64
+
65
+ def forward(self, waveform: torch.Tensor) -> torch.Tensor:
66
+ """
67
+ Args:
68
+ waveform: (B, T) float32, 16kHz
69
+
70
+ Returns:
71
+ logits: (B, 7)
72
+ """
73
+ # Layer norm (per-sample, ONNX-compatible: normalize along time axis)
74
+ if self.encoder.cfg.normalize:
75
+ mean = waveform.mean(dim=-1, keepdim=True)
76
+ var = waveform.var(dim=-1, keepdim=True, unbiased=False)
77
+ waveform = (waveform - mean) / torch.sqrt(var + 1e-5)
78
+
79
+ # Extract features
80
+ feats = self.encoder.extract_features(waveform, padding_mask=None)
81
+ x = feats["x"] # (B, T', 768)
82
+
83
+ # Mean pool
84
+ pooled = x.mean(dim=1) # (B, 768)
85
+
86
+ # Classify
87
+ logits = self.encoder.proj(pooled) # (B, 7)
88
+ return logits
89
+
90
+
91
+ def export_onnx(checkpoint_path: str, output_path: str, device: str = "cpu"):
92
+ """Load base emotion2vec, inject LoRA, load checkpoint, merge, export ONNX.
93
+
94
+ Args:
95
+ checkpoint_path: path to LoRA checkpoint (.pt)
96
+ output_path: path for output ONNX file
97
+ device: "cpu" or "cuda"
98
+ """
99
+ from funasr import AutoModel
100
+
101
+ checkpoint_path = Path(checkpoint_path)
102
+ output_path = Path(output_path)
103
+ output_path.parent.mkdir(parents=True, exist_ok=True)
104
+
105
+ # Load checkpoint to get config
106
+ logger.info("Loading checkpoint: %s", checkpoint_path)
107
+ ckpt = torch.load(str(checkpoint_path), map_location=device, weights_only=True)
108
+
109
+ # Load base model
110
+ logger.info("Loading emotion2vec_plus_base...")
111
+ fmodel = AutoModel(model="iic/emotion2vec_plus_base", device=device, hub="hf")
112
+ encoder = fmodel.model
113
+
114
+ # Freeze + inject LoRA (dropout=0 for inference)
115
+ for param in encoder.parameters():
116
+ param.requires_grad = False
117
+ inject_lora(encoder, r=16, alpha=32, dropout=0.0)
118
+
119
+ # Replace proj with MLPHead
120
+ num_classes = ckpt.get("num_classes", NUM_CLASSES)
121
+ encoder.proj = MLPHead(768, num_classes, dropout=0.0).to(device)
122
+
123
+ # Load LoRA weights
124
+ lora_weights = ckpt["lora_weights"]
125
+ for name, module in encoder.named_modules():
126
+ if isinstance(module, LoRALinear):
127
+ a_key = f"{name}.lora_A.weight"
128
+ b_key = f"{name}.lora_B.weight"
129
+ if a_key in lora_weights:
130
+ module.lora_A.weight.data.copy_(lora_weights[a_key])
131
+ if b_key in lora_weights:
132
+ module.lora_B.weight.data.copy_(lora_weights[b_key])
133
+
134
+ # Load proj state
135
+ encoder.proj.load_state_dict(ckpt["proj"])
136
+
137
+ # Merge LoRA into base weights
138
+ logger.info("Merging LoRA weights...")
139
+ merge_all_lora(encoder)
140
+
141
+ # Verify no LoRALinear remains
142
+ lora_count = sum(1 for m in encoder.modules() if isinstance(m, LoRALinear))
143
+ assert lora_count == 0, f"Merge failed: {lora_count} LoRALinear remain"
144
+
145
+ # Wrap for ONNX
146
+ wrapper = Emotion2vecONNXWrapper(encoder)
147
+ wrapper.eval()
148
+
149
+ # Dummy input (1 second of audio at 16kHz)
150
+ dummy_input = torch.randn(1, 16000, device=device)
151
+
152
+ # Export
153
+ logger.info("Exporting ONNX to %s ...", output_path)
154
+ torch.onnx.export(
155
+ wrapper,
156
+ dummy_input,
157
+ str(output_path),
158
+ opset_version=17,
159
+ input_names=["waveform"],
160
+ output_names=["logits"],
161
+ dynamic_axes={
162
+ "waveform": {0: "batch", 1: "time"},
163
+ "logits": {0: "batch"},
164
+ },
165
+ )
166
+ logger.info("ONNX export complete: %s", output_path)
167
+
168
+ # Save label metadata JSON alongside
169
+ meta_path = output_path.with_suffix(".json")
170
+ meta = {
171
+ "model": "emotion2vec_plus_base + LoRA (merged)",
172
+ "num_classes": num_classes,
173
+ "labels": ckpt.get("labels", LABELS_7CLASS),
174
+ "input": "waveform: (batch, time) float32, 16kHz mono",
175
+ "output": f"logits: (batch, {num_classes}) float32",
176
+ "checkpoint": str(checkpoint_path),
177
+ }
178
+ with open(meta_path, "w") as f:
179
+ json.dump(meta, f, indent=2)
180
+ logger.info("Metadata saved: %s", meta_path)
181
+
182
+
183
+ def export_kcelectra_onnx(model_dir: str, output_path: str, base_model_id: str = "beomi/KcELECTRA-base-v2022"):
184
+ """Export LoRA-finetuned KcELECTRA to ONNX.
185
+
186
+ Merges PEFT LoRA into base model, then exports text โ†’ logits ONNX.
187
+ """
188
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
189
+ from peft import PeftModel
190
+
191
+ output_path = Path(output_path)
192
+ model_dir = Path(model_dir)
193
+
194
+ logger.info("Loading base model: %s", base_model_id)
195
+ base_model = AutoModelForSequenceClassification.from_pretrained(
196
+ base_model_id, num_labels=7,
197
+ )
198
+ tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
199
+
200
+ logger.info("Loading PEFT adapter: %s", model_dir)
201
+ model = PeftModel.from_pretrained(base_model, str(model_dir))
202
+
203
+ logger.info("Merging LoRA weights...")
204
+ model = model.merge_and_unload()
205
+ model.eval()
206
+
207
+ # Dummy input
208
+ dummy = tokenizer("ํ…Œ์ŠคํŠธ ๋ฌธ์žฅ์ž…๋‹ˆ๋‹ค", return_tensors="pt", max_length=128, truncation=True, padding="max_length")
209
+
210
+ output_path.parent.mkdir(parents=True, exist_ok=True)
211
+ logger.info("Exporting ONNX to %s", output_path)
212
+
213
+ torch.onnx.export(
214
+ model,
215
+ (dummy["input_ids"], dummy["attention_mask"]),
216
+ str(output_path),
217
+ opset_version=17,
218
+ input_names=["input_ids", "attention_mask"],
219
+ output_names=["logits"],
220
+ dynamic_axes={
221
+ "input_ids": {0: "batch", 1: "seq_len"},
222
+ "attention_mask": {0: "batch", 1: "seq_len"},
223
+ "logits": {0: "batch"},
224
+ },
225
+ )
226
+
227
+ # Save metadata
228
+ labels = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
229
+ meta_path = output_path.with_suffix(".json")
230
+ meta = {
231
+ "model": f"{base_model_id} + LoRA (merged)",
232
+ "num_classes": 7,
233
+ "labels": labels,
234
+ "input": "input_ids: (batch, seq_len) int64, attention_mask: (batch, seq_len) int64",
235
+ "output": "logits: (batch, 7) float32",
236
+ "max_length": 128,
237
+ }
238
+ with open(meta_path, "w") as f:
239
+ json.dump(meta, f, indent=2, ensure_ascii=False)
240
+
241
+ logger.info("ONNX export complete: %s (+ %s)", output_path, meta_path)
242
+
243
+
244
+ def main():
245
+ parser = argparse.ArgumentParser(description="Export LoRA models to ONNX")
246
+ parser.add_argument("--mode", required=True, choices=["audio", "text"],
247
+ help="audio: emotion2vec, text: KcELECTRA")
248
+ # Audio args
249
+ parser.add_argument("--checkpoint", help="Path to LoRA checkpoint (.pt) for audio mode")
250
+ # Text args
251
+ parser.add_argument("--model-dir", help="Path to PEFT model directory for text mode")
252
+ parser.add_argument("--base-model", default="beomi/KcELECTRA-base-v2022")
253
+ # Common
254
+ parser.add_argument("--output", required=True, help="Output ONNX path")
255
+ parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
256
+ args = parser.parse_args()
257
+
258
+ if args.mode == "audio":
259
+ if not args.checkpoint:
260
+ parser.error("--checkpoint required for audio mode")
261
+ export_onnx(args.checkpoint, args.output, args.device)
262
+ else:
263
+ if not args.model_dir:
264
+ parser.error("--model-dir required for text mode")
265
+ export_kcelectra_onnx(args.model_dir, args.output, args.base_model)
266
+
267
+
268
+ if __name__ == "__main__":
269
+ main()
scripts/optimize_fusion_weights.py ADDED
@@ -0,0 +1,618 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Grid Search for Emotion-Specific Fusion Weights.
3
+
4
+ Uses AI Hub 263 val split (audio + text + ground truth) to find optimal
5
+ audio/text fusion weights per emotion class.
6
+
7
+ Outputs:
8
+ - fusion_grid_search.json โ€” full weight-F1 curves per emotion
9
+ - optimal_fusion_weights.json โ€” best weights per emotion
10
+ - fusion_grid_search.png โ€” 7 subplots: weight vs F1 per emotion
11
+ - fusion_comparison.png โ€” bar chart: fixed 60/40 vs optimal
12
+ - fusion_report.md โ€” text summary
13
+
14
+ Usage:
15
+ python scripts/optimize_fusion_weights.py \
16
+ --val-manifest data/lora_dataset/val_manifest.json \
17
+ --onnx-model data/models/lora_emotion2vec_7class/model.onnx \
18
+ --anchor-dir "data/AI Hub 263" \
19
+ --output-dir data/models/lora_emotion2vec_7class
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import csv
25
+ import gc
26
+ import json
27
+ import logging
28
+ import sys
29
+ from collections import Counter, defaultdict
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+
34
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
35
+ logger = logging.getLogger(__name__)
36
+
37
+ PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
38
+
39
+ # LoRA model labels โ†’ project labels
40
+ LORA_LABELS = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
41
+ LORA_TO_PROJECT = {
42
+ "happiness": "joy", "anger": "anger", "disgust": "disgust",
43
+ "fear": "fear", "neutral": "neutral", "sadness": "sadness", "surprise": "surprise",
44
+ }
45
+
46
+ # 263 label mapping (same as prepare_lora_dataset.py)
47
+ MAP_263 = {
48
+ "angry": "anger", "anger": "anger",
49
+ "sadness": "sadness", "sad": "sadness",
50
+ "happiness": "happiness", "happy": "happiness",
51
+ "fear": "fear", "disgust": "disgust",
52
+ "surprise": "surprise", "neutral": "neutral",
53
+ }
54
+
55
+ # KcELECTRA 44-class โ†’ 7-class (from src/stage2/text_emotion.py)
56
+ KO_LABEL_MAP = {
57
+ "๊ธฐ์จ": "joy", "์ฆ๊ฑฐ์›€/์‹ ๋‚จ": "joy", "ํ–‰๋ณต": "joy",
58
+ "๊ฐ๋™/๊ฐํƒ„": "joy", "๊ณ ๋งˆ์›€": "joy", "ํ™˜์˜/ํ˜ธ์˜": "joy",
59
+ "๋ฟŒ๋“ฏํ•จ": "joy", "ํ๋ญ‡ํ•จ(๊ท€์—ฌ์›€/์˜ˆ์จ)": "joy", "๊ธฐ๋Œ€๊ฐ": "joy",
60
+ "ํŽธ์•ˆ/์พŒ์ ": "joy", "์•ˆ์‹ฌ/์‹ ๋ขฐ": "joy", "์•„๊ปด์ฃผ๋Š”": "joy", "์กด๊ฒฝ": "joy",
61
+ "๋†€๋žŒ": "surprise", "์‹ ๊ธฐํ•จ/๊ด€์‹ฌ": "surprise", "๊ฒฝ์•…": "surprise", "์–ด์ด์—†์Œ": "surprise",
62
+ "์Šฌํ””": "sadness", "์„œ๋Ÿฌ์›€": "sadness", "์•ˆํƒ€๊นŒ์›€/์‹ค๋ง": "sadness",
63
+ "์ ˆ๋ง": "sadness", "๋ถ€๋„๋Ÿฌ์›€": "sadness", "๋ถˆ์Œํ•จ/์—ฐ๋ฏผ": "sadness",
64
+ "ํŒจ๋ฐฐ/์ž๊ธฐํ˜์˜ค": "sadness", "ํž˜๋“ฆ/์ง€์นจ": "sadness", "์ฃ„์ฑ…๊ฐ": "sadness",
65
+ "ํ™”๋‚จ/๋ถ„๋…ธ": "anger", "์งœ์ฆ": "anger", "๋ถˆํ‰/๋ถˆ๋งŒ": "anger",
66
+ "์ง€๊ธ‹์ง€๊ธ‹": "anger", "์šฐ์ญ๋Œ/๋ฌด์‹œํ•จ": "anger", "ํ•œ์‹ฌํ•จ": "anger",
67
+ "์ฆ์˜ค/ํ˜์˜ค": "anger", "๊ท€์ฐฎ์Œ": "anger",
68
+ "๊ณตํฌ/๋ฌด์„œ์›€": "fear", "๋ถˆ์•ˆ/๊ฑฑ์ •": "fear", "๋‹นํ™ฉ/๋‚œ์ฒ˜": "fear", "์˜์‹ฌ/๋ถˆ์‹ ": "fear",
69
+ "์—†์Œ": "neutral", "๊นจ๋‹ฌ์Œ": "neutral", "์žฌ๋ฏธ์—†์Œ": "neutral",
70
+ "๋ถ€๋‹ด/์•ˆ_๋‚ดํ‚ด": "neutral", "๋น„์žฅํ•จ": "neutral",
71
+ "์—ญ๊ฒจ์›€/์ง•๊ทธ๋Ÿฌ์›€": "disgust",
72
+ }
73
+
74
+
75
+ def load_263_texts(anchor_dir: Path) -> dict[str, str]:
76
+ """Load wav_id โ†’ ๋ฐœํ™”๋ฌธ mapping from 263 CSVs."""
77
+ texts = {}
78
+ for csv_path in sorted(anchor_dir.glob("*.csv")):
79
+ with open(csv_path, encoding="cp949") as f:
80
+ reader = csv.reader(f)
81
+ next(reader) # skip header
82
+ for row in reader:
83
+ wav_id = row[0]
84
+ text = row[1]
85
+ texts[wav_id] = text
86
+ logger.info("Loaded %d texts from 263 CSVs", len(texts))
87
+ return texts
88
+
89
+
90
+ def predict_audio_base(audio_path: str, funasr_model, max_seconds: float = 15.0) -> dict[str, float]:
91
+ """Run base (non-finetuned) emotion2vec via FunASR, 9-class โ†’ 7-class mapping.
92
+
93
+ Audio trimmed to max_seconds โ€” FunASR transformer has quadratic memory in sequence length,
94
+ so a 100s clip can blow past 15GB RAM. Matches predict_audio_onnx() behavior.
95
+ """
96
+ # emotion2vec base native labels โ†’ project labels
97
+ LABEL_MAP = {
98
+ "angry": "anger", "disgusted": "disgust", "fearful": "fear",
99
+ "happy": "joy", "neutral": "neutral", "sad": "sadness", "surprised": "surprise",
100
+ "other": "neutral", "unknown": "neutral",
101
+ "็”Ÿๆฐ”/angry": "anger", "ๅŽŒๆถ/disgusted": "disgust", "ๆๆƒง/fearful": "fear",
102
+ "ๅผ€ๅฟƒ/happy": "joy", "ไธญ็ซ‹/neutral": "neutral", "้šพ่ฟ‡/sad": "sadness",
103
+ "ๅƒๆƒŠ/surprised": "surprise", "ๅ…ถไป–/other": "neutral", "<unk>": "neutral",
104
+ }
105
+
106
+ import soundfile as sf
107
+
108
+ audio, sr = sf.read(audio_path, dtype="float32")
109
+ if audio.ndim == 2:
110
+ audio = audio.mean(axis=1)
111
+ if sr != 16000:
112
+ import librosa
113
+ audio = librosa.resample(audio, orig_sr=sr, target_sr=16000)
114
+
115
+ max_samples = int(max_seconds * 16000)
116
+ if len(audio) > max_samples:
117
+ audio = audio[:max_samples]
118
+
119
+ try:
120
+ output = funasr_model.generate(
121
+ audio, granularity="utterance", extract_embedding=False,
122
+ )
123
+ except Exception:
124
+ return {label: 1.0 / len(PROJECT_LABELS) for label in PROJECT_LABELS}
125
+
126
+ scores = {label: 0.0 for label in PROJECT_LABELS}
127
+ if output and isinstance(output, list) and len(output) > 0:
128
+ rec = output[0]
129
+ raw_labels = rec.get("labels", [])
130
+ raw_scores = rec.get("scores", [])
131
+ for native_label, score in zip(raw_labels, raw_scores):
132
+ project_label = LABEL_MAP.get(native_label, "neutral")
133
+ scores[project_label] += float(score)
134
+
135
+ total = sum(scores.values())
136
+ if total > 0:
137
+ scores = {k: v / total for k, v in scores.items()}
138
+ return scores
139
+
140
+
141
+ def predict_audio_onnx(audio_path: str, session, max_seconds: float = 15.0) -> dict[str, float]:
142
+ """Run ONNX audio emotion prediction (trimmed to max_seconds to avoid OOM)."""
143
+ import soundfile as sf
144
+
145
+ audio, sr = sf.read(audio_path, dtype="float32")
146
+ if audio.ndim == 2:
147
+ audio = audio.mean(axis=1)
148
+ if sr != 16000:
149
+ import librosa
150
+ audio = librosa.resample(audio, orig_sr=sr, target_sr=16000)
151
+
152
+ # Trim to max_seconds to prevent OOM on very long audio
153
+ max_samples = int(max_seconds * 16000)
154
+ if len(audio) > max_samples:
155
+ audio = audio[:max_samples]
156
+
157
+ waveform = audio.reshape(1, -1).astype(np.float32)
158
+ logits = session.run(None, {"waveform": waveform})[0]
159
+
160
+ exp_logits = np.exp(logits - logits.max(axis=-1, keepdims=True))
161
+ probs = (exp_logits / exp_logits.sum(axis=-1, keepdims=True)).squeeze()
162
+
163
+ scores = {}
164
+ for lora_label, prob in zip(LORA_LABELS, probs):
165
+ project_label = LORA_TO_PROJECT[lora_label]
166
+ scores[project_label] = float(prob)
167
+ return scores
168
+
169
+
170
+ def predict_text_onnx(text: str, tokenizer, session) -> dict[str, float]:
171
+ """Run fine-tuned KcELECTRA ONNX text emotion prediction (7-class direct)."""
172
+ if not text or not text.strip():
173
+ return {label: 1.0 / len(PROJECT_LABELS) for label in PROJECT_LABELS}
174
+
175
+ enc = tokenizer(text, return_tensors="np", truncation=True, max_length=128, padding="max_length")
176
+ logits = session.run(None, {
177
+ "input_ids": enc["input_ids"],
178
+ "attention_mask": enc["attention_mask"],
179
+ })[0]
180
+
181
+ # Softmax
182
+ exp_logits = np.exp(logits - logits.max(axis=-1, keepdims=True))
183
+ probs = (exp_logits / exp_logits.sum(axis=-1, keepdims=True)).squeeze()
184
+
185
+ # LoRA KcELECTRA labels โ†’ project labels (happiness โ†’ joy)
186
+ text_labels = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
187
+ text_to_project = {
188
+ "happiness": "joy", "anger": "anger", "disgust": "disgust",
189
+ "fear": "fear", "neutral": "neutral", "sadness": "sadness", "surprise": "surprise",
190
+ }
191
+ scores = {label: 0.0 for label in PROJECT_LABELS}
192
+ for tl, prob in zip(text_labels, probs):
193
+ pl = text_to_project[tl]
194
+ scores[pl] = float(prob)
195
+ return scores
196
+
197
+
198
+ def fuse_scores(audio_scores, text_scores, weights):
199
+ """Fuse with emotion-specific weights."""
200
+ fused = {}
201
+ for label in PROJECT_LABELS:
202
+ aw = weights.get(label, {}).get("audio", 0.6)
203
+ tw = weights.get(label, {}).get("text", 0.4)
204
+ fused[label] = audio_scores.get(label, 0.0) * aw + text_scores.get(label, 0.0) * tw
205
+
206
+ total = sum(fused.values())
207
+ if total > 0:
208
+ fused = {k: v / total for k, v in fused.items()}
209
+ return fused
210
+
211
+
212
+ def compute_f1(y_true, y_pred, target_label):
213
+ """Compute F1 for a specific label (binary: target vs rest)."""
214
+ tp = sum(1 for t, p in zip(y_true, y_pred) if t == target_label and p == target_label)
215
+ fp = sum(1 for t, p in zip(y_true, y_pred) if t != target_label and p == target_label)
216
+ fn = sum(1 for t, p in zip(y_true, y_pred) if t == target_label and p != target_label)
217
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 0
218
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 0
219
+ if precision + recall == 0:
220
+ return 0.0
221
+ return 2 * precision * recall / (precision + recall)
222
+
223
+
224
+ def compute_macro_f1(y_true, y_pred):
225
+ """Compute macro F1 across all 7 classes."""
226
+ f1s = [compute_f1(y_true, y_pred, label) for label in PROJECT_LABELS]
227
+ return np.mean(f1s)
228
+
229
+
230
+ def grid_search(samples, audio_preds, text_preds):
231
+ """Run grid search for emotion-specific weights.
232
+
233
+ Returns:
234
+ grid_results: dict[emotion] โ†’ list of {"audio_weight": float, "f1": float}
235
+ optimal_weights: dict[emotion] โ†’ {"audio": float, "text": float, "f1": float}
236
+ """
237
+ weight_range = np.arange(0.0, 1.05, 0.05)
238
+ grid_results = {}
239
+ optimal_weights = {}
240
+
241
+ for target_emotion in PROJECT_LABELS:
242
+ results = []
243
+ best_f1 = -1
244
+ best_aw = 0.6
245
+
246
+ for aw in weight_range:
247
+ tw = 1.0 - aw
248
+ # Build per-emotion weight dict: target emotion uses (aw, tw), others use 0.6/0.4
249
+ weights = {}
250
+ for label in PROJECT_LABELS:
251
+ if label == target_emotion:
252
+ weights[label] = {"audio": float(aw), "text": float(tw)}
253
+ else:
254
+ weights[label] = {"audio": 0.6, "text": 0.4}
255
+
256
+ # Predict with these weights
257
+ y_true = [s["label"] for s in samples]
258
+ y_pred = []
259
+ for i, s in enumerate(samples):
260
+ fused = fuse_scores(audio_preds[i], text_preds[i], weights)
261
+ pred = max(fused, key=fused.get)
262
+ y_pred.append(pred)
263
+
264
+ f1 = compute_f1(y_true, y_pred, target_emotion)
265
+ results.append({"audio_weight": round(float(aw), 2), "f1": round(f1, 4)})
266
+
267
+ if f1 > best_f1:
268
+ best_f1 = f1
269
+ best_aw = float(aw)
270
+
271
+ grid_results[target_emotion] = results
272
+ optimal_weights[target_emotion] = {
273
+ "audio": round(best_aw, 2),
274
+ "text": round(1.0 - best_aw, 2),
275
+ "f1": round(best_f1, 4),
276
+ }
277
+ logger.info("%s: optimal audio_weight=%.2f (F1=%.4f)", target_emotion, best_aw, best_f1)
278
+
279
+ return grid_results, optimal_weights
280
+
281
+
282
+ def compute_overall_comparison(samples, audio_preds, text_preds, optimal_weights):
283
+ """Compare fixed 60/40 vs optimal weights on macro F1."""
284
+ fixed_weights = {label: {"audio": 0.6, "text": 0.4} for label in PROJECT_LABELS}
285
+ y_true = [s["label"] for s in samples]
286
+
287
+ # Fixed 60/40
288
+ y_pred_fixed = []
289
+ for i in range(len(samples)):
290
+ fused = fuse_scores(audio_preds[i], text_preds[i], fixed_weights)
291
+ y_pred_fixed.append(max(fused, key=fused.get))
292
+ fixed_macro = compute_macro_f1(y_true, y_pred_fixed)
293
+ fixed_per_class = {label: compute_f1(y_true, y_pred_fixed, label) for label in PROJECT_LABELS}
294
+
295
+ # Optimal
296
+ opt_weight_dict = {e: {"audio": w["audio"], "text": w["text"]} for e, w in optimal_weights.items()}
297
+ y_pred_opt = []
298
+ for i in range(len(samples)):
299
+ fused = fuse_scores(audio_preds[i], text_preds[i], opt_weight_dict)
300
+ y_pred_opt.append(max(fused, key=fused.get))
301
+ opt_macro = compute_macro_f1(y_true, y_pred_opt)
302
+ opt_per_class = {label: compute_f1(y_true, y_pred_opt, label) for label in PROJECT_LABELS}
303
+
304
+ # Audio-only baseline
305
+ y_pred_audio = []
306
+ for i in range(len(samples)):
307
+ pred = max(audio_preds[i], key=audio_preds[i].get)
308
+ y_pred_audio.append(pred)
309
+ audio_macro = compute_macro_f1(y_true, y_pred_audio)
310
+ audio_per_class = {label: compute_f1(y_true, y_pred_audio, label) for label in PROJECT_LABELS}
311
+
312
+ return {
313
+ "audio_only": {"macro_f1": round(audio_macro, 4), "per_class": {k: round(v, 4) for k, v in audio_per_class.items()}},
314
+ "fixed_60_40": {"macro_f1": round(fixed_macro, 4), "per_class": {k: round(v, 4) for k, v in fixed_per_class.items()}},
315
+ "optimal": {"macro_f1": round(opt_macro, 4), "per_class": {k: round(v, 4) for k, v in opt_per_class.items()}},
316
+ }
317
+
318
+
319
+ def plot_grid_search(grid_results, optimal_weights, output_path: Path):
320
+ """Plot 7 subplots: weight vs F1 per emotion."""
321
+ import matplotlib
322
+ matplotlib.use("Agg")
323
+ import matplotlib.pyplot as plt
324
+
325
+ fig, axes = plt.subplots(2, 4, figsize=(18, 9))
326
+ axes = axes.flatten()
327
+
328
+ for i, emotion in enumerate(PROJECT_LABELS):
329
+ ax = axes[i]
330
+ data = grid_results[emotion]
331
+ weights = [d["audio_weight"] for d in data]
332
+ f1s = [d["f1"] for d in data]
333
+ opt = optimal_weights[emotion]
334
+
335
+ ax.plot(weights, f1s, "b-o", markersize=3, linewidth=1.5)
336
+ ax.axvline(x=opt["audio"], color="r", linestyle="--", alpha=0.7,
337
+ label=f"optimal={opt['audio']:.2f}")
338
+ ax.axvline(x=0.6, color="gray", linestyle=":", alpha=0.5, label="fixed=0.60")
339
+ ax.set_title(f"{emotion} (best F1={opt['f1']:.3f})", fontsize=11, fontweight="bold")
340
+ ax.set_xlabel("Audio Weight")
341
+ ax.set_ylabel("F1 Score")
342
+ ax.set_xlim(-0.05, 1.05)
343
+ ax.legend(fontsize=8)
344
+ ax.grid(True, alpha=0.3)
345
+
346
+ # Hide last subplot (2x4 = 8, but only 7 emotions)
347
+ axes[7].set_visible(False)
348
+
349
+ fig.suptitle("Emotion-Specific Fusion Weight Grid Search", fontsize=14, fontweight="bold")
350
+ plt.tight_layout()
351
+ plt.savefig(str(output_path), dpi=150)
352
+ plt.close()
353
+ logger.info("Grid search plot saved: %s", output_path)
354
+
355
+
356
+ def plot_comparison(comparison, optimal_weights, output_path: Path):
357
+ """Bar chart: audio-only vs fixed 60/40 vs optimal per emotion."""
358
+ import matplotlib
359
+ matplotlib.use("Agg")
360
+ import matplotlib.pyplot as plt
361
+
362
+ emotions = PROJECT_LABELS
363
+ audio_f1s = [comparison["audio_only"]["per_class"][e] for e in emotions]
364
+ fixed_f1s = [comparison["fixed_60_40"]["per_class"][e] for e in emotions]
365
+ opt_f1s = [comparison["optimal"]["per_class"][e] for e in emotions]
366
+
367
+ x = np.arange(len(emotions))
368
+ width = 0.25
369
+
370
+ fig, ax = plt.subplots(figsize=(12, 6))
371
+ bars1 = ax.bar(x - width, audio_f1s, width, label=f"Audio Only (macro={comparison['audio_only']['macro_f1']:.3f})", color="#2196F3", alpha=0.8)
372
+ bars2 = ax.bar(x, fixed_f1s, width, label=f"Fixed 60/40 (macro={comparison['fixed_60_40']['macro_f1']:.3f})", color="#FF9800", alpha=0.8)
373
+ bars3 = ax.bar(x + width, opt_f1s, width, label=f"Optimal (macro={comparison['optimal']['macro_f1']:.3f})", color="#4CAF50", alpha=0.8)
374
+
375
+ # Add weight annotations on optimal bars
376
+ for i, e in enumerate(emotions):
377
+ aw = optimal_weights[e]["audio"]
378
+ ax.text(x[i] + width, opt_f1s[i] + 0.01, f"a={aw:.0%}", ha="center", fontsize=7, color="#2E7D32")
379
+
380
+ ax.set_ylabel("F1 Score")
381
+ ax.set_title("Fusion Strategy Comparison: Audio Only vs Fixed 60/40 vs Emotion-Specific Optimal", fontweight="bold")
382
+ ax.set_xticks(x)
383
+ ax.set_xticklabels(emotions, fontsize=10)
384
+ ax.legend(fontsize=10)
385
+ ax.set_ylim(0, 1.0)
386
+ ax.grid(axis="y", alpha=0.3)
387
+
388
+ plt.tight_layout()
389
+ plt.savefig(str(output_path), dpi=150)
390
+ plt.close()
391
+ logger.info("Comparison plot saved: %s", output_path)
392
+
393
+
394
+ def write_report(comparison, optimal_weights, output_path: Path):
395
+ """Write markdown summary report."""
396
+ lines = [
397
+ "# Fusion Weight Optimization Report",
398
+ "",
399
+ "## Summary",
400
+ "",
401
+ f"| Strategy | Macro F1 |",
402
+ f"|---|---|",
403
+ f"| Audio Only | {comparison['audio_only']['macro_f1']:.4f} |",
404
+ f"| Fixed 60/40 | {comparison['fixed_60_40']['macro_f1']:.4f} |",
405
+ f"| **Emotion-Specific Optimal** | **{comparison['optimal']['macro_f1']:.4f}** |",
406
+ f"| Improvement over Fixed | **+{comparison['optimal']['macro_f1'] - comparison['fixed_60_40']['macro_f1']:.4f}** |",
407
+ "",
408
+ "## Optimal Weights Per Emotion",
409
+ "",
410
+ "| Emotion | Audio Weight | Text Weight | F1 (optimal) | F1 (fixed 60/40) | Delta |",
411
+ "|---|---|---|---|---|---|",
412
+ ]
413
+ for e in PROJECT_LABELS:
414
+ aw = optimal_weights[e]["audio"]
415
+ tw = optimal_weights[e]["text"]
416
+ opt_f1 = comparison["optimal"]["per_class"][e]
417
+ fixed_f1 = comparison["fixed_60_40"]["per_class"][e]
418
+ delta = opt_f1 - fixed_f1
419
+ sign = "+" if delta >= 0 else ""
420
+ lines.append(f"| {e} | {aw:.0%} | {tw:.0%} | {opt_f1:.4f} | {fixed_f1:.4f} | {sign}{delta:.4f} |")
421
+
422
+ lines.extend([
423
+ "",
424
+ "## Methodology",
425
+ "",
426
+ "- **Data:** AI Hub 263 val split (1,294 samples, 7-class, speaker-isolated)",
427
+ "- **Audio model:** LoRA emotion2vec ONNX (7-class, macro F1=0.552)",
428
+ "- **Text model:** KcELECTRA LoRA fine-tuned (beomi/KcELECTRA-base-v2022, 7-class direct)",
429
+ "- **Search:** Per-emotion audio weight 0.0~1.0 in 0.05 steps (21 points ร— 7 emotions)",
430
+ "- **Metric:** Per-emotion F1 score on val set",
431
+ "",
432
+ "## Files",
433
+ "",
434
+ "- `fusion_grid_search.json` โ€” full weight-F1 curve data",
435
+ "- `optimal_fusion_weights.json` โ€” best weights",
436
+ "- `fusion_grid_search.png` โ€” per-emotion weight vs F1 plots",
437
+ "- `fusion_comparison.png` โ€” strategy comparison bar chart",
438
+ ])
439
+
440
+ output_path.write_text("\n".join(lines), encoding="utf-8")
441
+ logger.info("Report saved: %s", output_path)
442
+
443
+
444
+ def predict_text_distilroberta(text: str, tokenizer, model) -> dict[str, float]:
445
+ """Run j-hartmann/DistilRoBERTa text emotion prediction (7-class direct)."""
446
+ import torch
447
+
448
+ if not text or not text.strip():
449
+ return {label: 1.0 / len(PROJECT_LABELS) for label in PROJECT_LABELS}
450
+
451
+ inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
452
+ with torch.no_grad():
453
+ outputs = model(**inputs)
454
+ probs = torch.softmax(outputs.logits, dim=-1).squeeze().cpu().numpy()
455
+
456
+ # DistilRoBERTa labels: anger, disgust, fear, joy, neutral, sadness, surprise
457
+ dr_labels = [model.config.id2label[i] for i in range(len(probs))]
458
+ scores = {label: 0.0 for label in PROJECT_LABELS}
459
+ for dl, prob in zip(dr_labels, probs):
460
+ if dl in scores:
461
+ scores[dl] = float(prob)
462
+ return scores
463
+
464
+
465
+ def main():
466
+ parser = argparse.ArgumentParser(description="Optimize emotion-specific fusion weights")
467
+ parser.add_argument("--lang", default="ko", choices=["ko", "en"], help="Language: ko=Korean, en=English")
468
+ parser.add_argument("--val-manifest", type=Path, default=Path("data/lora_dataset/val_manifest.json"))
469
+ parser.add_argument("--onnx-model", type=Path, default=Path("data/models/lora_emotion2vec_7class/model.onnx"))
470
+ parser.add_argument("--anchor-dir", type=Path, default=Path("data/AI Hub 263"))
471
+ parser.add_argument("--output-dir", type=Path, default=Path("data/models/fusion_optimization"))
472
+ parser.add_argument("--text-onnx", type=Path, default=Path("data/models/lora_kcelectra_7class/model.onnx"))
473
+ parser.add_argument("--text-tokenizer", default="data/models/lora_kcelectra_7class/best_model")
474
+ parser.add_argument("--en-text-model", default="j-hartmann/emotion-english-distilroberta-base")
475
+ parser.add_argument("--use-base-audio", action="store_true",
476
+ help="Use base (non-finetuned) emotion2vec via FunASR instead of LoRA ONNX")
477
+ args = parser.parse_args()
478
+
479
+ lang = args.lang
480
+ prefix = "en_" if lang == "en" else ""
481
+ output_dir = args.output_dir
482
+ output_dir.mkdir(parents=True, exist_ok=True)
483
+
484
+ # Step 1: Load manifest
485
+ with open(args.val_manifest, encoding="utf-8") as f:
486
+ val_all = json.load(f)
487
+
488
+ if lang == "ko":
489
+ # Korean: 263 val only
490
+ samples = [s for s in val_all if s.get("source") == "263"]
491
+ logger.info("Korean 263 val samples: %d", len(samples))
492
+ else:
493
+ # English: MELD test (all samples have text)
494
+ samples = val_all
495
+ logger.info("English MELD test samples: %d", len(samples))
496
+
497
+ # Map label: happiness โ†’ joy for consistency
498
+ for s in samples:
499
+ if s["label"] == "happiness":
500
+ s["label"] = "joy"
501
+
502
+ # Step 2: Filter samples with text
503
+ matched = [s for s in samples if s.get("text", "").strip()]
504
+
505
+ # Korean fallback: load from CSV if no text in manifest
506
+ if not matched and lang == "ko":
507
+ logger.info("No text in manifest, loading from 263 CSVs...")
508
+ texts_map = load_263_texts(args.anchor_dir)
509
+ for s in samples:
510
+ wav_id = Path(s["path"]).stem
511
+ text = texts_map.get(wav_id, "")
512
+ if text:
513
+ s["text"] = text
514
+ matched.append(s)
515
+
516
+ logger.info("Matched audio+text: %d / %d", len(matched), len(samples))
517
+ if len(matched) < 50:
518
+ logger.error("Too few matched samples.")
519
+ sys.exit(1)
520
+
521
+ # Step 3: Load models
522
+ import onnxruntime as ort
523
+
524
+ if args.use_base_audio:
525
+ from funasr import AutoModel
526
+ logger.info("Loading base emotion2vec_plus_base via FunASR (not LoRA)...")
527
+ funasr_model = AutoModel(model="iic/emotion2vec_plus_base", device="cpu", hub="hf")
528
+ audio_predict_fn = lambda path: predict_audio_base(path, funasr_model)
529
+ else:
530
+ logger.info("Loading audio ONNX (LoRA): %s", args.onnx_model)
531
+ onnx_session = ort.InferenceSession(str(args.onnx_model), providers=["CPUExecutionProvider"])
532
+ audio_predict_fn = lambda path: predict_audio_onnx(path, onnx_session)
533
+
534
+ if lang == "ko":
535
+ from transformers import AutoTokenizer
536
+ logger.info("Loading KcELECTRA ONNX: %s", args.text_onnx)
537
+ text_session = ort.InferenceSession(str(args.text_onnx), providers=["CPUExecutionProvider"])
538
+ tokenizer = AutoTokenizer.from_pretrained(args.text_tokenizer)
539
+ text_predict_fn = lambda text: predict_text_onnx(text, tokenizer, text_session)
540
+ else:
541
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
542
+ logger.info("Loading DistilRoBERTa: %s", args.en_text_model)
543
+ en_tokenizer = AutoTokenizer.from_pretrained(args.en_text_model)
544
+ en_model = AutoModelForSequenceClassification.from_pretrained(args.en_text_model)
545
+ en_model.eval()
546
+ text_predict_fn = lambda text: predict_text_distilroberta(text, en_tokenizer, en_model)
547
+
548
+ # Step 4: Predict all samples (with checkpoint for resume safety)
549
+ preds_cache_path = output_dir / f"{prefix}preds_cache.json"
550
+ audio_preds = []
551
+ text_preds = []
552
+ start_idx = 0
553
+
554
+ if preds_cache_path.exists():
555
+ with open(preds_cache_path) as f:
556
+ cache = json.load(f)
557
+ audio_preds = cache.get("audio_preds", [])
558
+ text_preds = cache.get("text_preds", [])
559
+ start_idx = len(audio_preds)
560
+ logger.info("Resumed from checkpoint: %d predictions already done", start_idx)
561
+
562
+ for i in range(start_idx, len(matched)):
563
+ s = matched[i]
564
+ audio_scores = audio_predict_fn(s["path"])
565
+ audio_preds.append(audio_scores)
566
+
567
+ text_scores = text_predict_fn(s["text"])
568
+ text_preds.append(text_scores)
569
+
570
+ # FunASR/PyTorch leak audio tensors across .generate() calls โ€” force release every 25 samples
571
+ if (i + 1) % 25 == 0:
572
+ gc.collect()
573
+ try:
574
+ import torch
575
+ if torch.cuda.is_available():
576
+ torch.cuda.empty_cache()
577
+ except ImportError:
578
+ pass
579
+
580
+ # Checkpoint every 100 samples
581
+ if (i + 1) % 100 == 0:
582
+ logger.info("Predicted %d / %d (saving checkpoint)", i + 1, len(matched))
583
+ with open(preds_cache_path, "w") as f:
584
+ json.dump({"audio_preds": audio_preds, "text_preds": text_preds}, f)
585
+
586
+ # Final checkpoint save
587
+ with open(preds_cache_path, "w") as f:
588
+ json.dump({"audio_preds": audio_preds, "text_preds": text_preds}, f)
589
+
590
+ logger.info("All predictions done (%d samples)", len(matched))
591
+
592
+ # Step 5: Grid search
593
+ grid_results, optimal_weights = grid_search(matched, audio_preds, text_preds)
594
+
595
+ # Step 6: Overall comparison
596
+ comparison = compute_overall_comparison(matched, audio_preds, text_preds, optimal_weights)
597
+ logger.info("Audio-only macro F1: %.4f", comparison["audio_only"]["macro_f1"])
598
+ logger.info("Fixed 60/40 macro F1: %.4f", comparison["fixed_60_40"]["macro_f1"])
599
+ logger.info("Optimal macro F1: %.4f", comparison["optimal"]["macro_f1"])
600
+
601
+ # Step 7: Save everything
602
+ with open(output_dir / f"{prefix}fusion_grid_search.json", "w") as f:
603
+ json.dump(grid_results, f, indent=2)
604
+ with open(output_dir / f"{prefix}optimal_fusion_weights.json", "w") as f:
605
+ json.dump(optimal_weights, f, indent=2, ensure_ascii=False)
606
+ with open(output_dir / f"{prefix}fusion_comparison.json", "w") as f:
607
+ json.dump(comparison, f, indent=2)
608
+
609
+ # Step 8: Plots + report
610
+ plot_grid_search(grid_results, optimal_weights, output_dir / f"{prefix}fusion_grid_search.png")
611
+ plot_comparison(comparison, optimal_weights, output_dir / f"{prefix}fusion_comparison.png")
612
+ write_report(comparison, optimal_weights, output_dir / f"{prefix}fusion_report.md")
613
+
614
+ logger.info("Done! All results saved to %s", output_dir)
615
+
616
+
617
+ if __name__ == "__main__":
618
+ main()
scripts/prepare_aihub_test_subset.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """AI Hub ๊ฐ์ • ํƒœ๊น… ์ž์œ ๋Œ€ํ™”(์„ฑ์ธ) ๋ฐ์ดํ„ฐ์…‹ โ†’ ๋ฒค์น˜๋งˆํฌ ํ…Œ์ŠคํŠธ ์„œ๋ธŒ์…‹ ์ค€๋น„.
3
+
4
+ AI Hub #71631 ๋ฐ์ดํ„ฐ์…‹์˜ JSON ๋ผ๋ฒจ + ์Šคํ…Œ๋ ˆ์˜ค WAV์—์„œ ๋ฐœํ™” ๋‹จ์œ„๋ฅผ ์ถ”์ถœํ•˜์—ฌ
5
+ ๊ท ํ˜• ์žกํžŒ 6-class ํ…Œ์ŠคํŠธ์…‹์„ ์ƒ์„ฑํ•œ๋‹ค.
6
+
7
+ Usage:
8
+ python scripts/prepare_aihub_test_subset.py --aihub-dir data/samples
9
+ python scripts/prepare_aihub_test_subset.py --aihub-dir /path/to/full/dataset --samples-per-class 83
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import csv
16
+ import json
17
+ import logging
18
+ import os
19
+ import random
20
+ import sys
21
+ from collections import defaultdict
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ import soundfile as sf
26
+
27
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
31
+ # Label Mapping: AI Hub ํ•œ๊ตญ์–ด โ†’ Project 6-class
32
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
33
+
34
+ AIHUB_LABEL_MAP = {
35
+ "๊ธฐ์จ": "joy",
36
+ "๋†€๋ผ์›€": "surprise",
37
+ "๋‘๋ ค์›€": "fear",
38
+ "์‚ฌ๋ž‘์Šค๋Ÿฌ์›€": "joy", # Affection โ†’ joy (user confirmed)
39
+ "์Šฌํ””": "sadness",
40
+ "ํ™”๋‚จ": "anger",
41
+ "์—†์Œ": "neutral",
42
+ "์ค‘๋ฆฝ": "neutral", # appears in SpeakerEmotionCategory
43
+ }
44
+
45
+ EVAL_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear"]
46
+
47
+ # Minimum utterance duration (seconds) โ€” too short = unreliable emotion
48
+ MIN_DURATION_SEC = 0.5
49
+ # Maximum utterance duration โ€” cap very long utterances
50
+ MAX_DURATION_SEC = 30.0
51
+
52
+
53
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
54
+ # Step 1: Parse AI Hub JSON + discover WAV pairs
55
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
56
+
57
+ def discover_pairs(aihub_dir: str) -> list[tuple[Path, Path]]:
58
+ """Find matched WAV-JSON file pairs in AI Hub directory structure.
59
+
60
+ Expected structure:
61
+ aihub_dir/01.์›์ฒœ๋ฐ์ดํ„ฐ/{01.์‹ค๋‚ด,02.์‹ค์™ธ}/xxx.wav
62
+ aihub_dir/02.๋ผ๋ฒจ๋ง๋ฐ์ดํ„ฐ/{01.์‹ค๋‚ด,02.์‹ค์™ธ}/xxx.json
63
+ """
64
+ source_dir = Path(aihub_dir) / "01.์›์ฒœ๋ฐ์ดํ„ฐ"
65
+ label_dir = Path(aihub_dir) / "02.๋ผ๋ฒจ๋ง๋ฐ์ดํ„ฐ"
66
+
67
+ if not source_dir.exists() or not label_dir.exists():
68
+ logger.error("Expected 01.์›์ฒœ๋ฐ์ดํ„ฐ and 02.๋ผ๋ฒจ๋ง๋ฐ์ดํ„ฐ under %s", aihub_dir)
69
+ sys.exit(1)
70
+
71
+ # Build WAV lookup: stem โ†’ path
72
+ wav_lookup = {}
73
+ for wav_path in source_dir.rglob("*.wav"):
74
+ if str(wav_path).endswith(":Zone.Identifier"):
75
+ continue
76
+ wav_lookup[wav_path.stem] = wav_path
77
+
78
+ # Match JSON โ†’ WAV
79
+ pairs = []
80
+ for json_path in label_dir.rglob("*.json"):
81
+ if str(json_path).endswith(":Zone.Identifier"):
82
+ continue
83
+ stem = json_path.stem
84
+ wav_path = wav_lookup.get(stem)
85
+ if wav_path:
86
+ pairs.append((wav_path, json_path))
87
+ else:
88
+ logger.warning("No WAV match for %s", json_path.name)
89
+
90
+ logger.info("Discovered %d WAV-JSON pairs", len(pairs))
91
+ return pairs
92
+
93
+
94
+ def parse_utterances(pairs: list[tuple[Path, Path]]) -> list[dict]:
95
+ """Parse all utterances from JSON label files.
96
+
97
+ Uses VerifyEmotionTarget as ground truth (annotator-verified label).
98
+ """
99
+ utterances = []
100
+
101
+ for wav_path, json_path in pairs:
102
+ with open(json_path, encoding="utf-8") as f:
103
+ data = json.load(f)
104
+
105
+ wav_info = data.get("Wav", {})
106
+ file_info = data.get("File", {})
107
+ sr = int(wav_info.get("SamplingRate", 16000))
108
+ n_channels = int(wav_info.get("NumberOfChannel", 2))
109
+
110
+ # Speaker info
111
+ speakers = {}
112
+ for key in ("Speaker1", "Speaker2"):
113
+ spk = data.get(key, {})
114
+ speakers[key] = {
115
+ "id": spk.get("ID", ""),
116
+ "gender": spk.get("Gender", ""),
117
+ "age": spk.get("Age", ""),
118
+ }
119
+
120
+ for utt in data.get("Conversation", []):
121
+ emotion_kr = utt.get("VerifyEmotionTarget", "").strip()
122
+ emotion_en = AIHUB_LABEL_MAP.get(emotion_kr)
123
+ if emotion_en is None:
124
+ continue # Unknown label, skip
125
+
126
+ if emotion_en not in EVAL_LABELS:
127
+ continue
128
+
129
+ try:
130
+ start = float(str(utt["StartTime"]).replace(",", ""))
131
+ end = float(str(utt["EndTime"]).replace(",", ""))
132
+ except (KeyError, ValueError):
133
+ continue
134
+
135
+ duration = end - start
136
+ if duration < MIN_DURATION_SEC or duration > MAX_DURATION_SEC:
137
+ continue
138
+
139
+ speaker_no = utt.get("SpeakerNo", "Speaker1")
140
+ speaker_info = speakers.get(speaker_no, {})
141
+
142
+ # Determine which channel to extract (0-indexed)
143
+ # Speaker1 = left channel (0), Speaker2 = right channel (1)
144
+ channel = 0 if speaker_no == "Speaker1" else 1
145
+ if n_channels == 1:
146
+ channel = 0
147
+
148
+ utterances.append({
149
+ "wav_path": str(wav_path),
150
+ "json_path": str(json_path),
151
+ "file_stem": wav_path.stem,
152
+ "text_no": utt.get("TextNo", ""),
153
+ "text": utt.get("Text", ""),
154
+ "start": start,
155
+ "end": end,
156
+ "duration": duration,
157
+ "emotion": emotion_en,
158
+ "emotion_kr": emotion_kr,
159
+ "intensity": utt.get("VerifyEmotionLevel", ""),
160
+ "speaker_no": speaker_no,
161
+ "speaker_id": speaker_info.get("id", ""),
162
+ "speaker_gender": speaker_info.get("gender", ""),
163
+ "speaker_age": speaker_info.get("age", ""),
164
+ "channel": channel,
165
+ "sample_rate": sr,
166
+ "n_channels": n_channels,
167
+ })
168
+
169
+ logger.info("Parsed %d valid utterances across %d files", len(utterances), len(pairs))
170
+ return utterances
171
+
172
+
173
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
174
+ # Step 2: Balanced sampling
175
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
176
+
177
+ def balanced_sample(
178
+ utterances: list[dict],
179
+ samples_per_class: int,
180
+ seed: int = 42,
181
+ ) -> list[dict]:
182
+ """Stratified balanced sampling: target samples_per_class per emotion.
183
+
184
+ Ensures:
185
+ - Duration diversity (short/medium/long mix)
186
+ - Speaker diversity (spread across speakers)
187
+ - For rare classes (e.g., fear), takes all available if < target
188
+ """
189
+ rng = random.Random(seed)
190
+
191
+ # Group by emotion
192
+ by_emotion: dict[str, list[dict]] = defaultdict(list)
193
+ for utt in utterances:
194
+ by_emotion[utt["emotion"]].append(utt)
195
+
196
+ selected = []
197
+ stats = {}
198
+
199
+ for emotion in EVAL_LABELS:
200
+ pool = by_emotion.get(emotion, [])
201
+ if not pool:
202
+ logger.warning("No samples for emotion '%s'", emotion)
203
+ stats[emotion] = 0
204
+ continue
205
+
206
+ if len(pool) <= samples_per_class:
207
+ # Take all for rare classes
208
+ chosen = pool
209
+ else:
210
+ # Duration-stratified sampling
211
+ short = [u for u in pool if u["duration"] < 3.0]
212
+ medium = [u for u in pool if 3.0 <= u["duration"] < 10.0]
213
+ long = [u for u in pool if u["duration"] >= 10.0]
214
+
215
+ # Target ratio: 30% short, 50% medium, 20% long
216
+ n_short = max(1, int(samples_per_class * 0.3))
217
+ n_long = max(1, int(samples_per_class * 0.2))
218
+ n_medium = samples_per_class - n_short - n_long
219
+
220
+ chosen = []
221
+ for bucket, n in [(short, n_short), (medium, n_medium), (long, n_long)]:
222
+ rng.shuffle(bucket)
223
+ chosen.extend(bucket[:n])
224
+
225
+ # Fill remaining if any bucket was short
226
+ if len(chosen) < samples_per_class:
227
+ remaining = [u for u in pool if u not in chosen]
228
+ rng.shuffle(remaining)
229
+ chosen.extend(remaining[: samples_per_class - len(chosen)])
230
+
231
+ chosen = chosen[:samples_per_class]
232
+
233
+ selected.extend(chosen)
234
+ stats[emotion] = len(chosen)
235
+
236
+ logger.info("Sampling result: %s (total: %d)", stats, len(selected))
237
+ return selected
238
+
239
+
240
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
241
+ # Step 3: Extract utterance WAVs
242
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
243
+
244
+ def extract_utterances(
245
+ selected: list[dict],
246
+ output_dir: str,
247
+ ) -> list[dict]:
248
+ """Extract individual utterance WAV segments from conversation files.
249
+
250
+ Reads the stereo WAV, extracts the correct speaker channel,
251
+ and saves as mono 16kHz WAV.
252
+ """
253
+ out_path = Path(output_dir)
254
+ records = []
255
+
256
+ # Cache loaded audio files (avoid re-reading same WAV)
257
+ audio_cache: dict[str, tuple[np.ndarray, int]] = {}
258
+
259
+ for i, utt in enumerate(selected):
260
+ emotion = utt["emotion"]
261
+ emotion_dir = out_path / "test_audio" / emotion
262
+ emotion_dir.mkdir(parents=True, exist_ok=True)
263
+
264
+ # Load audio (cached)
265
+ wav_path = utt["wav_path"]
266
+ if wav_path not in audio_cache:
267
+ try:
268
+ audio, sr = sf.read(wav_path, dtype="float32")
269
+ audio_cache[wav_path] = (audio, sr)
270
+ except Exception as e:
271
+ logger.warning("Failed to read %s: %s", wav_path, e)
272
+ continue
273
+
274
+ audio, sr = audio_cache[wav_path]
275
+
276
+ # Extract channel
277
+ if audio.ndim == 2:
278
+ channel = min(utt["channel"], audio.shape[1] - 1)
279
+ mono = audio[:, channel]
280
+ else:
281
+ mono = audio
282
+
283
+ # Extract time range
284
+ start_sample = int(utt["start"] * sr)
285
+ end_sample = int(utt["end"] * sr)
286
+ start_sample = max(0, start_sample)
287
+ end_sample = min(len(mono), end_sample)
288
+
289
+ segment = mono[start_sample:end_sample]
290
+
291
+ if len(segment) < int(MIN_DURATION_SEC * sr):
292
+ logger.warning("Segment too short after extraction: %s_%s", utt["file_stem"], utt["text_no"])
293
+ continue
294
+
295
+ # Resample to 16kHz if needed
296
+ if sr != 16000:
297
+ import librosa
298
+ segment = librosa.resample(segment, orig_sr=sr, target_sr=16000)
299
+ sr = 16000
300
+
301
+ # Save
302
+ filename = f"kr_{emotion}_{i:04d}.wav"
303
+ filepath = emotion_dir / filename
304
+ sf.write(str(filepath), segment, 16000, subtype="PCM_16")
305
+
306
+ records.append({
307
+ "file_path": str(filepath.relative_to(out_path)),
308
+ "emotion": emotion,
309
+ "duration": round(len(segment) / 16000, 3),
310
+ "speaker_id": utt["speaker_id"],
311
+ "speaker_gender": utt["speaker_gender"],
312
+ "intensity": utt["intensity"],
313
+ "text": utt["text"],
314
+ "source_file": utt["file_stem"],
315
+ })
316
+
317
+ if (i + 1) % 100 == 0:
318
+ logger.info("Extracted %d/%d utterances", i + 1, len(selected))
319
+
320
+ logger.info("Extracted %d utterance WAVs to %s", len(records), output_dir)
321
+ return records
322
+
323
+
324
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
325
+ # Step 4: Write labels CSV + metadata JSON
326
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
327
+
328
+ def write_outputs(records: list[dict], output_dir: str, utterances: list[dict]):
329
+ """Write test_labels.csv and metadata.json."""
330
+ out_path = Path(output_dir)
331
+
332
+ # CSV
333
+ csv_path = out_path / "test_labels.csv"
334
+ with open(csv_path, "w", newline="", encoding="utf-8") as f:
335
+ writer = csv.DictWriter(f, fieldnames=[
336
+ "file_path", "emotion", "duration", "speaker_id",
337
+ "speaker_gender", "intensity", "text", "source_file",
338
+ ])
339
+ writer.writeheader()
340
+ writer.writerows(records)
341
+ logger.info("Wrote %s (%d records)", csv_path, len(records))
342
+
343
+ # Metadata
344
+ from collections import Counter
345
+ emotion_dist = Counter(r["emotion"] for r in records)
346
+ duration_stats = [r["duration"] for r in records]
347
+ intensity_dist = Counter(r["intensity"] for r in records)
348
+
349
+ metadata = {
350
+ "dataset": "AI Hub #71631 - ๊ฐ์ •์ด ํƒœ๊น…๋œ ์ž์œ ๋Œ€ํ™” (์„ฑ์ธ)",
351
+ "subset": "test",
352
+ "total_samples": len(records),
353
+ "eval_classes": EVAL_LABELS,
354
+ "label_mapping": AIHUB_LABEL_MAP,
355
+ "emotion_distribution": dict(emotion_dist),
356
+ "intensity_distribution": dict(intensity_dist),
357
+ "duration_stats": {
358
+ "mean": round(sum(duration_stats) / max(len(duration_stats), 1), 2),
359
+ "min": round(min(duration_stats, default=0), 2),
360
+ "max": round(max(duration_stats, default=0), 2),
361
+ },
362
+ "total_source_utterances": len(utterances),
363
+ "note": "disgust class absent from AI Hub dataset โ€” 6-class evaluation",
364
+ }
365
+
366
+ meta_path = out_path / "metadata.json"
367
+ with open(meta_path, "w", encoding="utf-8") as f:
368
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
369
+ logger.info("Wrote %s", meta_path)
370
+
371
+
372
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
373
+ # Main
374
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
375
+
376
+ def main():
377
+ parser = argparse.ArgumentParser(
378
+ description="AI Hub ๊ฐ์ • ๋ฐ์ดํ„ฐ์…‹ โ†’ ๋ฒค์น˜๋งˆํฌ ํ…Œ์ŠคํŠธ ์„œ๋ธŒ์…‹ ์ค€๋น„",
379
+ )
380
+ parser.add_argument("--aihub-dir", required=True, help="AI Hub ๋ฐ์ดํ„ฐ ๋ฃจํŠธ (01.์›์ฒœ๋ฐ์ดํ„ฐ, 02.๋ผ๋ฒจ๋ง๋ฐ์ดํ„ฐ ํฌํ•จ)")
381
+ parser.add_argument("--output-dir", default="data/evaluation/korean", help="์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ")
382
+ parser.add_argument("--samples-per-class", type=int, default=83, help="ํด๋ž˜์Šค๋‹น ๋ชฉํ‘œ ์ƒ˜ํ”Œ ์ˆ˜ (default: 83)")
383
+ parser.add_argument("--seed", type=int, default=42, help="๋žœ๋ค ์‹œ๋“œ")
384
+ parser.add_argument("--ground-truth", default="verify", choices=["verify", "speaker"],
385
+ help="Ground truth ์†Œ์Šค: verify=๊ฒ€์ฆ์ž ๋ผ๋ฒจ, speaker=ํ™”์ž ์ž๊ธฐ๋ณด๊ณ ")
386
+ args = parser.parse_args()
387
+
388
+ # 1. Discover pairs
389
+ pairs = discover_pairs(args.aihub_dir)
390
+ if not pairs:
391
+ logger.error("No WAV-JSON pairs found")
392
+ sys.exit(1)
393
+
394
+ # 2. Parse utterances
395
+ utterances = parse_utterances(pairs)
396
+ if not utterances:
397
+ logger.error("No valid utterances parsed")
398
+ sys.exit(1)
399
+
400
+ # Log distribution before sampling
401
+ from collections import Counter
402
+ raw_dist = Counter(u["emotion"] for u in utterances)
403
+ logger.info("Raw distribution: %s", dict(raw_dist))
404
+
405
+ # 3. Balanced sampling
406
+ selected = balanced_sample(utterances, args.samples_per_class, seed=args.seed)
407
+
408
+ # 4. Extract WAVs
409
+ records = extract_utterances(selected, args.output_dir)
410
+
411
+ # 5. Write outputs
412
+ write_outputs(records, args.output_dir, utterances)
413
+
414
+ print(f"\nDone! Test subset ready at {args.output_dir}/")
415
+ print(f" - {len(records)} utterance WAVs in test_audio/")
416
+ print(f" - test_labels.csv")
417
+ print(f" - metadata.json")
418
+
419
+
420
+ if __name__ == "__main__":
421
+ main()
scripts/prepare_dataset.py ADDED
File without changes
scripts/prepare_lora_dataset.py ADDED
@@ -0,0 +1,875 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Unified data preprocessing pipeline for LoRA emotion2vec 7-class fine-tuning.
3
+
4
+ Extracts, preprocesses, and merges samples from three sources:
5
+ 1. AI Hub 263 โ€” anchor (acted Korean, 7-class)
6
+ 2. AI Hub 71631 โ€” booster (outdoor spontaneous Korean, mapped to 7-class)
7
+ 3. RAVDESS โ€” English (acted, 7-class)
8
+
9
+ Outputs a unified manifest (train/val JSONs) ready for LoRA training.
10
+
11
+ Usage:
12
+ python scripts/prepare_lora_dataset.py \
13
+ --anchor-dir "data/AI Hub 263" \
14
+ --booster-label-zip "data/AI Hub 71631/01-1.์ •์‹๊ฐœ๋ฐฉ๋ฐ์ดํ„ฐ/Training/02.๋ผ๋ฒจ๋ง๋ฐ์ดํ„ฐ/TL_02.์‹ค์™ธ.zip" \
15
+ --booster-audio-zip "data/AI Hub 71631/01-1.์ •์‹๊ฐœ๋ฐฉ๋ฐ์ดํ„ฐ/Training/01.์›์ฒœ๋ฐ์ดํ„ฐ/TS_02.์‹ค์™ธ.zip" \
16
+ --ravdess-dir data/ravdess \
17
+ --output-dir data/lora_7class
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import csv
24
+ import io
25
+ import json
26
+ import logging
27
+ import random
28
+ import zipfile
29
+ from collections import Counter, defaultdict
30
+ from pathlib import Path
31
+
32
+ import torch
33
+ import torchaudio
34
+
35
+ logging.basicConfig(
36
+ level=logging.INFO,
37
+ format="%(asctime)s %(levelname)-8s %(message)s",
38
+ datefmt="%H:%M:%S",
39
+ )
40
+ log = logging.getLogger(__name__)
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Constants
44
+ # ---------------------------------------------------------------------------
45
+ LABEL2IDX: dict[str, int] = {
46
+ "happiness": 0,
47
+ "anger": 1,
48
+ "disgust": 2,
49
+ "fear": 3,
50
+ "neutral": 4,
51
+ "sadness": 5,
52
+ "surprise": 6,
53
+ }
54
+
55
+ VALID_LABELS = set(LABEL2IDX.keys())
56
+
57
+ TARGET_SR = 16_000
58
+ RMS_THRESHOLD = 0.001 # 0.005โ†’0.001: disgust ๋“ฑ ์ €์—๋„ˆ์ง€ ๋ฐœํ™” ๋ณด์กด (์ง„์งœ ๋ฌด์Œ๋งŒ ์ œ๊ฑฐ)
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Task 1 โ€” Label Mappers
62
+ # ---------------------------------------------------------------------------
63
+
64
+ _MAP_263: dict[str, str] = {
65
+ "angry": "anger",
66
+ "happiness": "happiness",
67
+ "neutral": "neutral",
68
+ "sadness": "sadness",
69
+ "surprise": "surprise",
70
+ "fear": "fear",
71
+ "disgust": "disgust",
72
+ }
73
+
74
+
75
+ def map_263_label(raw: str) -> str | None:
76
+ """Map AI Hub 263 annotator label to 7-class. Case-insensitive."""
77
+ if not raw:
78
+ return None
79
+ return _MAP_263.get(raw.strip().lower())
80
+
81
+
82
+ _MAP_71631: dict[str, str] = {
83
+ "๊ธฐ์จ": "happiness",
84
+ "ํ™”๋‚จ": "anger",
85
+ "๋†€๋ผ์›€": "surprise",
86
+ "์Šฌํ””": "sadness",
87
+ "๋‘๋ ค์›€": "fear",
88
+ "์—†์Œ": "neutral",
89
+ "์ค‘๋ฆฝ": "neutral",
90
+ }
91
+
92
+
93
+ def map_71631_label(raw: str) -> str | None:
94
+ """Map AI Hub 71631 Korean emotion label to 7-class."""
95
+ if not raw:
96
+ return None
97
+ return _MAP_71631.get(raw.strip())
98
+
99
+
100
+ def map_ravdess_label(raw: str) -> str | None:
101
+ """Map RAVDESS label to 7-class. Passthrough except joyโ†’happiness."""
102
+ if not raw:
103
+ return None
104
+ lbl = raw.strip().lower()
105
+ if lbl == "joy":
106
+ return "happiness"
107
+ if lbl in VALID_LABELS:
108
+ return lbl
109
+ return None
110
+
111
+
112
+ def majority_vote_263(emotions: list[str | None]) -> str | None:
113
+ """Return majority label (3/5+) or None for no majority/tie."""
114
+ valid = [e for e in emotions if e is not None]
115
+ if not valid:
116
+ return None
117
+ counts = Counter(valid)
118
+ top_label, top_count = counts.most_common(1)[0]
119
+ if top_count < 3:
120
+ return None
121
+ # Check for tie at top count
122
+ tied = [lbl for lbl, c in counts.items() if c == top_count]
123
+ if len(tied) > 1:
124
+ return None
125
+ return top_label
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Task 1 โ€” Audio Preprocessing
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ import re
134
+
135
+ def _clean_text(text: str) -> str:
136
+ """Clean text for STT-friendly format.
137
+
138
+ Removes non-verbal tags like (์›ƒ์Œ), (ํ•œ์ˆจ), keeps ?, !, ...
139
+ """
140
+ # Remove non-verbal tags: (์›ƒ์Œ), (ํ•œ์ˆจ), (์นจ๋ฌต), [noise], etc.
141
+ text = re.sub(r"[(\[๏ผˆ][^)\]๏ผ‰]*[)\]๏ผ‰]", "", text)
142
+ # Remove trailing/leading whitespace, collapse multiple spaces
143
+ text = re.sub(r"\s+", " ", text).strip()
144
+ return text
145
+
146
+
147
+ def _compute_rms(waveform: torch.Tensor) -> float:
148
+ """Compute RMS of a waveform tensor."""
149
+ return float(torch.sqrt(torch.mean(waveform.float() ** 2)))
150
+
151
+
152
+ def preprocess_audio(input_path: Path, output_path: Path) -> bool:
153
+ """Resample to 16kHz mono, trim silence, reject if RMS < threshold.
154
+
155
+ Returns True if file was saved, False if rejected.
156
+ """
157
+ input_path = Path(input_path)
158
+ output_path = Path(output_path)
159
+
160
+ waveform, sr = torchaudio.load(str(input_path))
161
+
162
+ # Mono
163
+ if waveform.shape[0] > 1:
164
+ waveform = waveform.mean(dim=0, keepdim=True)
165
+
166
+ # Resample
167
+ if sr != TARGET_SR:
168
+ waveform = torchaudio.functional.resample(waveform, sr, TARGET_SR)
169
+
170
+ # Trim silence (leading/trailing)
171
+ waveform_trimmed = torchaudio.functional.vad(waveform, TARGET_SR)
172
+ if waveform_trimmed.numel() > 0:
173
+ waveform = waveform_trimmed
174
+
175
+ # RMS check
176
+ rms = _compute_rms(waveform)
177
+ if rms < RMS_THRESHOLD:
178
+ return False
179
+
180
+ output_path.parent.mkdir(parents=True, exist_ok=True)
181
+ torchaudio.save(str(output_path), waveform, TARGET_SR)
182
+ return True
183
+
184
+
185
+ def preprocess_audio_from_tensor(
186
+ waveform: torch.Tensor, sr: int, output_path: Path
187
+ ) -> bool:
188
+ """Preprocess an in-memory waveform tensor and save to output_path.
189
+
190
+ Used for 71631 where we slice in memory from the full conversation wav.
191
+ """
192
+ output_path = Path(output_path)
193
+
194
+ # Mono
195
+ if waveform.dim() > 1 and waveform.shape[0] > 1:
196
+ waveform = waveform.mean(dim=0, keepdim=True)
197
+ elif waveform.dim() == 1:
198
+ waveform = waveform.unsqueeze(0)
199
+
200
+ # Resample
201
+ if sr != TARGET_SR:
202
+ waveform = torchaudio.functional.resample(waveform, sr, TARGET_SR)
203
+
204
+ # Trim silence (leading/trailing) โ€” same as preprocess_audio
205
+ waveform_trimmed = torchaudio.functional.vad(waveform, TARGET_SR)
206
+ if waveform_trimmed.numel() > 0:
207
+ waveform = waveform_trimmed
208
+
209
+ # RMS check
210
+ rms = _compute_rms(waveform)
211
+ if rms < RMS_THRESHOLD:
212
+ return False
213
+
214
+ output_path.parent.mkdir(parents=True, exist_ok=True)
215
+ torchaudio.save(str(output_path), waveform, TARGET_SR)
216
+ return True
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Task 2 โ€” AI Hub 263 Extraction
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ def parse_263_row(row: list[str]) -> dict | None:
225
+ """Parse a CSV row from AI Hub 263.
226
+
227
+ Columns: wav_id, ๋ฐœํ™”๋ฌธ, ์ƒํ™ฉ, 1๋ฒˆ๊ฐ์ •, 1๋ฒˆ๊ฐ์ •์„ธ๊ธฐ, 2๋ฒˆ๊ฐ์ •, 2๋ฒˆ๊ฐ์ •์„ธ๊ธฐ,
228
+ 3๋ฒˆ๊ฐ์ •, 3๋ฒˆ๊ฐ์ •์„ธ๊ธฐ, 4๋ฒˆ๊ฐ์ •, 4๋ฒˆ๊ฐ์ •์„ธ๊ธฐ, 5๋ฒˆ๊ฐ์ •, 5๋ฒˆ๊ฐ์ •์„ธ๊ธฐ, ๋‚˜์ด, ์„ฑ๋ณ„
229
+
230
+ Returns dict with wav_id, label, agreement, max_intensity, or None if no majority.
231
+ """
232
+ if len(row) < 15:
233
+ return None
234
+
235
+ wav_id = row[0].strip()
236
+ # Extract 5 annotator emotions and intensities
237
+ annotator_emotions: list[str | None] = []
238
+ intensities: list[int] = []
239
+ for i in range(5):
240
+ emo_col = 3 + i * 2 # 3, 5, 7, 9, 11
241
+ int_col = 3 + i * 2 + 1 # 4, 6, 8, 10, 12
242
+ raw_emo = row[emo_col].strip() if emo_col < len(row) else ""
243
+ raw_int = row[int_col].strip() if int_col < len(row) else "0"
244
+ mapped = map_263_label(raw_emo)
245
+ annotator_emotions.append(mapped)
246
+ try:
247
+ intensities.append(int(raw_int))
248
+ except ValueError:
249
+ intensities.append(0)
250
+
251
+ label = majority_vote_263(annotator_emotions)
252
+ if label is None:
253
+ return None
254
+
255
+ agreement = sum(1 for e in annotator_emotions if e == label)
256
+ # Max intensity among annotators who voted for the majority label
257
+ max_intensity = max(
258
+ (intensities[i] for i, e in enumerate(annotator_emotions) if e == label),
259
+ default=0,
260
+ )
261
+
262
+ # ๋ฐœํ™”๋ฌธ (text) โ€” column 1
263
+ text = row[1].strip() if len(row) > 1 else ""
264
+ text = _clean_text(text)
265
+
266
+ return {
267
+ "wav_id": wav_id,
268
+ "text": text,
269
+ "label": label,
270
+ "agreement": agreement,
271
+ "max_intensity": max_intensity,
272
+ }
273
+
274
+
275
+ def extract_anchor_263(
276
+ anchor_dir: Path,
277
+ output_dir: Path,
278
+ cap_per_class: int = 2100,
279
+ ) -> list[dict]:
280
+ """Extract and preprocess AI Hub 263 dataset.
281
+
282
+ Parses 3 CSVs (cp949), majority-votes annotator labels,
283
+ priority-sorts (agreement desc, intensity desc), caps per class,
284
+ extracts wavs from ZIPs, preprocesses audio.
285
+ """
286
+ anchor_dir = Path(anchor_dir)
287
+ output_dir = Path(output_dir)
288
+ output_dir.mkdir(parents=True, exist_ok=True)
289
+
290
+ csv_files = sorted(anchor_dir.glob("*.csv"))
291
+ zip_files = sorted(anchor_dir.glob("*.zip"))
292
+
293
+ log.info("263: Found %d CSVs, %d ZIPs", len(csv_files), len(zip_files))
294
+
295
+ # Step 1: Parse all CSVs
296
+ all_parsed: list[dict] = []
297
+ for csv_path in csv_files:
298
+ with open(csv_path, encoding="cp949", newline="") as f:
299
+ reader = csv.reader(f)
300
+ header = next(reader) # skip header
301
+ for row in reader:
302
+ result = parse_263_row(row)
303
+ if result is not None:
304
+ result["csv_source"] = csv_path.stem
305
+ all_parsed.append(result)
306
+
307
+ log.info("263: Parsed %d rows with majority vote", len(all_parsed))
308
+
309
+ # Step 2: Group by label, priority sort, cap
310
+ by_label: dict[str, list[dict]] = defaultdict(list)
311
+ for item in all_parsed:
312
+ by_label[item["label"]].append(item)
313
+
314
+ selected: list[dict] = []
315
+ for label, items in by_label.items():
316
+ # Sort by agreement desc, then intensity desc
317
+ items.sort(key=lambda x: (x["agreement"], x["max_intensity"]), reverse=True)
318
+ capped = items[:cap_per_class]
319
+ selected.extend(capped)
320
+ log.info("263: %s โ€” %d available, %d selected", label, len(items), len(capped))
321
+
322
+ # Step 3: Build wav_id โ†’ zip lookup
323
+ wav_to_zip: dict[str, tuple[zipfile.ZipFile, str]] = {}
324
+ zip_handles = [zipfile.ZipFile(zp) for zp in zip_files]
325
+ for zf in zip_handles:
326
+ for name in zf.namelist():
327
+ if name.endswith(".wav"):
328
+ basename = Path(name).stem
329
+ wav_to_zip[basename] = (zf, name)
330
+
331
+ # Step 4: Extract and preprocess
332
+ samples: list[dict] = []
333
+ skipped = 0
334
+ for item in selected:
335
+ wav_id = item["wav_id"]
336
+ if wav_id not in wav_to_zip:
337
+ skipped += 1
338
+ continue
339
+
340
+ zf, zip_entry = wav_to_zip[wav_id]
341
+ out_path = output_dir / item["label"] / f"{wav_id}.wav"
342
+
343
+ try:
344
+ with zf.open(zip_entry) as src:
345
+ audio_bytes = src.read()
346
+
347
+ # Write to temp, then preprocess
348
+ tmp_path = output_dir / f"_tmp_{wav_id}.wav"
349
+ tmp_path.write_bytes(audio_bytes)
350
+
351
+ ok = preprocess_audio(tmp_path, out_path)
352
+ tmp_path.unlink(missing_ok=True)
353
+
354
+ if ok:
355
+ samples.append({
356
+ "path": str(out_path),
357
+ "label": item["label"],
358
+ "label_idx": LABEL2IDX[item["label"]],
359
+ "source": "263",
360
+ "speaker_id": f"263_{wav_id[:8]}",
361
+ "text": item.get("text", ""),
362
+ "agreement": item["agreement"],
363
+ "intensity": item["max_intensity"],
364
+ })
365
+ else:
366
+ skipped += 1
367
+ except Exception as e:
368
+ log.warning("263: Failed to process %s: %s", wav_id, e)
369
+ skipped += 1
370
+
371
+ # Close zip handles
372
+ for zf in zip_handles:
373
+ zf.close()
374
+
375
+ log.info("263: Extracted %d samples, skipped %d", len(samples), skipped)
376
+ return samples
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # Task 3 โ€” AI Hub 71631 Outdoor Extraction
381
+ # ---------------------------------------------------------------------------
382
+
383
+
384
+ def parse_71631_utterance(conv_entry: dict) -> dict | None:
385
+ """Parse a conversation entry from 71631 JSON.
386
+
387
+ Filters by VerifyEmotionLevel (๋ณดํ†ต/๊ฐ•ํ•จ only, rejects ์•ฝํ•จ).
388
+ Returns dict with label, intensity, start_time, end_time, speaker_no or None.
389
+ """
390
+ level = conv_entry.get("VerifyEmotionLevel", "")
391
+ if level not in ("๋ณดํ†ต", "๊ฐ•ํ•จ"):
392
+ return None
393
+
394
+ emotion = conv_entry.get("VerifyEmotionTarget", "")
395
+ label = map_71631_label(emotion)
396
+ if label is None:
397
+ return None
398
+
399
+ try:
400
+ start_time = float(conv_entry["StartTime"])
401
+ end_time = float(conv_entry["EndTime"])
402
+ except (KeyError, ValueError):
403
+ return None
404
+
405
+ if end_time <= start_time:
406
+ return None
407
+
408
+ # Text from conversation entry
409
+ text = _clean_text(conv_entry.get("Text", ""))
410
+
411
+ return {
412
+ "label": label,
413
+ "intensity": level,
414
+ "start_time": start_time,
415
+ "end_time": end_time,
416
+ "speaker_no": conv_entry.get("SpeakerNo", ""),
417
+ "text": text,
418
+ }
419
+
420
+
421
+ def extract_booster_71631(
422
+ label_zip: Path,
423
+ audio_zip: Path,
424
+ output_dir: Path,
425
+ cap: int = 3500,
426
+ max_per_speaker: int = 20,
427
+ ) -> list[dict]:
428
+ """Extract and preprocess AI Hub 71631 outdoor dataset.
429
+
430
+ Parses label ZIP JSONs, filters intensity, applies speaker cap,
431
+ slices wav segments, resamples to 16kHz, RMS-filters neutral.
432
+ """
433
+ label_zip = Path(label_zip)
434
+ audio_zip = Path(audio_zip)
435
+ output_dir = Path(output_dir)
436
+ output_dir.mkdir(parents=True, exist_ok=True)
437
+
438
+ # Step 1: Parse all label JSONs
439
+ all_utterances: list[dict] = []
440
+ with zipfile.ZipFile(label_zip) as lzf:
441
+ json_files = [n for n in lzf.namelist() if n.endswith(".json")]
442
+ log.info("71631: Found %d label JSONs", len(json_files))
443
+
444
+ for jf in json_files:
445
+ try:
446
+ with lzf.open(jf) as f:
447
+ data = json.load(f)
448
+ except Exception as e:
449
+ log.warning("71631: Failed to parse %s: %s", jf, e)
450
+ continue
451
+
452
+ filename = data.get("File", {}).get("FileName", "")
453
+ conv_id = filename # Use filename as conversation_id
454
+ spk1_id = data.get("Speaker1", {}).get("ID", "")
455
+ spk2_id = data.get("Speaker2", {}).get("ID", "")
456
+
457
+ for entry in data.get("Conversation", []):
458
+ parsed = parse_71631_utterance(entry)
459
+ if parsed is None:
460
+ continue
461
+ # Determine speaker ID
462
+ spk_no = parsed["speaker_no"]
463
+ if spk_no == "Speaker1":
464
+ spk_id = spk1_id
465
+ elif spk_no == "Speaker2":
466
+ spk_id = spk2_id
467
+ else:
468
+ spk_id = spk_no
469
+
470
+ parsed["conversation_id"] = conv_id
471
+ parsed["speaker_id"] = f"71631_{spk_id}"
472
+ parsed["wav_filename"] = filename
473
+ parsed["text_no"] = entry.get("TextNo", "")
474
+ all_utterances.append(parsed)
475
+
476
+ log.info("71631: Parsed %d utterances (๋ณดํ†ต/๊ฐ•ํ•จ)", len(all_utterances))
477
+
478
+ # Step 2: Speaker cap
479
+ speaker_counts: Counter = Counter()
480
+ speaker_capped: list[dict] = []
481
+ # Priority: ๊ฐ•ํ•จ first, then ๋ณดํ†ต
482
+ all_utterances.sort(key=lambda x: (0 if x["intensity"] == "๊ฐ•ํ•จ" else 1))
483
+ for utt in all_utterances:
484
+ spk = utt["speaker_id"]
485
+ if speaker_counts[spk] < max_per_speaker:
486
+ speaker_capped.append(utt)
487
+ speaker_counts[spk] += 1
488
+
489
+ log.info("71631: After speaker cap (%d/spk): %d utterances", max_per_speaker, len(speaker_capped))
490
+
491
+ # Step 3: Group by label, cap per class
492
+ by_label: dict[str, list[dict]] = defaultdict(list)
493
+ for utt in speaker_capped:
494
+ by_label[utt["label"]].append(utt)
495
+
496
+ selected: list[dict] = []
497
+ for label, items in by_label.items():
498
+ # Priority: ๊ฐ•ํ•จ first (already sorted)
499
+ capped = items[:cap]
500
+ selected.extend(capped)
501
+ log.info("71631: %s โ€” %d available, %d selected", label, len(items), len(capped))
502
+
503
+ # Step 4: Group by wav filename for efficient audio loading
504
+ by_wav: dict[str, list[dict]] = defaultdict(list)
505
+ for utt in selected:
506
+ by_wav[utt["wav_filename"]].append(utt)
507
+
508
+ # Step 5: Extract audio segments
509
+ samples: list[dict] = []
510
+ skipped = 0
511
+
512
+ with zipfile.ZipFile(audio_zip) as azf:
513
+ wav_lookup: dict[str, str] = {}
514
+ for name in azf.namelist():
515
+ if name.endswith(".wav"):
516
+ stem = Path(name).stem
517
+ wav_lookup[stem] = name
518
+
519
+ for wav_filename, utterances in by_wav.items():
520
+ if wav_filename not in wav_lookup:
521
+ log.warning("71631: WAV not found in zip: %s", wav_filename)
522
+ skipped += len(utterances)
523
+ continue
524
+
525
+ zip_entry = wav_lookup[wav_filename]
526
+ try:
527
+ with azf.open(zip_entry) as src:
528
+ audio_bytes = src.read()
529
+
530
+ buf = io.BytesIO(audio_bytes)
531
+ waveform, sr = torchaudio.load(buf)
532
+ except Exception as e:
533
+ log.warning("71631: Failed to load %s: %s", wav_filename, e)
534
+ skipped += len(utterances)
535
+ continue
536
+
537
+ # Mono
538
+ if waveform.shape[0] > 1:
539
+ waveform = waveform.mean(dim=0, keepdim=True)
540
+
541
+ for utt in utterances:
542
+ start_sample = int(utt["start_time"] * sr)
543
+ end_sample = int(utt["end_time"] * sr)
544
+
545
+ if end_sample > waveform.shape[1]:
546
+ end_sample = waveform.shape[1]
547
+ if start_sample >= end_sample:
548
+ skipped += 1
549
+ continue
550
+
551
+ segment = waveform[:, start_sample:end_sample]
552
+
553
+ out_name = f"{wav_filename}_{utt['text_no']}.wav"
554
+ out_path = output_dir / utt["label"] / out_name
555
+
556
+ ok = preprocess_audio_from_tensor(segment, sr, out_path)
557
+ if ok:
558
+ samples.append({
559
+ "path": str(out_path),
560
+ "label": utt["label"],
561
+ "label_idx": LABEL2IDX[utt["label"]],
562
+ "source": "71631",
563
+ "speaker_id": utt["speaker_id"],
564
+ "text": utt.get("text", ""),
565
+ "conversation_id": utt["conversation_id"],
566
+ "intensity": utt["intensity"],
567
+ })
568
+ else:
569
+ skipped += 1
570
+
571
+ log.info("71631: Extracted %d samples, skipped %d", len(samples), skipped)
572
+ return samples
573
+
574
+
575
+ # ---------------------------------------------------------------------------
576
+ # Task 4 โ€” RAVDESS Extraction
577
+ # ---------------------------------------------------------------------------
578
+
579
+
580
+ def extract_ravdess(ravdess_dir: Path, output_dir: Path) -> list[dict]:
581
+ """Extract and preprocess RAVDESS dataset from manifest.csv."""
582
+ ravdess_dir = Path(ravdess_dir)
583
+ output_dir = Path(output_dir)
584
+ output_dir.mkdir(parents=True, exist_ok=True)
585
+
586
+ manifest_path = ravdess_dir / "manifest.csv"
587
+ if not manifest_path.exists():
588
+ log.error("RAVDESS manifest not found: %s", manifest_path)
589
+ return []
590
+
591
+ samples: list[dict] = []
592
+ skipped = 0
593
+
594
+ with open(manifest_path) as f:
595
+ reader = csv.DictReader(f)
596
+ for row in reader:
597
+ clean_path = Path(row["clean_path"])
598
+ emotion_raw = row.get("emotion", "")
599
+ label = map_ravdess_label(emotion_raw)
600
+ if label is None:
601
+ skipped += 1
602
+ continue
603
+
604
+ actor_id = int(row["actor_id"])
605
+ out_name = clean_path.name
606
+ out_path = output_dir / label / out_name
607
+
608
+ if not clean_path.exists():
609
+ skipped += 1
610
+ continue
611
+
612
+ ok = preprocess_audio(clean_path, out_path)
613
+ if ok:
614
+ samples.append({
615
+ "path": str(out_path),
616
+ "label": label,
617
+ "label_idx": LABEL2IDX[label],
618
+ "source": "ravdess",
619
+ "actor_id": actor_id,
620
+ "speaker_id": f"ravdess_{actor_id}",
621
+ "text": "", # RAVDESS uses fixed sentences, not useful for text emotion
622
+ })
623
+ else:
624
+ skipped += 1
625
+
626
+ log.info("RAVDESS: Extracted %d samples, skipped %d", len(samples), skipped)
627
+ return samples
628
+
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # Task 4 โ€” Train/Val Splits
632
+ # ---------------------------------------------------------------------------
633
+
634
+
635
+ def speaker_isolated_split(
636
+ samples: list[dict], val_ratio: float = 0.1
637
+ ) -> tuple[list[dict], list[dict]]:
638
+ """Split by speaker_id โ€” no leakage between train/val."""
639
+ if not samples:
640
+ return [], []
641
+
642
+ # Group by speaker
643
+ by_speaker: dict[str, list[dict]] = defaultdict(list)
644
+ for s in samples:
645
+ by_speaker[s["speaker_id"]].append(s)
646
+
647
+ speakers = list(by_speaker.keys())
648
+ random.shuffle(speakers)
649
+
650
+ total = len(samples)
651
+ target_val = int(total * val_ratio)
652
+
653
+ val_samples: list[dict] = []
654
+ val_speakers: set[str] = set()
655
+ for spk in speakers:
656
+ if len(val_samples) >= target_val:
657
+ break
658
+ val_samples.extend(by_speaker[spk])
659
+ val_speakers.add(spk)
660
+
661
+ train_samples = [s for s in samples if s["speaker_id"] not in val_speakers]
662
+ return train_samples, val_samples
663
+
664
+
665
+ def conversation_isolated_split(
666
+ samples: list[dict], val_ratio: float = 0.1
667
+ ) -> tuple[list[dict], list[dict]]:
668
+ """Split by conversation_id โ€” no leakage between train/val."""
669
+ if not samples:
670
+ return [], []
671
+
672
+ by_conv: dict[str, list[dict]] = defaultdict(list)
673
+ for s in samples:
674
+ by_conv[s["conversation_id"]].append(s)
675
+
676
+ convs = list(by_conv.keys())
677
+ random.shuffle(convs)
678
+
679
+ total = len(samples)
680
+ target_val = int(total * val_ratio)
681
+
682
+ val_samples: list[dict] = []
683
+ val_convs: set[str] = set()
684
+ for conv in convs:
685
+ if len(val_samples) >= target_val:
686
+ break
687
+ val_samples.extend(by_conv[conv])
688
+ val_convs.add(conv)
689
+
690
+ train_samples = [s for s in samples if s["conversation_id"] not in val_convs]
691
+ return train_samples, val_samples
692
+
693
+
694
+ def actor_split_ravdess(
695
+ samples: list[dict], val_actors: list[int]
696
+ ) -> tuple[list[dict], list[dict]]:
697
+ """Split RAVDESS by actor โ€” specified actors go to val."""
698
+ val_set = set(val_actors)
699
+ train = [s for s in samples if s["actor_id"] not in val_set]
700
+ val = [s for s in samples if s["actor_id"] in val_set]
701
+ return train, val
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # Task 4 โ€” Manifest & Stats
706
+ # ---------------------------------------------------------------------------
707
+
708
+
709
+ def save_manifest(samples: list[dict], path: Path) -> None:
710
+ """Save manifest as JSON Lines."""
711
+ path = Path(path)
712
+ path.parent.mkdir(parents=True, exist_ok=True)
713
+ with open(path, "w") as f:
714
+ json.dump(samples, f, indent=2, ensure_ascii=False)
715
+ log.info("Saved manifest: %s (%d samples)", path, len(samples))
716
+
717
+
718
+ def save_stats(train: list[dict], val: list[dict], path: Path) -> None:
719
+ """Save dataset statistics."""
720
+ path = Path(path)
721
+ path.parent.mkdir(parents=True, exist_ok=True)
722
+
723
+ def _count_stats(samples: list[dict]) -> dict:
724
+ by_label: Counter = Counter()
725
+ by_source: Counter = Counter()
726
+ for s in samples:
727
+ by_label[s["label"]] += 1
728
+ by_source[s["source"]] += 1
729
+ return {
730
+ "total": len(samples),
731
+ "by_label": dict(sorted(by_label.items())),
732
+ "by_source": dict(sorted(by_source.items())),
733
+ }
734
+
735
+ stats = {
736
+ "train": _count_stats(train),
737
+ "val": _count_stats(val),
738
+ "label2idx": LABEL2IDX,
739
+ }
740
+
741
+ with open(path, "w") as f:
742
+ json.dump(stats, f, indent=2, ensure_ascii=False)
743
+ log.info("Saved stats: %s", path)
744
+
745
+
746
+ # ---------------------------------------------------------------------------
747
+ # Main
748
+ # ---------------------------------------------------------------------------
749
+
750
+
751
+ def main():
752
+ parser = argparse.ArgumentParser(
753
+ description="Prepare unified LoRA 7-class dataset"
754
+ )
755
+ parser.add_argument(
756
+ "--anchor-dir",
757
+ type=Path,
758
+ default=Path("data/AI Hub 263"),
759
+ help="Path to AI Hub 263 directory with CSVs + ZIPs",
760
+ )
761
+ parser.add_argument(
762
+ "--booster-label-zip",
763
+ type=Path,
764
+ default=Path(
765
+ "data/AI Hub 71631/01-1.์ •์‹๊ฐœ๋ฐฉ๋ฐ์ดํ„ฐ/Training/"
766
+ "02.๋ผ๋ฒจ๋ง๋ฐ์ดํ„ฐ/TL_02.์‹ค์™ธ.zip"
767
+ ),
768
+ help="Path to 71631 label ZIP",
769
+ )
770
+ parser.add_argument(
771
+ "--booster-audio-zip",
772
+ type=Path,
773
+ default=Path(
774
+ "data/AI Hub 71631/01-1.์ •์‹๊ฐœ๋ฐฉ๋ฐ์ดํ„ฐ/Training/"
775
+ "01.์›์ฒœ๋ฐ์ดํ„ฐ/TS_02.์‹ค์™ธ.zip"
776
+ ),
777
+ help="Path to 71631 audio ZIP",
778
+ )
779
+ parser.add_argument(
780
+ "--ravdess-dir",
781
+ type=Path,
782
+ default=Path("data/ravdess"),
783
+ help="Path to RAVDESS directory with manifest.csv",
784
+ )
785
+ parser.add_argument(
786
+ "--output-dir",
787
+ type=Path,
788
+ default=Path("data/lora_7class"),
789
+ help="Output directory for processed dataset",
790
+ )
791
+ parser.add_argument("--cap-263", type=int, default=2100, help="Cap per class for 263")
792
+ parser.add_argument("--cap-71631", type=int, default=3500, help="Cap per class for 71631")
793
+ parser.add_argument("--max-per-speaker-71631", type=int, default=20, help="Max utterances per speaker for 71631")
794
+ parser.add_argument("--val-ratio", type=float, default=0.1, help="Val ratio for splits")
795
+ parser.add_argument("--seed", type=int, default=42, help="Random seed")
796
+ parser.add_argument(
797
+ "--skip-263", action="store_true", help="Skip AI Hub 263 extraction"
798
+ )
799
+ parser.add_argument(
800
+ "--skip-71631", action="store_true", help="Skip AI Hub 71631 extraction"
801
+ )
802
+ parser.add_argument(
803
+ "--skip-ravdess", action="store_true", help="Skip RAVDESS extraction"
804
+ )
805
+ args = parser.parse_args()
806
+
807
+ random.seed(args.seed)
808
+
809
+ output_dir = args.output_dir
810
+ output_dir.mkdir(parents=True, exist_ok=True)
811
+
812
+ all_train: list[dict] = []
813
+ all_val: list[dict] = []
814
+
815
+ # ---- 263 Anchor ----
816
+ if not args.skip_263:
817
+ log.info("=" * 60)
818
+ log.info("Extracting AI Hub 263 (anchor)")
819
+ samples_263 = extract_anchor_263(
820
+ args.anchor_dir,
821
+ output_dir / "263",
822
+ cap_per_class=args.cap_263,
823
+ )
824
+ train_263, val_263 = speaker_isolated_split(samples_263, args.val_ratio)
825
+ log.info("263: train=%d, val=%d", len(train_263), len(val_263))
826
+ all_train.extend(train_263)
827
+ all_val.extend(val_263)
828
+
829
+ # ---- 71631 Booster ----
830
+ if not args.skip_71631:
831
+ log.info("=" * 60)
832
+ log.info("Extracting AI Hub 71631 (booster)")
833
+ samples_71631 = extract_booster_71631(
834
+ args.booster_label_zip,
835
+ args.booster_audio_zip,
836
+ output_dir / "71631",
837
+ cap=args.cap_71631,
838
+ max_per_speaker=args.max_per_speaker_71631,
839
+ )
840
+ train_71631, val_71631 = conversation_isolated_split(
841
+ samples_71631, args.val_ratio
842
+ )
843
+ log.info("71631: train=%d, val=%d", len(train_71631), len(val_71631))
844
+ all_train.extend(train_71631)
845
+ all_val.extend(val_71631)
846
+
847
+ # ---- RAVDESS ----
848
+ if not args.skip_ravdess:
849
+ log.info("=" * 60)
850
+ log.info("Extracting RAVDESS")
851
+ samples_ravdess = extract_ravdess(
852
+ args.ravdess_dir,
853
+ output_dir / "ravdess",
854
+ )
855
+ val_actors = [21, 22, 23, 24]
856
+ train_ravdess, val_ravdess = actor_split_ravdess(
857
+ samples_ravdess, val_actors
858
+ )
859
+ log.info("RAVDESS: train=%d, val=%d", len(train_ravdess), len(val_ravdess))
860
+ all_train.extend(train_ravdess)
861
+ all_val.extend(val_ravdess)
862
+
863
+ # ---- Save ----
864
+ log.info("=" * 60)
865
+ log.info("Total: train=%d, val=%d", len(all_train), len(all_val))
866
+
867
+ save_manifest(all_train, output_dir / "train_manifest.json")
868
+ save_manifest(all_val, output_dir / "val_manifest.json")
869
+ save_stats(all_train, all_val, output_dir / "stats.json")
870
+
871
+ log.info("Done! Output: %s", output_dir)
872
+
873
+
874
+ if __name__ == "__main__":
875
+ main()
scripts/prepare_meld_fusion_data.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Prepare MELD test split for English fusion grid search.
3
+
4
+ Extracts mp4 โ†’ wav (16kHz mono) and builds a manifest with text + emotion labels.
5
+
6
+ Usage:
7
+ python scripts/prepare_meld_fusion_data.py
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import io
13
+ import json
14
+ import logging
15
+ import subprocess
16
+ import tempfile
17
+ import zipfile
18
+ from collections import Counter
19
+ from pathlib import Path
20
+
21
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
22
+ logger = logging.getLogger(__name__)
23
+
24
+ PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
25
+
26
+ # MELD emotions map 1:1 to project labels
27
+ MELD_LABEL_MAP = {
28
+ "neutral": "neutral",
29
+ "joy": "joy",
30
+ "sadness": "sadness",
31
+ "anger": "anger",
32
+ "surprise": "surprise",
33
+ "fear": "fear",
34
+ "disgust": "disgust",
35
+ }
36
+
37
+
38
+ def main():
39
+ zip_path = Path("data/english_test.zip")
40
+ output_dir = Path("data/meld_fusion")
41
+ audio_dir = output_dir / "audio"
42
+ audio_dir.mkdir(parents=True, exist_ok=True)
43
+
44
+ zf = zipfile.ZipFile(zip_path)
45
+
46
+ # Step 1: Parse test CSV
47
+ logger.info("Parsing MELD test CSV...")
48
+ with zf.open("MELD.Raw/MELD.Raw/test_sent_emo.csv") as f:
49
+ reader = csv.DictReader(io.TextIOWrapper(f, encoding="utf-8"))
50
+ rows = list(reader)
51
+ logger.info("MELD test: %d utterances", len(rows))
52
+
53
+ # Build lookup: (dia_id, utt_id) โ†’ row
54
+ csv_lookup = {}
55
+ for r in rows:
56
+ key = (int(r["Dialogue_ID"]), int(r["Utterance_ID"]))
57
+ csv_lookup[key] = r
58
+
59
+ # Step 2: Find mp4 files in zip
60
+ test_mp4s = {}
61
+ for name in zf.namelist():
62
+ if "output_repeated_splits_test" in name and name.endswith(".mp4"):
63
+ fname = Path(name).name
64
+ if fname.startswith("._"):
65
+ continue # skip macOS metadata
66
+ # Parse dia{D}_utt{U}.mp4
67
+ try:
68
+ parts = fname.replace(".mp4", "").split("_")
69
+ dia_id = int(parts[0].replace("dia", ""))
70
+ utt_id = int(parts[1].replace("utt", ""))
71
+ test_mp4s[(dia_id, utt_id)] = name
72
+ except (ValueError, IndexError):
73
+ continue
74
+
75
+ logger.info("Found %d test mp4 files (excluding macOS metadata)", len(test_mp4s))
76
+
77
+ # Step 3: Match CSV โ†” mp4, extract wav
78
+ manifest = []
79
+ skipped = 0
80
+
81
+ matched_keys = set(csv_lookup.keys()) & set(test_mp4s.keys())
82
+ logger.info("Matched CSVโ†”mp4: %d", len(matched_keys))
83
+
84
+ for i, key in enumerate(sorted(matched_keys)):
85
+ row = csv_lookup[key]
86
+ mp4_name = test_mp4s[key]
87
+ dia_id, utt_id = key
88
+
89
+ label = MELD_LABEL_MAP.get(row["Emotion"])
90
+ if label is None:
91
+ skipped += 1
92
+ continue
93
+
94
+ text = row["Utterance"].strip()
95
+ if not text:
96
+ skipped += 1
97
+ continue
98
+
99
+ wav_path = audio_dir / f"dia{dia_id}_utt{utt_id}.wav"
100
+
101
+ if not wav_path.exists():
102
+ # Extract mp4 from zip โ†’ convert to 16kHz mono wav
103
+ try:
104
+ mp4_bytes = zf.read(mp4_name)
105
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
106
+ tmp.write(mp4_bytes)
107
+ tmp_path = tmp.name
108
+
109
+ result = subprocess.run(
110
+ ["ffmpeg", "-y", "-i", tmp_path,
111
+ "-ar", "16000", "-ac", "1", "-f", "wav",
112
+ str(wav_path)],
113
+ capture_output=True, timeout=30,
114
+ )
115
+ Path(tmp_path).unlink(missing_ok=True)
116
+
117
+ if result.returncode != 0:
118
+ skipped += 1
119
+ continue
120
+ except Exception as e:
121
+ logger.warning("Failed dia%d_utt%d: %s", dia_id, utt_id, e)
122
+ skipped += 1
123
+ continue
124
+
125
+ manifest.append({
126
+ "path": str(wav_path),
127
+ "text": text,
128
+ "label": label,
129
+ "source": "meld_test",
130
+ "dialogue_id": dia_id,
131
+ "utterance_id": utt_id,
132
+ })
133
+
134
+ if (i + 1) % 200 == 0:
135
+ logger.info("Processed %d / %d", i + 1, len(matched_keys))
136
+
137
+ zf.close()
138
+
139
+ # Step 4: Save manifest
140
+ manifest_path = output_dir / "manifest.json"
141
+ with open(manifest_path, "w", encoding="utf-8") as f:
142
+ json.dump(manifest, f, indent=2, ensure_ascii=False)
143
+
144
+ logger.info("Saved %d samples to %s (skipped %d)", len(manifest), manifest_path, skipped)
145
+
146
+ # Stats
147
+ emotions = Counter(s["label"] for s in manifest)
148
+ for e, c in sorted(emotions.items(), key=lambda x: -x[1]):
149
+ print(f" {e}: {c}")
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
scripts/prepare_ravdess.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """RAVDESS ์˜์–ด ๊ฐ์ • ์Œ์„ฑ ๋ฐ์ดํ„ฐ์…‹ ์ค€๋น„ ์Šคํฌ๋ฆฝํŠธ.
3
+
4
+ data/archive.zip์„ ์••์ถ• ํ•ด์ œํ•˜๊ณ , ์ „ํ™” ํ’ˆ์งˆ ์ „์ฒ˜๋ฆฌ๋ฅผ ์ ์šฉํ•˜์—ฌ
5
+ emotion2vec ์˜์–ด ํ‰๊ฐ€์šฉ manifest.csv๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
6
+
7
+ Usage:
8
+ python scripts/prepare_ravdess.py
9
+ python scripts/prepare_ravdess.py --skip-phone # ์ „ํ™” ์ „์ฒ˜๋ฆฌ ์ƒ๋žต
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import csv
16
+ import logging
17
+ import sys
18
+ import zipfile
19
+ from pathlib import Path
20
+
21
+ import librosa
22
+ import numpy as np
23
+ import soundfile as sf
24
+
25
+ PROJECT_ROOT = Path(__file__).parent.parent
26
+ sys.path.insert(0, str(PROJECT_ROOT))
27
+
28
+ from src.common.phone_simulator import CompandingType, PhoneSimulator
29
+
30
+ logging.basicConfig(
31
+ level=logging.INFO,
32
+ format="%(asctime)s - %(levelname)s - %(message)s",
33
+ )
34
+ logger = logging.getLogger("prepare_ravdess")
35
+
36
+ # RAVDESS emotion code โ†’ project 7-class taxonomy
37
+ RAVDESS_EMOTION_MAP = {
38
+ 1: "neutral", # neutral
39
+ 2: "neutral", # calm โ†’ neutral (ํ”„๋กœ์ ํŠธ taxonomy์— calm ์—†์Œ)
40
+ 3: "joy", # happy โ†’ joy
41
+ 4: "sadness", # sad โ†’ sadness
42
+ 5: "anger", # angry โ†’ anger
43
+ 6: "fear", # fearful โ†’ fear
44
+ 7: "disgust", # disgust
45
+ 8: "surprise", # surprised โ†’ surprise
46
+ }
47
+
48
+ RAVDESS_EMOTION_NAME = {
49
+ 1: "neutral", 2: "calm", 3: "happy", 4: "sad",
50
+ 5: "angry", 6: "fearful", 7: "disgust", 8: "surprised",
51
+ }
52
+
53
+ ARCHIVE_PATH = PROJECT_ROOT / "data" / "archive.zip"
54
+ OUTPUT_DIR = PROJECT_ROOT / "data" / "ravdess"
55
+
56
+
57
+ def parse_ravdess_filename(filename: str) -> dict | None:
58
+ """RAVDESS ํŒŒ์ผ๋ช…์—์„œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ถ”์ถœ.
59
+
60
+ Format: Modality-VocalChannel-Emotion-Intensity-Statement-Repetition-Actor.wav
61
+ Example: 03-01-05-02-01-01-12.wav
62
+ """
63
+ stem = Path(filename).stem
64
+ parts = stem.split("-")
65
+ if len(parts) != 7:
66
+ return None
67
+
68
+ emotion_code = int(parts[2])
69
+ return {
70
+ "modality": int(parts[0]),
71
+ "vocal_channel": int(parts[1]),
72
+ "emotion_code": emotion_code,
73
+ "emotion_raw": RAVDESS_EMOTION_NAME.get(emotion_code, "unknown"),
74
+ "emotion": RAVDESS_EMOTION_MAP.get(emotion_code, "neutral"),
75
+ "intensity": int(parts[3]), # 1=normal, 2=strong
76
+ "statement": int(parts[4]), # 1="Kids...", 2="Dogs..."
77
+ "repetition": int(parts[5]),
78
+ "actor_id": int(parts[6]),
79
+ }
80
+
81
+
82
+ def extract_archive(archive_path: Path, output_dir: Path) -> list[Path]:
83
+ """archive.zip ์••์ถ• ํ•ด์ œ โ†’ clean/ ๋””๋ ‰ํ† ๋ฆฌ."""
84
+ clean_dir = output_dir / "clean"
85
+
86
+ if clean_dir.exists() and any(clean_dir.rglob("*.wav")):
87
+ wavs = sorted(clean_dir.rglob("*.wav"))
88
+ logger.info(f"์ด๋ฏธ ์••์ถ• ํ•ด์ œ๋จ: {len(wavs)}๊ฐœ WAV in {clean_dir}")
89
+ return wavs
90
+
91
+ clean_dir.mkdir(parents=True, exist_ok=True)
92
+ logger.info(f"์••์ถ• ํ•ด์ œ ์ค‘: {archive_path} โ†’ {clean_dir}")
93
+
94
+ with zipfile.ZipFile(archive_path, "r") as zf:
95
+ wav_members = [m for m in zf.namelist() if m.endswith(".wav")]
96
+ for i, member in enumerate(wav_members, 1):
97
+ # Actor_NN/filename.wav โ†’ clean/Actor_NN/filename.wav
98
+ target = clean_dir / member
99
+ target.parent.mkdir(parents=True, exist_ok=True)
100
+ with zf.open(member) as src, open(target, "wb") as dst:
101
+ dst.write(src.read())
102
+ if i % 500 == 0:
103
+ logger.info(f" [{i}/{len(wav_members)}] ์••์ถ• ํ•ด์ œ ์ค‘...")
104
+
105
+ wavs = sorted(clean_dir.rglob("*.wav"))
106
+ logger.info(f"์••์ถ• ํ•ด์ œ ์™„๋ฃŒ: {len(wavs)}๊ฐœ WAV")
107
+ return wavs
108
+
109
+
110
+ def apply_phone_simulation(clean_wavs: list[Path], output_dir: Path) -> dict[str, Path]:
111
+ """clean WAV โ†’ phone ํ’ˆ์งˆ ๋ณ€ํ™˜. {clean_path_str: phone_path} ๋ฐ˜ํ™˜."""
112
+ phone_dir = output_dir / "phone"
113
+ simulator = PhoneSimulator(companding=CompandingType.ULAW) # ์˜์–ด = ๋ถ๋ฏธ ฮผ-law
114
+
115
+ mapping = {}
116
+ total = len(clean_wavs)
117
+
118
+ for i, wav_path in enumerate(clean_wavs, 1):
119
+ # clean/Actor_NN/file.wav โ†’ phone/Actor_NN/file.wav
120
+ relative = wav_path.relative_to(output_dir / "clean")
121
+ phone_path = phone_dir / relative
122
+ phone_path.parent.mkdir(parents=True, exist_ok=True)
123
+
124
+ if phone_path.exists():
125
+ mapping[str(wav_path)] = phone_path
126
+ continue
127
+
128
+ try:
129
+ audio, sr = librosa.load(str(wav_path), sr=None, mono=True)
130
+ processed, new_sr = simulator.process(audio, sr)
131
+ sf.write(str(phone_path), processed, new_sr, subtype="PCM_16")
132
+ mapping[str(wav_path)] = phone_path
133
+ except Exception as e:
134
+ logger.warning(f"์ „ํ™” ๋ณ€ํ™˜ ์‹คํŒจ [{wav_path.name}]: {e}")
135
+
136
+ if i % 500 == 0:
137
+ logger.info(f" [{i}/{total}] ์ „ํ™” ํ’ˆ์งˆ ๋ณ€ํ™˜ ์ค‘...")
138
+
139
+ logger.info(f"์ „ํ™” ํ’ˆ์งˆ ๋ณ€ํ™˜ ์™„๋ฃŒ: {len(mapping)}/{total}")
140
+ return mapping
141
+
142
+
143
+ def build_manifest(
144
+ clean_wavs: list[Path],
145
+ phone_mapping: dict[str, Path] | None,
146
+ output_dir: Path,
147
+ ) -> Path:
148
+ """manifest.csv ์ƒ์„ฑ."""
149
+ manifest_path = output_dir / "manifest.csv"
150
+ rows = []
151
+
152
+ for wav_path in clean_wavs:
153
+ meta = parse_ravdess_filename(wav_path.name)
154
+ if meta is None:
155
+ logger.warning(f"ํŒŒ์ผ๋ช… ํŒŒ์‹ฑ ์‹คํŒจ: {wav_path.name}")
156
+ continue
157
+
158
+ phone_path = ""
159
+ if phone_mapping and str(wav_path) in phone_mapping:
160
+ phone_path = str(phone_mapping[str(wav_path)])
161
+
162
+ rows.append({
163
+ "clean_path": str(wav_path),
164
+ "phone_path": phone_path,
165
+ "emotion": meta["emotion"],
166
+ "emotion_raw": meta["emotion_raw"],
167
+ "actor_id": meta["actor_id"],
168
+ "intensity": meta["intensity"],
169
+ "statement": meta["statement"],
170
+ "repetition": meta["repetition"],
171
+ })
172
+
173
+ # ๊ฐ์ •๋ณ„ ํ†ต๊ณ„ ์ถœ๋ ฅ
174
+ from collections import Counter
175
+ emotion_counts = Counter(r["emotion"] for r in rows)
176
+ logger.info("๊ฐ์ • ๋ถ„ํฌ:")
177
+ for emotion, count in sorted(emotion_counts.items()):
178
+ logger.info(f" {emotion}: {count}")
179
+
180
+ with open(manifest_path, "w", newline="") as f:
181
+ writer = csv.DictWriter(f, fieldnames=[
182
+ "clean_path", "phone_path", "emotion", "emotion_raw",
183
+ "actor_id", "intensity", "statement", "repetition",
184
+ ])
185
+ writer.writeheader()
186
+ writer.writerows(rows)
187
+
188
+ logger.info(f"manifest ์ €์žฅ: {manifest_path} ({len(rows)}ํ–‰)")
189
+ return manifest_path
190
+
191
+
192
+ def main():
193
+ parser = argparse.ArgumentParser(description="RAVDESS ์˜์–ด ๊ฐ์ • ๋ฐ์ดํ„ฐ ์ค€๋น„")
194
+ parser.add_argument("--skip-phone", action="store_true", help="์ „ํ™” ํ’ˆ์งˆ ์ „์ฒ˜๋ฆฌ ์ƒ๋žต")
195
+ args = parser.parse_args()
196
+
197
+ if not ARCHIVE_PATH.exists():
198
+ logger.error(f"archive.zip์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {ARCHIVE_PATH}")
199
+ sys.exit(1)
200
+
201
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
202
+
203
+ # 1. ์••์ถ• ํ•ด์ œ
204
+ clean_wavs = extract_archive(ARCHIVE_PATH, OUTPUT_DIR)
205
+
206
+ # 2. ์ „ํ™” ํ’ˆ์งˆ ์ „์ฒ˜๋ฆฌ
207
+ phone_mapping = None
208
+ if not args.skip_phone:
209
+ phone_mapping = apply_phone_simulation(clean_wavs, OUTPUT_DIR)
210
+ else:
211
+ logger.info("์ „ํ™” ํ’ˆ์งˆ ์ „์ฒ˜๋ฆฌ ์ƒ๋žต (--skip-phone)")
212
+
213
+ # 3. manifest.csv ์ƒ์„ฑ
214
+ build_manifest(clean_wavs, phone_mapping, OUTPUT_DIR)
215
+
216
+ logger.info("์™„๋ฃŒ!")
217
+
218
+
219
+ if __name__ == "__main__":
220
+ main()
scripts/preprocess_phone_audio.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """AI Hub ๋“ฑ ์ŠคํŠœ๋””์˜ค ๋…น์Œ ๋ฐ์ดํ„ฐ๋ฅผ ์ „ํ™” ํ†ตํ™” ํ’ˆ์งˆ๋กœ ์ „์ฒ˜๋ฆฌํ•˜๋Š” ์Šคํฌ๋ฆฝํŠธ.
3
+
4
+ ๊นจ๋—ํ•œ ์˜ค๋””์˜ค์— PSTN ์‹œ๋ฎฌ๋ ˆ์ด์…˜(๋ฐด๋“œํŒจ์Šค + ๋‹ค์šด์ƒ˜ํ”Œ๋ง + G.711 companding)์„ ์ ์šฉํ•˜์—ฌ
5
+ ์‹ค์ œ ํ†ตํ™” ๋…น์Œ๊ณผ ์œ ์‚ฌํ•œ ํ•™์Šต ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
6
+
7
+ Usage:
8
+ # ๋‹จ์ผ ํŒŒ์ผ
9
+ python scripts/preprocess_phone_audio.py data/aihub_raw/sample.wav
10
+
11
+ # ๋””๋ ‰ํ† ๋ฆฌ ์ผ๊ด„ ์ฒ˜๋ฆฌ
12
+ python scripts/preprocess_phone_audio.py data/aihub_raw/ -o data/aihub_phone/
13
+
14
+ # companding ๋ฐฉ์‹ ์ง€์ • (๊ธฐ๋ณธ: random)
15
+ python scripts/preprocess_phone_audio.py data/aihub_raw/ --companding alaw
16
+
17
+ # ์›๋ณธ๋„ ํ•จ๊ป˜ ๋ณต์‚ฌ (์›๋ณธ+์ „ํ™” ํ˜ผํ•ฉ ํ•™์Šต์šฉ)
18
+ python scripts/preprocess_phone_audio.py data/aihub_raw/ -o data/training/ --keep-original
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import logging
25
+ import shutil
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ import librosa
30
+ import soundfile as sf
31
+
32
+ # ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ sys.path์— ์ถ”๊ฐ€
33
+ PROJECT_ROOT = Path(__file__).parent.parent
34
+ sys.path.insert(0, str(PROJECT_ROOT))
35
+
36
+ from src.common.phone_simulator import CompandingType, PhoneSimulator
37
+
38
+ logging.basicConfig(
39
+ level=logging.INFO,
40
+ format="%(asctime)s - %(levelname)s - %(message)s",
41
+ )
42
+ logger = logging.getLogger("preprocess_phone_audio")
43
+
44
+ SUPPORTED_EXTENSIONS = {".wav", ".mp3", ".m4a", ".ogg", ".flac"}
45
+
46
+
47
+ def find_audio_files(input_path: Path) -> list[Path]:
48
+ """์ž…๋ ฅ ๊ฒฝ๋กœ์—์„œ ์˜ค๋””์˜ค ํŒŒ์ผ ๋ชฉ๋ก ๋ฐ˜ํ™˜."""
49
+ if input_path.is_file():
50
+ if input_path.suffix.lower() in SUPPORTED_EXTENSIONS:
51
+ return [input_path]
52
+ logger.warning(f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹: {input_path.suffix}")
53
+ return []
54
+
55
+ files = []
56
+ for ext in SUPPORTED_EXTENSIONS:
57
+ files.extend(input_path.rglob(f"*{ext}"))
58
+ return sorted(files)
59
+
60
+
61
+ def process_file(
62
+ input_file: Path,
63
+ output_dir: Path,
64
+ simulator: PhoneSimulator,
65
+ input_root: Path,
66
+ keep_original: bool = False,
67
+ ) -> bool:
68
+ """๋‹จ์ผ ํŒŒ์ผ์„ ์ „ํ™” ํ’ˆ์งˆ๋กœ ๋ณ€ํ™˜."""
69
+ try:
70
+ # ์›๋ณธ ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ ์œ ์ง€
71
+ relative = input_file.relative_to(input_root)
72
+ output_file = output_dir / relative.with_suffix(".wav")
73
+ output_file.parent.mkdir(parents=True, exist_ok=True)
74
+
75
+ # ์˜ค๋””์˜ค ๋กœ๋“œ (mono, ์›๋ณธ SR ์œ ์ง€)
76
+ audio, sr = librosa.load(str(input_file), sr=None, mono=True)
77
+
78
+ # ์ „ํ™” ํ’ˆ์งˆ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์ ์šฉ
79
+ processed, new_sr = simulator.process(audio, sr)
80
+
81
+ # phone_ ์ ‘๋‘์‚ฌ๋กœ ์ €์žฅ
82
+ phone_output = output_file.with_name(f"phone_{output_file.name}")
83
+ sf.write(str(phone_output), processed, new_sr, subtype="PCM_16")
84
+
85
+ # ์›๋ณธ๋„ ๋ณต์‚ฌ (ํ˜ผํ•ฉ ํ•™์Šต์šฉ)
86
+ if keep_original:
87
+ orig_output = output_file.with_name(f"orig_{output_file.name}")
88
+ shutil.copy2(str(input_file), str(orig_output))
89
+
90
+ return True
91
+
92
+ except Exception as e:
93
+ logger.error(f"์ฒ˜๋ฆฌ ์‹คํŒจ [{input_file.name}]: {e}")
94
+ return False
95
+
96
+
97
+ def main():
98
+ parser = argparse.ArgumentParser(
99
+ description="์ŠคํŠœ๋””์˜ค ๋…น์Œ โ†’ ์ „ํ™” ํ†ตํ™” ํ’ˆ์งˆ ์ „์ฒ˜๋ฆฌ",
100
+ )
101
+ parser.add_argument(
102
+ "input",
103
+ type=Path,
104
+ help="์ž…๋ ฅ ์˜ค๋””์˜ค ํŒŒ์ผ ๋˜๋Š” ๋””๋ ‰ํ† ๋ฆฌ",
105
+ )
106
+ parser.add_argument(
107
+ "-o", "--output",
108
+ type=Path,
109
+ default=None,
110
+ help="์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ (๊ธฐ๋ณธ: {input}_phone/)",
111
+ )
112
+ parser.add_argument(
113
+ "--companding",
114
+ type=str,
115
+ choices=["alaw", "ulaw", "random"],
116
+ default="random",
117
+ help="G.711 companding ๋ฐฉ์‹ (๊ธฐ๋ณธ: random โ€” ํŒŒ์ผ๋งˆ๋‹ค ๋žœ๋ค ์„ ํƒ)",
118
+ )
119
+ parser.add_argument(
120
+ "--keep-original",
121
+ action="store_true",
122
+ help="์›๋ณธ ํŒŒ์ผ๋„ ์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ์— ๋ณต์‚ฌ (์›๋ณธ+์ „ํ™” ํ˜ผํ•ฉ ํ•™์Šต์šฉ)",
123
+ )
124
+ args = parser.parse_args()
125
+
126
+ # ์ž…๋ ฅ ๊ฒฝ๋กœ ํ™•์ธ
127
+ input_path = args.input.resolve()
128
+ if not input_path.exists():
129
+ logger.error(f"์ž…๋ ฅ ๊ฒฝ๋กœ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค: {input_path}")
130
+ sys.exit(1)
131
+
132
+ # ์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฐ์ •
133
+ if args.output:
134
+ output_dir = args.output.resolve()
135
+ else:
136
+ if input_path.is_file():
137
+ output_dir = input_path.parent / f"{input_path.stem}_phone"
138
+ else:
139
+ output_dir = input_path.parent / f"{input_path.name}_phone"
140
+ output_dir.mkdir(parents=True, exist_ok=True)
141
+
142
+ # ์ž…๋ ฅ ๋ฃจํŠธ (์ƒ๋Œ€ ๊ฒฝ๋กœ ๊ณ„์‚ฐ์šฉ)
143
+ input_root = input_path if input_path.is_dir() else input_path.parent
144
+
145
+ # ์˜ค๋””์˜ค ํŒŒ์ผ ํƒ์ƒ‰
146
+ audio_files = find_audio_files(input_path)
147
+ if not audio_files:
148
+ logger.error("์ฒ˜๋ฆฌํ•  ์˜ค๋””์˜ค ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.")
149
+ sys.exit(1)
150
+
151
+ logger.info(f"์˜ค๋””์˜ค ํŒŒ์ผ {len(audio_files)}๊ฐœ ๋ฐœ๊ฒฌ")
152
+ logger.info(f"์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ: {output_dir}")
153
+ logger.info(f"Companding: {args.companding}")
154
+ if args.keep_original:
155
+ logger.info("์›๋ณธ ํŒŒ์ผ๋„ ํ•จ๊ป˜ ๋ณต์‚ฌํ•ฉ๋‹ˆ๋‹ค")
156
+
157
+ # ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ์ƒ์„ฑ
158
+ companding = CompandingType(args.companding)
159
+ simulator = PhoneSimulator(companding=companding)
160
+
161
+ # ์ผ๊ด„ ์ฒ˜๋ฆฌ
162
+ success = 0
163
+ fail = 0
164
+ for i, audio_file in enumerate(audio_files, 1):
165
+ logger.info(f"[{i}/{len(audio_files)}] {audio_file.name}")
166
+ if process_file(audio_file, output_dir, simulator, input_root, args.keep_original):
167
+ success += 1
168
+ else:
169
+ fail += 1
170
+
171
+ logger.info(f"์™„๋ฃŒ: ์„ฑ๊ณต {success}, ์‹คํŒจ {fail}, ์ „์ฒด {len(audio_files)}")
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()
scripts/quantize_model.py ADDED
File without changes
scripts/run_pipeline.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Stage 1 โ†’ Stage 2 E2E ํŒŒ์ดํ”„๋ผ์ธ ์‹คํ–‰ ์Šคํฌ๋ฆฝํŠธ.
3
+
4
+ Usage:
5
+ python scripts/run_pipeline.py # ๊ธฐ๋ณธ ์ƒ˜ํ”Œ ์‚ฌ์šฉ
6
+ python scripts/run_pipeline.py data/samples/my_call.wav # ํŠน์ • ํŒŒ์ผ ์ง€์ •
7
+ python scripts/run_pipeline.py --stage2-only # Stage 2๋งŒ ์‹คํ–‰ (stage1_output.json ํ•„์š”)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import logging
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ # ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋ฅผ sys.path์— ์ถ”๊ฐ€
19
+ PROJECT_ROOT = Path(__file__).parent.parent
20
+ sys.path.insert(0, str(PROJECT_ROOT))
21
+
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
25
+ )
26
+ logger = logging.getLogger("run_pipeline")
27
+
28
+
29
+ def run_stage1(audio_path: str) -> dict:
30
+ """Stage 1 ์‹คํ–‰: ํ™”์ž๋ถ„๋ฆฌ + ASR."""
31
+ from src.stage1.process import process as stage1_process
32
+
33
+ logger.info("=" * 60)
34
+ logger.info("Stage 1 ์‹œ์ž‘: %s", audio_path)
35
+ logger.info("=" * 60)
36
+
37
+ result = stage1_process(audio_path)
38
+
39
+ logger.info(
40
+ "Stage 1 ์™„๋ฃŒ: %d segments, %.1fs ์ฒ˜๋ฆฌ์‹œ๊ฐ„",
41
+ len(result.segments),
42
+ result.processing_info.processing_time_sec,
43
+ )
44
+ return result
45
+
46
+
47
+ def run_stage2(stage1_output) -> dict:
48
+ """Stage 2 ์‹คํ–‰: ๊ฐ์ • ๋ถ„์„."""
49
+ from src.stage2.process import process as stage2_process
50
+
51
+ logger.info("=" * 60)
52
+ logger.info("Stage 2 ์‹œ์ž‘: %s (%d segments)", stage1_output.call_id, len(stage1_output.segments))
53
+ logger.info("=" * 60)
54
+
55
+ result = stage2_process(stage1_output)
56
+
57
+ logger.info(
58
+ "Stage 2 ์™„๋ฃŒ: %d emotions, speakers=%s",
59
+ len(result.emotions),
60
+ list(result.speaker_summaries.keys()),
61
+ )
62
+
63
+ # ๊ฒฐ๊ณผ ์š”์•ฝ ์ถœ๋ ฅ
64
+ for speaker_id, summary in result.speaker_summaries.items():
65
+ logger.info(
66
+ " %s: dominant=%s (%.1f%%), avg_confidence=%.2f",
67
+ speaker_id,
68
+ summary.dominant_emotion,
69
+ summary.emotion_distribution.get(summary.dominant_emotion, 0) * 100,
70
+ summary.avg_confidence,
71
+ )
72
+
73
+ return result
74
+
75
+
76
+ def main():
77
+ parser = argparse.ArgumentParser(description="Stage 1 โ†’ Stage 2 ํŒŒ์ดํ”„๋ผ์ธ ์‹คํ–‰")
78
+ parser.add_argument(
79
+ "audio_path",
80
+ nargs="?",
81
+ default="data/samples/sample_data.wav",
82
+ help="์ž…๋ ฅ ์˜ค๋””์˜ค ํŒŒ์ผ ๊ฒฝ๋กœ (๊ธฐ๋ณธ: data/samples/sample_data.wav)",
83
+ )
84
+ parser.add_argument(
85
+ "--stage2-only",
86
+ action="store_true",
87
+ help="Stage 2๋งŒ ์‹คํ–‰ (data/stage1_output.json ํ•„์š”)",
88
+ )
89
+ args = parser.parse_args()
90
+
91
+ if args.stage2_only:
92
+ # Stage 2๋งŒ ์‹คํ–‰
93
+ from src.common.schemas import Stage1Output
94
+
95
+ stage1_path = PROJECT_ROOT / "data" / "stage1_output.json"
96
+ if not stage1_path.exists():
97
+ logger.error("data/stage1_output.json ์—†์Œ. Stage 1์„ ๋จผ์ € ์‹คํ–‰ํ•˜์„ธ์š”.")
98
+ sys.exit(1)
99
+
100
+ stage1_output = Stage1Output.model_validate_json(stage1_path.read_text())
101
+ run_stage2(stage1_output)
102
+ else:
103
+ # Stage 1 โ†’ Stage 2 ์ „์ฒด ์‹คํ–‰
104
+ audio_path = str(PROJECT_ROOT / args.audio_path) if not Path(args.audio_path).is_absolute() else args.audio_path
105
+
106
+ if not Path(audio_path).exists():
107
+ logger.error("์˜ค๋””์˜ค ํŒŒ์ผ ์—†์Œ: %s", audio_path)
108
+ sys.exit(1)
109
+
110
+ stage1_output = run_stage1(audio_path)
111
+ run_stage2(stage1_output)
112
+
113
+ logger.info("=" * 60)
114
+ logger.info("ํŒŒ์ดํ”„๋ผ์ธ ์™„๋ฃŒ!")
115
+ logger.info(" Stage 1 ์ถœ๋ ฅ: data/stage1_output.json")
116
+ logger.info(" Stage 2 ์ถœ๋ ฅ: data/stage2_output.json")
117
+ logger.info("=" * 60)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
scripts/test_20hours_e2e_server.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """20Hours Korean demo set โ€” E2E server test.
3
+
4
+ Uploads 7 curated Korean demo WAVs to the deployed HF Spaces server,
5
+ runs the full pipeline (Stage 1 โ†’ 2 โ†’ 3), and compares results
6
+ against the intended demo emotion labels (data/20hours_test/ground_truth.json).
7
+
8
+ Usage:
9
+ python scripts/test_20hours_e2e_server.py
10
+ python scripts/test_20hours_e2e_server.py --server http://localhost:8000
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import requests
22
+
23
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
24
+ TEST_DIR = PROJECT_ROOT / "data" / "20hours_test"
25
+ GT_PATH = TEST_DIR / "ground_truth.json"
26
+
27
+ DEFAULT_SERVER = "https://bbbakery-ustwo-api.hf.space"
28
+ POLL_INTERVAL = 5
29
+ MAX_WAIT = 300
30
+
31
+
32
+ def health_check(base: str) -> bool:
33
+ try:
34
+ r = requests.get(f"{base}/api/health", timeout=10)
35
+ data = r.json()
36
+ if data.get("status") == "ok":
37
+ print(f" Server OK ({data.get('timestamp', '?')})")
38
+ return True
39
+ except Exception as e:
40
+ print(f" Health check failed: {e}")
41
+ return False
42
+
43
+
44
+ def upload(base: str, wav_path: Path) -> str | None:
45
+ with open(wav_path, "rb") as f:
46
+ r = requests.post(
47
+ f"{base}/api/upload",
48
+ files={"file": (wav_path.name, f, "audio/wav")},
49
+ timeout=60,
50
+ )
51
+ if r.status_code != 200:
52
+ print(f" Upload failed ({r.status_code}): {r.text[:200]}")
53
+ return None
54
+ return r.json().get("call_id")
55
+
56
+
57
+ def analyze_and_poll(base: str, call_id: str) -> dict | None:
58
+ r = requests.post(f"{base}/api/analyze", params={"call_id": call_id}, timeout=30)
59
+ if r.status_code not in (200, 202):
60
+ print(f" Analyze start failed ({r.status_code}): {r.text[:200]}")
61
+ return None
62
+
63
+ data = r.json()
64
+ if data.get("status") == "done":
65
+ return data.get("result")
66
+
67
+ elapsed = 0
68
+ while elapsed < MAX_WAIT:
69
+ time.sleep(POLL_INTERVAL)
70
+ elapsed += POLL_INTERVAL
71
+ r = requests.get(f"{base}/api/analyze/{call_id}/status", timeout=15)
72
+ data = r.json()
73
+ status = data.get("status")
74
+ if status == "done":
75
+ return data.get("result")
76
+ if status == "error":
77
+ print(f" Pipeline error: {data.get('error', '?')}")
78
+ return None
79
+ mins, secs = divmod(elapsed, 60)
80
+ print(f" {status}... ({int(mins)}m{int(secs)}s)", end="\r")
81
+
82
+ print(f" Timeout after {MAX_WAIT}s")
83
+ return None
84
+
85
+
86
+ def extract_emotions(result: dict) -> dict:
87
+ info: dict = {}
88
+ reactions = result.get("character_reactions", [])
89
+ for i, rx in enumerate(reactions):
90
+ info[f"speaker_{i}_state"] = rx.get("solo_state", "?")
91
+ garden = result.get("garden_update", {})
92
+ info["garden_mood"] = garden.get("mood", "?")
93
+ info["garden_delta"] = garden.get("growth_delta", 0)
94
+ recap = result.get("recap_card", {}) or {}
95
+ info["recap_headline"] = recap.get("headline") or recap.get("title", "?")
96
+
97
+ stage2 = result.get("stage2_output", {})
98
+ if stage2:
99
+ for spk, summary in stage2.get("speaker_summaries", {}).items():
100
+ info[f"{spk}_dominant"] = summary.get("dominant_emotion", "?")
101
+ info[f"{spk}_distribution"] = summary.get("emotion_distribution", {})
102
+
103
+ # Segment-level language breakdown
104
+ emotions = result.get("emotions") or stage2.get("emotions", [])
105
+ segs_by_lang: dict[str, int] = {}
106
+ for e in emotions:
107
+ lang = e.get("language") or "?"
108
+ segs_by_lang[lang] = segs_by_lang.get(lang, 0) + 1
109
+ info["segments"] = len(emotions)
110
+ info["segments_by_lang"] = segs_by_lang
111
+ return info
112
+
113
+
114
+ def main():
115
+ parser = argparse.ArgumentParser()
116
+ parser.add_argument("--server", default=DEFAULT_SERVER)
117
+ args = parser.parse_args()
118
+ base = args.server.rstrip("/")
119
+
120
+ print("=" * 70)
121
+ print(" 20Hours Korean Demo โ€” E2E Server Test")
122
+ print(f" Server: {base}")
123
+ print("=" * 70)
124
+
125
+ print("\n[1] Health check")
126
+ if not health_check(base):
127
+ sys.exit(1)
128
+
129
+ print("\n[2] Loading intended emotion labels")
130
+ gt = json.loads(GT_PATH.read_text())
131
+ print(f" {len(gt)} demo clips loaded")
132
+
133
+ print("\n[3] Running E2E tests\n")
134
+ results = {}
135
+ hit = 0
136
+ total = 0
137
+
138
+ for tag in sorted(gt.keys()):
139
+ wav_path = TEST_DIR / f"{tag}.wav"
140
+ if not wav_path.exists():
141
+ print(f" {tag}: WAV not found, skipping")
142
+ continue
143
+ gt_entry = gt[tag]
144
+ print(f" {tag} โ€” {gt_entry['description'][:55]}")
145
+ print(f" Intended: {gt_entry['primary_emotion']} | Duration: {gt_entry['duration_sec']}s | Utts: {gt_entry['total_utterances']}")
146
+
147
+ call_id = upload(base, wav_path)
148
+ if not call_id:
149
+ results[tag] = {"status": "upload_failed"}
150
+ continue
151
+ print(f" Upload OK โ†’ {call_id}")
152
+
153
+ print(f" Analyzing...", end="")
154
+ start_time = time.time()
155
+ result = analyze_and_poll(base, call_id)
156
+ elapsed = time.time() - start_time
157
+
158
+ if not result:
159
+ results[tag] = {"status": "analyze_failed", "call_id": call_id}
160
+ print()
161
+ continue
162
+
163
+ print(f"\r Done in {elapsed:.1f}s ")
164
+
165
+ emotions = extract_emotions(result)
166
+ total += 1
167
+ intended = gt_entry["primary_emotion"]
168
+ speaker_states = {k: v for k, v in emotions.items() if k.endswith("_state")}
169
+ if intended in speaker_states.values():
170
+ hit += 1
171
+ match = "HIT"
172
+ else:
173
+ match = "miss"
174
+
175
+ results[tag] = {
176
+ "status": "pass",
177
+ "call_id": call_id,
178
+ "elapsed_sec": round(elapsed, 1),
179
+ "intended_emotion": intended,
180
+ "match": match,
181
+ "emotions": emotions,
182
+ "full_result": result,
183
+ }
184
+
185
+ for k, v in emotions.items():
186
+ if not k.endswith("_distribution"):
187
+ print(f" {k}: {v}")
188
+ else:
189
+ dist = ", ".join(f"{kk}:{vv:.2f}" for kk, vv in sorted(v.items(), key=lambda x: -x[1])[:3])
190
+ print(f" {k}: {dist}")
191
+ print(f" โ†’ {match}")
192
+ print()
193
+
194
+ out_path = TEST_DIR / "e2e_results.json"
195
+ save = {}
196
+ for tag, r in results.items():
197
+ save[tag] = {k: v for k, v in r.items() if k != "full_result"}
198
+ out_path.write_text(json.dumps(save, indent=2, ensure_ascii=False))
199
+
200
+ print("=" * 70)
201
+ print(" SUMMARY")
202
+ print("=" * 70)
203
+ print(f"\n {'Tag':<24} {'Intended':<12} {'Match':<6} {'Time':>6}")
204
+ print(" " + "-" * 55)
205
+ for tag in sorted(results.keys()):
206
+ r = results[tag]
207
+ if r.get("status") != "pass":
208
+ continue
209
+ intended = r["intended_emotion"]
210
+ m = r["match"]
211
+ t = f"{r['elapsed_sec']:.0f}s"
212
+ print(f" {tag:<24} {intended:<12} {m:<6} {t:>6}")
213
+ print(f"\n Intended-emotion match: {hit}/{total}")
214
+ print(f" Results saved: {out_path}")
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
scripts/test_english_e2e.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """์˜์–ด E2E ํŒŒ์ดํ”„๋ผ์ธ ํ…Œ์ŠคํŠธ.
3
+
4
+ RAVDESS WAV + ์ˆ˜๋™ ์ž‘์„ฑ ํ…์ŠคํŠธ๋กœ Stage2 ์ „์ฒด ํŒŒ์ดํ”„๋ผ์ธ์„ ํ…Œ์ŠคํŠธํ•œ๋‹ค.
5
+ ๊ฐ์ •๋ณ„ 2๊ฐœ์”ฉ 14๊ฐœ ์„ธ๊ทธ๋จผํŠธ๋ฅผ ํ†ต๊ณผ์‹œ์ผœ audio + text + fusion ๋™์ž‘์„ ๊ฒ€์ฆ.
6
+
7
+ Usage:
8
+ python scripts/test_english_e2e.py
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import json
15
+ import logging
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ PROJECT_ROOT = Path(__file__).parent.parent
20
+ sys.path.insert(0, str(PROJECT_ROOT))
21
+
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(asctime)s - %(levelname)s - %(message)s",
25
+ )
26
+ logger = logging.getLogger("test_english_e2e")
27
+
28
+ MANIFEST_PATH = PROJECT_ROOT / "data" / "ravdess" / "manifest.csv"
29
+
30
+ # ๊ฐ์ •๋ณ„ ๋งค์นญ ํ…์ŠคํŠธ (RAVDESS๋Š” ํ…์ŠคํŠธ ์—†์œผ๋ฏ€๋กœ ์ˆ˜๋™ ์ž‘์„ฑ)
31
+ EMOTION_TEXTS = {
32
+ "neutral": [
33
+ "The meeting is scheduled for three o'clock.",
34
+ "I'll pick up the groceries on the way home.",
35
+ ],
36
+ "joy": [
37
+ "I'm so happy to hear from you!",
38
+ "That's wonderful news, I'm thrilled!",
39
+ ],
40
+ "sadness": [
41
+ "I miss you so much, it hurts.",
42
+ "I feel really down today, nothing is going right.",
43
+ ],
44
+ "anger": [
45
+ "I can't believe you did that, I'm so angry!",
46
+ "This is completely unacceptable, stop it now!",
47
+ ],
48
+ "surprise": [
49
+ "Oh my god, I can't believe it!",
50
+ "What?! I never expected that!",
51
+ ],
52
+ "fear": [
53
+ "I'm scared, something doesn't feel right.",
54
+ "Please help, I'm terrified right now.",
55
+ ],
56
+ "disgust": [
57
+ "That's absolutely disgusting, I feel sick.",
58
+ "This is revolting, I can't stand it.",
59
+ ],
60
+ }
61
+
62
+
63
+ def pick_samples(manifest_path: Path) -> list[dict]:
64
+ """๊ฐ์ •๋ณ„ 2๊ฐœ์”ฉ WAV ์„ ํƒ."""
65
+ by_emotion: dict[str, list[dict]] = {}
66
+ with open(manifest_path) as f:
67
+ for row in csv.DictReader(f):
68
+ emotion = row["emotion"]
69
+ if emotion not in by_emotion:
70
+ by_emotion[emotion] = []
71
+ if len(by_emotion[emotion]) < 2:
72
+ by_emotion[emotion].append(row)
73
+
74
+ samples = []
75
+ for emotion in EMOTION_TEXTS:
76
+ if emotion in by_emotion:
77
+ samples.extend(by_emotion[emotion])
78
+ return samples
79
+
80
+
81
+ def run_e2e():
82
+ """E2E ํ…Œ์ŠคํŠธ ์‹คํ–‰."""
83
+ from src.common.schemas import (
84
+ Models,
85
+ ProcessingInfo,
86
+ Segment,
87
+ Stage1Output,
88
+ )
89
+ from src.stage2.process import process
90
+
91
+ if not MANIFEST_PATH.exists():
92
+ logger.error("manifest.csv๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋จผ์ € prepare_ravdess.py๋ฅผ ์‹คํ–‰ํ•˜์„ธ์š”.")
93
+ return False
94
+
95
+ samples = pick_samples(MANIFEST_PATH)
96
+ logger.info(f"์„ ํƒ๋œ ํ…Œ์ŠคํŠธ ์ƒ˜ํ”Œ: {len(samples)}๊ฐœ")
97
+
98
+ # Stage1Output ์ƒ์„ฑ
99
+ segments = []
100
+ ground_truths = []
101
+ for i, sample in enumerate(samples):
102
+ emotion = sample["emotion"]
103
+ texts = EMOTION_TEXTS[emotion]
104
+ text = texts[i % len(texts)]
105
+
106
+ segments.append(Segment(
107
+ segment_id=i,
108
+ speaker_id=f"speaker_{int(sample['actor_id']) % 2}",
109
+ start=float(i * 3.0),
110
+ end=float(i * 3.0 + 3.0),
111
+ text=text,
112
+ language="en",
113
+ audio_path=sample["clean_path"],
114
+ confidence=0.95,
115
+ ))
116
+ ground_truths.append(emotion)
117
+
118
+ stage1_output = Stage1Output(
119
+ call_id="ravdess_e2e_test",
120
+ duration=float(len(segments) * 3.0),
121
+ speakers=["speaker_0", "speaker_1"],
122
+ audio_path="data/ravdess/test_call.wav",
123
+ segments=segments,
124
+ processing_info=ProcessingInfo(
125
+ processing_time_sec=1.0,
126
+ models=Models(
127
+ diarization="pyannote/speaker-diarization-3.1",
128
+ asr="large-v3-turbo",
129
+ language_id="whisper",
130
+ alignment="whisperx",
131
+ ),
132
+ device="cpu",
133
+ ),
134
+ )
135
+
136
+ # Stage2 config (์˜์–ด ์ „์šฉ)
137
+ config = {
138
+ "audio_emotion": {
139
+ "model": "iic/emotion2vec_plus_base",
140
+ },
141
+ "text_emotion": {
142
+ "korean_model": "searle-j/kote_for_easygoing_people",
143
+ "english_model": "j-hartmann/emotion-english-distilroberta-base",
144
+ },
145
+ "fusion": {
146
+ "audio_weight": 0.6,
147
+ "text_weight": 0.4,
148
+ },
149
+ "output_path": "data/e2e_test_output.json",
150
+ }
151
+
152
+ logger.info("Stage 2 process() ์‹คํ–‰ ์ค‘...")
153
+ output = process(stage1_output, config=config)
154
+
155
+ # ๊ฒ€์ฆ
156
+ logger.info("\n" + "=" * 70)
157
+ logger.info("E2E ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ")
158
+ logger.info("=" * 70)
159
+
160
+ errors = []
161
+
162
+ # 1. EmotionResult ๊ฐœ์ˆ˜ ํ™•์ธ
163
+ if len(output.emotions) != len(segments):
164
+ errors.append(f"EmotionResult ๊ฐœ์ˆ˜ ๋ถˆ์ผ์น˜: {len(output.emotions)} != {len(segments)}")
165
+ else:
166
+ logger.info(f"[PASS] EmotionResult ๊ฐœ์ˆ˜: {len(output.emotions)}")
167
+
168
+ # 2. SpeakerSummary ํ™•์ธ
169
+ if len(output.speaker_summaries) > 0:
170
+ logger.info(f"[PASS] SpeakerSummary ์ƒ์„ฑ: {len(output.speaker_summaries)}๋ช…")
171
+ else:
172
+ errors.append("SpeakerSummary๊ฐ€ ๋น„์–ด์žˆ์Œ")
173
+
174
+ # 3. ๊ฐ EmotionResult ์œ ํšจ์„ฑ ํ™•์ธ
175
+ valid_emotions = {"neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"}
176
+ for em in output.emotions:
177
+ if em.fused_emotion not in valid_emotions:
178
+ errors.append(f"์œ ํšจํ•˜์ง€ ์•Š์€ fused_emotion: {em.fused_emotion}")
179
+ if not (0.0 <= em.fused_confidence <= 1.0):
180
+ errors.append(f"fused_confidence ๋ฒ”์œ„ ์ดˆ๊ณผ: {em.fused_confidence}")
181
+
182
+ if not any("์œ ํšจํ•˜์ง€ ์•Š์€" in e for e in errors):
183
+ logger.info("[PASS] ๋ชจ๋“  EmotionResult ์œ ํšจ")
184
+
185
+ # 4. JSON ์ง๋ ฌํ™” ํ…Œ์ŠคํŠธ
186
+ try:
187
+ json_str = output.model_dump_json(indent=2)
188
+ roundtrip = json.loads(json_str)
189
+ logger.info(f"[PASS] JSON ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™” ์„ฑ๊ณต ({len(json_str)} bytes)")
190
+ except Exception as e:
191
+ errors.append(f"JSON ์ง๋ ฌํ™” ์‹คํŒจ: {e}")
192
+
193
+ # 5. ๊ฒฐ๊ณผ ํ…Œ์ด๋ธ”
194
+ logger.info(f"\n{'Seg':>3s} | {'Ground Truth':>12s} | {'Audio':>10s} | {'Text':>10s} | {'Fused':>10s} | {'Conf':>5s}")
195
+ logger.info("-" * 65)
196
+ audio_correct = 0
197
+ text_correct = 0
198
+ fused_correct = 0
199
+ for i, (em, gt) in enumerate(zip(output.emotions, ground_truths)):
200
+ a_match = "o" if em.audio_emotion == gt else "x"
201
+ t_match = "o" if em.text_emotion == gt else "x"
202
+ f_match = "o" if em.fused_emotion == gt else "x"
203
+ if em.audio_emotion == gt:
204
+ audio_correct += 1
205
+ if em.text_emotion == gt:
206
+ text_correct += 1
207
+ if em.fused_emotion == gt:
208
+ fused_correct += 1
209
+ logger.info(
210
+ f"{i:3d} | {gt:>12s} | {em.audio_emotion:>8s} {a_match} | "
211
+ f"{em.text_emotion:>8s} {t_match} | {em.fused_emotion:>8s} {f_match} | "
212
+ f"{em.fused_confidence:.3f}"
213
+ )
214
+
215
+ n = len(ground_truths)
216
+ logger.info(f"\nAccuracy: audio={audio_correct}/{n} text={text_correct}/{n} fused={fused_correct}/{n}")
217
+
218
+ # SpeakerSummary ์ถœ๋ ฅ
219
+ logger.info("\nSpeaker Summaries:")
220
+ for spk, summary in output.speaker_summaries.items():
221
+ logger.info(f" {spk}: dominant={summary.dominant_emotion}, "
222
+ f"conf={summary.avg_confidence:.3f}, "
223
+ f"dist={summary.emotion_distribution}")
224
+
225
+ if errors:
226
+ logger.error(f"\nFAILED โ€” {len(errors)} errors:")
227
+ for e in errors:
228
+ logger.error(f" - {e}")
229
+ return False
230
+
231
+ logger.info(f"\n{'='*70}")
232
+ logger.info("E2E ํ…Œ์ŠคํŠธ PASS โ€” ์˜์–ด ํŒŒ์ดํ”„๋ผ์ธ ์ •์ƒ ๋™์ž‘")
233
+ logger.info(f"{'='*70}")
234
+ return True
235
+
236
+
237
+ if __name__ == "__main__":
238
+ success = run_e2e()
239
+ sys.exit(0 if success else 1)
scripts/test_meld_e2e_server.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """MELD English test sets โ€” E2E server test.
3
+
4
+ Uploads 8 MELD test WAVs to the deployed HF Spaces server,
5
+ runs the full pipeline (Stage 1โ†’2โ†’3), and compares results
6
+ against ground truth emotion labels.
7
+
8
+ Usage:
9
+ python scripts/test_meld_e2e_server.py
10
+ python scripts/test_meld_e2e_server.py --server http://localhost:8000
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import requests
22
+
23
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
24
+ MELD_DIR = PROJECT_ROOT / "data" / "meld_test"
25
+ GT_PATH = MELD_DIR / "ground_truth.json"
26
+
27
+ DEFAULT_SERVER = "https://bbbakery-ustwo-api.hf.space"
28
+ POLL_INTERVAL = 5 # seconds
29
+ MAX_WAIT = 300 # 5 minutes per file
30
+
31
+
32
+ def health_check(base: str) -> bool:
33
+ try:
34
+ r = requests.get(f"{base}/api/health", timeout=10)
35
+ data = r.json()
36
+ if data.get("status") == "ok":
37
+ print(f" โœ… Server OK ({data.get('timestamp', '?')})")
38
+ return True
39
+ except Exception as e:
40
+ print(f" โŒ Health check failed: {e}")
41
+ return False
42
+
43
+
44
+ def upload(base: str, wav_path: Path) -> str | None:
45
+ """Upload WAV and return call_id."""
46
+ with open(wav_path, "rb") as f:
47
+ r = requests.post(
48
+ f"{base}/api/upload",
49
+ files={"file": (wav_path.name, f, "audio/wav")},
50
+ timeout=60,
51
+ )
52
+ if r.status_code != 200:
53
+ print(f" โŒ Upload failed ({r.status_code}): {r.text[:200]}")
54
+ return None
55
+ data = r.json()
56
+ return data.get("call_id")
57
+
58
+
59
+ def analyze_and_poll(base: str, call_id: str) -> dict | None:
60
+ """Start analysis and poll until done."""
61
+ # Start
62
+ r = requests.post(f"{base}/api/analyze", params={"call_id": call_id}, timeout=30)
63
+ if r.status_code not in (200, 202):
64
+ print(f" โŒ Analyze start failed ({r.status_code}): {r.text[:200]}")
65
+ return None
66
+
67
+ data = r.json()
68
+ if data.get("status") == "done":
69
+ return data.get("result")
70
+
71
+ # Poll
72
+ elapsed = 0
73
+ while elapsed < MAX_WAIT:
74
+ time.sleep(POLL_INTERVAL)
75
+ elapsed += POLL_INTERVAL
76
+
77
+ r = requests.get(f"{base}/api/analyze/{call_id}/status", timeout=15)
78
+ data = r.json()
79
+ status = data.get("status")
80
+
81
+ if status == "done":
82
+ return data.get("result")
83
+ elif status == "error":
84
+ print(f" โŒ Pipeline error: {data.get('error', '?')}")
85
+ return None
86
+
87
+ mins, secs = divmod(elapsed, 60)
88
+ print(f" โณ {status}... ({int(mins)}m{int(secs)}s)", end="\r")
89
+
90
+ print(f" โŒ Timeout after {MAX_WAIT}s")
91
+ return None
92
+
93
+
94
+ def extract_emotions(result: dict) -> dict:
95
+ """Extract emotion info from Stage 3 result."""
96
+ info = {}
97
+
98
+ # Character reactions โ†’ emotions
99
+ reactions = result.get("character_reactions", [])
100
+ for i, rx in enumerate(reactions):
101
+ speaker = rx.get("speaker_id", f"speaker_{i}")
102
+ info[f"speaker_{i}_state"] = rx.get("solo_state", "?")
103
+
104
+ # Garden update
105
+ garden = result.get("garden_update", {})
106
+ info["garden_mood"] = garden.get("mood", "?")
107
+ info["garden_delta"] = garden.get("growth_delta", 0)
108
+
109
+ # Recap
110
+ recap = result.get("recap_card", {})
111
+ info["recap_headline"] = recap.get("headline", "?")
112
+
113
+ # Stage 2 emotions (if exposed in result)
114
+ stage2 = result.get("stage2_output", {})
115
+ if stage2:
116
+ for spk, summary in stage2.get("speaker_summaries", {}).items():
117
+ info[f"{spk}_dominant"] = summary.get("dominant_emotion", "?")
118
+ info[f"{spk}_distribution"] = summary.get("emotion_distribution", {})
119
+
120
+ return info
121
+
122
+
123
+ def main():
124
+ parser = argparse.ArgumentParser(description="MELD E2E server test")
125
+ parser.add_argument("--server", default=DEFAULT_SERVER, help="Server base URL")
126
+ args = parser.parse_args()
127
+ base = args.server.rstrip("/")
128
+
129
+ print("=" * 70)
130
+ print(" MELD E2E Server Test")
131
+ print(f" Server: {base}")
132
+ print("=" * 70)
133
+
134
+ # Health check
135
+ print("\n[1] Health check")
136
+ if not health_check(base):
137
+ sys.exit(1)
138
+
139
+ # Load ground truth
140
+ print("\n[2] Loading ground truth")
141
+ with open(GT_PATH) as f:
142
+ gt = json.load(f)
143
+ print(f" {len(gt)} test sets loaded")
144
+
145
+ # Process each test set
146
+ print("\n[3] Running E2E tests\n")
147
+ results = {}
148
+ pass_count = 0
149
+ fail_count = 0
150
+
151
+ for tag in sorted(gt.keys()):
152
+ wav_path = MELD_DIR / f"{tag}.wav"
153
+ if not wav_path.exists():
154
+ print(f" โš ๏ธ {tag}: WAV not found, skipping")
155
+ continue
156
+
157
+ gt_entry = gt[tag]
158
+ print(f" ๐Ÿ“ฆ {tag} โ€” {gt_entry['description']}")
159
+ print(f" Primary: {gt_entry['primary_emotion']} | Duration: {gt_entry['duration_sec']}s | Utts: {gt_entry['total_utterances']}")
160
+
161
+ # Upload
162
+ call_id = upload(base, wav_path)
163
+ if not call_id:
164
+ fail_count += 1
165
+ results[tag] = {"status": "upload_failed"}
166
+ continue
167
+ print(f" Upload OK โ†’ {call_id}")
168
+
169
+ # Analyze + poll
170
+ print(f" Analyzing...", end="")
171
+ start_time = time.time()
172
+ result = analyze_and_poll(base, call_id)
173
+ elapsed = time.time() - start_time
174
+
175
+ if not result:
176
+ fail_count += 1
177
+ results[tag] = {"status": "analyze_failed", "call_id": call_id}
178
+ print()
179
+ continue
180
+
181
+ print(f"\r โœ… Done in {elapsed:.1f}s")
182
+
183
+ # Extract emotions
184
+ emotions = extract_emotions(result)
185
+
186
+ # Check pipeline completeness
187
+ has_reactions = len(result.get("character_reactions", [])) > 0
188
+ has_garden = "garden_update" in result
189
+ has_recap = "recap_card" in result
190
+
191
+ status = "pass" if (has_reactions and has_garden and has_recap) else "partial"
192
+ if status == "pass":
193
+ pass_count += 1
194
+ else:
195
+ fail_count += 1
196
+
197
+ results[tag] = {
198
+ "status": status,
199
+ "call_id": call_id,
200
+ "elapsed_sec": round(elapsed, 1),
201
+ "has_reactions": has_reactions,
202
+ "has_garden": has_garden,
203
+ "has_recap": has_recap,
204
+ "emotions": emotions,
205
+ "full_result": result,
206
+ "ground_truth": {
207
+ "primary_emotion": gt_entry["primary_emotion"],
208
+ "emotion_distribution": gt_entry["emotion_distribution"],
209
+ },
210
+ }
211
+
212
+ # Print details
213
+ print(f" Reactions: {'โœ…' if has_reactions else 'โŒ'} | Garden: {'โœ…' if has_garden else 'โŒ'} | Recap: {'โœ…' if has_recap else 'โŒ'}")
214
+ for k, v in emotions.items():
215
+ if not k.startswith("full_"):
216
+ print(f" {k}: {v}")
217
+ print()
218
+
219
+ # Save results
220
+ out_path = MELD_DIR / "e2e_results.json"
221
+ with open(out_path, "w", encoding="utf-8") as f:
222
+ # Don't save full_result to keep file manageable
223
+ save_results = {}
224
+ for tag, r in results.items():
225
+ save_copy = {k: v for k, v in r.items() if k != "full_result"}
226
+ save_results[tag] = save_copy
227
+ json.dump(save_results, f, indent=2, ensure_ascii=False)
228
+
229
+ # Summary
230
+ print("=" * 70)
231
+ print(" SUMMARY")
232
+ print("=" * 70)
233
+ print(f"\n {'Tag':<25} {'Status':<10} {'Time':>6} {'Reactions':>10} {'Garden':>8} {'Recap':>7}")
234
+ print(" " + "-" * 70)
235
+ for tag in sorted(results.keys()):
236
+ r = results[tag]
237
+ status_icon = "โœ…" if r["status"] == "pass" else "โŒ"
238
+ elapsed = f"{r.get('elapsed_sec', 0):.1f}s" if "elapsed_sec" in r else "โ€”"
239
+ react = "โœ…" if r.get("has_reactions") else "โŒ"
240
+ garden = "โœ…" if r.get("has_garden") else "โŒ"
241
+ recap = "โœ…" if r.get("has_recap") else "โŒ"
242
+ print(f" {tag:<25} {status_icon:<10} {elapsed:>6} {react:>10} {garden:>8} {recap:>7}")
243
+
244
+ print(f"\n Total: {pass_count} pass / {fail_count} fail / {len(results)} total")
245
+ print(f" Results saved: {out_path}")
246
+ print("=" * 70)
247
+
248
+ return fail_count == 0
249
+
250
+
251
+ if __name__ == "__main__":
252
+ success = main()
253
+ sys.exit(0 if success else 1)
scripts/train_emotion2vec.py ADDED
File without changes
scripts/train_fusion_weights.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train per-emotion fusion weights via gradient descent.
3
+
4
+ Inputs:
5
+ - --manifest JSON: list of {"path","text","label","source",...} (2,821 samples)
6
+ - --preds-cache JSON: {"audio_preds": [dict(7)], "text_preds": [dict(7)]}
7
+
8
+ Output dir receives:
9
+ - trained_weights.json โ€” learned w_a / w_t / val_macro_f1
10
+ - trained_fusion_report.md โ€” comparison: audio-only, fixed 60/40, greedy optimal, trained
11
+ - trained_fusion_curve.png โ€” train/val loss + F1 curves
12
+
13
+ Parameterization:
14
+ w_a[L] = sigmoid(ฮฑ[L]), w_t[L] = 1 - w_a[L] # 7 params total
15
+ fused[L] = p_a[L]*w_a[L] + p_t[L]*w_t[L]
16
+ fused โ† normalize over L
17
+ loss = NLL(log fused, y) + ฮป * ||ฮฑ||ยฒ
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import logging
24
+ from collections import Counter
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+ import torch
29
+ import torch.nn as nn
30
+ from sklearn.metrics import f1_score
31
+ from sklearn.model_selection import train_test_split
32
+
33
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
34
+ logger = logging.getLogger(__name__)
35
+
36
+ PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
37
+
38
+
39
+ class FusionHead(nn.Module):
40
+ def __init__(self, init_audio_frac: float = 0.6):
41
+ super().__init__()
42
+ init_val = float(torch.logit(torch.tensor(init_audio_frac)))
43
+ self.alpha = nn.Parameter(torch.full((7,), init_val))
44
+
45
+ @property
46
+ def w_a(self) -> torch.Tensor:
47
+ return torch.sigmoid(self.alpha)
48
+
49
+ @property
50
+ def w_t(self) -> torch.Tensor:
51
+ return 1.0 - self.w_a
52
+
53
+ def forward(self, p_a: torch.Tensor, p_t: torch.Tensor) -> torch.Tensor:
54
+ fused = p_a * self.w_a + p_t * self.w_t
55
+ return fused / fused.sum(dim=1, keepdim=True).clamp(min=1e-8)
56
+
57
+
58
+ def probs_to_tensor(preds: list[dict]) -> torch.Tensor:
59
+ arr = np.array([[p.get(l, 0.0) for l in PROJECT_LABELS] for p in preds], dtype=np.float32)
60
+ return torch.from_numpy(arr)
61
+
62
+
63
+ def map_label(lbl: str) -> str:
64
+ return "joy" if lbl == "happiness" else lbl
65
+
66
+
67
+ def eval_weights(p_a: torch.Tensor, p_t: torch.Tensor, y: np.ndarray,
68
+ w_a_vec: np.ndarray) -> dict:
69
+ w_a = torch.from_numpy(w_a_vec.astype(np.float32))
70
+ w_t = 1.0 - w_a
71
+ fused = p_a * w_a + p_t * w_t
72
+ fused = fused / fused.sum(dim=1, keepdim=True).clamp(min=1e-8)
73
+ pred = fused.argmax(dim=1).numpy()
74
+ macro = f1_score(y, pred, average="macro")
75
+ per_class = {
76
+ PROJECT_LABELS[i]: f1_score((y == i).astype(int), (pred == i).astype(int))
77
+ for i in range(7)
78
+ }
79
+ return {"macro_f1": float(macro), "per_class": {k: float(v) for k, v in per_class.items()}}
80
+
81
+
82
+ def train(p_a_tr, p_t_tr, y_tr, p_a_vl, p_t_vl, y_vl,
83
+ lr=0.05, epochs=500, l2=0.01, patience=50):
84
+ model = FusionHead()
85
+ opt = torch.optim.Adam(model.parameters(), lr=lr)
86
+ nll = nn.NLLLoss()
87
+
88
+ history = {"train_loss": [], "val_f1": []}
89
+ best_f1, best_alpha, waited = -1.0, None, 0
90
+
91
+ y_tr_t = torch.from_numpy(y_tr).long()
92
+ y_vl_t = torch.from_numpy(y_vl).long()
93
+
94
+ for epoch in range(epochs):
95
+ model.train()
96
+ opt.zero_grad()
97
+ fused = model(p_a_tr, p_t_tr)
98
+ loss = nll(torch.log(fused.clamp(min=1e-8)), y_tr_t) + l2 * (model.alpha ** 2).sum()
99
+ loss.backward()
100
+ opt.step()
101
+
102
+ model.eval()
103
+ with torch.no_grad():
104
+ val_fused = model(p_a_vl, p_t_vl)
105
+ val_pred = val_fused.argmax(dim=1).numpy()
106
+ val_f1 = f1_score(y_vl, val_pred, average="macro")
107
+
108
+ history["train_loss"].append(float(loss.item()))
109
+ history["val_f1"].append(float(val_f1))
110
+
111
+ if val_f1 > best_f1:
112
+ best_f1, best_alpha, waited = float(val_f1), model.alpha.detach().clone(), 0
113
+ else:
114
+ waited += 1
115
+ if waited >= patience:
116
+ logger.info("Early stop at epoch %d (patience=%d)", epoch, patience)
117
+ break
118
+
119
+ final_alpha = model.alpha.detach().clone()
120
+ model.alpha.data = best_alpha
121
+ return model, best_f1, history, final_alpha
122
+
123
+
124
+ def plot_curve(history, output_path: Path) -> None:
125
+ import matplotlib
126
+ matplotlib.use("Agg")
127
+ import matplotlib.pyplot as plt
128
+
129
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
130
+ ax1.plot(history["train_loss"], color="#F44336", label="train CE + L2")
131
+ ax1.set_xlabel("Epoch"); ax1.set_ylabel("Loss"); ax1.set_title("Training loss")
132
+ ax1.grid(alpha=0.3); ax1.legend()
133
+
134
+ ax2.plot(history["val_f1"], color="#4CAF50", label="val macro F1")
135
+ ax2.set_xlabel("Epoch"); ax2.set_ylabel("Macro F1"); ax2.set_title("Validation macro F1")
136
+ ax2.grid(alpha=0.3); ax2.legend()
137
+
138
+ plt.tight_layout()
139
+ plt.savefig(str(output_path), dpi=150)
140
+ plt.close()
141
+
142
+
143
+ def write_report(output_path: Path, num_train: int, num_val: int, labels_dist: dict,
144
+ audio_only: dict, fixed: dict, greedy: dict, trained: dict,
145
+ trained_weights: dict, greedy_weights: dict) -> None:
146
+ lines = ["# Fusion Weight Training Report (v2)\n"]
147
+ lines.append(f"## Dataset\n\nTotal samples: **{num_train + num_val}** (train {num_train}, val {num_val})\n")
148
+ lines.append("### Label distribution\n\n| Label | Count |\n|---|---|")
149
+ for lbl, c in sorted(labels_dist.items(), key=lambda x: -x[1]):
150
+ lines.append(f"| {lbl} | {c} |")
151
+ lines.append("")
152
+ lines.append("## Macro F1 Comparison (validation set)\n")
153
+ lines.append("| Strategy | Macro F1 |")
154
+ lines.append("|---|---|")
155
+ lines.append(f"| Audio-only (argmax p_audio) | {audio_only['macro_f1']:.4f} |")
156
+ lines.append(f"| Fixed 60/40 | {fixed['macro_f1']:.4f} |")
157
+ lines.append(f"| Greedy grid (v1 weights) | {greedy['macro_f1']:.4f} |")
158
+ lines.append(f"| **Trained (gradient descent)** | **{trained['macro_f1']:.4f}** |")
159
+ lines.append("")
160
+ lines.append("## Per-class F1 (validation set)\n")
161
+ lines.append("| Emotion | Audio-only | Fixed 60/40 | Greedy | Trained |")
162
+ lines.append("|---|---|---|---|---|")
163
+ for lbl in PROJECT_LABELS:
164
+ lines.append(f"| {lbl} | {audio_only['per_class'][lbl]:.4f} | "
165
+ f"{fixed['per_class'][lbl]:.4f} | {greedy['per_class'][lbl]:.4f} | "
166
+ f"{trained['per_class'][lbl]:.4f} |")
167
+ lines.append("")
168
+ lines.append("## Learned weights\n")
169
+ lines.append("| Emotion | Audio (trained) | Text (trained) | Audio (greedy v1) |")
170
+ lines.append("|---|---|---|---|")
171
+ for lbl in PROJECT_LABELS:
172
+ w = trained_weights[lbl]
173
+ gw = greedy_weights.get(lbl, {"audio": None})
174
+ g_a = f"{gw['audio']:.2f}" if gw.get("audio") is not None else "โ€”"
175
+ lines.append(f"| {lbl} | {w['audio']:.2f} | {w['text']:.2f} | {g_a} |")
176
+ lines.append("")
177
+ output_path.write_text("\n".join(lines))
178
+
179
+
180
+ def main() -> None:
181
+ parser = argparse.ArgumentParser()
182
+ parser.add_argument("--manifest", type=Path, required=True)
183
+ parser.add_argument("--preds-cache", type=Path, required=True)
184
+ parser.add_argument("--output-dir", type=Path, required=True)
185
+ parser.add_argument("--lr", type=float, default=0.05)
186
+ parser.add_argument("--epochs", type=int, default=500)
187
+ parser.add_argument("--l2", type=float, default=0.01)
188
+ parser.add_argument("--patience", type=int, default=50)
189
+ parser.add_argument("--seed", type=int, default=42)
190
+ parser.add_argument("--use-last-alpha", action="store_true",
191
+ help="Use final-epoch alpha instead of best-val-F1 alpha (keeps differentiation)")
192
+ args = parser.parse_args()
193
+
194
+ args.output_dir.mkdir(parents=True, exist_ok=True)
195
+
196
+ manifest = json.loads(args.manifest.read_text())
197
+ cache = json.loads(args.preds_cache.read_text())
198
+ audio_preds = cache["audio_preds"]
199
+ text_preds = cache["text_preds"]
200
+
201
+ if not (len(manifest) == len(audio_preds) == len(text_preds)):
202
+ raise ValueError(f"Size mismatch: manifest={len(manifest)}, audio={len(audio_preds)}, text={len(text_preds)}")
203
+
204
+ labels = [map_label(r["label"]) for r in manifest]
205
+ labels_dist = dict(Counter(labels))
206
+ logger.info("Loaded %d samples. Distribution: %s", len(manifest), labels_dist)
207
+
208
+ p_a = probs_to_tensor(audio_preds)
209
+ p_t = probs_to_tensor(text_preds)
210
+ y = np.array([PROJECT_LABELS.index(l) for l in labels])
211
+
212
+ # Stratified 80/20
213
+ tr_idx, vl_idx = train_test_split(
214
+ np.arange(len(y)), test_size=0.2, stratify=y, random_state=args.seed,
215
+ )
216
+ logger.info("Train/Val: %d / %d", len(tr_idx), len(vl_idx))
217
+
218
+ torch.manual_seed(args.seed)
219
+ model, best_f1, history, final_alpha = train(
220
+ p_a[tr_idx], p_t[tr_idx], y[tr_idx],
221
+ p_a[vl_idx], p_t[vl_idx], y[vl_idx],
222
+ lr=args.lr, epochs=args.epochs, l2=args.l2, patience=args.patience,
223
+ )
224
+ logger.info("Best val macro F1: %.4f", best_f1)
225
+
226
+ # Select which alpha to deploy: "best" (peak val F1) or "last" (final epoch)
227
+ if args.use_last_alpha:
228
+ model.alpha.data = final_alpha
229
+ logger.info("Using LAST-epoch alpha (--use-last-alpha)")
230
+ # Derive trained weights dict
231
+ w_a_np = model.w_a.detach().numpy()
232
+ trained_weights = {
233
+ PROJECT_LABELS[i]: {"audio": round(float(w_a_np[i]), 2), "text": round(float(1 - w_a_np[i]), 2)}
234
+ for i in range(7)
235
+ }
236
+
237
+ # Also compute last-alpha weights for comparison
238
+ last_w_a = torch.sigmoid(final_alpha).numpy()
239
+ last_weights = {
240
+ PROJECT_LABELS[i]: {"audio": round(float(last_w_a[i]), 2), "text": round(float(1 - last_w_a[i]), 2)}
241
+ for i in range(7)
242
+ }
243
+
244
+ # Baselines on val split
245
+ pa_vl = p_a[vl_idx]; pt_vl = p_t[vl_idx]; y_vl = y[vl_idx]
246
+
247
+ # Audio-only argmax
248
+ audio_only_pred = pa_vl.argmax(dim=1).numpy()
249
+ audio_only = {
250
+ "macro_f1": float(f1_score(y_vl, audio_only_pred, average="macro")),
251
+ "per_class": {
252
+ PROJECT_LABELS[i]: float(f1_score((y_vl == i).astype(int), (audio_only_pred == i).astype(int)))
253
+ for i in range(7)
254
+ },
255
+ }
256
+
257
+ fixed = eval_weights(pa_vl, pt_vl, y_vl, np.full(7, 0.6))
258
+ # Greedy v1 weights โ€” hardcode from previous report
259
+ GREEDY_V1 = {
260
+ "neutral": 0.75, "joy": 0.55, "sadness": 0.40, "anger": 0.65,
261
+ "surprise": 0.45, "fear": 0.00, "disgust": 0.80,
262
+ }
263
+ greedy_w_a = np.array([GREEDY_V1[l] for l in PROJECT_LABELS])
264
+ greedy = eval_weights(pa_vl, pt_vl, y_vl, greedy_w_a)
265
+ greedy_weights = {l: {"audio": GREEDY_V1[l]} for l in PROJECT_LABELS}
266
+
267
+ trained = eval_weights(pa_vl, pt_vl, y_vl, w_a_np)
268
+
269
+ logger.info("Audio-only val macro F1: %.4f", audio_only["macro_f1"])
270
+ logger.info("Fixed 60/40 val macro F1: %.4f", fixed["macro_f1"])
271
+ logger.info("Greedy v1 val macro F1: %.4f", greedy["macro_f1"])
272
+ logger.info("Trained val macro F1: %.4f", trained["macro_f1"])
273
+
274
+ # Save
275
+ (args.output_dir / "trained_weights.json").write_text(json.dumps({
276
+ "val_macro_f1": trained["macro_f1"],
277
+ "weights": trained_weights,
278
+ "last_epoch_weights": last_weights,
279
+ "train_size": int(len(tr_idx)),
280
+ "val_size": int(len(vl_idx)),
281
+ "hyperparams": {"lr": args.lr, "l2": args.l2, "epochs": args.epochs,
282
+ "patience": args.patience, "seed": args.seed,
283
+ "use_last_alpha": args.use_last_alpha},
284
+ }, indent=2, ensure_ascii=False))
285
+
286
+ plot_curve(history, args.output_dir / "trained_fusion_curve.png")
287
+ write_report(
288
+ args.output_dir / "trained_fusion_report.md",
289
+ len(tr_idx), len(vl_idx), labels_dist,
290
+ audio_only, fixed, greedy, trained,
291
+ trained_weights, greedy_weights,
292
+ )
293
+ logger.info("Done. Results saved to %s", args.output_dir)
294
+
295
+
296
+ if __name__ == "__main__":
297
+ main()
scripts/train_kcelectra.py ADDED
File without changes
scripts/train_lora_emotion2vec.py ADDED
@@ -0,0 +1,749 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Manual LoRA fine-tuning for emotion2vec_plus_base โ€” 7-class emotion.
3
+
4
+ Wraps frozen attention layers with low-rank adapters (LoRALinear),
5
+ replaces the 9-class proj head with a 7-class MLPHead, and trains
6
+ with FocalLoss + disgust F1 gating.
7
+
8
+ Usage:
9
+ # Quick smoke test (CPU)
10
+ python scripts/train_lora_emotion2vec.py \
11
+ --train-manifest data/lora_7class/train_manifest.json \
12
+ --val-manifest data/lora_7class/val_manifest.json \
13
+ --output-dir data/models/lora_emotion2vec_7class \
14
+ --epochs 3 --device cpu
15
+
16
+ # Full training (GPU, RTX 5050 8GB)
17
+ PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
18
+ python scripts/train_lora_emotion2vec.py \
19
+ --train-manifest data/lora_7class/train_manifest.json \
20
+ --val-manifest data/lora_7class/val_manifest.json \
21
+ --output-dir data/models/lora_emotion2vec_7class \
22
+ --epochs 20 --batch-size 4 --accumulate-steps 8 --device cuda
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import logging
30
+ import random
31
+ import sys
32
+ import time
33
+ from pathlib import Path
34
+
35
+ import numpy as np
36
+ import torch
37
+ import torch.nn as nn
38
+ import torch.nn.functional as F
39
+ from torch.utils.data import DataLoader, Dataset
40
+
41
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # 7-class label taxonomy (matches prepare_lora_dataset.py)
45
+ LABELS_7CLASS = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
46
+ LABEL2IDX = {label: i for i, label in enumerate(LABELS_7CLASS)}
47
+ NUM_CLASSES = len(LABELS_7CLASS)
48
+ DISGUST_IDX = LABEL2IDX["disgust"] # 2
49
+
50
+
51
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
52
+ # LoRA Components
53
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
54
+
55
+ class LoRALinear(nn.Module):
56
+ """Low-rank adapter wrapping a frozen nn.Linear.
57
+
58
+ At init, B is zero so LoRA contribution is zero (original behavior preserved).
59
+ scaling = alpha / r controls the magnitude of the LoRA update.
60
+ """
61
+
62
+ def __init__(self, original: nn.Linear, r: int = 16, alpha: int = 32, dropout: float = 0.1):
63
+ super().__init__()
64
+ self.original = original
65
+ self.r = r
66
+ self.scaling = alpha / r
67
+
68
+ # Freeze original weights
69
+ self.original.weight.requires_grad = False
70
+ if self.original.bias is not None:
71
+ self.original.bias.requires_grad = False
72
+
73
+ in_features = original.in_features
74
+ out_features = original.out_features
75
+
76
+ # Low-rank matrices
77
+ self.lora_A = nn.Linear(in_features, r, bias=False)
78
+ self.lora_B = nn.Linear(r, out_features, bias=False)
79
+ self.dropout = nn.Dropout(dropout)
80
+
81
+ # Init: A = kaiming, B = zero (so initial LoRA output = 0)
82
+ nn.init.kaiming_uniform_(self.lora_A.weight)
83
+ nn.init.zeros_(self.lora_B.weight)
84
+
85
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
86
+ base_out = self.original(x)
87
+ lora_out = self.lora_B(self.dropout(self.lora_A(x))) * self.scaling
88
+ return base_out + lora_out
89
+
90
+
91
+ def merge_lora_linear(lora: LoRALinear) -> nn.Linear:
92
+ """Merge LoRA weights into a plain nn.Linear for inference.
93
+
94
+ W_merged = W_original + scaling * B.weight @ A.weight
95
+ """
96
+ with torch.no_grad():
97
+ merged_weight = (
98
+ lora.original.weight
99
+ + lora.scaling * lora.lora_B.weight @ lora.lora_A.weight
100
+ )
101
+ bias = lora.original.bias
102
+
103
+ merged = nn.Linear(
104
+ lora.original.in_features,
105
+ lora.original.out_features,
106
+ bias=bias is not None,
107
+ )
108
+ merged.weight = nn.Parameter(merged_weight)
109
+ if bias is not None:
110
+ merged.bias = nn.Parameter(bias.clone())
111
+ return merged
112
+
113
+
114
+ def inject_lora(
115
+ encoder: nn.Module,
116
+ r: int = 16,
117
+ alpha: int = 32,
118
+ dropout: float = 0.1,
119
+ ) -> None:
120
+ """Replace attn.qkv and attn.proj in each block with LoRALinear.
121
+
122
+ emotion2vec uses FUSED QKV: attn.qkv: Linear(768, 2304)
123
+ and attn.proj: Linear(768, 768). 8 blocks total.
124
+ MLP layers are NOT wrapped.
125
+ """
126
+ for block in encoder.blocks:
127
+ block.attn.qkv = LoRALinear(block.attn.qkv, r=r, alpha=alpha, dropout=dropout)
128
+ block.attn.proj = LoRALinear(block.attn.proj, r=r, alpha=alpha, dropout=dropout)
129
+
130
+
131
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
132
+ # Model Components
133
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
134
+
135
+ class MLPHead(nn.Module):
136
+ """Multi-layer classification head: 768 โ†’ 512 โ†’ 256 โ†’ num_classes."""
137
+
138
+ def __init__(self, in_dim: int = 768, num_classes: int = NUM_CLASSES, dropout: float = 0.3):
139
+ super().__init__()
140
+ self.net = nn.Sequential(
141
+ nn.Linear(in_dim, 512),
142
+ nn.BatchNorm1d(512),
143
+ nn.GELU(),
144
+ nn.Dropout(dropout),
145
+ nn.Linear(512, 256),
146
+ nn.BatchNorm1d(256),
147
+ nn.GELU(),
148
+ nn.Dropout(dropout),
149
+ nn.Linear(256, num_classes),
150
+ )
151
+
152
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
153
+ return self.net(x)
154
+
155
+
156
+ class FocalLoss(nn.Module):
157
+ """Focal Loss with optional label smoothing and class weights."""
158
+
159
+ def __init__(self, weight=None, gamma: float = 2.0, label_smoothing: float = 0.05):
160
+ super().__init__()
161
+ self.gamma = gamma
162
+ self.weight = weight
163
+ self.label_smoothing = label_smoothing
164
+
165
+ def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
166
+ ce_loss = F.cross_entropy(
167
+ logits, targets, weight=self.weight,
168
+ label_smoothing=self.label_smoothing, reduction="none",
169
+ )
170
+ pt = torch.exp(-ce_loss)
171
+ focal_loss = ((1 - pt) ** self.gamma) * ce_loss
172
+ return focal_loss.mean()
173
+
174
+
175
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
176
+ # Dataset
177
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
178
+
179
+ class EmotionDataset(Dataset):
180
+ """Load audio from unified manifest JSON for 7-class LoRA training.
181
+
182
+ Manifest format: list of {"audio_path": str, "label": str, ...}
183
+ OR {"path": str, "label": str, ...} (for backward compat).
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ manifest_path: str,
189
+ max_duration_sec: float = 8.0,
190
+ phone_augment_prob: float = 0.0,
191
+ noise_augment_prob: float = 0.0,
192
+ ):
193
+ import torchaudio # lazy import
194
+
195
+ with open(manifest_path, encoding="utf-8") as f:
196
+ self.samples = json.load(f)
197
+
198
+ self.max_samples = int(max_duration_sec * 16000)
199
+ self.phone_augment_prob = phone_augment_prob
200
+ self.noise_augment_prob = noise_augment_prob
201
+ self._phone_sim = None
202
+
203
+ def _get_phone_sim(self):
204
+ if self._phone_sim is None:
205
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
206
+ from common.phone_simulator import PhoneSimulator, CompandingType
207
+ self._phone_sim = PhoneSimulator(companding=CompandingType.ALAW)
208
+ return self._phone_sim
209
+
210
+ def __len__(self):
211
+ return len(self.samples)
212
+
213
+ def __getitem__(self, idx):
214
+ import torchaudio
215
+
216
+ sample = self.samples[idx]
217
+ # Support both "audio_path" and "path" keys
218
+ audio_path = sample.get("audio_path") or sample.get("path", "")
219
+
220
+ waveform, sr = torchaudio.load(audio_path)
221
+ # Mono
222
+ if waveform.shape[0] > 1:
223
+ waveform = waveform.mean(dim=0, keepdim=True)
224
+ waveform = waveform.squeeze(0) # (T,)
225
+
226
+ # Resample to 16kHz
227
+ if sr != 16000:
228
+ waveform = torchaudio.functional.resample(waveform, sr, 16000)
229
+
230
+ # Truncate
231
+ if waveform.shape[0] > self.max_samples:
232
+ waveform = waveform[:self.max_samples]
233
+
234
+ audio = waveform.numpy()
235
+
236
+ # Augmentation
237
+ r = random.random()
238
+ if r < self.phone_augment_prob:
239
+ sim = self._get_phone_sim()
240
+ audio, _ = sim.process(audio, 16000)
241
+ import librosa
242
+ audio = librosa.resample(audio, orig_sr=8000, target_sr=16000)
243
+ elif r < self.phone_augment_prob + self.noise_augment_prob:
244
+ snr_db = random.uniform(10, 20)
245
+ signal_power = np.mean(audio ** 2)
246
+ noise_power = signal_power / (10 ** (snr_db / 10))
247
+ noise = np.random.normal(0, np.sqrt(max(noise_power, 1e-10)), len(audio)).astype(np.float32)
248
+ audio = audio + noise
249
+
250
+ label = LABEL2IDX[sample["label"]]
251
+ return torch.tensor(audio, dtype=torch.float32), label
252
+
253
+
254
+ def collate_fn(batch):
255
+ """Pad waveforms to same length in batch."""
256
+ waveforms, labels = zip(*batch)
257
+ max_len = max(w.shape[0] for w in waveforms)
258
+ padded = torch.zeros(len(waveforms), max_len)
259
+ for i, w in enumerate(waveforms):
260
+ padded[i, :w.shape[0]] = w
261
+ return padded, torch.tensor(labels, dtype=torch.long)
262
+
263
+
264
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
265
+ # Model Loading & Forward
266
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
267
+
268
+ def load_model(device: str, r: int = 16, alpha: int = 32, dropout: float = 0.1):
269
+ """Load emotion2vec_plus_base, freeze all, inject LoRA, replace proj."""
270
+ from funasr import AutoModel
271
+
272
+ logger.info("Loading emotion2vec_plus_base...")
273
+ fmodel = AutoModel(model="iic/emotion2vec_plus_base", device=device, hub="hf")
274
+ encoder = fmodel.model
275
+
276
+ # Freeze everything
277
+ for param in encoder.parameters():
278
+ param.requires_grad = False
279
+
280
+ # Inject LoRA adapters into attention layers
281
+ inject_lora(encoder, r=r, alpha=alpha, dropout=dropout)
282
+
283
+ # Replace 9-class proj with 7-class MLPHead
284
+ old_proj = encoder.proj
285
+ encoder.proj = MLPHead(768, NUM_CLASSES)
286
+ logger.info("Replaced proj: Linear(768, %d) -> MLPHead(768->512->256->%d)",
287
+ old_proj.out_features, NUM_CLASSES)
288
+
289
+ # Move entire model (including new LoRA params + MLPHead) to device
290
+ encoder = encoder.to(device)
291
+ return encoder
292
+
293
+
294
+ def forward_pass(encoder, waveforms: torch.Tensor, device: str) -> torch.Tensor:
295
+ """Differentiable forward pass through emotion2vec with LoRA.
296
+
297
+ Args:
298
+ encoder: emotion2vec model with LoRA injected
299
+ waveforms: (B, T) float32, 16kHz
300
+ device: compute device
301
+
302
+ Returns:
303
+ logits: (B, 7)
304
+ """
305
+ waveforms = waveforms.to(device)
306
+
307
+ # Layer norm (per emotion2vec inference)
308
+ if encoder.cfg.normalize:
309
+ normed = []
310
+ for i in range(waveforms.shape[0]):
311
+ normed.append(F.layer_norm(waveforms[i], waveforms[i].shape))
312
+ waveforms = torch.stack(normed)
313
+
314
+ # Extract features
315
+ feats = encoder.extract_features(waveforms, padding_mask=None)
316
+ x = feats["x"] # (B, T', 768)
317
+
318
+ # Mean pool + classify
319
+ pooled = x.mean(dim=1) # (B, 768)
320
+ logits = encoder.proj(pooled) # (B, 7)
321
+ return logits
322
+
323
+
324
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
325
+ # Validation
326
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
327
+
328
+ @torch.no_grad()
329
+ def validate(encoder, val_loader, device, criterion):
330
+ """Run validation, return metrics including disgust_f1."""
331
+ encoder.eval()
332
+ total_loss = 0
333
+ y_true, y_pred = [], []
334
+
335
+ for waveforms, labels in val_loader:
336
+ labels = labels.to(device)
337
+ logits = forward_pass(encoder, waveforms, device)
338
+ loss = criterion(logits, labels)
339
+ total_loss += loss.item() * labels.size(0)
340
+
341
+ preds = logits.argmax(dim=-1)
342
+ y_true.extend(labels.cpu().tolist())
343
+ y_pred.extend(preds.cpu().tolist())
344
+
345
+ from sklearn.metrics import precision_recall_fscore_support, accuracy_score, confusion_matrix
346
+
347
+ accuracy = accuracy_score(y_true, y_pred)
348
+ _, _, f1_per_class, _ = precision_recall_fscore_support(
349
+ y_true, y_pred, labels=list(range(NUM_CLASSES)), average=None, zero_division=0,
350
+ )
351
+ macro_f1 = float(np.mean(f1_per_class))
352
+ disgust_f1 = float(f1_per_class[DISGUST_IDX])
353
+
354
+ per_class = {LABELS_7CLASS[i]: round(float(f1_per_class[i]), 4) for i in range(NUM_CLASSES)}
355
+ avg_loss = total_loss / max(len(y_true), 1)
356
+ cm = confusion_matrix(y_true, y_pred, labels=list(range(NUM_CLASSES)))
357
+
358
+ return {
359
+ "loss": round(avg_loss, 4),
360
+ "accuracy": round(accuracy, 4),
361
+ "macro_f1": round(macro_f1, 4),
362
+ "disgust_f1": round(disgust_f1, 4),
363
+ "per_class_f1": per_class,
364
+ "confusion_matrix": cm.tolist(),
365
+ "y_true": y_true,
366
+ "y_pred": y_pred,
367
+ }
368
+
369
+
370
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
371
+ # Checkpoint
372
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
373
+
374
+ def save_lora_checkpoint(encoder, path: Path, epoch: int, metrics: dict,
375
+ best_f1: float = 0.0, patience_counter: int = 0,
376
+ optimizer=None, scheduler=None, scaler=None,
377
+ training_log=None):
378
+ """Save LoRA weights + MLPHead + optimizer state for resume."""
379
+ lora_state = {}
380
+ for name, module in encoder.named_modules():
381
+ if isinstance(module, LoRALinear):
382
+ lora_state[f"{name}.lora_A.weight"] = module.lora_A.weight.data.cpu()
383
+ lora_state[f"{name}.lora_B.weight"] = module.lora_B.weight.data.cpu()
384
+
385
+ state = {
386
+ "lora_weights": lora_state,
387
+ "proj": encoder.proj.state_dict(),
388
+ "epoch": epoch,
389
+ "metrics": metrics,
390
+ "best_f1": best_f1,
391
+ "patience_counter": patience_counter,
392
+ "num_classes": NUM_CLASSES,
393
+ "labels": LABELS_7CLASS,
394
+ }
395
+ if optimizer is not None:
396
+ state["optimizer"] = optimizer.state_dict()
397
+ if scheduler is not None:
398
+ state["scheduler"] = scheduler.state_dict()
399
+ if scaler is not None:
400
+ state["scaler"] = scaler.state_dict()
401
+ if training_log is not None:
402
+ state["training_log"] = training_log
403
+ torch.save(state, str(path))
404
+
405
+
406
+ def load_lora_checkpoint(encoder, path: Path, device: str,
407
+ optimizer=None, scheduler=None, scaler=None):
408
+ """Load LoRA checkpoint and restore training state."""
409
+ logger.info("Resuming from checkpoint: %s", path)
410
+ ckpt = torch.load(str(path), map_location=device, weights_only=False)
411
+
412
+ # Restore LoRA weights
413
+ for name, module in encoder.named_modules():
414
+ if isinstance(module, LoRALinear):
415
+ a_key = f"{name}.lora_A.weight"
416
+ b_key = f"{name}.lora_B.weight"
417
+ if a_key in ckpt["lora_weights"]:
418
+ module.lora_A.weight.data.copy_(ckpt["lora_weights"][a_key].to(device))
419
+ module.lora_B.weight.data.copy_(ckpt["lora_weights"][b_key].to(device))
420
+
421
+ # Restore MLPHead
422
+ encoder.proj.load_state_dict(ckpt["proj"])
423
+
424
+ # Restore optimizer/scheduler/scaler if available
425
+ if optimizer is not None and "optimizer" in ckpt:
426
+ optimizer.load_state_dict(ckpt["optimizer"])
427
+ if scheduler is not None and "scheduler" in ckpt:
428
+ scheduler.load_state_dict(ckpt["scheduler"])
429
+ if scaler is not None and "scaler" in ckpt:
430
+ scaler.load_state_dict(ckpt["scaler"])
431
+
432
+ logger.info("Resumed from epoch %d (best_f1=%.4f, patience=%d)",
433
+ ckpt["epoch"], ckpt["best_f1"], ckpt["patience_counter"])
434
+ return {
435
+ "epoch": ckpt["epoch"],
436
+ "best_f1": ckpt["best_f1"],
437
+ "patience_counter": ckpt["patience_counter"],
438
+ "training_log": ckpt.get("training_log", []),
439
+ }
440
+
441
+
442
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
443
+ # Confusion Matrix Plot
444
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
445
+
446
+ def plot_confusion_matrix(cm, output_path: Path, epoch: int):
447
+ """Save confusion matrix as PNG."""
448
+ try:
449
+ import matplotlib
450
+ matplotlib.use("Agg")
451
+ import matplotlib.pyplot as plt
452
+ import seaborn as sns
453
+
454
+ fig, ax = plt.subplots(figsize=(9, 7))
455
+ cm_norm = cm / np.maximum(cm.sum(axis=1, keepdims=True), 1)
456
+ sns.heatmap(
457
+ cm_norm, annot=True, fmt=".2f", cmap="Blues",
458
+ xticklabels=LABELS_7CLASS, yticklabels=LABELS_7CLASS, ax=ax,
459
+ )
460
+ for i in range(NUM_CLASSES):
461
+ for j in range(NUM_CLASSES):
462
+ ax.text(j + 0.5, i + 0.7, f"({cm[i][j]})",
463
+ ha="center", va="center", fontsize=6, color="gray")
464
+
465
+ ax.set_xlabel("Predicted")
466
+ ax.set_ylabel("True")
467
+ ax.set_title(f"LoRA 7-Class Confusion Matrix (Epoch {epoch})")
468
+ plt.tight_layout()
469
+ plt.savefig(str(output_path), dpi=150)
470
+ plt.close()
471
+ logger.info("Confusion matrix saved to %s", output_path)
472
+ except ImportError:
473
+ logger.warning("matplotlib/seaborn not available โ€” skipping confusion matrix plot")
474
+
475
+
476
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
477
+ # Training
478
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
479
+
480
+ def train(args):
481
+ device = args.device
482
+ use_amp = (device == "cuda")
483
+ accumulate_steps = args.accumulate_steps
484
+
485
+ # Load model
486
+ encoder = load_model(device, r=args.lora_r, alpha=args.lora_alpha, dropout=args.lora_dropout)
487
+
488
+ # Log trainable vs total
489
+ trainable = sum(p.numel() for p in encoder.parameters() if p.requires_grad)
490
+ total = sum(p.numel() for p in encoder.parameters())
491
+ logger.info("Trainable: %dK / Total: %dK (%.1f%%)",
492
+ trainable // 1000, total // 1000, 100 * trainable / total)
493
+
494
+ # Datasets
495
+ train_dataset = EmotionDataset(
496
+ args.train_manifest,
497
+ max_duration_sec=8.0,
498
+ phone_augment_prob=args.phone_augment_prob,
499
+ noise_augment_prob=args.noise_augment_prob,
500
+ )
501
+ val_dataset = EmotionDataset(args.val_manifest, max_duration_sec=8.0)
502
+
503
+ logger.info("Train: %d samples, Val: %d samples", len(train_dataset), len(val_dataset))
504
+ logger.info("AMP: %s, Accumulate: %d, Effective batch: %d",
505
+ use_amp, accumulate_steps, args.batch_size * accumulate_steps)
506
+
507
+ n_workers = 2
508
+ use_pin = (device == "cuda")
509
+ train_loader = DataLoader(
510
+ train_dataset, batch_size=args.batch_size, shuffle=True,
511
+ collate_fn=collate_fn, num_workers=n_workers, drop_last=True,
512
+ pin_memory=use_pin, persistent_workers=(n_workers > 0),
513
+ )
514
+ val_loader = DataLoader(
515
+ val_dataset, batch_size=args.batch_size, shuffle=False,
516
+ collate_fn=collate_fn, num_workers=n_workers,
517
+ pin_memory=use_pin, persistent_workers=(n_workers > 0),
518
+ )
519
+
520
+ # Class weights: inverse frequency + disgust 2.5x boost
521
+ class_counts = np.zeros(NUM_CLASSES)
522
+ for sample in train_dataset.samples:
523
+ class_counts[LABEL2IDX[sample["label"]]] += 1
524
+ class_weights = 1.0 / np.maximum(class_counts, 1)
525
+ class_weights = class_weights / class_weights.sum() * NUM_CLASSES
526
+ class_weights[DISGUST_IDX] *= 2.5 # Disgust boost
527
+ logger.info("Class weights: %s",
528
+ {LABELS_7CLASS[i]: round(float(class_weights[i]), 3) for i in range(NUM_CLASSES)})
529
+
530
+ criterion = FocalLoss(
531
+ weight=torch.tensor(class_weights, dtype=torch.float32).to(device),
532
+ gamma=2.0,
533
+ label_smoothing=0.05,
534
+ )
535
+
536
+ # Optimizer: differential LR
537
+ lora_params = []
538
+ proj_params = list(encoder.proj.parameters())
539
+ proj_ids = {id(p) for p in proj_params}
540
+ for name, param in encoder.named_parameters():
541
+ if param.requires_grad and id(param) not in proj_ids:
542
+ lora_params.append(param)
543
+
544
+ optimizer = torch.optim.AdamW([
545
+ {"params": lora_params, "lr": args.lora_lr},
546
+ {"params": proj_params, "lr": args.proj_lr},
547
+ ], weight_decay=args.weight_decay)
548
+
549
+ # OneCycleLR scheduler
550
+ steps_per_epoch = max(len(train_loader) // accumulate_steps, 1)
551
+ total_steps = steps_per_epoch * args.epochs
552
+ scheduler = torch.optim.lr_scheduler.OneCycleLR(
553
+ optimizer,
554
+ max_lr=[args.lora_lr, args.proj_lr],
555
+ total_steps=total_steps,
556
+ pct_start=0.1,
557
+ anneal_strategy="cos",
558
+ )
559
+
560
+ # Output dir
561
+ output_dir = Path(args.output_dir)
562
+ output_dir.mkdir(parents=True, exist_ok=True)
563
+
564
+ # AMP scaler
565
+ scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
566
+
567
+ # Training state
568
+ training_log = []
569
+ best_f1 = 0.0
570
+ patience_counter = 0
571
+ start_epoch = 1
572
+ disgust_gate = 0.3
573
+
574
+ # Resume from checkpoint if requested
575
+ if args.resume:
576
+ resume_path = output_dir / "last_lora.pt"
577
+ if resume_path.exists():
578
+ resume_state = load_lora_checkpoint(
579
+ encoder, resume_path, device,
580
+ optimizer=optimizer, scheduler=scheduler, scaler=scaler,
581
+ )
582
+ start_epoch = resume_state["epoch"] + 1
583
+ best_f1 = resume_state["best_f1"]
584
+ patience_counter = resume_state["patience_counter"]
585
+ training_log = resume_state["training_log"]
586
+ logger.info("Resuming training from epoch %d (best_f1=%.4f)", start_epoch, best_f1)
587
+ else:
588
+ logger.warning("--resume set but no checkpoint found at %s, starting fresh", resume_path)
589
+
590
+ for epoch in range(start_epoch, args.epochs + 1):
591
+ epoch_start = time.time()
592
+ encoder.train()
593
+ total_loss = 0
594
+ correct = 0
595
+ total_samples = 0
596
+ optimizer.zero_grad()
597
+
598
+ for batch_idx, (waveforms, labels) in enumerate(train_loader):
599
+ labels = labels.to(device)
600
+
601
+ with torch.amp.autocast("cuda", enabled=use_amp):
602
+ logits = forward_pass(encoder, waveforms, device)
603
+ loss = criterion(logits, labels) / accumulate_steps
604
+
605
+ scaler.scale(loss).backward()
606
+
607
+ if (batch_idx + 1) % accumulate_steps == 0 or (batch_idx + 1) == len(train_loader):
608
+ scaler.unscale_(optimizer)
609
+ trainable_params = [p for p in encoder.parameters() if p.requires_grad]
610
+ torch.nn.utils.clip_grad_norm_(trainable_params, 1.0)
611
+ scaler.step(optimizer)
612
+ scaler.update()
613
+ optimizer.zero_grad()
614
+ scheduler.step()
615
+
616
+ total_loss += loss.item() * accumulate_steps * labels.size(0)
617
+ preds = logits.argmax(dim=-1)
618
+ correct += (preds == labels).sum().item()
619
+ total_samples += labels.size(0)
620
+
621
+ if (batch_idx + 1) % 10 == 0:
622
+ cur_lr = optimizer.param_groups[0]["lr"]
623
+ logger.info(" Epoch %d [%d/%d] loss=%.4f lr=%.2e",
624
+ epoch, batch_idx + 1, len(train_loader),
625
+ loss.item() * accumulate_steps, cur_lr)
626
+
627
+ train_loss = total_loss / max(total_samples, 1)
628
+ train_acc = correct / max(total_samples, 1)
629
+
630
+ # Validate
631
+ val_metrics = validate(encoder, val_loader, device, criterion)
632
+
633
+ epoch_time = time.time() - epoch_start
634
+ logger.info(
635
+ "Epoch %d/%d (%.0fs): train_loss=%.4f train_acc=%.3f | "
636
+ "val_loss=%.4f val_f1=%.3f val_acc=%.3f disgust_f1=%.3f",
637
+ epoch, args.epochs, epoch_time,
638
+ train_loss, train_acc,
639
+ val_metrics["loss"], val_metrics["macro_f1"],
640
+ val_metrics["accuracy"], val_metrics["disgust_f1"],
641
+ )
642
+ logger.info(" Per-class F1: %s", val_metrics["per_class_f1"])
643
+
644
+ epoch_log = {
645
+ "epoch": epoch,
646
+ "train_loss": round(train_loss, 4),
647
+ "train_acc": round(train_acc, 4),
648
+ "val_loss": val_metrics["loss"],
649
+ "val_accuracy": val_metrics["accuracy"],
650
+ "val_macro_f1": val_metrics["macro_f1"],
651
+ "val_disgust_f1": val_metrics["disgust_f1"],
652
+ "val_per_class_f1": val_metrics["per_class_f1"],
653
+ "epoch_time_sec": round(epoch_time, 1),
654
+ }
655
+ training_log.append(epoch_log)
656
+
657
+ # Disgust F1 Gate + best model save
658
+ gate_pass = val_metrics["disgust_f1"] >= disgust_gate
659
+ if not gate_pass:
660
+ logger.warning("[GATE FAIL] disgust_f1=%.3f < %.1f โ€” not saving as best",
661
+ val_metrics["disgust_f1"], disgust_gate)
662
+
663
+ if val_metrics["macro_f1"] > best_f1 and gate_pass:
664
+ best_f1 = val_metrics["macro_f1"]
665
+ patience_counter = 0
666
+ save_lora_checkpoint(
667
+ encoder, output_dir / "best_lora.pt", epoch, val_metrics, best_f1,
668
+ optimizer=optimizer, scheduler=scheduler, scaler=scaler,
669
+ training_log=training_log,
670
+ )
671
+ logger.info(" New best! macro_f1=%.4f (gate passed) saved to best_lora.pt", best_f1)
672
+
673
+ if "confusion_matrix" in val_metrics:
674
+ cm = np.array(val_metrics["confusion_matrix"])
675
+ plot_confusion_matrix(cm, output_dir / f"confusion_matrix_epoch{epoch}.png", epoch)
676
+ elif gate_pass:
677
+ patience_counter += 1
678
+ else:
679
+ # Gate fail does not increment patience
680
+ pass
681
+
682
+ # Always save last (with full state for resume)
683
+ save_lora_checkpoint(
684
+ encoder, output_dir / "last_lora.pt", epoch, val_metrics, best_f1, patience_counter,
685
+ optimizer=optimizer, scheduler=scheduler, scaler=scaler,
686
+ training_log=training_log,
687
+ )
688
+
689
+ # Save training log
690
+ with open(output_dir / "training_log.json", "w") as f:
691
+ json.dump(training_log, f, indent=2)
692
+
693
+ # Early stopping (only on gate-passing epochs)
694
+ if patience_counter >= args.patience:
695
+ logger.info("Early stopping at epoch %d (patience=%d)", epoch, args.patience)
696
+ break
697
+
698
+ if device == "cuda":
699
+ torch.cuda.empty_cache()
700
+
701
+ # Save config
702
+ config = {
703
+ "model": "iic/emotion2vec_plus_base",
704
+ "method": "LoRA",
705
+ "lora_r": args.lora_r,
706
+ "lora_alpha": args.lora_alpha,
707
+ "num_classes": NUM_CLASSES,
708
+ "labels": LABELS_7CLASS,
709
+ "label2idx": LABEL2IDX,
710
+ "best_val_f1": best_f1,
711
+ "training_args": vars(args),
712
+ }
713
+ with open(output_dir / "config.json", "w") as f:
714
+ json.dump(config, f, indent=2, ensure_ascii=False)
715
+
716
+ logger.info("Training complete. Best F1=%.4f at %s", best_f1, output_dir / "best_lora.pt")
717
+
718
+
719
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
720
+ # Main
721
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
722
+
723
+ def main():
724
+ parser = argparse.ArgumentParser(description="LoRA fine-tune emotion2vec 7-class")
725
+ parser.add_argument("--train-manifest", required=True)
726
+ parser.add_argument("--val-manifest", required=True)
727
+ parser.add_argument("--output-dir", default="data/models/lora_emotion2vec_7class")
728
+ parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
729
+ parser.add_argument("--epochs", type=int, default=20)
730
+ parser.add_argument("--batch-size", type=int, default=4)
731
+ parser.add_argument("--accumulate-steps", type=int, default=8)
732
+ parser.add_argument("--lora-r", type=int, default=16)
733
+ parser.add_argument("--lora-alpha", type=int, default=32)
734
+ parser.add_argument("--lora-dropout", type=float, default=0.1)
735
+ parser.add_argument("--lora-lr", type=float, default=2e-4)
736
+ parser.add_argument("--proj-lr", type=float, default=2e-3)
737
+ parser.add_argument("--weight-decay", type=float, default=0.01)
738
+ parser.add_argument("--patience", type=int, default=7)
739
+ parser.add_argument("--phone-augment-prob", type=float, default=0.3)
740
+ parser.add_argument("--noise-augment-prob", type=float, default=0.15)
741
+ parser.add_argument("--resume", action="store_true",
742
+ help="Resume from last_lora.pt checkpoint in output-dir")
743
+ args = parser.parse_args()
744
+
745
+ train(args)
746
+
747
+
748
+ if __name__ == "__main__":
749
+ main()
scripts/train_lora_kcelectra.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """LoRA Fine-Tuning KcELECTRA for 7-Class Korean Text Emotion Recognition.
3
+
4
+ Uses PEFT LoRA on beomi/KcELECTRA-base-v2022 for text-based emotion classification.
5
+ Filters out samples without text (e.g., RAVDESS English data).
6
+
7
+ Usage:
8
+ python scripts/train_lora_kcelectra.py \
9
+ --train-manifest data/lora_dataset/train_manifest.json \
10
+ --val-manifest data/lora_dataset/val_manifest.json \
11
+ --output-dir data/models/lora_kcelectra_7class \
12
+ --epochs 10 --batch-size 16 --device cuda
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import logging
19
+ import random
20
+ import time
21
+ from collections import Counter
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ import torch
26
+ import torch.nn as nn
27
+ from torch.utils.data import DataLoader, Dataset
28
+
29
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
30
+ logger = logging.getLogger(__name__)
31
+
32
+ LABELS_7CLASS = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
33
+ LABEL2IDX = {l: i for i, l in enumerate(LABELS_7CLASS)}
34
+ NUM_CLASSES = len(LABELS_7CLASS)
35
+
36
+
37
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
38
+ # Dataset
39
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
40
+
41
+ class TextEmotionDataset(Dataset):
42
+ """Load text + label from manifest, skip samples without text."""
43
+
44
+ def __init__(self, manifest_path: str, tokenizer, max_length: int = 128):
45
+ with open(manifest_path, encoding="utf-8") as f:
46
+ raw = json.load(f)
47
+
48
+ # Filter: only samples with non-empty Korean text
49
+ self.samples = [
50
+ s for s in raw
51
+ if s.get("text", "").strip() and s["label"] in LABEL2IDX
52
+ ]
53
+ self.tokenizer = tokenizer
54
+ self.max_length = max_length
55
+
56
+ logger.info("TextEmotionDataset: %d samples (filtered from %d, skipped %d without text)",
57
+ len(self.samples), len(raw), len(raw) - len(self.samples))
58
+
59
+ def __len__(self):
60
+ return len(self.samples)
61
+
62
+ def __getitem__(self, idx):
63
+ sample = self.samples[idx]
64
+ text = sample["text"]
65
+ label = LABEL2IDX[sample["label"]]
66
+
67
+ encoding = self.tokenizer(
68
+ text,
69
+ truncation=True,
70
+ max_length=self.max_length,
71
+ padding="max_length",
72
+ return_tensors="pt",
73
+ )
74
+
75
+ return {
76
+ "input_ids": encoding["input_ids"].squeeze(0),
77
+ "attention_mask": encoding["attention_mask"].squeeze(0),
78
+ "labels": torch.tensor(label, dtype=torch.long),
79
+ }
80
+
81
+
82
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
83
+ # Validation
84
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
85
+
86
+ @torch.no_grad()
87
+ def validate(model, val_loader, device, criterion):
88
+ model.eval()
89
+ total_loss = 0
90
+ y_true, y_pred = [], []
91
+
92
+ for batch in val_loader:
93
+ input_ids = batch["input_ids"].to(device)
94
+ attention_mask = batch["attention_mask"].to(device)
95
+ labels = batch["labels"].to(device)
96
+
97
+ outputs = model(input_ids=input_ids, attention_mask=attention_mask)
98
+ logits = outputs.logits
99
+ loss = criterion(logits, labels)
100
+
101
+ total_loss += loss.item() * labels.size(0)
102
+ y_true.extend(labels.cpu().tolist())
103
+ y_pred.extend(logits.argmax(dim=-1).cpu().tolist())
104
+
105
+ from sklearn.metrics import accuracy_score, f1_score, confusion_matrix
106
+ acc = accuracy_score(y_true, y_pred)
107
+ f1_per_class = f1_score(y_true, y_pred, labels=list(range(NUM_CLASSES)),
108
+ average=None, zero_division=0)
109
+ macro_f1 = float(np.mean(f1_per_class))
110
+ per_class = {LABELS_7CLASS[i]: round(float(f1_per_class[i]), 4) for i in range(NUM_CLASSES)}
111
+ cm = confusion_matrix(y_true, y_pred, labels=list(range(NUM_CLASSES)))
112
+
113
+ return {
114
+ "loss": round(total_loss / max(len(y_true), 1), 4),
115
+ "accuracy": round(acc, 4),
116
+ "macro_f1": round(macro_f1, 4),
117
+ "per_class_f1": per_class,
118
+ "confusion_matrix": cm.tolist(),
119
+ }
120
+
121
+
122
+ def plot_confusion_matrix(cm, output_path: Path, epoch: int):
123
+ try:
124
+ import matplotlib; matplotlib.use("Agg")
125
+ import matplotlib.pyplot as plt
126
+ import seaborn as sns
127
+ fig, ax = plt.subplots(figsize=(9, 7))
128
+ cm_norm = cm / cm.sum(axis=1, keepdims=True)
129
+ sns.heatmap(cm_norm, annot=True, fmt=".2f", cmap="Blues",
130
+ xticklabels=LABELS_7CLASS, yticklabels=LABELS_7CLASS, ax=ax)
131
+ for i in range(NUM_CLASSES):
132
+ for j in range(NUM_CLASSES):
133
+ ax.text(j + 0.5, i + 0.7, f"({cm[i][j]})",
134
+ ha="center", va="center", fontsize=6, color="gray")
135
+ ax.set_xlabel("Predicted"); ax.set_ylabel("True")
136
+ ax.set_title(f"KcELECTRA LoRA โ€” Epoch {epoch}")
137
+ plt.tight_layout(); plt.savefig(str(output_path), dpi=150); plt.close()
138
+ except ImportError:
139
+ pass
140
+
141
+
142
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
143
+ # Training
144
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
145
+
146
+ def train(args):
147
+ device = args.device
148
+ use_amp = (device == "cuda")
149
+
150
+ # Load tokenizer + base model
151
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
152
+ from peft import LoraConfig, get_peft_model, TaskType
153
+
154
+ logger.info("Loading KcELECTRA: %s", args.model_id)
155
+ tokenizer = AutoTokenizer.from_pretrained(args.model_id)
156
+ model = AutoModelForSequenceClassification.from_pretrained(
157
+ args.model_id,
158
+ num_labels=NUM_CLASSES,
159
+ id2label={i: l for i, l in enumerate(LABELS_7CLASS)},
160
+ label2id=LABEL2IDX,
161
+ )
162
+
163
+ # Apply LoRA
164
+ lora_config = LoraConfig(
165
+ r=args.lora_r,
166
+ lora_alpha=args.lora_alpha,
167
+ lora_dropout=args.lora_dropout,
168
+ target_modules=["query", "value"],
169
+ task_type=TaskType.SEQ_CLS,
170
+ bias="none",
171
+ )
172
+ model = get_peft_model(model, lora_config)
173
+ model.print_trainable_parameters()
174
+ model = model.to(device)
175
+
176
+ # Datasets
177
+ train_ds = TextEmotionDataset(args.train_manifest, tokenizer, max_length=args.max_length)
178
+ val_ds = TextEmotionDataset(args.val_manifest, tokenizer, max_length=args.max_length)
179
+ logger.info("Train: %d, Val: %d", len(train_ds), len(val_ds))
180
+
181
+ train_loader = DataLoader(
182
+ train_ds, batch_size=args.batch_size, shuffle=True,
183
+ num_workers=2, pin_memory=(device == "cuda"), drop_last=True,
184
+ )
185
+ val_loader = DataLoader(
186
+ val_ds, batch_size=args.batch_size, shuffle=False,
187
+ num_workers=2, pin_memory=(device == "cuda"),
188
+ )
189
+
190
+ # Class weights: inverse frequency only (no extra boost)
191
+ class_counts = np.zeros(NUM_CLASSES)
192
+ for s in train_ds.samples:
193
+ class_counts[LABEL2IDX[s["label"]]] += 1
194
+ class_weights = 1.0 / np.maximum(class_counts, 1)
195
+ class_weights = class_weights / class_weights.sum() * NUM_CLASSES
196
+ logger.info("Class weights: %s",
197
+ {LABELS_7CLASS[i]: round(float(class_weights[i]), 3) for i in range(NUM_CLASSES)})
198
+
199
+ criterion = nn.CrossEntropyLoss(
200
+ weight=torch.tensor(class_weights, dtype=torch.float32).to(device),
201
+ )
202
+
203
+ # Optimizer
204
+ optimizer = torch.optim.AdamW(
205
+ [p for p in model.parameters() if p.requires_grad],
206
+ lr=args.lr,
207
+ weight_decay=args.weight_decay,
208
+ )
209
+
210
+ # Scheduler
211
+ steps_per_epoch = max(len(train_loader) // args.accumulate_steps, 1)
212
+ total_steps = steps_per_epoch * args.epochs
213
+ scheduler = torch.optim.lr_scheduler.OneCycleLR(
214
+ optimizer, max_lr=args.lr, total_steps=total_steps,
215
+ pct_start=0.1, anneal_strategy="cos",
216
+ )
217
+
218
+ scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
219
+ output_dir = Path(args.output_dir)
220
+ output_dir.mkdir(parents=True, exist_ok=True)
221
+
222
+ # Training state
223
+ training_log = []
224
+ best_f1 = 0.0
225
+ patience_counter = 0
226
+ start_epoch = 1
227
+
228
+ # Resume
229
+ if args.resume:
230
+ ckpt_path = output_dir / "last_checkpoint.json"
231
+ if ckpt_path.exists():
232
+ with open(ckpt_path) as f:
233
+ ckpt_info = json.load(f)
234
+ start_epoch = ckpt_info["epoch"] + 1
235
+ best_f1 = ckpt_info["best_f1"]
236
+ patience_counter = ckpt_info["patience_counter"]
237
+ training_log = ckpt_info.get("training_log", [])
238
+ # Load model weights
239
+ model_path = output_dir / "last_model"
240
+ if model_path.exists():
241
+ from peft import PeftModel
242
+ model = AutoModelForSequenceClassification.from_pretrained(
243
+ args.model_id, num_labels=NUM_CLASSES,
244
+ id2label={i: l for i, l in enumerate(LABELS_7CLASS)},
245
+ label2id=LABEL2IDX,
246
+ )
247
+ model = PeftModel.from_pretrained(model, str(model_path))
248
+ model = model.to(device)
249
+ # Rebuild optimizer
250
+ optimizer = torch.optim.AdamW(
251
+ [p for p in model.parameters() if p.requires_grad],
252
+ lr=args.lr, weight_decay=args.weight_decay,
253
+ )
254
+ remaining_steps = steps_per_epoch * (args.epochs - start_epoch + 1)
255
+ scheduler = torch.optim.lr_scheduler.OneCycleLR(
256
+ optimizer, max_lr=args.lr, total_steps=max(remaining_steps, 1),
257
+ pct_start=0.1, anneal_strategy="cos",
258
+ )
259
+ logger.info("Resumed from epoch %d (best_f1=%.4f)", start_epoch, best_f1)
260
+ else:
261
+ logger.warning("--resume but no checkpoint found, starting fresh")
262
+
263
+ for epoch in range(start_epoch, args.epochs + 1):
264
+ epoch_start = time.time()
265
+ model.train()
266
+ total_loss = 0; correct = 0; total_samples = 0
267
+ optimizer.zero_grad()
268
+
269
+ for batch_idx, batch in enumerate(train_loader):
270
+ input_ids = batch["input_ids"].to(device)
271
+ attention_mask = batch["attention_mask"].to(device)
272
+ labels = batch["labels"].to(device)
273
+
274
+ with torch.amp.autocast("cuda", enabled=use_amp):
275
+ outputs = model(input_ids=input_ids, attention_mask=attention_mask)
276
+ logits = outputs.logits
277
+ loss = criterion(logits, labels) / args.accumulate_steps
278
+
279
+ scaler.scale(loss).backward()
280
+
281
+ if (batch_idx + 1) % args.accumulate_steps == 0 or (batch_idx + 1) == len(train_loader):
282
+ scaler.unscale_(optimizer)
283
+ torch.nn.utils.clip_grad_norm_(
284
+ [p for p in model.parameters() if p.requires_grad], 1.0,
285
+ )
286
+ scaler.step(optimizer); scaler.update()
287
+ optimizer.zero_grad(); scheduler.step()
288
+
289
+ total_loss += loss.item() * args.accumulate_steps * labels.size(0)
290
+ preds = logits.argmax(dim=-1)
291
+ correct += (preds == labels).sum().item()
292
+ total_samples += labels.size(0)
293
+
294
+ if (batch_idx + 1) % 50 == 0:
295
+ logger.info(" Epoch %d [%d/%d] loss=%.4f lr=%.2e",
296
+ epoch, batch_idx + 1, len(train_loader),
297
+ loss.item() * args.accumulate_steps,
298
+ optimizer.param_groups[0]["lr"])
299
+
300
+ train_loss = total_loss / max(total_samples, 1)
301
+ train_acc = correct / max(total_samples, 1)
302
+
303
+ # Validate
304
+ val_metrics = validate(model, val_loader, device, criterion)
305
+ epoch_time = time.time() - epoch_start
306
+
307
+ logger.info(
308
+ "Epoch %d/%d (%.0fs): train_loss=%.4f train_acc=%.3f | "
309
+ "val_loss=%.4f val_f1=%.3f val_acc=%.3f",
310
+ epoch, args.epochs, epoch_time, train_loss, train_acc,
311
+ val_metrics["loss"], val_metrics["macro_f1"], val_metrics["accuracy"],
312
+ )
313
+ logger.info(" Per-class F1: %s", val_metrics["per_class_f1"])
314
+
315
+ training_log.append({
316
+ "epoch": epoch,
317
+ "train_loss": round(train_loss, 4),
318
+ "train_acc": round(train_acc, 4),
319
+ **{f"val_{k}": v for k, v in val_metrics.items() if k != "confusion_matrix"},
320
+ "epoch_time_sec": round(epoch_time, 1),
321
+ })
322
+
323
+ # Best model
324
+ if val_metrics["macro_f1"] > best_f1:
325
+ best_f1 = val_metrics["macro_f1"]
326
+ patience_counter = 0
327
+ model.save_pretrained(str(output_dir / "best_model"))
328
+ tokenizer.save_pretrained(str(output_dir / "best_model"))
329
+ logger.info(" New best! macro_f1=%.4f saved to best_model/", best_f1)
330
+ if "confusion_matrix" in val_metrics:
331
+ cm = np.array(val_metrics["confusion_matrix"])
332
+ plot_confusion_matrix(cm, output_dir / f"cm_epoch{epoch}.png", epoch)
333
+ else:
334
+ patience_counter += 1
335
+
336
+ # Save last (for resume)
337
+ model.save_pretrained(str(output_dir / "last_model"))
338
+ tokenizer.save_pretrained(str(output_dir / "last_model"))
339
+ with open(output_dir / "last_checkpoint.json", "w") as f:
340
+ json.dump({
341
+ "epoch": epoch, "best_f1": best_f1,
342
+ "patience_counter": patience_counter,
343
+ "training_log": training_log,
344
+ }, f, indent=2)
345
+
346
+ with open(output_dir / "training_log.json", "w") as f:
347
+ json.dump(training_log, f, indent=2)
348
+
349
+ if patience_counter >= args.patience:
350
+ logger.info("Early stopping at epoch %d (patience=%d)", epoch, args.patience)
351
+ break
352
+
353
+ if device == "cuda":
354
+ torch.cuda.empty_cache()
355
+
356
+ # Save config
357
+ config = {
358
+ "base_model": args.model_id,
359
+ "method": "PEFT LoRA",
360
+ "lora_r": args.lora_r,
361
+ "lora_alpha": args.lora_alpha,
362
+ "target_modules": ["query", "value"],
363
+ "num_classes": NUM_CLASSES,
364
+ "labels": LABELS_7CLASS,
365
+ "best_val_f1": best_f1,
366
+ "training_args": vars(args),
367
+ }
368
+ with open(output_dir / "config.json", "w") as f:
369
+ json.dump(config, f, indent=2, ensure_ascii=False)
370
+
371
+ logger.info("Training complete. Best F1=%.4f", best_f1)
372
+
373
+
374
+ def main():
375
+ parser = argparse.ArgumentParser(description="LoRA fine-tune KcELECTRA 7-class")
376
+ parser.add_argument("--train-manifest", required=True)
377
+ parser.add_argument("--val-manifest", required=True)
378
+ parser.add_argument("--output-dir", default="data/models/lora_kcelectra_7class")
379
+ parser.add_argument("--model-id", default="beomi/KcELECTRA-base-v2022")
380
+ parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
381
+ parser.add_argument("--epochs", type=int, default=10)
382
+ parser.add_argument("--batch-size", type=int, default=16)
383
+ parser.add_argument("--accumulate-steps", type=int, default=2)
384
+ parser.add_argument("--lr", type=float, default=2e-4)
385
+ parser.add_argument("--weight-decay", type=float, default=0.01)
386
+ parser.add_argument("--patience", type=int, default=3)
387
+ parser.add_argument("--max-length", type=int, default=128)
388
+ parser.add_argument("--lora-r", type=int, default=16)
389
+ parser.add_argument("--lora-alpha", type=int, default=32)
390
+ parser.add_argument("--lora-dropout", type=float, default=0.1)
391
+ parser.add_argument("--resume", action="store_true")
392
+ args = parser.parse_args()
393
+
394
+ torch.manual_seed(42)
395
+ random.seed(42)
396
+ np.random.seed(42)
397
+
398
+ train(args)
399
+
400
+
401
+ if __name__ == "__main__":
402
+ main()
scripts/train_whisper_emotion_head.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Whisper-Medium Encoder + Linear Emotion Head ํ•™์Šต.
3
+
4
+ Whisper encoder๋ฅผ freezeํ•˜๊ณ  linear classifier head๋งŒ ํ•™์Šตํ•˜์—ฌ
5
+ benchmark_ser_models.py์˜ WhisperMediumAdapter์— ์‚ฌ์šฉํ•  ์ฒดํฌํฌ์ธํŠธ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
6
+
7
+ Usage:
8
+ # AI Hub ํ…Œ์ŠคํŠธ ์„œ๋ธŒ์…‹ ์™ธ์˜ ๋ฐ์ดํ„ฐ๋กœ ํ•™์Šต (test leakage ๋ฐฉ์ง€)
9
+ python scripts/train_whisper_emotion_head.py \\
10
+ --train-dir data/evaluation/korean/train_audio \\
11
+ --val-dir data/evaluation/korean/val_audio \\
12
+ --output data/models/whisper_emotion_head.pt
13
+
14
+ # prepare_aihub_test_subset.py์˜ ์ถœ๋ ฅ์œผ๋กœ quick test (test leakage ์ฃผ์˜)
15
+ python scripts/train_whisper_emotion_head.py \\
16
+ --train-dir data/evaluation/korean/test_audio \\
17
+ --epochs 3 --output data/models/whisper_emotion_head.pt
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import glob
24
+ import logging
25
+ import os
26
+ import time
27
+ from pathlib import Path
28
+
29
+ import numpy as np
30
+ import torch
31
+ import torch.nn as nn
32
+ from torch.utils.data import DataLoader, Dataset
33
+
34
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
35
+ logger = logging.getLogger(__name__)
36
+
37
+ EVAL_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear"]
38
+ LABEL_TO_IDX = {label: i for i, label in enumerate(EVAL_LABELS)}
39
+
40
+
41
+ class EmotionAudioDataset(Dataset):
42
+ """Load WAV files organized by emotion class directory."""
43
+
44
+ def __init__(self, root_dir: str, processor, max_samples_per_class: int | None = None):
45
+ self.samples = []
46
+ self.processor = processor
47
+
48
+ for label in EVAL_LABELS:
49
+ class_dir = Path(root_dir) / label
50
+ if not class_dir.exists():
51
+ logger.warning("Class directory not found: %s", class_dir)
52
+ continue
53
+
54
+ wavs = sorted(glob.glob(str(class_dir / "*.wav")))
55
+ if max_samples_per_class and len(wavs) > max_samples_per_class:
56
+ wavs = wavs[:max_samples_per_class]
57
+
58
+ for wav_path in wavs:
59
+ self.samples.append({
60
+ "path": wav_path,
61
+ "label": LABEL_TO_IDX[label],
62
+ })
63
+
64
+ logger.info("Dataset: %d samples from %s", len(self.samples), root_dir)
65
+
66
+ def __len__(self):
67
+ return len(self.samples)
68
+
69
+ def __getitem__(self, idx):
70
+ sample = self.samples[idx]
71
+ import librosa
72
+ audio, sr = librosa.load(sample["path"], sr=16000)
73
+ inputs = self.processor(audio, sampling_rate=16000, return_tensors="pt")
74
+ features = inputs.input_features.squeeze(0) # (n_mels, T)
75
+ return features, sample["label"]
76
+
77
+
78
+ def collate_fn(batch):
79
+ features, labels = zip(*batch)
80
+ # Pad features to same length
81
+ max_len = max(f.shape[1] for f in features)
82
+ padded = []
83
+ for f in features:
84
+ if f.shape[1] < max_len:
85
+ pad = torch.zeros(f.shape[0], max_len - f.shape[1])
86
+ f = torch.cat([f, pad], dim=1)
87
+ padded.append(f)
88
+ return torch.stack(padded), torch.tensor(labels, dtype=torch.long)
89
+
90
+
91
+ def train(args):
92
+ from transformers import WhisperModel, WhisperFeatureExtractor
93
+
94
+ device = torch.device(args.device)
95
+
96
+ # Load Whisper encoder (frozen)
97
+ logger.info("Loading Whisper-Medium encoder...")
98
+ processor = WhisperFeatureExtractor.from_pretrained("openai/whisper-medium")
99
+ whisper = WhisperModel.from_pretrained("openai/whisper-medium").to(device)
100
+ whisper.eval()
101
+ for param in whisper.parameters():
102
+ param.requires_grad = False
103
+
104
+ hidden_dim = whisper.config.d_model # 1024
105
+ head = nn.Linear(hidden_dim, len(EVAL_LABELS)).to(device)
106
+
107
+ # Dataset
108
+ train_dataset = EmotionAudioDataset(args.train_dir, processor, args.max_samples_per_class)
109
+ if len(train_dataset) == 0:
110
+ logger.error("No training samples found")
111
+ return
112
+
113
+ train_loader = DataLoader(
114
+ train_dataset, batch_size=args.batch_size, shuffle=True,
115
+ collate_fn=collate_fn, num_workers=0,
116
+ )
117
+
118
+ val_loader = None
119
+ if args.val_dir and Path(args.val_dir).exists():
120
+ val_dataset = EmotionAudioDataset(args.val_dir, processor)
121
+ if len(val_dataset) > 0:
122
+ val_loader = DataLoader(
123
+ val_dataset, batch_size=args.batch_size, shuffle=False,
124
+ collate_fn=collate_fn, num_workers=0,
125
+ )
126
+
127
+ # Optimizer
128
+ optimizer = torch.optim.Adam(head.parameters(), lr=args.lr)
129
+ criterion = nn.CrossEntropyLoss()
130
+
131
+ # Training loop
132
+ best_val_acc = 0.0
133
+ for epoch in range(args.epochs):
134
+ head.train()
135
+ total_loss = 0
136
+ correct = 0
137
+ total = 0
138
+
139
+ for batch_idx, (features, labels) in enumerate(train_loader):
140
+ features = features.to(device)
141
+ labels = labels.to(device)
142
+
143
+ with torch.no_grad():
144
+ encoder_out = whisper.encoder(features)
145
+ hidden = encoder_out.last_hidden_state # (B, T, D)
146
+ pooled = hidden.mean(dim=1) # (B, D)
147
+
148
+ logits = head(pooled)
149
+ loss = criterion(logits, labels)
150
+
151
+ optimizer.zero_grad()
152
+ loss.backward()
153
+ optimizer.step()
154
+
155
+ total_loss += loss.item() * labels.size(0)
156
+ preds = logits.argmax(dim=1)
157
+ correct += (preds == labels).sum().item()
158
+ total += labels.size(0)
159
+
160
+ train_acc = correct / max(total, 1)
161
+ avg_loss = total_loss / max(total, 1)
162
+ logger.info("Epoch %d/%d: loss=%.4f, train_acc=%.3f",
163
+ epoch + 1, args.epochs, avg_loss, train_acc)
164
+
165
+ # Validation
166
+ if val_loader:
167
+ head.eval()
168
+ val_correct = 0
169
+ val_total = 0
170
+ with torch.no_grad():
171
+ for features, labels in val_loader:
172
+ features = features.to(device)
173
+ labels = labels.to(device)
174
+ encoder_out = whisper.encoder(features)
175
+ pooled = encoder_out.last_hidden_state.mean(dim=1)
176
+ logits = head(pooled)
177
+ preds = logits.argmax(dim=1)
178
+ val_correct += (preds == labels).sum().item()
179
+ val_total += labels.size(0)
180
+ val_acc = val_correct / max(val_total, 1)
181
+ logger.info(" val_acc=%.3f", val_acc)
182
+
183
+ if val_acc > best_val_acc:
184
+ best_val_acc = val_acc
185
+ save_checkpoint(head, args.output, epoch, val_acc)
186
+ else:
187
+ # No val set โ€” save latest
188
+ save_checkpoint(head, args.output, epoch, train_acc)
189
+
190
+ logger.info("Training complete. Best checkpoint: %s", args.output)
191
+
192
+
193
+ def save_checkpoint(head: nn.Linear, path: str, epoch: int, accuracy: float):
194
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
195
+ torch.save(head.state_dict(), path)
196
+ logger.info("Saved checkpoint: %s (epoch=%d, acc=%.3f)", path, epoch + 1, accuracy)
197
+
198
+
199
+ def main():
200
+ parser = argparse.ArgumentParser(description="Train Whisper emotion classifier head")
201
+ parser.add_argument("--train-dir", required=True,
202
+ help="ํ•™์Šต ์˜ค๋””์˜ค ๋””๋ ‰ํ† ๋ฆฌ ({emotion}/*.wav ๊ตฌ์กฐ)")
203
+ parser.add_argument("--val-dir", default=None,
204
+ help="๊ฒ€์ฆ ์˜ค๋””์˜ค ๋””๋ ‰ํ† ๋ฆฌ (์—†์œผ๋ฉด train accuracy๋กœ ํŒ๋‹จ)")
205
+ parser.add_argument("--output", default="data/models/whisper_emotion_head.pt",
206
+ help="์ถœ๋ ฅ ์ฒดํฌํฌ์ธํŠธ ๊ฒฝ๋กœ")
207
+ parser.add_argument("--epochs", type=int, default=10)
208
+ parser.add_argument("--batch-size", type=int, default=8)
209
+ parser.add_argument("--lr", type=float, default=1e-3)
210
+ parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
211
+ parser.add_argument("--max-samples-per-class", type=int, default=None,
212
+ help="ํด๋ž˜์Šค๋‹น ์ตœ๋Œ€ ํ•™์Šต ์ƒ˜ํ”Œ ์ˆ˜")
213
+ args = parser.parse_args()
214
+
215
+ train(args)
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
scripts/validate_text_emotion_english.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """์˜์–ด ํ…์ŠคํŠธ ๊ฐ์ • ๋ชจ๋ธ (DistilRoBERTa) sanity check.
3
+
4
+ hand-crafted ์˜์–ด ๋ฌธ์žฅ์œผ๋กœ ๋ชจ๋ธ ๋กœ๋”ฉ, ์ถœ๋ ฅ ํฌ๋งท, ๊ธฐ๋ณธ ์ •ํ™•๋„๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค.
5
+
6
+ Usage:
7
+ python scripts/validate_text_emotion_english.py
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ PROJECT_ROOT = Path(__file__).parent.parent
17
+ sys.path.insert(0, str(PROJECT_ROOT))
18
+
19
+ logging.basicConfig(
20
+ level=logging.INFO,
21
+ format="%(asctime)s - %(levelname)s - %(message)s",
22
+ )
23
+ logger = logging.getLogger("validate_text_en")
24
+
25
+ PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
26
+
27
+ # (text, expected_emotion) โ€” ๋ช…ํ™•ํ•œ ๊ฐ์ • ํ‘œํ˜„ ๋ฌธ์žฅ
28
+ TEST_CASES = [
29
+ # joy
30
+ ("I'm so happy today, everything is wonderful!", "joy"),
31
+ ("This is the best day of my life!", "joy"),
32
+ ("I'm thrilled about the good news!", "joy"),
33
+ # anger
34
+ ("This makes me absolutely furious!", "anger"),
35
+ ("I can't believe how unfair this is, I'm so angry!", "anger"),
36
+ ("Stop doing that, it's driving me crazy!", "anger"),
37
+ # sadness
38
+ ("I feel so sad and lonely right now.", "sadness"),
39
+ ("I miss you so much, it hurts.", "sadness"),
40
+ ("I can't stop crying, everything feels hopeless.", "sadness"),
41
+ # fear
42
+ ("I'm really scared, something is wrong.", "fear"),
43
+ ("I'm terrified of what might happen next.", "fear"),
44
+ ("Help me, I'm so afraid!", "fear"),
45
+ # surprise
46
+ ("Oh my god, I can't believe it!", "surprise"),
47
+ ("Wow, I never expected that to happen!", "surprise"),
48
+ ("What?! That's absolutely incredible!", "surprise"),
49
+ # neutral
50
+ ("The meeting is scheduled for 3pm.", "neutral"),
51
+ ("I need to buy groceries on the way home.", "neutral"),
52
+ ("The temperature today is around 20 degrees.", "neutral"),
53
+ # disgust
54
+ ("That's absolutely disgusting, I feel sick.", "disgust"),
55
+ ("This food tastes terrible, it's revolting.", "disgust"),
56
+ ("I can't stand the smell, it's nauseating.", "disgust"),
57
+ ]
58
+
59
+
60
+ def validate():
61
+ """DistilRoBERTa ๋ชจ๋ธ ๊ฒ€์ฆ."""
62
+ from src.stage2.text_emotion import predict as text_predict
63
+
64
+ logger.info("=" * 60)
65
+ logger.info("์˜์–ด ํ…์ŠคํŠธ ๊ฐ์ • ๋ชจ๋ธ (DistilRoBERTa) ๊ฒ€์ฆ ์‹œ์ž‘")
66
+ logger.info("=" * 60)
67
+
68
+ passed = 0
69
+ failed = 0
70
+ results = []
71
+
72
+ for text, expected in TEST_CASES:
73
+ result = text_predict(text, language="en")
74
+
75
+ # ์ถœ๋ ฅ ํฌ๋งท ๊ฒ€์ฆ
76
+ assert "emotion" in result, f"Missing 'emotion' key in result"
77
+ assert "confidence" in result, f"Missing 'confidence' key in result"
78
+ assert "scores" in result, f"Missing 'scores' key in result"
79
+
80
+ # ๋ชจ๋“  ํ”„๋กœ์ ํŠธ ๋ผ๋ฒจ์ด scores์— ์žˆ๋Š”์ง€ ํ™•์ธ
81
+ for label in PROJECT_LABELS:
82
+ assert label in result["scores"], f"Missing label '{label}' in scores"
83
+
84
+ # scores ํ•ฉ๊ณ„ ~1.0 ํ™•์ธ
85
+ score_sum = sum(result["scores"].values())
86
+ assert abs(score_sum - 1.0) < 0.01, f"Scores sum={score_sum}, expected ~1.0"
87
+
88
+ predicted = result["emotion"]
89
+ confidence = result["confidence"]
90
+ match = predicted == expected
91
+
92
+ if match:
93
+ passed += 1
94
+ status = "PASS"
95
+ else:
96
+ failed += 1
97
+ status = "FAIL"
98
+
99
+ results.append({
100
+ "text": text[:50],
101
+ "expected": expected,
102
+ "predicted": predicted,
103
+ "confidence": confidence,
104
+ "match": match,
105
+ })
106
+
107
+ logger.info(
108
+ f" [{status}] expected={expected:10s} predicted={predicted:10s} "
109
+ f"conf={confidence:.3f} | \"{text[:45]}...\""
110
+ if len(text) > 45 else
111
+ f" [{status}] expected={expected:10s} predicted={predicted:10s} "
112
+ f"conf={confidence:.3f} | \"{text}\""
113
+ )
114
+
115
+ total = passed + failed
116
+ accuracy = passed / total if total > 0 else 0
117
+
118
+ logger.info(f"\n{'='*60}")
119
+ logger.info(f"๊ฒฐ๊ณผ: {passed}/{total} PASS ({accuracy:.1%})")
120
+ logger.info(f" Passed: {passed}")
121
+ logger.info(f" Failed: {failed}")
122
+
123
+ if accuracy >= 0.8:
124
+ logger.info("ํŒ์ •: PASS โ€” ๋ชจ๋ธ์ด ์ •์ƒ์ ์œผ๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.")
125
+ elif accuracy >= 0.6:
126
+ logger.info("ํŒ์ •: WARN โ€” ์ผ๋ถ€ ๊ฐ์ •์—์„œ ๋ถ€์ •ํ™•ํ•ฉ๋‹ˆ๋‹ค.")
127
+ else:
128
+ logger.info("ํŒ์ •: FAIL โ€” ๋ชจ๋ธ์— ๋ฌธ์ œ๊ฐ€ ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")
129
+
130
+ logger.info(f"{'='*60}")
131
+
132
+ return accuracy >= 0.6
133
+
134
+
135
+ if __name__ == "__main__":
136
+ success = validate()
137
+ sys.exit(0 if success else 1)