Spaces:
Runtime error
Runtime error
aether-raider commited on
Commit ·
9e1e4ee
1
Parent(s): 0345427
feat: mos form
Browse files- README.md +0 -12
- app/app.py +0 -347
- audios/female.wav +3 -0
- audios/male.wav +3 -0
- backend/__init__.py +26 -0
- backend/__pycache__/__init__.cpython-311.pyc +0 -0
- backend/__pycache__/config.cpython-311.pyc +0 -0
- backend/__pycache__/data_manager.cpython-311.pyc +0 -0
- backend/__pycache__/hf_integration.cpython-311.pyc +0 -0
- backend/__pycache__/hf_logging.cpython-311.pyc +0 -0
- backend/__pycache__/models.cpython-311.pyc +0 -0
- backend/__pycache__/session_manager.cpython-311.pyc +0 -0
- backend/config.py +5 -0
- backend/data_manager.py +119 -0
- backend/hf_logging.py +68 -0
- backend/models.py +60 -0
- backend/session_manager.py +456 -0
- backend/utils/hf_utils.py +0 -224
- config/config.json +0 -13
- frontend/__init__.py +0 -0
- frontend/__pycache__/__init__.cpython-311.pyc +0 -0
- frontend/__pycache__/css.cpython-311.pyc +0 -0
- frontend/app.py +511 -0
- frontend/css.py +160 -0
- frontend/pages/__init__.py +48 -0
- frontend/pages/__pycache__/__init__.cpython-311.pyc +0 -0
- frontend/pages/__pycache__/ab_gender.cpython-311.pyc +0 -0
- frontend/pages/__pycache__/ab_model.cpython-311.pyc +0 -0
- frontend/pages/__pycache__/conclusion.cpython-311.pyc +0 -0
- frontend/pages/__pycache__/intro.cpython-311.pyc +0 -0
- frontend/pages/__pycache__/mos.cpython-311.pyc +0 -0
- frontend/pages/__pycache__/mos_native.cpython-311.pyc +0 -0
- frontend/pages/__pycache__/samples.cpython-311.pyc +0 -0
- frontend/pages/__pycache__/thank_you.cpython-311.pyc +0 -0
- frontend/pages/ab_gender.py +238 -0
- frontend/pages/ab_model.py +240 -0
- frontend/pages/conclusion.py +45 -0
- frontend/pages/intro.py +48 -0
- frontend/pages/mos.py +252 -0
- frontend/pages/samples.py +64 -0
- frontend/pages/thank_you.py +27 -0
- 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
|