aether-raider commited on
Commit
9e1e4ee
·
1 Parent(s): 0345427

feat: mos form

Browse files
Files changed (42) hide show
  1. README.md +0 -12
  2. app/app.py +0 -347
  3. audios/female.wav +3 -0
  4. audios/male.wav +3 -0
  5. backend/__init__.py +26 -0
  6. backend/__pycache__/__init__.cpython-311.pyc +0 -0
  7. backend/__pycache__/config.cpython-311.pyc +0 -0
  8. backend/__pycache__/data_manager.cpython-311.pyc +0 -0
  9. backend/__pycache__/hf_integration.cpython-311.pyc +0 -0
  10. backend/__pycache__/hf_logging.cpython-311.pyc +0 -0
  11. backend/__pycache__/models.cpython-311.pyc +0 -0
  12. backend/__pycache__/session_manager.cpython-311.pyc +0 -0
  13. backend/config.py +5 -0
  14. backend/data_manager.py +119 -0
  15. backend/hf_logging.py +68 -0
  16. backend/models.py +60 -0
  17. backend/session_manager.py +456 -0
  18. backend/utils/hf_utils.py +0 -224
  19. config/config.json +0 -13
  20. frontend/__init__.py +0 -0
  21. frontend/__pycache__/__init__.cpython-311.pyc +0 -0
  22. frontend/__pycache__/css.cpython-311.pyc +0 -0
  23. frontend/app.py +511 -0
  24. frontend/css.py +160 -0
  25. frontend/pages/__init__.py +48 -0
  26. frontend/pages/__pycache__/__init__.cpython-311.pyc +0 -0
  27. frontend/pages/__pycache__/ab_gender.cpython-311.pyc +0 -0
  28. frontend/pages/__pycache__/ab_model.cpython-311.pyc +0 -0
  29. frontend/pages/__pycache__/conclusion.cpython-311.pyc +0 -0
  30. frontend/pages/__pycache__/intro.cpython-311.pyc +0 -0
  31. frontend/pages/__pycache__/mos.cpython-311.pyc +0 -0
  32. frontend/pages/__pycache__/mos_native.cpython-311.pyc +0 -0
  33. frontend/pages/__pycache__/samples.cpython-311.pyc +0 -0
  34. frontend/pages/__pycache__/thank_you.cpython-311.pyc +0 -0
  35. frontend/pages/ab_gender.py +238 -0
  36. frontend/pages/ab_model.py +240 -0
  37. frontend/pages/conclusion.py +45 -0
  38. frontend/pages/intro.py +48 -0
  39. frontend/pages/mos.py +252 -0
  40. frontend/pages/samples.py +64 -0
  41. frontend/pages/thank_you.py +27 -0
  42. requirements.txt +8 -0
README.md DELETED
@@ -1,12 +0,0 @@
1
- ---
2
- title: Atc Tts Mos
3
- emoji: 📉
4
- colorFrom: blue
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 5.49.1
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
app/app.py DELETED
@@ -1,347 +0,0 @@
1
- from __future__ import annotations
2
- import os, json, random, time
3
- from dataclasses import dataclass, asdict
4
- from pathlib import Path
5
- from typing import List, Tuple, Dict
6
- import gradio as gr
7
-
8
- # TODO: change later
9
- CLIPS: List[dict] = [
10
- # --- SAMPLE DATA (replace) ---
11
- {"model":"xtts","gender":"male","exercise":"Exercise 1","audio":"/audio/xtts/male/Exercise_1_1.wav","rt":"Tower controller requests clearance..."},
12
- {"model":"sesame","gender":"male","exercise":"Exercise 1","audio":"/audio/sesame/male/Exercise_1_1.wav","rt":"Tower controller requests clearance..."},
13
- {"model":"voxtream","gender":"male","exercise":"Exercise 1","audio":"/audio/voxtream/male/Exercise_1_1.wav","rt":"Tower controller requests clearance..."},
14
- {"model":"xtts","gender":"female","exercise":"Exercise 1","audio":"/audio/xtts/female/Exercise_1_1.wav","rt":"Tower controller requests clearance..."},
15
- {"model":"sesame","gender":"female","exercise":"Exercise 1","audio":"/audio/sesame/female/Exercise_1_1.wav","rt":"Tower controller requests clearance..."},
16
- ]
17
-
18
- # Locked counts per section
19
- N_MOS = 10
20
- N_ABMM = 4
21
- N_ABGF = 4
22
-
23
- SUPPORTED_GENDERS = {"male", "female"}
24
-
25
- @dataclass
26
- class Clip:
27
- model: str
28
- gender: str
29
- exercise: str
30
- audio: str
31
- rt: str
32
-
33
- @property
34
- def key(self) -> Tuple[str, str, str]:
35
- return (self.exercise or "", Path(self.audio).name, self.rt or "")
36
-
37
- @dataclass
38
- class MosItem:
39
- display_id: str
40
- clip: Clip
41
- alias: str # cosmetic Model A/B/C per item
42
-
43
- @dataclass
44
- class ABItem:
45
- display_id: str
46
- clip_A: Clip
47
- clip_B: Clip
48
- alias_A: str
49
- alias_B: str
50
-
51
- # ---------- helpers ----------
52
-
53
- def parse_clips(raw_list: List[dict]) -> List[Clip]:
54
- clips: List[Clip] = []
55
- for r in raw_list:
56
- c = Clip(
57
- model=str(r.get("model", "")).strip() or "unknown",
58
- gender=str(r.get("gender", "")).strip().lower(),
59
- exercise=str(r.get("exercise", "")).strip(),
60
- audio=str(r.get("audio", "")).strip(),
61
- rt=str(r.get("rt", "")).strip(),
62
- )
63
- if not c.audio:
64
- continue
65
- clips.append(c)
66
- return clips
67
-
68
-
69
- def build_indices(clips: List[Clip]):
70
- by_key: Dict[Tuple[str, str, str], List[Clip]] = {}
71
- for c in clips:
72
- by_key.setdefault(c.key, []).append(c)
73
- return by_key
74
-
75
-
76
- def sample_mos(clips: List[Clip], n_items: int, rng: random.Random) -> List[MosItem]:
77
- pool = [c for c in clips if c.audio]
78
- rng.shuffle(pool)
79
- sel = pool[:max(0, n_items)]
80
- items: List[MosItem] = []
81
- for i, c in enumerate(sel, 1):
82
- alias = rng.choice(["Model A", "Model B", "Model C"]) # cosmetic
83
- items.append(MosItem(display_id=f"MOS-{i}", clip=c, alias=alias))
84
- return items
85
-
86
-
87
- def sample_ab_model_vs_model(by_key, n_pairs: int, rng: random.Random) -> List[ABItem]:
88
- pairs: List[ABItem] = []
89
- keys = list(by_key.keys())
90
- rng.shuffle(keys)
91
- for key in keys:
92
- variants = by_key[key]
93
- # need two distinct models for same prompt
94
- uniq_by_model: Dict[str, Clip] = {}
95
- for v in variants:
96
- uniq_by_model.setdefault(v.model, v)
97
- if len(uniq_by_model) < 2:
98
- continue
99
- m = list(uniq_by_model.values())
100
- rng.shuffle(m)
101
- A, B = m[0], m[1]
102
- alias = ["Model A", "Model B"]
103
- rng.shuffle(alias)
104
- pairs.append(ABItem(display_id=f"ABMM-{len(pairs)+1}", clip_A=A, clip_B=B, alias_A=alias[0], alias_B=alias[1]))
105
- if len(pairs) >= n_pairs:
106
- break
107
- return pairs
108
-
109
-
110
- def sample_ab_gender_flip(by_key, n_pairs: int, rng: random.Random) -> List[ABItem]:
111
- pairs: List[ABItem] = []
112
- keys = list(by_key.keys())
113
- rng.shuffle(keys)
114
- for key in keys:
115
- variants = by_key[key]
116
- male = next((v for v in variants if v.gender == "male"), None)
117
- female = next((v for v in variants if v.gender == "female"), None)
118
- if not male or not female:
119
- continue
120
- A, B = male, female
121
- alias = ["Model A", "Model B"]
122
- rng.shuffle(alias)
123
- pairs.append(ABItem(display_id=f"ABGF-{len(pairs)+1}", clip_A=A, clip_B=B, alias_A=alias[0], alias_B=alias[1]))
124
- if len(pairs) >= n_pairs:
125
- break
126
- return pairs
127
-
128
- # ---------- UI builders ----------
129
-
130
- def human_name(c: Clip) -> str:
131
- rt_short = (c.rt[:120] + "…") if c.rt and len(c.rt) > 120 else (c.rt or "")
132
- ex = f" | {c.exercise}" if c.exercise else ""
133
- return f"{Path(c.audio).name}{ex} — {c.gender.title()}{rt_short}"
134
-
135
- def build_mos_block(items: List[MosItem]):
136
- blocks = []
137
- for it in items:
138
- gr.Markdown(f"### {it.display_id} — {it.alias}")
139
- audio = gr.Audio(label=human_name(it.clip), value=it.clip.audio, interactive=False)
140
- c1 = gr.Slider(1, 5, step=1, label="Clarity", elem_classes=["big-slider"])
141
- c2 = gr.Slider(1, 5, step=1, label="Pronunciation", elem_classes=["big-slider"])
142
- c3 = gr.Slider(1, 5, step=1, label="Prosody", elem_classes=["big-slider"])
143
- c4 = gr.Slider(1, 5, step=1, label="Naturalness", elem_classes=["big-slider"])
144
- c5 = gr.Slider(1, 5, step=1, label="Overall", elem_classes=["big-slider"])
145
- comment = gr.Textbox(label="Optional comment", lines=2)
146
- blocks.append({"item": it, "audio": audio, "sliders": [c1, c2, c3, c4, c5], "comment": comment})
147
- return blocks
148
-
149
-
150
- def build_ab_block(items: List[ABItem], title: str):
151
- blocks = []
152
- gr.Markdown(f"## {title}")
153
- for it in items:
154
- gr.Markdown(f"### {it.display_id}")
155
- a_audio = gr.Audio(label=f"{it.alias_A} — {human_name(it.clip_A)}", value=it.clip_A.audio, interactive=False)
156
- b_audio = gr.Audio(label=f"{it.alias_B} — {human_name(it.clip_B)}", value=it.clip_B.audio, interactive=False)
157
- choice = gr.Radio(["Left (first)", "Right (second)", "Tie"], label="Which is better?", value=None, elem_classes=["big-radio"])
158
- comment = gr.Textbox(label="Optional comment", lines=2)
159
- blocks.append({"item": it, "a_audio": a_audio, "b_audio": b_audio, "choice": choice, "comment": comment})
160
- return blocks
161
-
162
- # ---------- Wizard pages ----------
163
- css = """
164
- :root { --font-size: 20px; }
165
- .gradio-container { font-size: 20px; }
166
- h1, h2, h3 { line-height: 1.25; }
167
- .big-slider .wrap-inner { font-size: 18px; }
168
- .big-radio .wrap { font-size: 20px; }
169
- .gr-button { font-size: 18px; padding: 0.8rem 1.2rem; font-weight: 700; }
170
- """
171
-
172
- with gr.Blocks(title="ATC TTS Evaluation (Wizard)", css=css, theme=gr.themes.Default()) as demo:
173
- # States
174
- session = gr.State()
175
- mos_blocks_state = gr.State()
176
- abmm_blocks_state = gr.State()
177
- abgf_blocks_state = gr.State()
178
-
179
- # PAGE 1 — Intro
180
- page_intro = gr.Column(visible=True)
181
- with page_intro:
182
- gr.Markdown("""
183
- # ATC TTS Evaluation
184
- You will evaluate short ATC/aviation clips from three TTS systems. Within each item, models are anonymized as **Model A/B/C**.
185
-
186
- **Please use headphones in a quiet place.**
187
-
188
- **What you will do**
189
- - Rate each clip on five MOS dimensions (1–5): *Clarity, Pronunciation, Prosody, Naturalness, Overall*.
190
- - Do A/B comparisons on the same prompt: **Model vs Model**, and **Male vs Female**.
191
-
192
- *Estimated time: 10–15 minutes. Responses are anonymous and used only to improve the models.*
193
- """)
194
- start_btn = gr.Button("Start", variant="primary")
195
-
196
- # PAGE 2 — MOS
197
- page_mos = gr.Column(visible=False)
198
- with page_mos:
199
- gr.Markdown("## Section 2 — MOS Ratings")
200
- mos_container = gr.Column()
201
- mos_next = gr.Button("Next ▶", variant="primary")
202
-
203
- # PAGE 3 — AB: Model vs Model
204
- page_abmm = gr.Column(visible=False)
205
- with page_abmm:
206
- gr.Markdown("## Section 3 — A/B: Model vs Model (same prompt)")
207
- abmm_container = gr.Column()
208
- abmm_next = gr.Button("Next ▶", variant="primary")
209
-
210
- # PAGE 4 — AB: Male vs Female
211
- page_abgf = gr.Column(visible=False)
212
- with page_abgf:
213
- gr.Markdown("## Section 4 — A/B: Male vs Female (same model & prompt)")
214
- abgf_container = gr.Column()
215
- abgf_next = gr.Button("Next ▶", variant="primary")
216
-
217
- # PAGE 5 — Conclusion
218
- page_done = gr.Column(visible=False)
219
- with page_done:
220
- gr.Markdown("## Section 5 — Conclusion")
221
- pid = gr.Textbox(label="Participant ID (optional)")
222
- notes = gr.Textbox(label="Additional comments (optional)", lines=3)
223
- export_btn = gr.Button("Export Responses (JSON)", variant="primary")
224
- export_json = gr.Code(label="Copy/save this JSON", language="json")
225
-
226
- # ---- Handlers ----
227
- def _start():
228
- # Prepare randomized session using embedded CLIPS
229
- clips = parse_clips(CLIPS)
230
- if not clips:
231
- return {"visible": True, "value": "No clips configured in app."}, None, None, None, None
232
- rng = random.Random(time.time_ns())
233
- by_key = build_indices(clips)
234
- mos_items = sample_mos(clips, N_MOS, rng)
235
- abmm_items = sample_ab_model_vs_model(by_key, N_ABMM, rng)
236
- abgf_items = sample_ab_gender_flip(by_key, N_ABGF, rng)
237
- ses = {
238
- "seed": None,
239
- "timestamp": int(time.time()),
240
- "mos": [asdict(x) for x in mos_items],
241
- "abmm": [asdict(x) for x in abmm_items],
242
- "abgf": [asdict(x) for x in abgf_items],
243
- }
244
- # Build UI blocks now
245
- m_items = [MosItem(**{**x, "clip": Clip(**x["clip"])}) for x in ses["mos"]]
246
- a_items = [ABItem(**{**x, "clip_A": Clip(**x["clip_A"]), "clip_B": Clip(**x["clip_B"])}) for x in ses["abmm"]]
247
- g_items = [ABItem(**{**x, "clip_A": Clip(**x["clip_A"]), "clip_B": Clip(**x["clip_B"])}) for x in ses["abgf"]]
248
-
249
- # Render into containers
250
- with mos_container:
251
- mos_blocks = build_mos_block(m_items)
252
- with abmm_container:
253
- abmm_blocks = build_ab_block(a_items, title="Model vs Model")
254
- with abgf_container:
255
- abgf_blocks = build_ab_block(g_items, title="Male vs Female")
256
- return (
257
- gr.update(visible=False), # intro off
258
- gr.update(visible=True), # show MOS
259
- ses,
260
- mos_blocks,
261
- abmm_blocks,
262
- abgf_blocks,
263
- )
264
-
265
- start_btn.click(
266
- fn=_start,
267
- inputs=[],
268
- outputs=[page_intro, page_mos, session, mos_blocks_state, abmm_blocks_state, abgf_blocks_state],
269
- )
270
-
271
- def _to_abmm():
272
- return gr.update(visible=False), gr.update(visible=True)
273
-
274
- mos_next.click(fn=_to_abmm, inputs=[], outputs=[page_mos, page_abmm])
275
-
276
- def _to_abgf():
277
- return gr.update(visible=False), gr.update(visible=True)
278
-
279
- abmm_next.click(fn=_to_abgf, inputs=[], outputs=[page_abmm, page_abgf])
280
-
281
- def _to_done():
282
- return gr.update(visible=False), gr.update(visible=True)
283
-
284
- abgf_next.click(fn=_to_done, inputs=[], outputs=[page_abgf, page_done])
285
-
286
- def _collect(ses, mos_blocks, abmm_blocks, abgf_blocks, pid, notes):
287
- if not ses:
288
- return json.dumps({"error": "no session"}, indent=2)
289
- # MOS
290
- mos_resp = []
291
- for b in mos_blocks or []:
292
- sliders = [s.value for s in b["sliders"]]
293
- mos_resp.append({
294
- "display_id": b["item"].display_id,
295
- "model": b["item"].clip.model,
296
- "gender": b["item"].clip.gender,
297
- "exercise": b["item"].clip.exercise,
298
- "audio": b["item"].clip.audio,
299
- "rt": b["item"].clip.rt,
300
- "clarity": sliders[0],
301
- "pronunciation": sliders[1],
302
- "prosody": sliders[2],
303
- "naturalness": sliders[3],
304
- "overall": sliders[4],
305
- "comment": b["comment"].value,
306
- })
307
- # AB: model vs model
308
- abmm_resp = []
309
- for b in abmm_blocks or []:
310
- abmm_resp.append({
311
- "display_id": b["item"].display_id,
312
- "A": {"model": b["item"].clip_A.model, "gender": b["item"].clip_A.gender, "audio": b["item"].clip_A.audio},
313
- "B": {"model": b["item"].clip_B.model, "gender": b["item"].clip_B.gender, "audio": b["item"].clip_B.audio},
314
- "choice": b["choice"].value,
315
- "comment": b["comment"].value,
316
- })
317
- # AB: gender flip
318
- abgf_resp = []
319
- for b in abgf_blocks or []:
320
- abgf_resp.append({
321
- "display_id": b["item"].display_id,
322
- "male": {"model": b["item"].clip_A.model if b["item"].clip_A.gender=="male" else b["item"].clip_B.model,
323
- "audio": b["item"].clip_A.audio if b["item"].clip_A.gender=="male" else b["item"].clip_B.audio},
324
- "female": {"model": b["item"].clip_A.model if b["item"].clip_A.gender=="female" else b["item"].clip_B.model,
325
- "audio": b["item"].clip_A.audio if b["item"].clip_A.gender=="female" else b["item"].clip_B.audio},
326
- "choice": b["choice"].value,
327
- "comment": b["comment"].value,
328
- })
329
- out = {
330
- "pid": pid or "anon",
331
- "notes": notes or "",
332
- "timestamp": ses.get("timestamp"),
333
- "mos": mos_resp,
334
- "ab_model_vs_model": abmm_resp,
335
- "ab_gender_flip": abgf_resp,
336
- }
337
- return json.dumps(out, indent=2)
338
-
339
- export_btn.click(
340
- fn=_collect,
341
- inputs=[session, mos_blocks_state, abmm_blocks_state, abgf_blocks_state, pid, notes],
342
- outputs=[export_json],
343
- )
344
-
345
- if __name__ == "__main__":
346
- os.environ.setdefault("GRADIO_THEME", "default")
347
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
audios/female.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:59ada1cd9b9940a91544ed4068547dc56efe85d047aa1e174c5486183e57dfa1
3
+ size 442662
audios/male.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f5349f8fc82f18d9841a48b34cabca71872f0363218c0e5a46359c89ae653808
3
+ size 664600
backend/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/__init__.py
2
+
3
+ from .config import RATINGS_DATASET_ID, AUDIO_DATASET_ID
4
+ from .models import (
5
+ Clip,
6
+ MOSResponse,
7
+ ABResponse,
8
+ get_display_model_name,
9
+ audio_to_base64_url,
10
+ )
11
+ from .data_manager import DataManager
12
+ from .session_manager import SessionManager
13
+ from .hf_logging import push_session_to_hub
14
+
15
+ __all__ = [
16
+ "RATINGS_DATASET_ID",
17
+ "AUDIO_DATASET_ID",
18
+ "Clip",
19
+ "MOSResponse",
20
+ "ABResponse",
21
+ "get_display_model_name",
22
+ "audio_to_base64_url",
23
+ "DataManager",
24
+ "SessionManager",
25
+ "push_session_to_hub",
26
+ ]
backend/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (779 Bytes). View file
 
