Mia11939's picture
swap models: drop seamless-medium/large, add stepaudio2-notrain (now 8 models per sample)
90675c1 verified
Raw
History Blame Contribute Delete
16.2 kB
"""S2ST Subjective Test (MOS).
Anonymized listening test for speech-to-speech translation. Each sample shows
nine model outputs as `Model A` ... `Model I` in a per-sample randomized order
(rater-specific). Raters score three axes (1-5): translation quality, audio
naturalness, speaker similarity, plus an optional note.
Submissions are appended to mos_results.csv. NOTE: HF Spaces filesystem is
ephemeral by default - download results periodically from the Admin panel.
"""
from __future__ import annotations
import csv
import json
import os
import random
import string
from datetime import datetime
from pathlib import Path
# HF Spaces sets GRADIO_SSR_MODE=True via the runtime; SSR + the HF proxy currently
# generate broken file URLs like /gradio_a/gradio_api/file=... that 404. Force SSR
# off before gradio is imported.
os.environ["GRADIO_SSR_MODE"] = "False"
# Workaround for a long-standing gradio_client bug where the JSON-schema -> Python-type
# walker crashes on schemas where `additionalProperties: True` (boolean) instead of a
# nested object schema. The frontend hits /info on page load, so unpatched the UI fails
# to mount and audio never loads. Patch BEFORE importing gradio.
import gradio_client.utils as _gcu # noqa: E402
_orig_json_schema = _gcu._json_schema_to_python_type
_orig_get_type = _gcu.get_type
def _safe_get_type(schema):
if isinstance(schema, bool):
return "Any"
return _orig_get_type(schema)
def _safe_json_schema(schema, defs=None):
if isinstance(schema, bool):
return "Any"
return _orig_json_schema(schema, defs)
_gcu.get_type = _safe_get_type
_gcu._json_schema_to_python_type = _safe_json_schema
import gradio as gr # noqa: E402
import gradio.processing_utils as _gpu # noqa: E402
# HF Spaces' reverse proxy makes Gradio compute a root_url like
# "https://<space>.hf.space/gradio_a", which then gets prepended to relative file
# URLs as "/gradio_a/gradio_api/file=...". Strip that bogus suffix.
_orig_add_root_url = _gpu.add_root_url
def _fixed_add_root_url(data, root_url, previous_root_url):
if isinstance(root_url, str) and root_url.endswith("/gradio_a"):
print(f"[patch] stripping /gradio_a suffix from root_url={root_url!r}", flush=True)
root_url = root_url[: -len("/gradio_a")]
return _orig_add_root_url(data, root_url, previous_root_url)
_gpu.add_root_url = _fixed_add_root_url
ROOT = Path(__file__).parent
SAMPLES = json.loads((ROOT / "samples.json").read_text(encoding="utf-8"))
RESULTS_FILE = ROOT / "mos_results.csv"
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "") # set in Space Secrets
# When deployed on HF Spaces the platform's reverse proxy corrupts the URLs Gradio
# auto-constructs for cached files (they come out as /gradio_a/gradio_api/file=...).
# Build a complete public URL ourselves; Gradio treats HTTP URLs as opaque and
# passes them straight through to the frontend.
SPACE_HOST = os.environ.get("SPACE_HOST", "").strip()
def audio_url(filename: str) -> str:
local = ROOT / "audio" / filename
if SPACE_HOST:
return f"https://{SPACE_HOST}/gradio_api/file={local}"
return str(local)
DIRECTIONS = ["en2zh", "zh2en"]
DIRECTION_LABELS = {"en2zh": "English -> Chinese", "zh2en": "Chinese -> English"}
SRC_LANG_LABEL = {"en2zh": "English", "zh2en": "Chinese"}
TGT_LANG_LABEL = {"en2zh": "Chinese", "zh2en": "English"}
NUM_MODELS = 8
LETTERS = list(string.ascii_uppercase[:NUM_MODELS]) # A..H
# CSV header
CSV_HEADER = [
"timestamp", "user_id", "direction", "sample_idx", "sample_id",
# real model per letter (so we can de-anonymize later)
*[f"real_{L}" for L in LETTERS],
# ratings per letter (each axis 1-5)
*[f"{L}_translation" for L in LETTERS],
*[f"{L}_naturalness" for L in LETTERS],
*[f"{L}_spksim" for L in LETTERS],
*[f"{L}_note" for L in LETTERS],
]
def ensure_csv():
if not RESULTS_FILE.exists():
with open(RESULTS_FILE, "w", encoding="utf-8", newline="") as f:
csv.writer(f).writerow(CSV_HEADER)
def sample_at(direction: str, idx: int):
return SAMPLES[direction]["samples"][idx]
def total_samples(direction: str) -> int:
return len(SAMPLES[direction]["samples"])
def build_letter_assignment(rater_id: str, direction: str, sample_idx_zero_based: int):
"""Return a list mapping displayed letter index (0=A..8=I) -> real model key.
Deterministic per (rater, direction, sample_idx) so navigation is stable
within a session.
"""
real_models = SAMPLES[direction]["model_order"]
seed = f"{rater_id or 'anon'}::{direction}::{sample_idx_zero_based}"
rng = random.Random(seed)
order = list(real_models)
rng.shuffle(order)
return order
def render_sample(rater_id: str, direction: str, idx_zero: int):
"""Build all UI-bound values for the requested sample."""
s = sample_at(direction, idx_zero)
real_per_letter = build_letter_assignment(rater_id, direction, idx_zero)
src_audio_path = audio_url(s["source_audio"])
audio_paths = []
for letter_idx in range(NUM_MODELS):
real = real_per_letter[letter_idx]
rel = s["models"][real]["audio"]
audio_paths.append(audio_url(rel))
progress = f"Sample {idx_zero + 1} / {total_samples(direction)} ({DIRECTION_LABELS[direction]})"
src_header = f"Source ({SRC_LANG_LABEL[direction]})"
return src_audio_path, s["source_text"], src_header, progress, real_per_letter, audio_paths
def reset_widget_outputs():
"""Return default values for the 9*(audio+3 sliders+1 note) widget set."""
out = []
for _ in range(NUM_MODELS):
out.append(None) # audio
out.append(3) # translation
out.append(3) # naturalness
out.append(3) # spksim
out.append("") # note
return out
def load_existing_ratings(rater_id: str, direction: str, sample_id: str, real_per_letter):
"""If this rater has already rated this (direction, sample) — return previous values."""
if not RESULTS_FILE.exists():
return None
try:
with open(RESULTS_FILE, "r", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
last = None
for row in reader:
if (row.get("user_id") == (rater_id or "anonymous")
and row.get("direction") == direction
and row.get("sample_id") == sample_id):
last = row
if last is None:
return None
# Build a real_model -> ratings dict from the saved row
per_real = {}
for L in LETTERS:
real = last.get(f"real_{L}")
if not real:
continue
per_real[real] = {
"translation": int(last.get(f"{L}_translation") or 3),
"naturalness": int(last.get(f"{L}_naturalness") or 3),
"spksim": int(last.get(f"{L}_spksim") or 3),
"note": last.get(f"{L}_note") or "",
}
# Project onto current letter assignment for the rater
proj = []
for letter_idx in range(NUM_MODELS):
real = real_per_letter[letter_idx]
r = per_real.get(real, {"translation": 3, "naturalness": 3, "spksim": 3, "note": ""})
proj.extend([r["translation"], r["naturalness"], r["spksim"], r["note"]])
return proj
except Exception:
return None
# ---------------------------- Gradio callbacks -------------------------------
def on_load_sample(rater_id, direction, idx_zero):
rater_id = (rater_id or "").strip()
n = total_samples(direction)
idx_zero = max(0, min(idx_zero, n - 1))
src_audio, src_text, src_header, progress, real_per_letter, audios = render_sample(
rater_id, direction, idx_zero
)
# Existing ratings (if any) for this rater/direction/sample
s = sample_at(direction, idx_zero)
existing = load_existing_ratings(rater_id, direction, s["id"], real_per_letter)
# Build all output widget values
widget_vals = []
for letter_idx in range(NUM_MODELS):
widget_vals.append(audios[letter_idx]) # audio
if existing is None:
widget_vals.extend([3, 3, 3, ""])
else:
widget_vals.extend(existing[letter_idx * 4: letter_idx * 4 + 4])
nav_prev_interactive = idx_zero > 0
nav_next_label = "Submit & Next ->" if idx_zero < n - 1 else "Submit & Finish"
msg = ""
if existing is not None:
msg = "Loaded your previously submitted ratings for this sample."
return (
src_audio, src_text, src_header, progress, idx_zero, real_per_letter, msg,
gr.update(interactive=nav_prev_interactive),
gr.update(value=nav_next_label),
*widget_vals,
)
def on_submit(rater_id, direction, idx_zero, real_per_letter, *vals):
"""vals: per letter (translation, naturalness, spksim, note) repeated NUM_MODELS times."""
rater_id = (rater_id or "").strip() or "anonymous"
ensure_csv()
s = sample_at(direction, idx_zero)
row = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"user_id": rater_id,
"direction": direction,
"sample_idx": s["sample_idx"],
"sample_id": s["id"],
}
for letter_idx, L in enumerate(LETTERS):
row[f"real_{L}"] = real_per_letter[letter_idx]
for letter_idx, L in enumerate(LETTERS):
base = letter_idx * 4
row[f"{L}_translation"] = vals[base]
row[f"{L}_naturalness"] = vals[base + 1]
row[f"{L}_spksim"] = vals[base + 2]
row[f"{L}_note"] = vals[base + 3]
# Overwrite if same (rater, direction, sample) submitted before; otherwise append.
rows = []
if RESULTS_FILE.exists():
with open(RESULTS_FILE, "r", encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
rows = [r for r in reader
if not (r.get("user_id") == rater_id
and r.get("direction") == direction
and r.get("sample_id") == s["id"])]
rows.append(row)
with open(RESULTS_FILE, "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=CSV_HEADER)
w.writeheader()
w.writerows(rows)
# Advance
n = total_samples(direction)
next_idx = min(idx_zero + 1, n - 1)
return next_idx, "Saved. " + (f"Moving to sample {next_idx + 1}." if next_idx > idx_zero
else "This was the last sample for this direction.")
def on_prev(idx_zero):
return max(0, idx_zero - 1)
def on_direction_change(_direction):
# Resetting to sample 0 of the new direction
return 0
def admin_download(password):
if not RESULTS_FILE.exists():
return None, "No submissions yet."
if ADMIN_PASSWORD and password != ADMIN_PASSWORD:
return None, "Wrong password."
return str(RESULTS_FILE), f"OK. {RESULTS_FILE.stat().st_size} bytes."
def admin_clear(password):
if ADMIN_PASSWORD and password != ADMIN_PASSWORD:
return "Wrong password."
if RESULTS_FILE.exists():
RESULTS_FILE.unlink()
return "mos_results.csv cleared."
# ----------------------------------- UI --------------------------------------
CSS = """
.model-card {border: 1px solid #d0d0d0; border-radius: 8px; padding: 10px;
margin-bottom: 8px; background: #fbfbfb;}
.model-card h3 {margin: 0 0 6px 0; font-size: 16px;}
.muted {color: #666; font-size: 12px;}
"""
gr.set_static_paths(paths=[str(ROOT / "audio")])
with gr.Blocks(title="S2ST Subjective Test", css=CSS, analytics_enabled=False) as demo:
gr.Markdown("## Speech-to-Speech Translation - Subjective Listening Test")
gr.Markdown(
f"Rate each anonymous model (A through {LETTERS[-1]}) on three axes "
"(1=worst, 5=best): **Translation Quality**, **Audio Naturalness**, "
"**Speaker Similarity**. Models are presented in a different random order "
"on every sample, so 'Model A' on this page is NOT the same model as "
"'Model A' on the next page."
)
with gr.Row():
rater_id = gr.Textbox(label="Rater ID (your name or email)", scale=2)
direction = gr.Radio(
choices=[(DIRECTION_LABELS[d], d) for d in DIRECTIONS],
value="en2zh",
label="Translation direction",
scale=2,
)
idx_state = gr.State(0)
real_per_letter_state = gr.State([])
progress_md = gr.Markdown()
status_md = gr.Markdown()
src_header_md = gr.Markdown()
with gr.Row():
src_text = gr.Textbox(label="Source text", interactive=False, lines=2, scale=3)
src_audio = gr.Audio(label="Source audio", interactive=False, scale=2)
gr.Markdown("### Model outputs (anonymized)")
audio_widgets = []
translation_widgets = []
naturalness_widgets = []
spksim_widgets = []
note_widgets = []
for i, L in enumerate(LETTERS):
with gr.Group(elem_classes="model-card"):
gr.Markdown(f"### Model {L}")
with gr.Row():
a = gr.Audio(label=f"Model {L} audio", interactive=False, scale=2)
with gr.Column(scale=3):
t = gr.Slider(1, 5, value=3, step=1, label=f"Translation Quality (Model {L})")
nat = gr.Slider(1, 5, value=3, step=1, label=f"Audio Naturalness (Model {L})")
spk = gr.Slider(1, 5, value=3, step=1, label=f"Speaker Similarity (Model {L})")
note = gr.Textbox(label=f"Note (Model {L}, optional)", lines=1)
audio_widgets.append(a)
translation_widgets.append(t)
naturalness_widgets.append(nat)
spksim_widgets.append(spk)
note_widgets.append(note)
with gr.Row():
prev_btn = gr.Button("<- Previous", variant="secondary", interactive=False)
submit_btn = gr.Button("Submit & Next ->", variant="primary")
with gr.Accordion("Admin", open=False):
admin_pwd = gr.Textbox(label="Admin password", type="password")
with gr.Row():
download_btn = gr.Button("Refresh & download mos_results.csv")
clear_btn = gr.Button("Clear all results", variant="stop")
download_file = gr.File(label="Latest mos_results.csv")
admin_status = gr.Markdown()
# Flat list of all per-letter widgets in the same order as on_load_sample returns
per_letter_widgets = []
for i in range(NUM_MODELS):
per_letter_widgets.extend([
audio_widgets[i],
translation_widgets[i],
naturalness_widgets[i],
spksim_widgets[i],
note_widgets[i],
])
load_outputs = [
src_audio, src_text, src_header_md, progress_md, idx_state, real_per_letter_state, status_md,
prev_btn, submit_btn,
*per_letter_widgets,
]
# Triggers that re-render the current sample
rater_id.change(on_load_sample, [rater_id, direction, idx_state], load_outputs)
direction.change(on_direction_change, [direction], [idx_state]).then(
on_load_sample, [rater_id, direction, idx_state], load_outputs
)
idx_state.change(on_load_sample, [rater_id, direction, idx_state], load_outputs)
demo.load(on_load_sample, [rater_id, direction, idx_state], load_outputs)
# Submit: collect per-letter ratings, write CSV, advance idx
rating_inputs = []
for i in range(NUM_MODELS):
rating_inputs.extend([
translation_widgets[i],
naturalness_widgets[i],
spksim_widgets[i],
note_widgets[i],
])
submit_btn.click(
on_submit,
[rater_id, direction, idx_state, real_per_letter_state, *rating_inputs],
[idx_state, status_md],
)
prev_btn.click(on_prev, [idx_state], [idx_state])
download_btn.click(admin_download, [admin_pwd], [download_file, admin_status])
clear_btn.click(admin_clear, [admin_pwd], [admin_status])
ensure_csv()
demo.queue(default_concurrency_limit=4)
if __name__ == "__main__":
demo.launch(allowed_paths=[str(ROOT / "audio")], show_api=False, ssr_mode=False)