backend/__pycache__/config.cpython-311.pyc ADDED
Binary file (289 Bytes). View file
 
backend/__pycache__/data_manager.cpython-311.pyc ADDED
Binary file (5.86 kB). View file
 
backend/__pycache__/hf_integration.cpython-311.pyc ADDED
Binary file (2.75 kB). View file
 
backend/__pycache__/hf_logging.cpython-311.pyc ADDED
Binary file (4.3 kB). View file
 
backend/__pycache__/models.cpython-311.pyc ADDED
Binary file (2.79 kB). View file
 
backend/__pycache__/session_manager.cpython-311.pyc ADDED
Binary file (22 kB). View file
 
backend/config.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # backend/config.py
2
+
3
+ # Dataset IDs
4
+ RATINGS_DATASET_ID = "aether-raid/atc-tts-mos-ratings"
5
+ AUDIO_DATASET_ID = "aether-raid/axite-tts"
backend/data_manager.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/data_manager.py
2
+
3
+ import os
4
+ import base64
5
+ import io
6
+ from typing import List, Optional, Any, Dict
7
+
8
+ import numpy as np
9
+ from datasets import load_dataset
10
+
11
+ from .config import AUDIO_DATASET_ID
12
+ from .models import Clip
13
+
14
+ try:
15
+ import soundfile as sf
16
+ except ImportError:
17
+ sf = None
18
+
19
+
20
+ class DataManager:
21
+ """Handles loading and processing data from Hugging Face."""
22
+
23
+ def __init__(self, dataset_id: str = AUDIO_DATASET_ID):
24
+ self.dataset_id = dataset_id
25
+ self._clips: Optional[List[Clip]] = None
26
+ self._loading = False
27
+
28
+ def _audio_to_data_url(self, audio_val) -> Optional[str]:
29
+ """
30
+ Accepts:
31
+ - torchcodec AudioDecoder
32
+ - dict-like with 'path' / 'array' / 'sampling_rate'
33
+ Returns data:audio/wav;base64,... or None.
34
+ """
35
+ # 1) Try to get a real file path and read it
36
+ try:
37
+ path = None
38
+ if isinstance(audio_val, dict) and "path" in audio_val:
39
+ path = audio_val["path"]
40
+ else:
41
+ # mapping-like: try __getitem__ then attribute
42
+ try:
43
+ path = audio_val["path"] # works on some decoders
44
+ except Exception:
45
+ path = getattr(audio_val, "path", None)
46
+
47
+ if isinstance(path, str) and os.path.exists(path):
48
+ with open(path, "rb") as f:
49
+ audio_bytes = f.read()
50
+ b64 = base64.b64encode(audio_bytes).decode("ascii")
51
+ return f"data:audio/wav;base64,{b64}"
52
+ except Exception as e:
53
+ print(f"[WARN] Failed to build data URL from path: {e}")
54
+
55
+ # 2) Fallback: use array + sampling_rate and render WAV in-memory
56
+ try:
57
+ array = None
58
+ sr = None
59
+
60
+ if isinstance(audio_val, dict):
61
+ array = audio_val.get("array")
62
+ sr = audio_val.get("sampling_rate")
63
+ if array is None or sr is None:
64
+ # try mapping-style then attributes
65
+ try:
66
+ array = audio_val["array"]
67
+ sr = audio_val["sampling_rate"]
68
+ except Exception:
69
+ array = getattr(audio_val, "array", None)
70
+ sr = getattr(audio_val, "sampling_rate", None)
71
+
72
+ if array is not None and sr is not None and sf is not None:
73
+ buf = io.BytesIO()
74
+ sf.write(buf, np.array(array), int(sr), format="WAV")
75
+ b64 = base64.b64encode(buf.getvalue()).decode("ascii")
76
+ return f"data:audio/wav;base64,{b64}"
77
+ except Exception as e:
78
+ print(f"[WARN] Failed to build data URL from array/sr: {e}")
79
+
80
+ print("[WARN] Could not build audio data URL for this example")
81
+ return None
82
+
83
+ def load_clips(self) -> List[Clip]:
84
+ if self._clips is not None:
85
+ return self._clips
86
+
87
+ if self._loading:
88
+ print("Dataset loading already in progress...")
89
+ return []
90
+
91
+ self._loading = True
92
+
93
+ print(f"Loading dataset {self.dataset_id}...")
94
+ dataset = load_dataset(self.dataset_id, split="train")
95
+
96
+ clips: List[Clip] = []
97
+ for row in dataset:
98
+ audio_val = row.get("audio")
99
+
100
+ audio_url = self._audio_to_data_url(audio_val)
101
+ if audio_url is None:
102
+ print(f"[WARN] Skipping clip {row.get('exercise_id')} – could not build audio URL")
103
+ continue
104
+
105
+ clip = Clip(
106
+ id=f"{row['model']}_{row['speaker']}_{row['exercise_id']}",
107
+ model=row["model"],
108
+ speaker=row["speaker"],
109
+ exercise=row["exercise"],
110
+ exercise_id=row["exercise_id"],
111
+ transcript=row["rt"],
112
+ audio_url=audio_url, # string usable in <audio src="...">
113
+ )
114
+ clips.append(clip)
115
+
116
+ self._clips = clips
117
+ self._loading = False
118
+ print(f"Loaded {len(clips)} clips")
119
+ return clips
backend/hf_logging.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/hf_logging.py
2
+
3
+ import json
4
+ import io
5
+ from datetime import datetime
6
+ from typing import Dict, Any
7
+
8
+ from huggingface_hub import HfApi, CommitOperationAdd
9
+
10
+ from .config import RATINGS_DATASET_ID
11
+
12
+ # Hugging Face API for logging
13
+ hf_api = HfApi()
14
+
15
+
16
+ def push_session_to_hub(session_id: str, export_data: Dict[str, Any]):
17
+ """
18
+ Save one session's ratings to the HF dataset as sessions/<session_id>.json
19
+ """
20
+ # Add human-readable timestamps and summary to session metadata
21
+ if "session_metadata" in export_data:
22
+ metadata = export_data["session_metadata"]
23
+ if "created_at" in metadata and metadata["created_at"]:
24
+ metadata["created_at_readable"] = datetime.fromtimestamp(metadata["created_at"]).isoformat()
25
+ if "exported_at" in metadata and metadata["exported_at"]:
26
+ metadata["exported_at_readable"] = datetime.fromtimestamp(metadata["exported_at"]).isoformat()
27
+
28
+ # Add a summary section for easy viewing
29
+ export_data["summary"] = {
30
+ "session_id": session_id,
31
+ "evaluation_completed": export_data.get("session_metadata", {}).get("completed", False),
32
+ "total_mos_ratings": len(export_data.get("mos_ratings", [])),
33
+ "total_ab_comparisons": len(export_data.get("ab_comparisons", [])),
34
+ "models_evaluated": list(set(
35
+ [r.get("model") for r in export_data.get("mos_ratings", [])] +
36
+ [r.get("clip_a_model") for r in export_data.get("ab_comparisons", [])] +
37
+ [r.get("clip_b_model") for r in export_data.get("ab_comparisons", [])]
38
+ )),
39
+ "evaluation_date": export_data.get("session_metadata", {}).get("created_at_readable", ""),
40
+ }
41
+
42
+ # Add readable timestamps to individual responses
43
+ for response_list in [export_data.get("mos_ratings", []), export_data.get("ab_comparisons", [])]:
44
+ for response in response_list:
45
+ if "response_timestamp" in response and response["response_timestamp"]:
46
+ response["response_timestamp_readable"] = datetime.fromtimestamp(response["response_timestamp"]).isoformat()
47
+
48
+ # Serialize to bytes with nice formatting
49
+ json_bytes = json.dumps(export_data, indent=2, default=str, ensure_ascii=False).encode("utf-8")
50
+ fileobj = io.BytesIO(json_bytes)
51
+
52
+ # Path inside the dataset repo
53
+ path_in_repo = f"sessions/{session_id}.json"
54
+
55
+ # Create a git commit adding/updating that file
56
+ hf_api.create_commit(
57
+ repo_id=RATINGS_DATASET_ID,
58
+ repo_type="dataset",
59
+ operations=[
60
+ CommitOperationAdd(
61
+ path_in_repo=path_in_repo,
62
+ path_or_fileobj=fileobj,
63
+ )
64
+ ],
65
+ commit_message=f"Add MOS ratings for session {session_id}",
66
+ )
67
+
68
+ print(f"[LOG] Pushed session {session_id} to {RATINGS_DATASET_ID}/{path_in_repo}")
backend/models.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/models.py
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Optional
5
+
6
+ # Model name mapping for anonymization
7
+ # Maps internal model names to display labels
8
+ MODEL_NAME_MAP = {
9
+ "xtts": "Model A", # Coqui XTTS -> Model A
10
+ "csm": "Model B", # Sesame CSM -> Model B
11
+ "orpheus": "Model C", # Orpheus -> Model C
12
+ }
13
+
14
+
15
+ def get_display_model_name(internal_name: str) -> str:
16
+ """Convert internal model name to display label."""
17
+ return MODEL_NAME_MAP.get(internal_name, internal_name.upper())
18
+
19
+
20
+ def audio_to_base64_url(audio_data):
21
+ """Return the audio data URL string as-is (no-op since audio_url is already a base64 data URL)."""
22
+ return audio_data if isinstance(audio_data, str) else None
23
+
24
+
25
+ # Data models
26
+ @dataclass
27
+ class Clip:
28
+ id: str
29
+ model: str
30
+ speaker: str # male/female
31
+ exercise: str
32
+ exercise_id: str
33
+ transcript: str
34
+ audio_url: Any # Can be string URL or tuple (array, sample_rate)
35
+ duration_s: Optional[float] = None
36
+
37
+
38
+ @dataclass
39
+ class MOSResponse:
40
+ session_id: str
41
+ clip_id: str
42
+ clarity: int
43
+ pronunciation: int
44
+ prosody: int
45
+ naturalness: int
46
+ overall: int
47
+ comment: str = ""
48
+ gender_mismatch: bool = False # Flag for wrong gender voice
49
+
50
+
51
+ @dataclass
52
+ class ABResponse:
53
+ session_id: str
54
+ clip_a_id: str
55
+ clip_b_id: str
56
+ comparison_type: str # "model_vs_model" or "gender_vs_gender"
57
+ choice: str # "A", "B", "tie"
58
+ comment: str = ""
59
+ gender_mismatch_a: bool = False # Flag for wrong gender voice in clip A
60
+ gender_mismatch_b: bool = False # Flag for wrong gender voice in clip B
backend/session_manager.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/session_manager.py
2
+
3
+ import json
4
+ import random
5
+ import time
6
+ import uuid
7
+ from typing import List, Dict, Any, Optional
8
+
9
+ from .models import Clip, get_display_model_name
10
+
11
+
12
+ class SessionManager:
13
+ """Manages evaluation sessions, responses, and export logic."""
14
+
15
+ def __init__(self, data_manager):
16
+ self.data_manager = data_manager
17
+ self.sessions: Dict[str, Dict[str, Any]] = {}
18
+ self.responses: Dict[str, List[Dict[str, Any]]] = {
19
+ "mos": [],
20
+ "ab": [],
21
+ "feedback": [],
22
+ }
23
+
24
+ # --------------------------
25
+ # Session creation
26
+ # --------------------------
27
+ def create_session(self) -> Dict[str, Any]:
28
+ session_id = str(uuid.uuid4())
29
+ clips = self.data_manager.load_clips()
30
+
31
+ rng = random.Random(time.time())
32
+
33
+ mos_clips: List[Clip] = []
34
+ models = {clip.model for clip in clips}
35
+
36
+ # Build MOS clip set
37
+ for model in models:
38
+ model_clips = [clip for clip in clips if clip.model == model]
39
+
40
+ exercise_groups: Dict[str, Dict[str, List[Clip]]] = {}
41
+ for clip in model_clips:
42
+ if clip.exercise_id not in exercise_groups:
43
+ exercise_groups[clip.exercise_id] = {"male": [], "female": []}
44
+ exercise_groups[clip.exercise_id][clip.speaker].append(clip)
45
+
46
+ paired_exercises = []
47
+ for _, speakers in exercise_groups.items():
48
+ if speakers["male"] and speakers["female"]:
49
+ male_clip = rng.choice(speakers["male"])
50
+ female_clip = rng.choice(speakers["female"])
51
+ paired_exercises.append((male_clip, female_clip))
52
+
53
+ if len(paired_exercises) >= 3:
54
+ selected_pairs = rng.sample(paired_exercises, 3)
55
+ for male_clip, female_clip in selected_pairs:
56
+ mos_clips.extend([male_clip, female_clip])
57
+ else:
58
+ for male_clip, female_clip in paired_exercises:
59
+ mos_clips.extend([male_clip, female_clip])
60
+
61
+ used_clip_ids = {clip.id for clip in mos_clips if clip.model == model}
62
+ remaining_clips = [
63
+ clip for clip in model_clips if clip.id not in used_clip_ids
64
+ ]
65
+ slots_needed = 6 - len([c for c in mos_clips if c.model == model])
66
+
67
+ if remaining_clips and slots_needed > 0:
68
+ additional_clips = rng.sample(
69
+ remaining_clips, min(slots_needed, len(remaining_clips))
70
+ )
71
+ mos_clips.extend(additional_clips)
72
+
73
+ # Group by content (exercise + transcript) for comparisons
74
+ content_groups: Dict[Any, List[Clip]] = {}
75
+ for clip in clips:
76
+ key = (clip.exercise, clip.exercise_id, clip.transcript)
77
+ content_groups.setdefault(key, []).append(clip)
78
+
79
+ # Model vs model pairs (ensure same gender)
80
+ ab_model_pairs = []
81
+ for _, group in content_groups.items():
82
+ # Group clips by model and speaker (gender)
83
+ model_speaker_map: Dict[str, Dict[str, List[Clip]]] = {}
84
+ for clip in group:
85
+ model_speaker_map.setdefault(clip.model, {}).setdefault(clip.speaker, []).append(clip)
86
+
87
+ model_names = list(model_speaker_map.keys())
88
+ if len(model_names) >= 2:
89
+ # Try each possible gender to find matching clips
90
+ for speaker in ["male", "female"]:
91
+ # Find models that have clips for this speaker
92
+ available_models = [
93
+ model for model in model_names
94
+ if speaker in model_speaker_map[model] and model_speaker_map[model][speaker]
95
+ ]
96
+
97
+ if len(available_models) >= 2:
98
+ model_a, model_b = rng.sample(available_models, 2)
99
+ clip_a = rng.choice(model_speaker_map[model_a][speaker])
100
+ clip_b = rng.choice(model_speaker_map[model_b][speaker])
101
+ ab_model_pairs.append((clip_a, clip_b))
102
+ break # Found a valid pair for this content group
103
+
104
+ if len(ab_model_pairs) >= 6:
105
+ break
106
+
107
+ # Gender vs gender pairs
108
+ ab_gender_pairs = []
109
+ for _, group in content_groups.items():
110
+ model_gender_groups: Dict[str, Dict[str, List[Clip]]] = {}
111
+ for clip in group:
112
+ model_gender_groups.setdefault(clip.model, {}).setdefault(
113
+ clip.speaker, []
114
+ ).append(clip)
115
+
116
+ for model, gender_groups in model_gender_groups.items():
117
+ if "male" in gender_groups and "female" in gender_groups:
118
+ clip_male = rng.choice(gender_groups["male"])
119
+ clip_female = rng.choice(gender_groups["female"])
120
+ ab_gender_pairs.append((clip_male, clip_female))
121
+ if len(ab_gender_pairs) >= 6:
122
+ break
123
+ if len(ab_gender_pairs) >= 6:
124
+ break
125
+
126
+ # Sample clips (for the reference/sample section)
127
+ remaining_clips = [c for c in clips if c not in mos_clips]
128
+ sample_clips = rng.sample(remaining_clips, min(3, len(remaining_clips)))
129
+
130
+ session_data: Dict[str, Any] = {
131
+ "session_id": session_id,
132
+ "created_at": time.time(),
133
+ "mos_clips": mos_clips,
134
+ "ab_model_pairs": ab_model_pairs,
135
+ "ab_gender_pairs": ab_gender_pairs,
136
+ "sample_clips": sample_clips,
137
+ "completed": False,
138
+ }
139
+
140
+ self.sessions[session_id] = session_data
141
+ return session_data
142
+
143
+ # --------------------------
144
+ # Response storage helpers
145
+ # --------------------------
146
+ def save_response(self, response_type: str, response: Dict[str, Any]):
147
+ """Generic low-level append with auto-timestamp."""
148
+ if "timestamp" not in response:
149
+ response["timestamp"] = time.time()
150
+ self.responses.setdefault(response_type, []).append(response)
151
+
152
+ def save_mos_rating(
153
+ self,
154
+ session: Dict[str, Any],
155
+ clip_id: str,
156
+ model: str,
157
+ clarity: Optional[int],
158
+ pronunciation: Optional[int],
159
+ prosody: Optional[int],
160
+ naturalness: Optional[int],
161
+ overall: Optional[int],
162
+ comment: str,
163
+ gender_mismatch: bool,
164
+ ) -> None:
165
+ """Optional helper for saving a single MOS rating."""
166
+ if not session:
167
+ return
168
+
169
+ mos_response = {
170
+ "session_id": session["session_id"],
171
+ "clip_id": clip_id,
172
+ "clarity": int(clarity) if clarity is not None else None,
173
+ "pronunciation": int(pronunciation) if pronunciation is not None else None,
174
+ "prosody": int(prosody) if prosody is not None else None,
175
+ "naturalness": int(naturalness) if naturalness is not None else None,
176
+ "overall": int(overall) if overall is not None else None,
177
+ "comment": comment or "",
178
+ "gender_mismatch": bool(gender_mismatch),
179
+ "timestamp": time.time(),
180
+ }
181
+ self.save_response("mos", mos_response)
182
+
183
+ def save_ab_rating(
184
+ self,
185
+ session: Dict[str, Any],
186
+ clip_a_id: str,
187
+ clip_b_id: str,
188
+ comparison_type: str,
189
+ choice: str,
190
+ comment: str,
191
+ gender_mismatch_a: bool,
192
+ gender_mismatch_b: bool,
193
+ ) -> None:
194
+ """Optional helper for saving a single A/B comparison."""
195
+ if not session:
196
+ return
197
+
198
+ ab_response = {
199
+ "session_id": session["session_id"],
200
+ "clip_a_id": clip_a_id,
201
+ "clip_b_id": clip_b_id,
202
+ "comparison_type": comparison_type,
203
+ "choice": choice,
204
+ "comment": comment or "",
205
+ "gender_mismatch_a": bool(gender_mismatch_a),
206
+ "gender_mismatch_b": bool(gender_mismatch_b),
207
+ "timestamp": time.time(),
208
+ }
209
+ self.save_response("ab", ab_response)
210
+
211
+ # --------------------------
212
+ # Bulk processing from JS JSON
213
+ # --------------------------
214
+ def process_mos_data(
215
+ self,
216
+ session: Dict[str, Any],
217
+ mos_data_json: str,
218
+ ) -> None:
219
+ """
220
+ Take the JSON string from the hidden MOS textbox and turn it into
221
+ individual MOS responses in self.responses["mos"].
222
+ """
223
+ print(f"[DEBUG] process_mos_data called with JSON: '{mos_data_json}'")
224
+ print(f"[DEBUG] Session ID: {session.get('session_id') if session else 'None'}")
225
+ if not session or not mos_data_json:
226
+ print(f"[DEBUG] Skipping MOS processing - session: {session is not None}, data length: {len(mos_data_json) if mos_data_json else 0}")
227
+ return
228
+
229
+ try:
230
+ ratings_data = json.loads(mos_data_json) if mos_data_json else {}
231
+ except json.JSONDecodeError as e:
232
+ print(f"[WARN] Failed to parse MOS data JSON: {e}")
233
+ return
234
+
235
+ try:
236
+ for clip_id, ratings in ratings_data.items():
237
+ # Only process if we have at least one dimension filled
238
+ if not any(
239
+ ratings.get(dim)
240
+ for dim in ["clarity", "pronunciation", "prosody", "naturalness", "overall"]
241
+ ):
242
+ continue
243
+
244
+ mos_response = {
245
+ "session_id": session["session_id"],
246
+ "clip_id": clip_id,
247
+ "clarity": int(ratings.get("clarity"))
248
+ if ratings.get("clarity")
249
+ else None,
250
+ "pronunciation": int(ratings.get("pronunciation"))
251
+ if ratings.get("pronunciation")
252
+ else None,
253
+ "prosody": int(ratings.get("prosody"))
254
+ if ratings.get("prosody")
255
+ else None,
256
+ "naturalness": int(ratings.get("naturalness"))
257
+ if ratings.get("naturalness")
258
+ else None,
259
+ "overall": int(ratings.get("overall"))
260
+ if ratings.get("overall")
261
+ else None,
262
+ "comment": ratings.get("comment", ""),
263
+ "gender_mismatch": ratings.get("gender_mismatch", False),
264
+ "timestamp": time.time(),
265
+ }
266
+
267
+ self.save_response("mos", mos_response)
268
+ print(f"[INFO] Processed MOS rating for clip {clip_id}")
269
+
270
+ except Exception as e:
271
+ print(f"[WARN] Error processing MOS data: {e}")
272
+
273
+ def process_ab_data(
274
+ self,
275
+ session: Dict[str, Any],
276
+ ab_data_json: str,
277
+ ) -> None:
278
+ """
279
+ Take the JSON string from the hidden AB textbox and turn it into
280
+ individual A/B responses in self.responses["ab"].
281
+ """
282
+ print(f"[DEBUG] process_ab_data called with JSON: '{ab_data_json}'")
283
+ print(f"[DEBUG] Session ID: {session.get('session_id') if session else 'None'}")
284
+ if not session or not ab_data_json:
285
+ print(f"[DEBUG] Skipping AB processing - session: {session is not None}, data length: {len(ab_data_json) if ab_data_json else 0}")
286
+ return
287
+
288
+ try:
289
+ comparisons_data = json.loads(ab_data_json) if ab_data_json else {}
290
+ except json.JSONDecodeError as e:
291
+ print(f"[WARN] Failed to parse A/B data JSON: {e}")
292
+ return
293
+
294
+ try:
295
+ for comp_id, comparison in comparisons_data.items():
296
+ if not comparison.get("choice"):
297
+ continue
298
+
299
+ ab_response = {
300
+ "session_id": session["session_id"],
301
+ "clip_a_id": comparison.get("clip_a_id"),
302
+ "clip_b_id": comparison.get("clip_b_id"),
303
+ "comparison_type": comparison.get("comparison_type"),
304
+ "choice": comparison.get("choice"),
305
+ "comment": comparison.get("comment", ""),
306
+ # Support both model_vs_model (gender_mismatch_a/b)
307
+ # and gender_vs_gender (gender_mismatch_male/female)
308
+ "gender_mismatch_a": comparison.get("gender_mismatch_a", False)
309
+ or comparison.get("gender_mismatch_male", False),
310
+ "gender_mismatch_b": comparison.get("gender_mismatch_b", False)
311
+ or comparison.get("gender_mismatch_female", False),
312
+ "timestamp": time.time(),
313
+ }
314
+
315
+ self.save_response("ab", ab_response)
316
+ print(
317
+ f"[INFO] Processed A/B rating for clips "
318
+ f"{comparison.get('clip_a_id')} vs {comparison.get('clip_b_id')}"
319
+ )
320
+
321
+ except Exception as e:
322
+ print(f"[WARN] Error processing A/B data: {e}")
323
+
324
+ # --------------------------
325
+ # Export
326
+ # --------------------------
327
+ def export_session(self, session_id: str) -> Dict[str, Any]:
328
+ """Build a fully annotated export dict for a given session."""
329
+ session = self.sessions.get(session_id, {})
330
+
331
+ # Create detailed MOS responses with full clip metadata
332
+ detailed_mos_responses = []
333
+ session_mos_clips = {clip.id: clip for clip in session.get("mos_clips", [])}
334
+
335
+ for r in self.responses.get("mos", []):
336
+ if r.get("session_id") != session_id:
337
+ continue
338
+
339
+ clip_id = r.get("clip_id")
340
+ clip = session_mos_clips.get(clip_id)
341
+ if not clip:
342
+ continue
343
+
344
+ detailed_response = {
345
+ # Session metadata
346
+ "session_id": session_id,
347
+ "response_timestamp": r.get("timestamp", time.time()),
348
+ # Full clip metadata
349
+ "clip_id": clip_id,
350
+ "exercise": clip.exercise,
351
+ "exercise_id": clip.exercise_id,
352
+ "transcript": clip.transcript,
353
+ "model": clip.model, # Original model name
354
+ "display_model": get_display_model_name(
355
+ clip.model
356
+ ), # Anonymized name
357
+ "speaker": clip.speaker,
358
+ # MOS ratings
359
+ "clarity": r.get("clarity"),
360
+ "pronunciation": r.get("pronunciation"),
361
+ "prosody": r.get("prosody"),
362
+ "naturalness": r.get("naturalness"),
363
+ "overall": r.get("overall"),
364
+ "comment": r.get("comment", ""),
365
+ # Quality control flags
366
+ "gender_mismatch": r.get(
367
+ "gender_mismatch", False
368
+ ), # True if user flagged wrong gender
369
+ # Response type
370
+ "evaluation_type": "mos_rating",
371
+ }
372
+ detailed_mos_responses.append(detailed_response)
373
+
374
+ # Create detailed A/B responses with full clip metadata
375
+ detailed_ab_responses = []
376
+ session_ab_model_pairs = session.get("ab_model_pairs", [])
377
+ session_ab_gender_pairs = session.get("ab_gender_pairs", [])
378
+
379
+ for r in self.responses.get("ab", []):
380
+ if r.get("session_id") != session_id:
381
+ continue
382
+
383
+ clip_a_id = r.get("clip_a_id")
384
+ clip_b_id = r.get("clip_b_id")
385
+ comparison_type = r.get("comparison_type")
386
+
387
+ # Find the clips from session pairs
388
+ clip_a, clip_b = None, None
389
+
390
+ if comparison_type == "model_vs_model":
391
+ for pair_a, pair_b in session_ab_model_pairs:
392
+ if pair_a.id == clip_a_id and pair_b.id == clip_b_id:
393
+ clip_a, clip_b = pair_a, pair_b
394
+ break
395
+ elif comparison_type == "gender_vs_gender":
396
+ for pair_a, pair_b in session_ab_gender_pairs:
397
+ if pair_a.id == clip_a_id and pair_b.id == clip_b_id:
398
+ clip_a, clip_b = pair_a, pair_b
399
+ break
400
+
401
+ if not (clip_a and clip_b):
402
+ continue
403
+
404
+ detailed_response = {
405
+ # Session metadata
406
+ "session_id": session_id,
407
+ "response_timestamp": r.get("timestamp", time.time()),
408
+ # Comparison metadata
409
+ "comparison_type": comparison_type,
410
+ "choice": r.get("choice"),
411
+ "comment": r.get("comment", ""),
412
+ # Clip A metadata
413
+ "clip_a_id": clip_a.id,
414
+ "clip_a_exercise": clip_a.exercise,
415
+ "clip_a_exercise_id": clip_a.exercise_id,
416
+ "clip_a_transcript": clip_a.transcript,
417
+ "clip_a_model": clip_a.model,
418
+ "clip_a_display_model": get_display_model_name(clip_a.model),
419
+ "clip_a_speaker": clip_a.speaker,
420
+ # Clip B metadata
421
+ "clip_b_id": clip_b.id,
422
+ "clip_b_exercise": clip_b.exercise,
423
+ "clip_b_exercise_id": clip_b.exercise_id,
424
+ "clip_b_transcript": clip_b.transcript,
425
+ "clip_b_model": clip_b.model,
426
+ "clip_b_display_model": get_display_model_name(clip_b.model),
427
+ "clip_b_speaker": clip_b.speaker,
428
+ # Quality control flags
429
+ "gender_mismatch_a": r.get(
430
+ "gender_mismatch_a", False
431
+ ), # True if clip A has wrong gender
432
+ "gender_mismatch_b": r.get(
433
+ "gender_mismatch_b", False
434
+ ), # True if clip B has wrong gender
435
+ # Response type
436
+ "evaluation_type": "ab_comparison",
437
+ }
438
+ detailed_ab_responses.append(detailed_response)
439
+
440
+ return {
441
+ "session_metadata": {
442
+ "session_id": session_id,
443
+ "created_at": session.get("created_at"),
444
+ "completed": session.get("completed", False),
445
+ "exported_at": time.time(),
446
+ "total_mos_ratings": len(detailed_mos_responses),
447
+ "total_ab_comparisons": len(detailed_ab_responses),
448
+ },
449
+ "mos_ratings": detailed_mos_responses,
450
+ "ab_comparisons": detailed_ab_responses,
451
+ "overall_feedback": [
452
+ r
453
+ for r in self.responses.get("feedback", [])
454
+ if r.get("session_id") == session_id
455
+ ],
456
+ }
backend/utils/hf_utils.py DELETED
@@ -1,224 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- from pathlib import Path
5
- from typing import Any, Dict, Optional
6
-
7
- import numpy as np
8
- import pandas as pd
9
-
10
- import json
11
- from urllib.parse import urlparse
12
-
13
- from datasets import load_dataset, Audio, Dataset
14
-
15
- DATASET_ID = "aether-raid/axite-tts"
16
- DEFAULT_SPLIT = "train"
17
- DEFAULT_OUTDIR = "/home/raid/atc-c3-comms/assets/tts/src/evaluation/mos/outputs"
18
-
19
-
20
- def _hf_raw_file_url(dataset_id: str, path: str) -> str:
21
- """Construct a raw file URL for a file stored in a Hugging Face dataset repo.
22
-
23
- This assumes the file is stored in the dataset repo under `main` branch. If the
24
- dataset audio files are stored elsewhere or use a different branch, this URL may be wrong.
25
- """
26
- # If path already looks like a URL, return it
27
- if path is None:
28
- return None
29
- parsed = urlparse(path)
30
- if parsed.scheme in ("http", "https"):
31
- return path
32
- # Otherwise assume relative path inside the dataset repo
33
- return f"https://huggingface.co/datasets/{dataset_id}/resolve/main/{path}"
34
-
35
- def compute_duration_from_audio_obj(a: Any) -> Optional[float]:
36
- """Return duration in seconds for a datasets audio object (decoded) or None.
37
-
38
- The audio object can be:
39
- - dict with 'array' and 'sampling_rate'
40
- - dict with 'path' (string)
41
- - a bytes or other lazy decoder after decoding (we rely on datasets Audio to decode)
42
- """
43
- if a is None:
44
- return None
45
- # Case: decoded dict from datasets Audio feature
46
- if isinstance(a, dict):
47
- # If decoded array present, compute from array length and sampling_rate
48
- if "array" in a and "sampling_rate" in a and a["array"] is not None:
49
- arr = a["array"]
50
- sr = a["sampling_rate"]
51
- try:
52
- return float(len(arr)) / float(sr) if sr and len(arr) else None
53
- except Exception:
54
- return None
55
- # If a duration field already present use it (some datasets include it)
56
- if "duration" in a and a["duration"] is not None:
57
- try:
58
- return float(a["duration"])
59
- except Exception:
60
- pass
61
- # fallback: path available — we DO NOT download files here; return None
62
- # (downstream code can decide whether to fetch/inspect remote files)
63
- return None
64
- # If it's a tuple/list like (array, sr)
65
- if isinstance(a, (tuple, list)) and len(a) >= 2:
66
- arr, sr = a[0], a[1]
67
- try:
68
- return float(len(arr)) / float(sr) if sr and len(arr) else None
69
- except Exception:
70
- return None
71
- return None
72
-
73
-
74
- def summarize_dataset(
75
- dataset_id: str, split: str = "train", out_dir: Optional[str] = None, decode_audio: bool = True
76
- ) -> pd.DataFrame:
77
- """Load HF dataset and build a summary DataFrame with requested fields.
78
-
79
- Expected columns in dataset: exercise, exercise_id, rt, model, speaker, audio
80
- """
81
- ds = load_dataset(dataset_id, split=split)
82
-
83
- # If audio column exists and decode_audio is True, cast to Audio to standardise decoding
84
- if "audio" in ds.column_names and decode_audio:
85
- try:
86
- ds = ds.cast_column("audio", Audio(sampling_rate=16000))
87
- except Exception:
88
- # casting may fail for some dataset types; we'll handle per-row
89
- pass
90
-
91
- records = []
92
- for i, row in enumerate(ds):
93
- rec: Dict[str, Any] = {}
94
- rec["exercise"] = row.get("exercise")
95
- rec["exercise_id"] = row.get("exercise_id")
96
- rec["rt"] = row.get("rt")
97
- rec["model"] = row.get("model")
98
- rec["speaker"] = row.get("speaker")
99
-
100
- audio_obj = row.get("audio")
101
- duration = None
102
- audio_path = None
103
- try:
104
- # If decoded to dict with array & sampling_rate, compute duration
105
- if isinstance(audio_obj, dict) and ("array" in audio_obj or "sampling_rate" in audio_obj):
106
- duration = compute_duration_from_audio_obj(audio_obj)
107
- audio_path = audio_obj.get("path") if isinstance(audio_obj, dict) else None
108
- else:
109
- # datasets Audio feature may return dict with 'path' before decoding
110
- # Try common locations for an audio path without forcing decoding
111
- if isinstance(audio_obj, dict) and audio_obj.get("path"):
112
- audio_path = audio_obj.get("path")
113
- # try to get duration from file header
114
- if decode_audio:
115
- duration = compute_duration_from_audio_obj(audio_obj)
116
- else:
117
- # final fallback: try to decode using the dataset's audio decoder by calling ds[i]['audio']
118
- # but avoid reloading if already decoded
119
- if decode_audio:
120
- try:
121
- a2 = ds[i]["audio"]
122
- duration = compute_duration_from_audio_obj(a2)
123
- if isinstance(a2, dict) and a2.get("path"):
124
- audio_path = a2.get("path")
125
- except Exception:
126
- duration = None
127
- except Exception:
128
- duration = None
129
-
130
- rec["audio_duration_s"] = float(duration) if duration is not None else None
131
- # If no explicit audio_path found, try to extract attribute 'path' from lazy object
132
- if audio_path is None and audio_obj is not None:
133
- audio_path = getattr(audio_obj, "path", None)
134
- # If still none and path looks local (no scheme), construct HF raw URL
135
- if audio_path and not urlparse(str(audio_path)).scheme:
136
- audio_path = _hf_raw_file_url(dataset_id, str(audio_path))
137
- rec["audio_path"] = audio_path
138
-
139
- records.append(rec)
140
-
141
- df = pd.DataFrame.from_records(records)
142
-
143
- # Basic summaries printed
144
- def safe_uniques(col: str):
145
- if col in df.columns:
146
- vals = df[col].dropna().unique()
147
- return len(vals), list(vals[:10])
148
- return 0, []
149
-
150
- for c in ["exercise", "model", "speaker"]:
151
- nuniq, sample = safe_uniques(c)
152
- print(f"{c}: {nuniq} unique (sample {sample})")
153
-
154
- # String length stats for exercise_id and rt
155
- for c in ["exercise_id", "rt"]:
156
- if c in df.columns:
157
- lens = df[c].dropna().astype(str).map(len)
158
- if len(lens):
159
- print(f"{c} lengths -> min={int(lens.min())} max={int(lens.max())} mean={float(lens.mean()):.1f}")
160
-
161
- # Audio duration stats
162
- if "audio_duration_s" in df.columns:
163
- dur = df["audio_duration_s"].dropna().astype(float)
164
- if len(dur):
165
- print(f"audio_duration_s -> min={dur.min():.2f}s max={dur.max():.2f}s mean={dur.mean():.2f}s")
166
-
167
- # write outputs
168
- if out_dir is not None:
169
- out = Path(out_dir)
170
- out.mkdir(parents=True, exist_ok=True)
171
- csv_path = out / f"{dataset_id.replace('/', '_')}_{split}_summary.csv"
172
- parquet_path = out / f"{dataset_id.replace('/', '_')}_{split}_summary.parquet"
173
- df.to_csv(csv_path, index=False)
174
- try:
175
- df.to_parquet(parquet_path, index=False)
176
- except Exception:
177
- # parquet optional
178
- pass
179
- print(f"Wrote summary to: {csv_path} (parquet: {parquet_path if parquet_path.exists() else 'skipped'})")
180
-
181
- return df
182
-
183
-
184
- def summarize_from_config(config_path: str = "/home/raid/atc-c3-comms/assets/tts/src/evaluation/mos/config/config.json") -> pd.DataFrame:
185
- """Read the MOS config.json and summarize all hf_utils entries into a single DataFrame.
186
-
187
- The config is expected to contain an `hf_utils` list with objects like:
188
- {"dataset_id": "user/dataset", "split": "train"}
189
-
190
- Returns a combined DataFrame with an additional `source_dataset` and `source_split` columns.
191
- """
192
- cfg_path = Path(config_path)
193
- if not cfg_path.exists():
194
- raise FileNotFoundError(f"Config not found: {config_path}")
195
- with open(cfg_path, "r") as f:
196
- cfg = json.load(f)
197
-
198
- entries = cfg.get("hf_utils", [])
199
- all_dfs = []
200
- for e in entries:
201
- dsid = e.get("dataset_id") or e.get("dataset")
202
- split = e.get("split", "train")
203
- name = e.get("name", f"{dsid.replace('/', '_')}_{split}")
204
- out_dir = e.get("out_dir") or cfg.get("base_dir") or DEFAULT_OUTDIR
205
- decode_audio = bool(e.get("decode_audio", False))
206
-
207
- df = summarize_dataset(dsid, split=split, out_dir=out_dir, decode_audio=decode_audio)
208
- df["source_dataset"] = dsid
209
- df["source_split"] = split
210
- df["source_name"] = name
211
- all_dfs.append(df)
212
-
213
- if all_dfs:
214
- return pd.concat(all_dfs, ignore_index=True)
215
- else:
216
- return pd.DataFrame()
217
-
218
-
219
- def get_mos_dataset_summary(split: str = DEFAULT_SPLIT, out_dir: Optional[str] = DEFAULT_OUTDIR, decode_audio: bool = True) -> pd.DataFrame:
220
- """Convenience wrapper that summarizes the MOS dataset configured for this repo.
221
-
222
- Returns a pandas DataFrame with the summary and writes CSV/Parquet to `out_dir`.
223
- """
224
- return summarize_dataset(DATASET_ID, split=split, out_dir=out_dir, decode_audio=decode_audio)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config/config.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "base_dir": "assets/tts/src/evaluation/outputs",
3
- "axite_script": "assets/tts/src/evaluation/axite.json",
4
- "n_mos": 10,
5
- "n_abmm": 4,
6
- "n_abgf": 4,
7
- "hf_utils": [
8
- {
9
- "dataset_id": "aether-raid/axite-tts",
10
- "split": "train"
11
- }
12
- ]
13
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/__init__.py ADDED
File without changes
frontend/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (183 Bytes). View file
 
frontend/__pycache__/css.cpython-311.pyc ADDED
Binary file (3.81 kB). View file
 
frontend/app.py ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ATC TTS MOS Evaluation App
3
+ Main entrypoint wiring backend + frontend pages.
4
+ """
5
+
6
+ import time
7
+ import gradio as gr
8
+
9
+ import os
10
+ import sys
11
+
12
+ # Add parent directory (mos) to path so we can import backend
13
+ mos_dir = os.path.dirname(os.path.dirname(__file__))
14
+ sys.path.insert(0, mos_dir)
15
+
16
+ # Backend pieces
17
+ from backend.data_manager import DataManager
18
+ from backend.session_manager import SessionManager
19
+ from backend.hf_logging import push_session_to_hub
20
+ from backend.models import get_display_model_name, audio_to_base64_url
21
+
22
+ # Frontend styling + pages
23
+ from frontend.css import CSS
24
+ from frontend.pages.intro import build_intro_page
25
+ from frontend.pages.samples import build_sample_page
26
+ from frontend.pages.mos import build_mos_page, render_mos_html
27
+ from frontend.pages.ab_model import build_ab_model_page, render_ab_model_html
28
+ from frontend.pages.ab_gender import build_ab_gender_page, render_ab_gender_html
29
+ from frontend.pages.conclusion import build_conclusion_page
30
+ from frontend.pages.thank_you import build_thank_you_page
31
+
32
+
33
+ def create_app(data_manager, session_manager):
34
+ with gr.Blocks(title="ATC TTS Evaluation", css=CSS) as app:
35
+ # Global state
36
+ session_state = gr.State()
37
+
38
+ # Hidden textboxes used by the JS collectors on MOS / AB pages
39
+ mos_data_textbox = gr.Textbox(
40
+ value="", visible=False, elem_id="mos_data_store"
41
+ )
42
+ ab_data_textbox = gr.Textbox(
43
+ value="", visible=False, elem_id="ab_data_store"
44
+ )
45
+
46
+ # Build all pages
47
+ intro_page, start_btn = build_intro_page()
48
+
49
+ (
50
+ sample_page,
51
+ back_to_intro_btn,
52
+ continue_samples_btn,
53
+ sample_audio_container,
54
+ ) = build_sample_page()
55
+
56
+ (
57
+ mos_page,
58
+ back_to_samples_btn,
59
+ continue_mos_btn,
60
+ mos_dynamic_content
61
+ ) = build_mos_page()
62
+
63
+ (
64
+ ab_model_page,
65
+ back_to_mos_btn,
66
+ continue_ab_model_btn,
67
+ ab_model_dynamic_content,
68
+ ) = build_ab_model_page()
69
+
70
+ (
71
+ ab_gender_page,
72
+ back_to_ab_model_btn,
73
+ continue_ab_gender_btn,
74
+ ab_gender_dynamic_content,
75
+ ) = build_ab_gender_page()
76
+
77
+ (
78
+ conclusion_page,
79
+ back_to_ab_gender_btn,
80
+ export_btn,
81
+ overall_feedback,
82
+ final_comments,
83
+ ) = build_conclusion_page()
84
+
85
+ thank_you_page = build_thank_you_page()
86
+
87
+ # =========================
88
+ # Event handlers
89
+ # =========================
90
+
91
+ def start_evaluation():
92
+ """Create a new session and populate sample page with sample clips."""
93
+ try:
94
+ session = session_manager.create_session()
95
+ print(f"[INFO] Session created: {session['session_id']}")
96
+ print(f"[INFO] Sample clips: {len(session.get('sample_clips', []))}")
97
+ except Exception as e:
98
+ print(f"[ERROR] Error creating session: {e}")
99
+ session = {
100
+ "session_id": "fallback",
101
+ "sample_clips": [],
102
+ "mos_clips": [],
103
+ "ab_model_pairs": [],
104
+ "ab_gender_pairs": [],
105
+ "created_at": time.time(),
106
+ "completed": False,
107
+ }
108
+
109
+ # Dynamically render the sample clips into the sample_audio_container
110
+ with sample_audio_container:
111
+ sample_clips = session.get("sample_clips", [])
112
+ if len(sample_clips) == 0:
113
+ with gr.Group(elem_classes=["clip-container"]):
114
+ gr.Markdown("### No sample clips available")
115
+ gr.Markdown(
116
+ "The dataset appears to be empty or not loading correctly. "
117
+ "You can still navigate through the interface."
118
+ )
119
+ else:
120
+ for i, clip in enumerate(sample_clips, 1):
121
+ with gr.Group(elem_classes=["clip-container"]):
122
+ gr.Markdown(f"### Sample {i}: TTS Generated Audio")
123
+
124
+ with gr.Group(elem_classes=["audio-info"]):
125
+ gr.HTML(
126
+ f"""
127
+ <div>
128
+ <span class="info-label">Model:</span>
129
+ <span class="info-value">{get_display_model_name(clip.model)}</span><br>
130
+ <span class="info-label">Speaker:</span>
131
+ <span class="info-value">{clip.speaker.title()} Voice</span><br>
132
+ </div>
133
+ """
134
+ )
135
+
136
+ audio_src = audio_to_base64_url(clip.audio_url) or ""
137
+ gr.HTML(
138
+ f"""
139
+ <div style="background: #1f2937; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
140
+ <audio controls style="width: 100%; height: 54px;">
141
+ <source src="{audio_src}" type="audio/wav">
142
+ Audio not available
143
+ </audio>
144
+ </div>
145
+ """
146
+ )
147
+
148
+ with gr.Group(elem_classes=["transcript-box"]):
149
+ gr.Markdown(f"**Transcript:** *{clip.transcript}*")
150
+
151
+ return (
152
+ gr.update(visible=False), # hide intro
153
+ gr.update(visible=True), # show sample page
154
+ session, # update session_state
155
+ )
156
+
157
+ def show_mos_page(session):
158
+ """Navigate from sample page to MOS page and render MOS HTML."""
159
+ if not session:
160
+ return gr.update(), gr.update(), ""
161
+
162
+ mos_html = render_mos_html(session)
163
+ return (
164
+ gr.update(visible=False), # hide sample page
165
+ gr.update(visible=True), # show MOS page
166
+ mos_html, # set MOS HTML content
167
+ )
168
+
169
+ def continue_from_mos_to_ab_model(session, mos_data_json):
170
+ """
171
+ Process MOS data (collected via JavaScript) and move to Model-vs-Model page.
172
+ """
173
+ print(f"[DEBUG] continue_from_mos_to_ab_model called")
174
+ print(f"[DEBUG] MOS data JSON received: '{mos_data_json}'")
175
+ session_manager.process_mos_data(session, mos_data_json)
176
+ ab_model_html = render_ab_model_html(session)
177
+ return (
178
+ gr.update(visible=False), # hide MOS page
179
+ gr.update(visible=True), # show AB model page
180
+ ab_model_html, # set AB model HTML content
181
+ )
182
+
183
+ def continue_from_ab_model_to_ab_gender(session, ab_data_json):
184
+ """
185
+ Process A/B model comparison data (collected via JavaScript) and move to Gender A/B page.
186
+ """
187
+ print(f"[DEBUG] continue_from_ab_model_to_ab_gender called")
188
+ print(f"[DEBUG] AB data JSON received: '{ab_data_json}'")
189
+ session_manager.process_ab_data(session, ab_data_json)
190
+ ab_gender_html = render_ab_gender_html(session)
191
+ return (
192
+ gr.update(visible=False), # hide AB model page
193
+ gr.update(visible=True), # show AB gender page
194
+ ab_gender_html, # set AB gender HTML content
195
+ )
196
+
197
+ def complete_evaluation_with_ab_gender_data(session, ab_data_json):
198
+ """
199
+ Process A/B gender comparison data (collected via JavaScript) and move to conclusion page.
200
+ """
201
+ session_manager.process_ab_data(session, ab_data_json)
202
+ if session:
203
+ session["completed"] = True
204
+ return (
205
+ gr.update(visible=False), # hide AB gender page
206
+ gr.update(visible=True), # show conclusion page
207
+ )
208
+
209
+ def export_results_with_data_processing(
210
+ session,
211
+ overall_preference,
212
+ comments,
213
+ mos_data_json,
214
+ ab_data_json,
215
+ ):
216
+ """
217
+ Final submit: process any remaining MOS/AB data (collected via JavaScript),
218
+ save overall feedback, export the session, and push to HF.
219
+ """
220
+ if not session:
221
+ return gr.update(), gr.update()
222
+
223
+ # Ensure all MOS / AB ratings are processed
224
+ session_manager.process_mos_data(session, mos_data_json)
225
+ session_manager.process_ab_data(session, ab_data_json)
226
+
227
+ # Save overall feedback
228
+ if overall_preference or comments:
229
+ feedback_response = {
230
+ "session_id": session["session_id"],
231
+ "overall_preference": overall_preference,
232
+ "final_comments": comments,
233
+ "timestamp": time.time(),
234
+ }
235
+ session_manager.save_response("feedback", feedback_response)
236
+
237
+ # Export and push to Hub
238
+ export_data = session_manager.export_session(session["session_id"])
239
+ try:
240
+ push_session_to_hub(session["session_id"], export_data)
241
+ print(
242
+ f"[INFO] Successfully submitted session {session['session_id']} to Hub"
243
+ )
244
+ except Exception as e:
245
+ print(
246
+ f"[WARN] Failed to push session {session['session_id']} to Hub: {e}"
247
+ )
248
+
249
+ return (
250
+ gr.update(visible=False), # hide conclusion page
251
+ gr.update(visible=True), # show thank you page
252
+ )
253
+
254
+ # =========================
255
+ # Wire buttons to handlers
256
+ # =========================
257
+
258
+ # Intro → Samples
259
+ start_btn.click(
260
+ start_evaluation,
261
+ inputs=[],
262
+ outputs=[intro_page, sample_page, session_state],
263
+ )
264
+
265
+ # Samples → MOS
266
+ continue_samples_btn.click(
267
+ show_mos_page,
268
+ inputs=[session_state],
269
+ outputs=[sample_page, mos_page, mos_dynamic_content],
270
+ )
271
+
272
+ # MOS → AB Model (process MOS first)
273
+ continue_mos_btn.click(
274
+ fn=continue_from_mos_to_ab_model,
275
+ inputs=[session_state, mos_data_textbox],
276
+ outputs=[mos_page, ab_model_page, ab_model_dynamic_content],
277
+ js="""
278
+ (session_data, current_mos_data) => {
279
+ // Collect MOS data from the current page
280
+ const ratings = {};
281
+
282
+ const selects = document.querySelectorAll('select[data-clip-id]');
283
+ const checkboxes = document.querySelectorAll('input[type="checkbox"][data-clip-id]');
284
+ const textareas = document.querySelectorAll('textarea[data-clip-id]');
285
+
286
+ console.log('[DEBUG] Found selects:', selects.length);
287
+ console.log('[DEBUG] Found checkboxes:', checkboxes.length);
288
+ console.log('[DEBUG] Found textareas:', textareas.length);
289
+
290
+ selects.forEach(select => {
291
+ const clipId = select.getAttribute('data-clip-id');
292
+ const dimension = select.getAttribute('data-dimension');
293
+ if (!ratings[clipId]) ratings[clipId] = {};
294
+ ratings[clipId][dimension] = select.value;
295
+ console.log('[DEBUG] Select:', clipId, dimension, select.value);
296
+ });
297
+
298
+ checkboxes.forEach(checkbox => {
299
+ const clipId = checkbox.getAttribute('data-clip-id');
300
+ const dimension = checkbox.getAttribute('data-dimension');
301
+ if (!ratings[clipId]) ratings[clipId] = {};
302
+ ratings[clipId][dimension] = checkbox.checked;
303
+ console.log('[DEBUG] Checkbox:', clipId, dimension, checkbox.checked);
304
+ });
305
+
306
+ textareas.forEach(textarea => {
307
+ const clipId = textarea.getAttribute('data-clip-id');
308
+ const dimension = textarea.getAttribute('data-dimension');
309
+ if (!ratings[clipId]) ratings[clipId] = {};
310
+ ratings[clipId][dimension] = textarea.value;
311
+ console.log('[DEBUG] Textarea:', clipId, dimension, textarea.value);
312
+ });
313
+
314
+ console.log('[DEBUG] Final ratings object:', ratings);
315
+ const ratingsJson = JSON.stringify(ratings);
316
+ console.log('[DEBUG] Ratings JSON:', ratingsJson);
317
+
318
+ return [session_data, ratingsJson];
319
+ }
320
+ """
321
+ )
322
+
323
+ # AB Model → AB Gender (process AB model first)
324
+ continue_ab_model_btn.click(
325
+ continue_from_ab_model_to_ab_gender,
326
+ inputs=[session_state, ab_data_textbox],
327
+ outputs=[ab_model_page, ab_gender_page, ab_gender_dynamic_content],
328
+ js="""
329
+ (session_data, current_ab_data) => {
330
+ // Collect A/B data from the current page
331
+ const ratings = {};
332
+
333
+ const radios = document.querySelectorAll('input[type="radio"]:checked');
334
+ const checkboxes = document.querySelectorAll('input[type="checkbox"][data-comparison-id]');
335
+ const textareas = document.querySelectorAll('textarea[data-comparison-id]');
336
+
337
+ console.log('[DEBUG] Found radios:', radios.length);
338
+ console.log('[DEBUG] Found checkboxes:', checkboxes.length);
339
+ console.log('[DEBUG] Found textareas:', textareas.length);
340
+
341
+ radios.forEach(radio => {
342
+ const compId = radio.getAttribute('data-comparison-id');
343
+ const clipAId = radio.getAttribute('data-clip-a-id');
344
+ const clipBId = radio.getAttribute('data-clip-b-id');
345
+ const compType = radio.getAttribute('data-type');
346
+
347
+ if (!ratings[compId]) ratings[compId] = {};
348
+ ratings[compId].clip_a_id = clipAId;
349
+ ratings[compId].clip_b_id = clipBId;
350
+ ratings[compId].comparison_type = compType;
351
+ ratings[compId].choice = radio.value;
352
+ console.log('[DEBUG] Radio:', compId, radio.value);
353
+ });
354
+
355
+ checkboxes.forEach(checkbox => {
356
+ const compId = checkbox.getAttribute('data-comparison-id');
357
+ const dimension = checkbox.getAttribute('data-dimension');
358
+ if (!ratings[compId]) ratings[compId] = {};
359
+ ratings[compId][dimension] = checkbox.checked;
360
+ console.log('[DEBUG] Checkbox:', compId, dimension, checkbox.checked);
361
+ });
362
+
363
+ textareas.forEach(textarea => {
364
+ const compId = textarea.getAttribute('data-comparison-id');
365
+ const dimension = textarea.getAttribute('data-dimension');
366
+ if (!ratings[compId]) ratings[compId] = {};
367
+ ratings[compId][dimension] = textarea.value;
368
+ console.log('[DEBUG] Textarea:', compId, dimension, textarea.value);
369
+ });
370
+
371
+ console.log('[DEBUG] Final A/B ratings object:', ratings);
372
+ const ratingsJson = JSON.stringify(ratings);
373
+ console.log('[DEBUG] A/B Ratings JSON:', ratingsJson);
374
+
375
+ return [session_data, ratingsJson];
376
+ }
377
+ """
378
+ )
379
+
380
+ # AB Gender → Conclusion (process AB gender data)
381
+ continue_ab_gender_btn.click(
382
+ complete_evaluation_with_ab_gender_data,
383
+ inputs=[session_state, ab_data_textbox],
384
+ outputs=[ab_gender_page, conclusion_page],
385
+ js="""
386
+ (session_data, current_ab_data) => {
387
+ // Collect A/B gender data from the current page
388
+ const ratings = {};
389
+
390
+ const radios = document.querySelectorAll('input[type="radio"]:checked');
391
+ const checkboxes = document.querySelectorAll('input[type="checkbox"][data-comparison-id]');
392
+ const textareas = document.querySelectorAll('textarea[data-comparison-id]');
393
+
394
+ console.log('[DEBUG] Found radios:', radios.length);
395
+ console.log('[DEBUG] Found checkboxes:', checkboxes.length);
396
+ console.log('[DEBUG] Found textareas:', textareas.length);
397
+
398
+ radios.forEach(radio => {
399
+ const compId = radio.getAttribute('data-comparison-id');
400
+ const clipAId = radio.getAttribute('data-clip-a-id');
401
+ const clipBId = radio.getAttribute('data-clip-b-id');
402
+ const compType = radio.getAttribute('data-type');
403
+
404
+ if (!ratings[compId]) ratings[compId] = {};
405
+ ratings[compId].clip_a_id = clipAId;
406
+ ratings[compId].clip_b_id = clipBId;
407
+ ratings[compId].comparison_type = compType;
408
+ ratings[compId].choice = radio.value;
409
+ console.log('[DEBUG] Radio:', compId, radio.value);
410
+ });
411
+
412
+ checkboxes.forEach(checkbox => {
413
+ const compId = checkbox.getAttribute('data-comparison-id');
414
+ const dimension = checkbox.getAttribute('data-dimension');
415
+ if (!ratings[compId]) ratings[compId] = {};
416
+ ratings[compId][dimension] = checkbox.checked;
417
+ console.log('[DEBUG] Checkbox:', compId, dimension, checkbox.checked);
418
+ });
419
+
420
+ textareas.forEach(textarea => {
421
+ const compId = textarea.getAttribute('data-comparison-id');
422
+ const dimension = textarea.getAttribute('data-dimension');
423
+ if (!ratings[compId]) ratings[compId] = {};
424
+ ratings[compId][dimension] = textarea.value;
425
+ console.log('[DEBUG] Textarea:', compId, dimension, textarea.value);
426
+ });
427
+
428
+ console.log('[DEBUG] Final A/B gender ratings object:', ratings);
429
+ const ratingsJson = JSON.stringify(ratings);
430
+ console.log('[DEBUG] A/B Gender Ratings JSON:', ratingsJson);
431
+
432
+ return [session_data, ratingsJson];
433
+ }
434
+ """
435
+ )
436
+
437
+ # Conclusion → Thank You (process any remaining data + export)
438
+ export_btn.click(
439
+ export_results_with_data_processing,
440
+ inputs=[
441
+ session_state,
442
+ overall_feedback,
443
+ final_comments,
444
+ mos_data_textbox,
445
+ ab_data_textbox,
446
+ ],
447
+ outputs=[conclusion_page, thank_you_page],
448
+ )
449
+
450
+ # Back navigation
451
+ back_to_intro_btn.click(
452
+ lambda: (gr.update(visible=True), gr.update(visible=False)),
453
+ inputs=[],
454
+ outputs=[intro_page, sample_page],
455
+ )
456
+
457
+ back_to_samples_btn.click(
458
+ lambda: (gr.update(visible=False), gr.update(visible=True)),
459
+ inputs=[],
460
+ outputs=[mos_page, sample_page],
461
+ )
462
+
463
+ back_to_mos_btn.click(
464
+ lambda: (gr.update(visible=False), gr.update(visible=True)),
465
+ inputs=[],
466
+ outputs=[ab_model_page, mos_page],
467
+ )
468
+
469
+ back_to_ab_model_btn.click(
470
+ lambda: (gr.update(visible=False), gr.update(visible=True)),
471
+ inputs=[],
472
+ outputs=[ab_gender_page, ab_model_page],
473
+ )
474
+
475
+ back_to_ab_gender_btn.click(
476
+ lambda: (gr.update(visible=False), gr.update(visible=True)),
477
+ inputs=[],
478
+ outputs=[conclusion_page, ab_gender_page],
479
+ )
480
+
481
+ return app
482
+
483
+
484
+ if __name__ == "__main__":
485
+ print("Starting ATC TTS MOS Evaluation...")
486
+
487
+ try:
488
+ data_manager = DataManager()
489
+ session_manager = SessionManager(data_manager)
490
+ clips = data_manager.load_clips()
491
+ print(f"Dataset ready with {len(clips)} clips")
492
+
493
+ models = {clip.model for clip in clips}
494
+ speakers = {clip.speaker for clip in clips}
495
+ exercises = {clip.exercise for clip in clips}
496
+
497
+ print(f"Models: {', '.join(models)}")
498
+ print(f"Speakers: {', '.join(speakers)}")
499
+ print(f"Exercises: {len(exercises)} unique exercises")
500
+ except Exception as e:
501
+ print(f"[ERROR] Error loading data: {e}")
502
+ print("Please check your dataset access and try again.")
503
+ raise SystemExit(1)
504
+
505
+ app = create_app(data_manager, session_manager)
506
+ app.launch(
507
+ server_name="0.0.0.0",
508
+ server_port=7860,
509
+ share=False,
510
+ show_error=True,
511
+ )
frontend/css.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/css.py
2
+
3
+ CSS = """
4
+ .gradio-container {
5
+ max-width: 1200px !important;
6
+ margin: auto;
7
+ background-color: #1a1a1a !important;
8
+ color: #e5e5e5 !important;
9
+ }
10
+
11
+ .dark .gradio-container {
12
+ background-color: #1a1a1a !important;
13
+ }
14
+
15
+ .section-header {
16
+ background: linear-gradient(135deg, #2563eb, #1d4ed8) !important;
17
+ padding: 25px;
18
+ border-radius: 12px;
19
+ border-left: 6px solid #60a5fa;
20
+ margin: 25px 0;
21
+ color: #ffffff !important;
22
+ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
23
+ }
24
+
25
+ .section-header h2, .section-header h3 {
26
+ color: #ffffff !important;
27
+ margin-bottom: 10px;
28
+ }
29
+
30
+ .section-header p {
31
+ color: #e0e7ff !important;
32
+ opacity: 0.95;
33
+ }
34
+
35
+ .clip-container {
36
+ background: #2d2d2d !important;
37
+ padding: 20px;
38
+ border-radius: 12px;
39
+ border: 2px solid #3f3f46;
40
+ margin: 15px 0;
41
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
42
+ }
43
+
44
+ .clip-container:hover {
45
+ border-color: #60a5fa;
46
+ box-shadow: 0 6px 16px rgba(96, 165, 250, 0.2);
47
+ }
48
+
49
+ .comparison-container {
50
+ background: #2a2a2a !important;
51
+ padding: 25px;
52
+ border-radius: 12px;
53
+ border: 2px solid #4f46e5;
54
+ margin: 20px 0;
55
+ box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
56
+ }
57
+
58
+ .reference-container {
59
+ background: linear-gradient(135deg, #059669, #047857) !important;
60
+ padding: 20px;
61
+ border-radius: 12px;
62
+ border: 2px solid #10b981;
63
+ margin: 15px 0;
64
+ box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
65
+ }
66
+
67
+ .reference-container h4, .reference-container p {
68
+ color: #ffffff !important;
69
+ }
70
+
71
+ .audio-info {
72
+ background: #374151 !important;
73
+ padding: 15px;
74
+ border-radius: 8px;
75
+ border-left: 4px solid #60a5fa;
76
+ margin: 10px 0;
77
+ font-family: 'Courier New', monospace;
78
+ }
79
+
80
+ .audio-info .info-label {
81
+ color: #60a5fa !important;
82
+ font-weight: bold;
83
+ font-size: 0.9em;
84
+ }
85
+
86
+ .audio-info .info-value {
87
+ color: #e5e5e5 !important;
88
+ margin-left: 10px;
89
+ }
90
+
91
+ .transcript-box {
92
+ background: #1f2937 !important;
93
+ padding: 15px;
94
+ border-radius: 8px;
95
+ border: 1px solid #4b5563;
96
+ font-style: italic;
97
+ color: #d1d5db !important;
98
+ line-height: 1.6;
99
+ }
100
+
101
+ .progress-text {
102
+ background: linear-gradient(135deg, #1e40af, #1d4ed8) !important;
103
+ border-left: 4px solid #60a5fa !important;
104
+ padding: 12px 16px;
105
+ border-radius: 8px;
106
+ font-weight: 600;
107
+ color: #ffffff !important;
108
+ box-shadow: 0 2px 8px rgba(30, 64, 175, 0.3);
109
+ }
110
+
111
+ .gr-button {
112
+ background: linear-gradient(135deg, #2563eb, #1d4ed8) !important;
113
+ border: none !important;
114
+ color: #ffffff !important;
115
+ padding: 12px 24px !important;
116
+ border-radius: 8px !important;
117
+ font-weight: 600 !important;
118
+ transition: all 0.3s ease !important;
119
+ }
120
+
121
+ .gr-button:hover {
122
+ background: linear-gradient(135deg, #1d4ed8, #1e40af) !important;
123
+ transform: translateY(-2px);
124
+ box-shadow: 0 6px 16px rgba(37, 99, 235, 0.4) !important;
125
+ }
126
+
127
+ .gr-button.secondary {
128
+ background: linear-gradient(135deg, #6b7280, #4b5563) !important;
129
+ color: #ffffff !important;
130
+ }
131
+
132
+ .gr-button.secondary:hover {
133
+ background: linear-gradient(135deg, #4b5563, #374151) !important;
134
+ box-shadow: 0 6px 16px rgba(107, 114, 128, 0.3) !important;
135
+ }
136
+
137
+ .gr-textbox, .gr-slider, .gr-radio, .gr-accordion {
138
+ background-color: #2d2d2d !important;
139
+ border-color: #4b5563 !important;
140
+ color: #e5e5e5 !important;
141
+ }
142
+
143
+ h1, h2, h3, h4, h5, h6, p, span, div {
144
+ color: #e5e5e5 !important;
145
+ }
146
+
147
+ a {
148
+ color: #60a5fa !important;
149
+ }
150
+
151
+ a:hover {
152
+ color: #93c5fd !important;
153
+ }
154
+
155
+ .gr-accordion .label-wrap {
156
+ background-color: #374151 !important;
157
+ color: #e5e5e5 !important;
158
+ border-color: #4b5563 !important;
159
+ }
160
+ """
frontend/pages/__init__.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/pages/intro.py
2
+
3
+ import gradio as gr
4
+
5
+
6
+ def build_intro_page():
7
+ """Builds the intro page and returns (container, start_button)."""
8
+ with gr.Column(visible=True) as intro_page:
9
+ gr.Markdown(
10
+ """
11
+ # ATC TTS Evaluation Study
12
+
13
+ You will evaluate short ATC/aviation clips from three TTS systems: Model A, Model B, and Model C.
14
+
15
+ <div class="section-header">
16
+ <h3>Evaluation Process (5 Sections):</h3>
17
+ <ol>
18
+ <li><strong>Reference Audio</strong> - Listen to sample clips to calibrate expectations</li>
19
+ <li><strong>MOS Block</strong> - Rate clips across several quality dimensions (9-12 sections; mix models and genders)</li>
20
+ <li><strong>AB Model Comparisons</strong> - Model vs Model comparisons (same gender)</li>
21
+ <li><strong>AB Gender Comparisons</strong> - Male vs Female voice comparisons (same model)</li>
22
+ <li><strong>Conclusion</strong> - Best overall model and additional comments</li>
23
+ </ol>
24
+ </div>
25
+
26
+ Please use headphones in a quiet place, rate what you hear.
27
+
28
+ ### What you will do
29
+
30
+ - Rate each clip on five MOS dimensions (1–5).
31
+ - Do a few A/B comparisons (two clips of the same prompt).
32
+ - Some clips are Male voice, others Female; you may also compare Male vs Female for the same model.
33
+
34
+ ### What to listen for (quick checklist)
35
+
36
+ - **Clarity**: Can you understand every word, number, call-sign, waypoint, runway, and altitude?
37
+ - **Pronunciation**: Are aviation terms spoken correctly (e.g., "flight level three zero zero")?
38
+ - **Prosody (Intonation/Rhythm)**: Is pacing appropriate for ATC (brisk, not rushed; natural stress/pauses)?
39
+ - **Naturalness**: Does it sound human-like (no robotic artifacts, buzz, hiss, warble)?
40
+ - **Overall**: Your total impression for ATC use.
41
+
42
+ **Estimated time**: ~10–15 minutes. Responses are anonymous and used only to improve the models.
43
+ """
44
+ )
45
+
46
+ start_btn = gr.Button("Start Evaluation", variant="primary")
47
+
48
+ return intro_page, start_btn
frontend/pages/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (2.67 kB). View file
 
frontend/pages/__pycache__/ab_gender.cpython-311.pyc ADDED
Binary file (13.3 kB). View file
 
frontend/pages/__pycache__/ab_model.cpython-311.pyc ADDED
Binary file (14 kB). View file
 
frontend/pages/__pycache__/conclusion.cpython-311.pyc ADDED
Binary file (2.38 kB). View file
 
frontend/pages/__pycache__/intro.cpython-311.pyc ADDED
Binary file (2.66 kB). View file
 
frontend/pages/__pycache__/mos.cpython-311.pyc ADDED
Binary file (15.3 kB). View file
 
frontend/pages/__pycache__/mos_native.cpython-311.pyc ADDED
Binary file (8.18 kB). View file
 
frontend/pages/__pycache__/samples.cpython-311.pyc ADDED
Binary file (4.71 kB). View file
 
frontend/pages/__pycache__/thank_you.cpython-311.pyc ADDED
Binary file (1.08 kB). View file
 
frontend/pages/ab_gender.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/pages/ab_gender.py
2
+
3
+ import gradio as gr
4
+ from typing import Any, Dict
5
+
6
+ from backend.models import get_display_model_name, audio_to_base64_url
7
+
8
+
9
+ def build_ab_gender_page():
10
+ """
11
+ Build the Gender A/B comparison page.
12
+
13
+ Returns:
14
+ ab_gender_page: Column container
15
+ back_btn: button to go back to model comparisons
16
+ continue_btn: button to go to conclusion
17
+ ab_gender_dynamic_content: HTML component where dynamic comparison blocks are rendered
18
+ """
19
+ with gr.Column(visible=False) as ab_gender_page:
20
+ with gr.Group(elem_classes=["progress-text"]):
21
+ gr.Markdown("### Section 4 of 5: Male vs Female Voice Comparisons")
22
+
23
+ gr.Markdown(
24
+ """
25
+ <div class="section-header">
26
+ <h2>Gender Voice Comparison</h2>
27
+ <p>Compare male vs female voices for the same model and content.</p>
28
+ <p>Consider which voice is more suitable for ATC communication.</p>
29
+ </div>
30
+ """
31
+ )
32
+
33
+ ab_gender_dynamic_content = gr.HTML(
34
+ value="<p>Loading gender comparisons...</p>", visible=True
35
+ )
36
+
37
+ with gr.Row():
38
+ back_btn = gr.Button(
39
+ "Back to Model Comparisons", variant="secondary"
40
+ )
41
+ continue_btn = gr.Button(
42
+ "Complete Evaluation", variant="primary"
43
+ )
44
+
45
+ return ab_gender_page, back_btn, continue_btn, ab_gender_dynamic_content
46
+
47
+
48
+ def render_ab_gender_html(session: Dict[str, Any]) -> str:
49
+ """
50
+ Render the HTML for Gender A/B comparisons based on session data.
51
+
52
+ Uses session['ab_gender_pairs'], which should be a list of (male_clip, female_clip) tuples.
53
+ """
54
+ if not session:
55
+ return ""
56
+
57
+ gender_pairs = session.get("ab_gender_pairs", [])
58
+
59
+ if len(gender_pairs) == 0:
60
+ return """
61
+ <div style="text-align: center; padding: 40px; background: #1f2937; border-radius: 12px; border: 2px solid #374151; margin: 20px 0;">
62
+ <h3 style="color: #9ca3af; margin-bottom: 10px;">No Gender Comparisons Available</h3>
63
+ <p style="color: #6b7280;">There are no male/female voice pairs to compare in your session.</p>
64
+ <p style="color: #6b7280;">Click "Complete Evaluation" to proceed to the final step.</p>
65
+ </div>
66
+ """
67
+
68
+ # Initialize html_content and add JavaScript collector (same as AB model page)
69
+ html_content = """
70
+ <script>
71
+ function collectABData() {
72
+ const ratings = {};
73
+
74
+ const radios = document.querySelectorAll('input[type="radio"]:checked');
75
+ const checkboxes = document.querySelectorAll('input[type="checkbox"][data-comparison-id]');
76
+ const textareas = document.querySelectorAll('textarea[data-comparison-id]');
77
+
78
+ radios.forEach(radio => {
79
+ const compId = radio.getAttribute('data-comparison-id');
80
+ const clipAId = radio.getAttribute('data-clip-a-id');
81
+ const clipBId = radio.getAttribute('data-clip-b-id');
82
+ const compType = radio.getAttribute('data-type');
83
+
84
+ if (!ratings[compId]) ratings[compId] = {};
85
+ ratings[compId].clip_a_id = clipAId;
86
+ ratings[compId].clip_b_id = clipBId;
87
+ ratings[compId].comparison_type = compType;
88
+ ratings[compId].choice = radio.value;
89
+ });
90
+
91
+ checkboxes.forEach(checkbox => {
92
+ const compId = checkbox.getAttribute('data-comparison-id');
93
+ const dimension = checkbox.getAttribute('data-dimension');
94
+ if (!ratings[compId]) ratings[compId] = {};
95
+ ratings[compId][dimension] = checkbox.checked;
96
+ });
97
+
98
+ textareas.forEach(textarea => {
99
+ const compId = textarea.getAttribute('data-comparison-id');
100
+ const dimension = textarea.getAttribute('data-dimension');
101
+ if (!ratings[compId]) ratings[compId] = {};
102
+ ratings[compId][dimension] = textarea.value;
103
+ });
104
+
105
+ // Find the hidden textbox and update it - try both possible selectors
106
+ let abDataElement = document.querySelector('#ab_data_store textarea');
107
+ if (!abDataElement) {
108
+ abDataElement = document.querySelector('#ab_data_store input');
109
+ }
110
+
111
+ console.log('[DEBUG] collectABData called (Gender comparison)');
112
+ console.log('[DEBUG] Found', Object.keys(ratings).length, 'comparisons');
113
+ console.log('[DEBUG] Ratings object:', ratings);
114
+ console.log('[DEBUG] AB data element found:', abDataElement);
115
+
116
+ if (abDataElement) {
117
+ abDataElement.value = JSON.stringify(ratings);
118
+ console.log('[DEBUG] Updated hidden textbox with:', abDataElement.value);
119
+ abDataElement.dispatchEvent(new Event('input', { bubbles: true }));
120
+ } else {
121
+ console.error('[ERROR] Could not find AB data textbox element');
122
+ }
123
+ }
124
+ </script>
125
+ """
126
+
127
+ for i, (male_clip, female_clip) in enumerate(gender_pairs, 1):
128
+ male_audio_url = audio_to_base64_url(male_clip.audio_url) or ""
129
+ female_audio_url = audio_to_base64_url(female_clip.audio_url) or ""
130
+
131
+ html_content += f"""
132
+ <div class="comparison-container" style="background: #2a2a2a; padding: 25px; border-radius: 12px; border: 2px solid #059669; margin: 20px 0;">
133
+ <h3>Gender Comparison {i}: {get_display_model_name(male_clip.model)} Male vs Female</h3>
134
+
135
+ <div style="background: #1e293b; padding: 15px; border-radius: 8px; margin: 15px 0;">
136
+ <p style="margin: 0; color: #cbd5e1; font-style: italic;">
137
+ <strong>Transcript:</strong> "{male_clip.transcript}"
138
+ </p>
139
+ </div>
140
+
141
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 20px 0;">
142
+ <div style="padding: 20px; background: #1e3a8a; border-radius: 8px; border: 2px solid #3b82f6;">
143
+ <h4>Male Voice</h4>
144
+ <audio controls style="width: 100%; margin-bottom: 15px;">
145
+ <source src="{male_audio_url}" type="audio/wav">
146
+ Audio not available
147
+ </audio>
148
+ </div>
149
+
150
+ <div style="padding: 20px; background: #7c2d92; border-radius: 8px; border: 2px solid #ec4899;">
151
+ <h4>Female Voice</h4>
152
+ <audio controls style="width: 100%; margin-bottom: 15px;">
153
+ <source src="{female_audio_url}" type="audio/wav">
154
+ Audio not available
155
+ </audio>
156
+ </div>
157
+ </div>
158
+
159
+ <div style="background: #2d3748; padding: 20px; border-radius: 8px; border: 1px solid #059669; margin-top: 20px;">
160
+ <h4 style="color: #e5e5e5; margin: 0 0 15px 0;">Which voice sounds better for ATC use?</h4>
161
+ <div style="display: flex; flex-direction: column; gap: 10px;">
162
+ <label style="color: #cbd5e1; cursor: pointer;">
163
+ <input type="radio" name="ab_gender_{i}" value="male"
164
+ data-comparison-id="{i}"
165
+ data-clip-a-id="{male_clip.id}"
166
+ data-clip-b-id="{female_clip.id}"
167
+ data-type="gender_vs_gender"
168
+ onchange="collectABData()"
169
+ style="margin-right: 10px;">
170
+ Male voice sounds better
171
+ </label>
172
+ <label style="color: #cbd5e1; cursor: pointer;">
173
+ <input type="radio" name="ab_gender_{i}" value="female"
174
+ data-comparison-id="{i}"
175
+ data-clip-a-id="{male_clip.id}"
176
+ data-clip-b-id="{female_clip.id}"
177
+ data-type="gender_vs_gender"
178
+ onchange="collectABData()"
179
+ style="margin-right: 10px;">
180
+ Female voice sounds better
181
+ </label>
182
+ <label style="color: #cbd5e1; cursor: pointer;">
183
+ <input type="radio" name="ab_gender_{i}" value="tie"
184
+ data-comparison-id="{i}"
185
+ data-clip-a-id="{male_clip.id}"
186
+ data-clip-b-id="{female_clip.id}"
187
+ data-type="gender_vs_gender"
188
+ onchange="collectABData()"
189
+ style="margin-right: 10px;">
190
+ Both voices sound equally good (tie)
191
+ </label>
192
+
193
+ <div style="margin-top: 15px; padding: 15px; background: #451a03; border-radius: 8px; border: 2px solid #92400e;">
194
+ <h5 style="color: #fbbf24; margin: 0 0 10px 0; font-weight: bold;">Gender Mismatch Check:</h5>
195
+ <label style="color: #fbbf24; cursor: pointer; display: flex; align-items: center; margin-bottom: 8px;">
196
+ <input type="checkbox"
197
+ data-comparison-id="{i}"
198
+ data-clip-a-id="{male_clip.id}"
199
+ data-clip-b-id="{female_clip.id}"
200
+ data-type="gender_vs_gender"
201
+ data-dimension="gender_mismatch_male"
202
+ onchange="collectABData()"
203
+ style="margin-right: 10px; transform: scale(1.2);">
204
+ ⚠️ {get_display_model_name(male_clip.model)} (male voice) sounds wrong
205
+ </label>
206
+ <label style="color: #fbbf24; cursor: pointer; display: flex; align-items: center;">
207
+ <input type="checkbox"
208
+ data-comparison-id="{i}"
209
+ data-clip-a-id="{male_clip.id}"
210
+ data-clip-b-id="{female_clip.id}"
211
+ data-type="gender_vs_gender"
212
+ data-dimension="gender_mismatch_female"
213
+ onchange="collectABData()"
214
+ style="margin-right: 10px; transform: scale(1.2);">
215
+ ⚠️ {get_display_model_name(female_clip.model)} (female voice) sounds wrong
216
+ </label>
217
+ </div>
218
+
219
+ <div style="margin-top: 15px; padding-top: 10px; border-top: 1px solid #059669;">
220
+ <label style="color: #cbd5e1; display: block; margin-bottom: 5px;">
221
+ <strong>Comments (optional):</strong>
222
+ </label>
223
+ <textarea data-comparison-id="{i}"
224
+ data-clip-a-id="{male_clip.id}"
225
+ data-clip-b-id="{female_clip.id}"
226
+ data-type="gender_vs_gender"
227
+ data-dimension="comment"
228
+ onchange="collectABData()"
229
+ placeholder="Why did you choose this option? Any specific observations?"
230
+ style="width: 100%; padding: 8px; background: #1f2937; color: #e5e5e5; border: 1px solid #059669; border-radius: 4px; resize: vertical; min-height: 60px; font-size: 13px;"
231
+ rows="3"></textarea>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ """
237
+
238
+ return html_content
frontend/pages/ab_model.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/pages/ab_model.py
2
+
3
+ import gradio as gr
4
+ from typing import Any, Dict
5
+
6
+ from backend.models import get_display_model_name, audio_to_base64_url
7
+
8
+
9
+ def build_ab_model_page():
10
+ """
11
+ Build the Model vs Model A/B comparison page.
12
+
13
+ Returns:
14
+ ab_model_page: Column container
15
+ back_btn: button to go back to MOS page
16
+ continue_btn: button to go to gender comparison page
17
+ ab_model_dynamic_content: HTML component where dynamic comparison blocks are rendered
18
+ """
19
+ with gr.Column(visible=False) as ab_model_page:
20
+ with gr.Group(elem_classes=["progress-text"]):
21
+ gr.Markdown("### Section 3 of 5: Model vs Model Comparisons")
22
+
23
+ gr.Markdown(
24
+ """
25
+ <div class="section-header">
26
+ <h2>Model vs Model Comparisons</h2>
27
+ <p>Compare pairs of clips with the same content but different TTS models.</p>
28
+ <p>Listen to both clips and decide which sounds better for ATC use.</p>
29
+ </div>
30
+ """
31
+ )
32
+
33
+ ab_model_dynamic_content = gr.HTML(
34
+ value="<p>Loading model comparisons...</p>", visible=True
35
+ )
36
+
37
+ with gr.Row():
38
+ back_btn = gr.Button(
39
+ "Back to Model Ratings", variant="secondary"
40
+ )
41
+ continue_btn = gr.Button(
42
+ "Continue to Gender Comparisons", variant="primary"
43
+ )
44
+
45
+ return ab_model_page, back_btn, continue_btn, ab_model_dynamic_content
46
+
47
+
48
+ def render_ab_model_html(session: Dict[str, Any]) -> str:
49
+ """
50
+ Render the HTML for Model vs Model A/B comparisons based on session data.
51
+
52
+ Uses session['ab_model_pairs'], which should be a list of (clip_a, clip_b) tuples.
53
+ """
54
+ if not session:
55
+ return ""
56
+
57
+ ab_model_pairs = session.get("ab_model_pairs", [])
58
+
59
+ if len(ab_model_pairs) == 0:
60
+ return """
61
+ <div class="comparison-container" style="padding: 20px; border-radius: 12px; border: 2px solid #f59e0b; margin: 15px 0;">
62
+ <h3>No model comparison pairs available</h3>
63
+ <p>No matching content found across different models for comparison.</p>
64
+ </div>
65
+ """
66
+
67
+ html_content = ""
68
+
69
+ for i, (clip_a, clip_b) in enumerate(ab_model_pairs, 1):
70
+ audio_a_url = audio_to_base64_url(clip_a.audio_url) or ""
71
+ audio_b_url = audio_to_base64_url(clip_b.audio_url) or ""
72
+
73
+ html_content += f"""
74
+ <div class="comparison-container" style="background: #2a2a2a; padding: 25px; border-radius: 12px; border: 2px solid #4f46e5; margin: 20px 0;">
75
+ <h3>Model Comparison {i}: {get_display_model_name(clip_a.model)} vs {get_display_model_name(clip_b.model)}</h3>
76
+ <p><strong>Speaker:</strong> {clip_a.speaker.title()} Voice</p>
77
+
78
+ <div style="background: #1e293b; padding: 15px; border-radius: 8px; margin: 15px 0;">
79
+ <p style="margin: 0; color: #cbd5e1; font-style: italic;">
80
+ <strong>Transcript:</strong> "{clip_a.transcript}"
81
+ </p>
82
+ </div>
83
+
84
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 20px 0;">
85
+ <div style="padding: 20px; background: #1e3a8a; border-radius: 8px; border: 2px solid #3b82f6;">
86
+ <h4>Model A</h4>
87
+ <audio controls style="width: 100%; margin-bottom: 15px;">
88
+ <source src="{audio_a_url}" type="audio/wav">
89
+ Audio not available
90
+ </audio>
91
+ </div>
92
+
93
+ <div style="padding: 20px; background: #7c2d92; border-radius: 8px; border: 2px solid #ec4899;">
94
+ <h4>Model B</h4>
95
+ <audio controls style="width: 100%; margin-bottom: 15px;">
96
+ <source src="{audio_b_url}" type="audio/wav">
97
+ Audio not available
98
+ </audio>
99
+ </div>
100
+ </div>
101
+
102
+ <div style="background: #2d3748; padding: 20px; border-radius: 8px; border: 1px solid #4f46e5; margin-top: 20px;">
103
+ <h4 style="color: #e5e5e5; margin: 0 0 15px 0;">Which model sounds better for ATC use?</h4>
104
+ <div style="display: flex; flex-direction: column; gap: 10px;">
105
+ <label style="color: #cbd5e1; cursor: pointer;">
106
+ <input type="radio" name="ab_model_{i}" value="A"
107
+ data-comparison-id="{i}"
108
+ data-clip-a-id="{clip_a.id}"
109
+ data-clip-b-id="{clip_b.id}"
110
+ data-type="model_vs_model"
111
+ onchange="collectABData()"
112
+ style="margin-right: 10px;">
113
+ {get_display_model_name(clip_a.model)} sounds better
114
+ </label>
115
+ <label style="color: #cbd5e1; cursor: pointer;">
116
+ <input type="radio" name="ab_model_{i}" value="B"
117
+ data-comparison-id="{i}"
118
+ data-clip-a-id="{clip_a.id}"
119
+ data-clip-b-id="{clip_b.id}"
120
+ data-type="model_vs_model"
121
+ onchange="collectABData()"
122
+ style="margin-right: 10px;">
123
+ {get_display_model_name(clip_b.model)} sounds better
124
+ </label>
125
+ <label style="color: #cbd5e1; cursor: pointer;">
126
+ <input type="radio" name="ab_model_{i}" value="tie"
127
+ data-comparison-id="{i}"
128
+ data-clip-a-id="{clip_a.id}"
129
+ data-clip-b-id="{clip_b.id}"
130
+ data-type="model_vs_model"
131
+ onchange="collectABData()"
132
+ style="margin-right: 10px;">
133
+ Both models sound equally good (tie)
134
+ </label>
135
+
136
+ <div style="margin-top: 15px; padding: 15px; background: #451a03; border-radius: 8px; border: 2px solid #92400e;">
137
+ <h5 style="color: #fbbf24; margin: 0 0 10px 0; font-weight: bold;">Gender Mismatch Check:</h5>
138
+ <label style="color: #fbbf24; cursor: pointer; display: flex; align-items: center; margin-bottom: 8px;">
139
+ <input type="checkbox"
140
+ data-comparison-id="{i}"
141
+ data-clip-a-id="{clip_a.id}"
142
+ data-clip-b-id="{clip_b.id}"
143
+ data-type="model_vs_model"
144
+ data-dimension="gender_mismatch_a"
145
+ onchange="collectABData()"
146
+ style="margin-right: 10px; transform: scale(1.2);">
147
+ ⚠️ {get_display_model_name(clip_a.model)} ({clip_a.speaker} voice) is the wrong gender
148
+ </label>
149
+ <label style="color: #fbbf24; cursor: pointer; display: flex; align-items: center;">
150
+ <input type="checkbox"
151
+ data-comparison-id="{i}"
152
+ data-clip-a-id="{clip_a.id}"
153
+ data-clip-b-id="{clip_b.id}"
154
+ data-type="model_vs_model"
155
+ data-dimension="gender_mismatch_b"
156
+ onchange="collectABData()"
157
+ style="margin-right: 10px; transform: scale(1.2);">
158
+ ⚠️ {get_display_model_name(clip_b.model)} ({clip_b.speaker} voice) is the wrong gender
159
+ </label>
160
+ </div>
161
+
162
+ <div style="margin-top: 15px; padding-top: 10px; border-top: 1px solid #4f46e5;">
163
+ <label style="color: #cbd5e1; display: block; margin-bottom: 5px;">
164
+ <strong>Comments (optional):</strong>
165
+ </label>
166
+ <textarea data-comparison-id="{i}"
167
+ data-clip-a-id="{clip_a.id}"
168
+ data-clip-b-id="{clip_b.id}"
169
+ data-type="model_vs_model"
170
+ data-dimension="comment"
171
+ onchange="collectABData()"
172
+ placeholder="Why did you choose this option? Any specific observations?"
173
+ style="width: 100%; padding: 8px; background: #1f2937; color: #e5e5e5; border: 1px solid #4f46e5; border-radius: 4px; resize: vertical; min-height: 60px; font-size: 13px;"
174
+ rows="3"></textarea>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ """
180
+
181
+ # Add JavaScript collector
182
+ html_content += """
183
+ <script>
184
+ function collectABData() {
185
+ const ratings = {};
186
+
187
+ const radios = document.querySelectorAll('input[type="radio"]:checked');
188
+ const checkboxes = document.querySelectorAll('input[type="checkbox"][data-comparison-id]');
189
+ const textareas = document.querySelectorAll('textarea[data-comparison-id]');
190
+
191
+ radios.forEach(radio => {
192
+ const compId = radio.getAttribute('data-comparison-id');
193
+ const clipAId = radio.getAttribute('data-clip-a-id');
194
+ const clipBId = radio.getAttribute('data-clip-b-id');
195
+ const compType = radio.getAttribute('data-type');
196
+
197
+ if (!ratings[compId]) ratings[compId] = {};
198
+ ratings[compId].clip_a_id = clipAId;
199
+ ratings[compId].clip_b_id = clipBId;
200
+ ratings[compId].comparison_type = compType;
201
+ ratings[compId].choice = radio.value;
202
+ });
203
+
204
+ checkboxes.forEach(checkbox => {
205
+ const compId = checkbox.getAttribute('data-comparison-id');
206
+ const dimension = checkbox.getAttribute('data-dimension');
207
+ if (!ratings[compId]) ratings[compId] = {};
208
+ ratings[compId][dimension] = checkbox.checked;
209
+ });
210
+
211
+ textareas.forEach(textarea => {
212
+ const compId = textarea.getAttribute('data-comparison-id');
213
+ const dimension = textarea.getAttribute('data-dimension');
214
+ if (!ratings[compId]) ratings[compId] = {};
215
+ ratings[compId][dimension] = textarea.value;
216
+ });
217
+
218
+ // Find the hidden textbox and update it - try both possible selectors
219
+ let abDataElement = document.querySelector('#ab_data_store textarea');
220
+ if (!abDataElement) {
221
+ abDataElement = document.querySelector('#ab_data_store input');
222
+ }
223
+
224
+ console.log('[DEBUG] collectABData called (Model comparison)');
225
+ console.log('[DEBUG] Found', Object.keys(ratings).length, 'comparisons');
226
+ console.log('[DEBUG] Ratings object:', ratings);
227
+ console.log('[DEBUG] AB data element found:', abDataElement);
228
+
229
+ if (abDataElement) {
230
+ abDataElement.value = JSON.stringify(ratings);
231
+ console.log('[DEBUG] Updated hidden textbox with:', abDataElement.value);
232
+ abDataElement.dispatchEvent(new Event('input', { bubbles: true }));
233
+ } else {
234
+ console.error('[ERROR] Could not find AB data textbox element');
235
+ }
236
+ }
237
+ </script>
238
+ """
239
+
240
+ return html_content
frontend/pages/conclusion.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/pages/conclusion.py
2
+
3
+ import gradio as gr
4
+
5
+
6
+ def build_conclusion_page():
7
+ """
8
+ Build the conclusion page (overall preference + comments).
9
+
10
+ Returns:
11
+ conclusion_page: Column container
12
+ back_btn: button to go back to gender comparisons
13
+ export_btn: button to submit results
14
+ overall_feedback: Radio input
15
+ final_comments: Textbox input
16
+ """
17
+ with gr.Column(visible=False) as conclusion_page:
18
+ with gr.Group(elem_classes=["progress-text"]):
19
+ gr.Markdown("### Section 5 of 5: Evaluation Complete")
20
+
21
+ overall_feedback = gr.Radio(
22
+ choices=[
23
+ "Model A was consistently best",
24
+ "Model B was consistently best",
25
+ "Model C was consistently best",
26
+ "Quality varied significantly",
27
+ "All models were similar quality",
28
+ ],
29
+ label="Overall, which TTS system performed best?",
30
+ value=None,
31
+ )
32
+
33
+ final_comments = gr.Textbox(
34
+ label="Additional Comments",
35
+ placeholder="Any other observations...",
36
+ lines=4,
37
+ )
38
+
39
+ with gr.Row():
40
+ back_btn = gr.Button(
41
+ "Back to Gender Comparisons", variant="secondary"
42
+ )
43
+ export_btn = gr.Button("Submit Results", variant="primary")
44
+
45
+ return conclusion_page, back_btn, export_btn, overall_feedback, final_comments
frontend/pages/intro.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/pages/intro.py
2
+
3
+ import gradio as gr
4
+
5
+
6
+ def build_intro_page():
7
+ """Builds the intro page and returns (container, start_button)."""
8
+ with gr.Column(visible=True) as intro_page:
9
+ gr.Markdown(
10
+ """
11
+ # ATC TTS Evaluation Study
12
+
13
+ You will evaluate short ATC/aviation clips from three TTS systems: Model A, Model B, and Model C.
14
+
15
+ <div class="section-header">
16
+ <h3>Evaluation Process (5 Sections):</h3>
17
+ <ol>
18
+ <li><strong>Reference Audio</strong> - Listen to sample clips to calibrate expectations</li>
19
+ <li><strong>MOS Block</strong> - Rate clips across several quality dimensions (9-12 sections; mix models and genders)</li>
20
+ <li><strong>AB Model Comparisons</strong> - Model vs Model comparisons (same gender)</li>
21
+ <li><strong>AB Gender Comparisons</strong> - Male vs Female voice comparisons (same model)</li>
22
+ <li><strong>Conclusion</strong> - Best overall model and additional comments</li>
23
+ </ol>
24
+ </div>
25
+
26
+ Please use headphones in a quiet place, rate what you hear.
27
+
28
+ ### What you will do
29
+
30
+ - Rate each clip on five MOS dimensions (1–5).
31
+ - Do a few A/B comparisons (two clips of the same prompt).
32
+ - Some clips are Male voice, others Female; you may also compare Male vs Female for the same model.
33
+
34
+ ### What to listen for (quick checklist)
35
+
36
+ - **Clarity**: Can you understand every word, number, call-sign, waypoint, runway, and altitude?
37
+ - **Pronunciation**: Are aviation terms spoken correctly (e.g., "flight level three zero zero")?
38
+ - **Prosody (Intonation/Rhythm)**: Is pacing appropriate for ATC (brisk, not rushed; natural stress/pauses)?
39
+ - **Naturalness**: Does it sound human-like (no robotic artifacts, buzz, hiss, warble)?
40
+ - **Overall**: Your total impression for ATC use.
41
+
42
+ **Estimated time**: ~10–15 minutes. Responses are anonymous and used only to improve the models.
43
+ """
44
+ )
45
+
46
+ start_btn = gr.Button("Start Evaluation", variant="primary")
47
+
48
+ return intro_page, start_btn
frontend/pages/mos.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/pages/mos.py
2
+
3
+ import random
4
+ import time
5
+ from typing import Any, Dict, List
6
+
7
+ import gradio as gr
8
+
9
+ from backend.models import get_display_model_name, audio_to_base64_url
10
+
11
+
12
+ def build_mos_page():
13
+ """
14
+ Build the MOS page (Section 2).
15
+
16
+ Returns:
17
+ mos_page, back_btn, continue_btn, mos_dynamic_content, mos_data_textbox
18
+ """
19
+ with gr.Column(visible=False) as mos_page:
20
+ with gr.Group(elem_classes=["progress-text"]):
21
+ gr.Markdown("### Section 2 of 5: Model Rating Evaluation")
22
+
23
+ gr.Markdown(
24
+ """
25
+ <div class="section-header">
26
+ <h2>Mixed Model Evaluation</h2>
27
+ <p>Listen to TTS clips and rate each one. Model identities are hidden to reduce bias.</p>
28
+
29
+ <h3>Instructions:</h3>
30
+ <ul>
31
+ <li>Listen to each clip carefully.</li>
32
+ <li>Rate each clip individually using the dropdown menus (1–5 scale).</li>
33
+ <li>Leave comments if you notice specific issues or strengths.</li>
34
+ <li>Rating dimensions:
35
+ <ul>
36
+ <li>Clarity</li>
37
+ <li>Pronunciation</li>
38
+ <li>Prosody (Intonation/Rhythm)</li>
39
+ <li>Naturalness</li>
40
+ <li>Overall quality</li>
41
+ </ul>
42
+ </li>
43
+ </ul>
44
+ </div>
45
+ """
46
+ )
47
+
48
+ mos_dynamic_content = gr.HTML(value="<p>Loading clips...</p>", visible=True)
49
+
50
+ with gr.Row():
51
+ back_btn = gr.Button("Back to Reference Audio", variant="secondary")
52
+ continue_btn = gr.Button(
53
+ "Continue to Model Comparisons", variant="primary"
54
+ )
55
+
56
+ return mos_page, back_btn, continue_btn, mos_dynamic_content
57
+
58
+
59
+ def render_mos_html(session) -> str:
60
+ """
61
+ Given a session (from SessionManager), return the big HTML string
62
+ used in your original show_mos_page().
63
+ """
64
+ if not session:
65
+ return ""
66
+
67
+ clips = session.get("mos_clips", [])
68
+ if len(clips) == 0:
69
+ return """
70
+ <div class="clip-container" style="padding: 20px; border-radius: 12px; border: 2px solid #f59e0b; margin: 15px 0;">
71
+ <h3>No MOS clips available</h3>
72
+ <p>The dataset appears to be empty or not loading correctly.</p>
73
+ <p>This is a demo of the interface structure.</p>
74
+ </div>
75
+ """
76
+
77
+ rng = random.Random(session.get("session_id", time.time()))
78
+ shuffled_clips = clips.copy()
79
+ rng.shuffle(shuffled_clips)
80
+
81
+ html_content = ""
82
+ for i, clip in enumerate(shuffled_clips, 1):
83
+ gender_color = "#3B82F6" if clip.speaker == "male" else "#EC4899"
84
+ audio_url = audio_to_base64_url(clip.audio_url) or ""
85
+
86
+ # --- this is exactly your original MOS HTML per clip ---
87
+ html_content += f"""
88
+ <div class="clip-container" style="background: linear-gradient(135deg, {gender_color}15, {gender_color}08);
89
+ padding: 20px; border-radius: 12px; border-left: 4px solid {gender_color}; margin: 15px 0;">
90
+ <h3>Clip {i}: {clip.speaker.title()} Voice - {get_display_model_name(clip.model)}</h3>
91
+
92
+ <div style="background: #1e293b; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
93
+ <p style="margin: 0; color: #cbd5e1; font-style: italic;">
94
+ <strong>Text:</strong> "{clip.transcript}"
95
+ </p>
96
+ </div>
97
+
98
+ <div style="background: #1f2937; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
99
+ <audio controls style="width: 100%; height: 54px;">
100
+ <source src="{audio_url}" type="audio/wav">
101
+ Audio not available
102
+ </audio>
103
+ </div>
104
+
105
+ <div style="background: #2d3748; padding: 15px; border-radius: 8px; border: 1px solid {gender_color};">
106
+ <h5 style="color: #e5e5e5; margin: 0 0 10px 0;">Rate this clip:</h5>
107
+ <div style="display: flex; flex-direction: column; gap: 8px; font-size: 14px;">
108
+ <label style="color: #cbd5e1;">
109
+ <strong>Clarity (1–5):</strong>
110
+ <select data-clip-id="{clip.id}" data-model="{clip.model}" data-dimension="clarity"
111
+ style="margin-left: 10px; padding: 5px; background: #4a5568; color: white; border: 1px solid {gender_color}; border-radius: 4px;">
112
+ <option value="">Select...</option>
113
+ <option value="1">1 - Poor</option>
114
+ <option value="2">2 - Fair</option>
115
+ <option value="3">3 - Good</option>
116
+ <option value="4">4 - Very Good</option>
117
+ <option value="5">5 - Excellent</option>
118
+ </select>
119
+ </label>
120
+
121
+ <label style="color: #cbd5e1;">
122
+ <strong>Pronunciation (1–5):</strong>
123
+ <select data-clip-id="{clip.id}" data-model="{clip.model}" data-dimension="pronunciation"
124
+ style="margin-left: 10px; padding: 5px; background: #4a5568; color: white; border: 1px solid {gender_color}; border-radius: 4px;">
125
+ <option value="">Select...</option>
126
+ <option value="1">1 - Poor</option>
127
+ <option value="2">2 - Fair</option>
128
+ <option value="3">3 - Good</option>
129
+ <option value="4">4 - Very Good</option>
130
+ <option value="5">5 - Excellent</option>
131
+ </select>
132
+ </label>
133
+
134
+ <label style="color: #cbd5e1;">
135
+ <strong>Prosody (1–5):</strong>
136
+ <select data-clip-id="{clip.id}" data-model="{clip.model}" data-dimension="prosody"
137
+ style="margin-left: 10px; padding: 5px; background: #4a5568; color: white; border: 1px solid {gender_color}; border-radius: 4px;">
138
+ <option value="">Select...</option>
139
+ <option value="1">1 - Very poor pacing</option>
140
+ <option value="2">2 - Poor pacing</option>
141
+ <option value="3">3 - Acceptable pacing</option>
142
+ <option value="4">4 - Good pacing</option>
143
+ <option value="5">5 - Excellent pacing</option>
144
+ </select>
145
+ </label>
146
+
147
+ <label style="color: #cbd5e1;">
148
+ <strong>Naturalness (1–5):</strong>
149
+ <select data-clip-id="{clip.id}" data-model="{clip.model}" data-dimension="naturalness"
150
+ style="margin-left: 10px; padding: 5px; background: #4a5568; color: white; border: 1px solid {gender_color}; border-radius: 4px;">
151
+ <option value="">Select...</option>
152
+ <option value="1">1 - Very robotic</option>
153
+ <option value="2">2 - Robotic</option>
154
+ <option value="3">3 - Neutral</option>
155
+ <option value="4">4 - Natural</option>
156
+ <option value="5">5 - Very natural</option>
157
+ </select>
158
+ </label>
159
+
160
+ <label style="color: #cbd5e1;">
161
+ <strong>Overall (1–5):</strong>
162
+ <select data-clip-id="{clip.id}" data-model="{clip.model}" data-dimension="overall"
163
+ style="margin-left: 10px; padding: 5px; background: #4a5568; color: white; border: 1px solid {gender_color}; border-radius: 4px;">
164
+ <option value="">Select...</option>
165
+ <option value="1">1 - Unacceptable</option>
166
+ <option value="2">2 - Poor</option>
167
+ <option value="3">3 - Acceptable</option>
168
+ <option value="4">4 - Good</option>
169
+ <option value="5">5 - Excellent</option>
170
+ </select>
171
+ </label>
172
+
173
+ <div style="margin-top: 15px; padding: 15px; background: #451a03; border-radius: 8px; border: 2px solid #92400e;">
174
+ <label style="color: #fbbf24; cursor: pointer; display: flex; align-items: center; font-weight: bold;">
175
+ <input type="checkbox" data-clip-id="{clip.id}" data-model="{clip.model}" data-dimension="gender_mismatch" onchange="collectMOSData()"
176
+ style="margin-right: 10px; transform: scale(1.2);">
177
+ ⚠️ {get_display_model_name(clip.model)} ({clip.speaker} voice) sounds wrong
178
+ </label>
179
+ <p style="margin: 8px 0 0 0; font-size: 12px; color: #fbbf24;">
180
+ Check this box if you hear the wrong gender voice for this clip.
181
+ </p>
182
+ </div>
183
+
184
+ <div style="margin-top: 15px; padding-top: 10px; border-top: 1px solid {gender_color};">
185
+ <label style="color: #cbd5e1; display: block; margin-bottom: 5px;">
186
+ <strong>Comments (optional):</strong>
187
+ </label>
188
+ <textarea data-clip-id="{clip.id}" data-model="{clip.model}" data-dimension="comment" onchange="collectMOSData()"
189
+ placeholder="Any specific observations about this clip?"
190
+ style="width: 100%; padding: 8px; background: #1f2937; color: #e5e5e5; border: 1px solid {gender_color}; border-radius: 4px; resize: vertical; min-height: 60px; font-size: 13px;"
191
+ rows="3"></textarea>
192
+ </div>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ """
197
+
198
+ # Add JavaScript collector that updates the hidden textbox
199
+ html_content += """
200
+ <script>
201
+ function collectMOSData() {
202
+ const ratings = {};
203
+
204
+ const selects = document.querySelectorAll('select[data-clip-id]');
205
+ const checkboxes = document.querySelectorAll('input[type="checkbox"][data-clip-id]');
206
+ const textareas = document.querySelectorAll('textarea[data-clip-id]');
207
+
208
+ selects.forEach(select => {
209
+ const clipId = select.getAttribute('data-clip-id');
210
+ const dimension = select.getAttribute('data-dimension');
211
+ if (!ratings[clipId]) ratings[clipId] = {};
212
+ ratings[clipId][dimension] = select.value;
213
+ });
214
+
215
+ checkboxes.forEach(checkbox => {
216
+ const clipId = checkbox.getAttribute('data-clip-id');
217
+ const dimension = checkbox.getAttribute('data-dimension');
218
+ if (!ratings[clipId]) ratings[clipId] = {};
219
+ ratings[clipId][dimension] = checkbox.checked;
220
+ });
221
+
222
+ textareas.forEach(textarea => {
223
+ const clipId = textarea.getAttribute('data-clip-id');
224
+ const dimension = textarea.getAttribute('data-dimension');
225
+ if (!ratings[clipId]) ratings[clipId] = {};
226
+ ratings[clipId][dimension] = textarea.value;
227
+ });
228
+
229
+ // Find the hidden textbox and update it - try both possible selectors
230
+ let mosDataElement = document.querySelector('#mos_data_store textarea');
231
+ if (!mosDataElement) {
232
+ mosDataElement = document.querySelector('#mos_data_store input');
233
+ }
234
+
235
+ console.log('[DEBUG] collectMOSData called');
236
+ console.log('[DEBUG] Found', Object.keys(ratings).length, 'rated clips');
237
+ console.log('[DEBUG] Ratings object:', ratings);
238
+ console.log('[DEBUG] MOS data element found:', mosDataElement);
239
+
240
+ if (mosDataElement) {
241
+ mosDataElement.value = JSON.stringify(ratings);
242
+ console.log('[DEBUG] Updated hidden textbox with:', mosDataElement.value);
243
+ mosDataElement.dispatchEvent(new Event('input', { bubbles: true }));
244
+ } else {
245
+ console.error('[ERROR] Could not find MOS data textbox element');
246
+ }
247
+ }
248
+
249
+ </script>
250
+ """
251
+
252
+ return html_content
frontend/pages/samples.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/pages/samples.py
2
+
3
+ import gradio as gr
4
+
5
+
6
+ def build_sample_page():
7
+ """
8
+ Builds the reference / sample audio page.
9
+
10
+ Returns:
11
+ sample_page: Column container
12
+ back_btn: button to go back to intro
13
+ continue_btn: button to go to MOS
14
+ sample_audio_container: Column where dynamic sample clips will be injected
15
+ """
16
+ with gr.Column(visible=False) as sample_page:
17
+ with gr.Group(elem_classes=["progress-text"]):
18
+ gr.Markdown("### Section 1 of 5: Reference Audio Samples")
19
+
20
+ gr.Markdown(
21
+ """
22
+ <div class="section-header">
23
+ <h2>Reference Audio Samples</h2>
24
+ <p>Listen to these reference clips to understand the expected ATC communication style and quality.</p>
25
+ <p>These clips help calibrate your internal scale before rating synthetic audio.</p>
26
+ </div>
27
+ """
28
+ )
29
+
30
+ with gr.Group(elem_classes=["reference-container"]):
31
+ gr.Markdown("### Reference Voices")
32
+ gr.Markdown("These are human reference recordings.")
33
+
34
+ with gr.Row():
35
+ with gr.Column():
36
+ gr.Markdown("#### Male Reference Voice")
37
+ gr.Audio(
38
+ "/home/raid/atc-c3-comms/assets/tts/src/evaluation/mos/audios/male.wav",
39
+ label="Male Reference - Professional ATC",
40
+ )
41
+ with gr.Group(elem_classes=["transcript-box"]):
42
+ gr.Markdown(
43
+ "**Text:** *TENGAH Approach to CAMEL, CAMEL identified on squawk, QNH 29.80, Delta 4 hot, report abeam Tuas.*"
44
+ )
45
+
46
+ with gr.Column():
47
+ gr.Markdown("#### Female Reference Voice")
48
+ gr.Audio(
49
+ "/home/raid/atc-c3-comms/assets/tts/src/evaluation/mos/audios/female.wav",
50
+ label="Female Reference - Professional ATC",
51
+ )
52
+ with gr.Group(elem_classes=["transcript-box"]):
53
+ gr.Markdown(
54
+ "**Text:** *JOHOR climbing to flight level 300, contact Approach on 123.45.*"
55
+ )
56
+
57
+ # container where we will render session.sample_clips dynamically
58
+ sample_audio_container = gr.Column()
59
+
60
+ with gr.Row():
61
+ back_btn = gr.Button("Back to Introduction", variant="secondary")
62
+ continue_btn = gr.Button("Continue to Model Ratings", variant="primary")
63
+
64
+ return sample_page, back_btn, continue_btn, sample_audio_container
frontend/pages/thank_you.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/pages/thank_you.py
2
+
3
+ import gradio as gr
4
+
5
+
6
+ def build_thank_you_page():
7
+ """
8
+ Build the final thank-you page.
9
+
10
+ Returns:
11
+ thank_you_page: Column container
12
+ """
13
+ with gr.Column(visible=False) as thank_you_page:
14
+ gr.Markdown(
15
+ """
16
+ <div class="section-header">
17
+ <h2>Thank You!</h2>
18
+ <p>Your evaluation has been successfully submitted.</p>
19
+ </div>
20
+
21
+ <div style="text-align: center; margin: 50px 0;">
22
+ <h3 style="color: #10b981;">✓ Evaluation Complete</h3>
23
+ </div>
24
+ """
25
+ )
26
+
27
+ return thank_you_page
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Core dependencies
2
+ gradio>=4.0.0
3
+ datasets>=2.14.0
4
+ pandas>=1.5.0
5
+ huggingface_hub>=0.17.0
6
+ soundfile>=0.12.1
7
+ torchcodec>=0.1.0
8
+ torch>=1.12.0