xbuzz's picture
한국어 TTS 사람 청취평가 — 의원 접수대 100문장 × TTS 2종 블라인드 평가
dcf8942 verified
Raw
History Blame Contribute Delete
14.3 kB
"""한국어 TTS 사람 청취평가 — 블라인드 받아쓰기 + 0~10점.
왜 필요한가
-----------
Open Ko-S2S 리더보드의 TTS 발음 지표는 합성음을 ASR 로 되받아쓴 CER 이다.
4090 실측에서 이 지표가 발음이 아니라 **숫자 표기**를 재고 있다는 것이 드러났다.
(기존 20문장 세트, 표기 보정 없음)
Qwen3-TTS 전체 CER 11.08%
├ 숫자 표기 문장(n=9) 23.67% ← ASR 이 "삼십 분"을 "30분"으로 씀
└ 숫자 없는 문장(n=11) 0.50% ← 발음은 사실상 완벽
허용 표기(variants)를 도입해 이 편향을 걷어내자 숫자문장 CER 이 23.67% → 3.09%
로 떨어졌다(의원 접수대 100문장 기준). 남은 것이 진짜 발음 오차다.
(의원 100문장, variants 적용)
Qwen3-TTS 전체 1.50% 숫자 3.09% 숫자없음 1.02%
MMS-TTS 전체 6.28% 숫자 7.74% 숫자없음 5.84%
다만 이 보정이 옳은지, 남은 오차가 정말 발음 문제인지는 자동 지표로 확정할 수
없다. 사람 귀가 유일한 기준선이라 이 앱으로 human CER 과 주관 점수를 모은다.
설계
----
- **블라인드**: 어떤 모델의 음성인지, 정답 문장이 무엇인지 제출 전에는 감춘다.
정답을 먼저 보여주면 받아쓰기가 베끼기가 되어 명료도 측정이 무의미해진다.
- **표기 통제**: 사람도 "삼십 분"을 듣고 "30분"이라 적는다. 그래서 안내로
한글 표기를 요청하고, 채점 시에도 숫자를 정규화해 표기 차이를 제거한다.
- **저장**: HF_TOKEN 시크릿이 있으면 데이터셋 repo 로 누적, 없으면 로컬 파일로
받아 두고 화면에 경고한다(조용히 유실되지 않게).
"""
from __future__ import annotations
import json
import os
import random
import re
import unicodedata
import uuid
from datetime import datetime, timezone
from pathlib import Path
import gradio as gr
ROOT = Path(__file__).resolve().parent
AUDIO_DIR = ROOT / "audio"
MANIFEST = json.loads((AUDIO_DIR / "manifest.json").read_text(encoding="utf-8"))
ITEMS = MANIFEST["items"]
# 도메인: 의원(1차 의료기관) 접수대 100문장 × TTS 2종 = 200 샘플.
# 음성봇이 가장 먼저 투입되는 구간이라 이 도메인의 발음이 실사용을 좌우한다.
DATASET_REPO = os.environ.get("EVAL_DATASET_REPO", "baryonlabs/ko-tts-human-eval-votes")
HF_TOKEN = os.environ.get("HF_TOKEN")
LOCAL_LOG = Path(os.environ.get("EVAL_LOCAL_LOG", "/tmp/ko_tts_votes.jsonl"))
# ── 채점 ────────────────────────────────────────────────────────────────
# 한글 수사 → 숫자. 사람도 ASR 도 "삼십 분"을 "30분"으로 적을 수 있어서, 표기
# 차이가 명료도 점수를 오염시키지 않도록 양쪽을 같은 형태로 만든 뒤 비교한다.
_SINO = {"영": 0, "공": 0, "일": 1, "이": 2, "삼": 3, "사": 4, "오": 5,
"육": 6, "칠": 7, "팔": 8, "구": 9}
_UNIT = {"십": 10, "백": 100, "천": 1000}
_NATIVE = {"한": 1, "두": 2, "세": 3, "네": 4, "다섯": 5, "여섯": 6, "일곱": 7,
"여덟": 8, "아홉": 9, "열": 10, "스물": 20, "서른": 30, "마흔": 40,
"쉰": 50, "예순": 60, "일흔": 70, "여든": 80, "아흔": 90}
_PCT = re.compile(r"퍼센트|프로")
def _sino_to_int(chunk: str):
"""'삼십사' → 34. 해석 불가하면 None."""
total, current, seen = 0, 0, False
for ch in chunk:
if ch in _SINO:
current = _SINO[ch]
seen = True
elif ch in _UNIT:
total += (current or 1) * _UNIT[ch]
current = 0
seen = True
else:
return None
return total + current if seen else None
def normalize_numbers(text: str) -> str:
"""숫자 표기를 통일한다: 아라비아 숫자·퍼센트 기호·한글 수사를 한 형태로."""
t = _PCT.sub("%", text)
t = re.sub(r"(\d),(?=\d{3})", r"\1", t) # 4,500 → 4500
t = t.replace("m", "미터").replace("M", "미터")
for word, val in sorted(_NATIVE.items(), key=lambda kv: -len(kv[0])):
t = t.replace(word, str(val))
def repl(m):
v = _sino_to_int(m.group(0))
return str(v) if v is not None else m.group(0)
t = re.sub(r"[영공일이삼사오육칠팔구십백천]{1,8}", repl, t)
return t
def normalize(text: str, fold_numbers: bool = True) -> str:
t = unicodedata.normalize("NFC", (text or "").strip())
if fold_numbers:
t = normalize_numbers(t)
t = re.sub(r"[^\w가-힣%]", "", t)
return t
def _edit_distance(a: str, b: str) -> int:
if not a:
return len(b)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return prev[-1]
def char_error_rate(ref: str, hyp: str, fold_numbers: bool = True) -> float:
r, h = normalize(ref, fold_numbers), normalize(hyp, fold_numbers)
if not r:
return 0.0
return _edit_distance(r, h) / len(r)
# ── 저장 ────────────────────────────────────────────────────────────────
def _scheduler():
"""HF_TOKEN 이 있으면 데이터셋 repo 로 누적 저장하는 스케줄러를 만든다."""
if not HF_TOKEN:
return None
try:
from huggingface_hub import CommitScheduler
LOCAL_LOG.parent.mkdir(parents=True, exist_ok=True)
return CommitScheduler(
repo_id=DATASET_REPO, repo_type="dataset", folder_path=LOCAL_LOG.parent,
path_in_repo="data", every=5, token=HF_TOKEN, private=False,
)
except Exception as exc: # 저장 실패를 조용히 삼키지 않는다
print(f"[warn] CommitScheduler 초기화 실패: {exc}")
return None
SCHEDULER = _scheduler()
STORAGE_NOTE = (
f"✅ 응답이 `{DATASET_REPO}` 데이터셋에 누적됩니다."
if SCHEDULER
else "⚠️ **HF_TOKEN 시크릿이 없어 응답이 이 컨테이너에만 임시 저장됩니다.** "
"Space 재시작 시 사라집니다. Settings → Secrets 에 write 권한 HF_TOKEN 을 넣어주세요."
)
def save_vote(record: dict) -> None:
LOCAL_LOG.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(record, ensure_ascii=False)
if SCHEDULER is not None:
with SCHEDULER.lock:
with LOCAL_LOG.open("a", encoding="utf-8") as f:
f.write(line + "\n")
else:
with LOCAL_LOG.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def load_votes() -> list[dict]:
if not LOCAL_LOG.exists():
return []
out = []
for line in LOCAL_LOG.read_text(encoding="utf-8").splitlines():
if line.strip():
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return out
# ── 출제 ────────────────────────────────────────────────────────────────
def pick_item(seen_ids: list[str]) -> dict:
"""아직 안 들은 것 우선, 그 안에서 응답 수가 적은 것 우선."""
counts = {}
for v in load_votes():
counts[v["item_id"]] = counts.get(v["item_id"], 0) + 1
pool = [it for it in ITEMS if it["id"] not in seen_ids] or ITEMS
fewest = min(counts.get(it["id"], 0) for it in pool)
return random.choice([it for it in pool if counts.get(it["id"], 0) == fewest])
def start(seen_ids):
item = pick_item(seen_ids or [])
return (
str(AUDIO_DIR / item["audio"]),
item,
gr.update(value="", interactive=True),
gr.update(value=5, interactive=True),
gr.update(visible=False, value=""),
gr.update(visible=True),
gr.update(visible=False),
)
def _best_cer(item, heard, fold_numbers):
"""참조에 허용 표기(variants)가 있으면 오류가 가장 적은 것을 택한다.
`삼십 분` / `30분` 처럼 발음이 같고 표기만 다른 형태를 하나만 정답으로 쓰면,
그 표기를 적은 평가자만 유리해진다. KsponSpeech 가 이중전사를 제공하는 것과
같은 이유다.
"""
refs = item.get("variants") or [item["ref"]]
return min(char_error_rate(r, heard, fold_numbers=fold_numbers) for r in refs)
def submit(item, heard, score, seen_ids, rater_id):
if not item:
return (gr.update(), gr.update(visible=True, value="먼저 '다음 문장'을 눌러주세요."),
gr.update(), gr.update(), seen_ids)
if not (heard or "").strip():
return (gr.update(), gr.update(visible=True, value="⚠️ 들린 내용을 입력해주세요."),
gr.update(visible=True), gr.update(visible=False), seen_ids)
cer_folded = _best_cer(item, heard, True)
cer_raw = _best_cer(item, heard, False)
record = {
"vote_id": str(uuid.uuid4()),
"rater_id": rater_id,
"ts": datetime.now(timezone.utc).isoformat(),
"item_id": item["id"],
"model_key": item["model_key"],
"model_name": item["model_name"],
"sentence_id": item.get("sentence_id", item.get("id")),
"category": item.get("category"),
"has_number": item.get("has_number"),
"ref": item["ref"],
"heard": heard.strip(),
"score": int(score),
"human_cer": round(cer_folded, 4),
"human_cer_raw": round(cer_raw, 4),
}
save_vote(record)
seen = (seen_ids or []) + [item["id"]]
reveal = (
f"### 제출 완료\n"
f"**정답** : {item['ref']}\n\n"
f"**입력** : {heard.strip()}\n\n"
f"| 지표 | 값 |\n| --- | --- |\n"
f"| 사람 CER (숫자 정규화) | **{cer_folded * 100:.1f}%** |\n"
f"| 사람 CER (원문 그대로) | {cer_raw * 100:.1f}% |\n"
f"| 내 점수 | {int(score)} / 10 |\n\n"
f"두 CER 이 다르면 그 차이가 곧 **표기 때문에 생긴 오차**입니다.\n\n"
f"누적 응답 {len(load_votes())}개 · 계속하려면 **다음 문장**"
)
return (gr.update(visible=True, value=reveal), gr.update(visible=False, value=""),
gr.update(visible=False), gr.update(visible=True), seen)
def leaderboard():
votes = load_votes()
if not votes:
return "아직 응답이 없습니다. 몇 개 평가하면 여기에 모델별 집계가 나옵니다."
agg = {}
for v in votes:
a = agg.setdefault(v["model_name"], {"n": 0, "score": 0.0, "cer": 0.0, "cer_raw": 0.0,
"n_num": 0, "cer_num": 0.0})
a["n"] += 1
a["score"] += v["score"]
a["cer"] += v["human_cer"]
a["cer_raw"] += v["human_cer_raw"]
if v.get("has_number"):
a["n_num"] += 1
a["cer_num"] += v["human_cer"]
rows = sorted(agg.items(), key=lambda kv: kv[1]["cer"] / kv[1]["n"])
out = ["| 모델 | 응답수 | 평균 점수 (0~10) | 사람 CER | 사람 CER(원문) | 숫자문장 CER |",
"| --- | ---: | ---: | ---: | ---: | ---: |"]
for name, a in rows:
n = a["n"]
num = f"{a['cer_num'] / a['n_num'] * 100:.2f}%" if a["n_num"] else "—"
out.append(f"| {name} | {n} | {a['score'] / n:.2f} | "
f"{a['cer'] / n * 100:.2f}% | {a['cer_raw'] / n * 100:.2f}% | {num} |")
out.append(f"\n총 {len(votes)}개 응답 · 평가자 {len({v['rater_id'] for v in votes})}명")
out.append("\n※ '사람 CER'은 숫자 표기를 정규화한 값, '원문'은 적힌 그대로입니다. "
"둘의 차이가 표기 관습이 만드는 오차의 크기입니다.")
return "\n".join(out)
INTRO = f"""# 🎧 한국어 TTS 사람 청취평가
합성음을 듣고 **들리는 대로** 받아쓴 뒤 0~10점을 매겨주세요.
어떤 모델인지, 정답 문장이 무엇인지는 제출 후에 공개됩니다.
**받아쓰기 요령**
- 들린 그대로 적어주세요. 못 알아들은 부분은 비워두거나 `?` 로 두면 됩니다.
- **숫자는 들린 대로 한글로** 적어주세요. (`30분`이 아니라 `삼십 분`)
자동 채점이 숫자 표기 때문에 발음 오류를 과대평가하는 문제를 확인하려는 것이 이 평가의 목적입니다.
**0~10점 기준** — 0: 전혀 못 알아들음 · 5: 알아듣지만 어색함 · 10: 사람 발화와 구분 안 됨
{STORAGE_NOTE}
"""
with gr.Blocks(title="한국어 TTS 사람 청취평가") as demo:
seen_state = gr.State([])
item_state = gr.State(None)
rater_state = gr.State(lambda: str(uuid.uuid4())[:8])
gr.Markdown(INTRO)
with gr.Tab("평가하기"):
audio = gr.Audio(label="합성음 (모델명 비공개)", interactive=False, autoplay=False)
heard_box = gr.Textbox(label="들린 대로 받아쓰기", lines=2,
placeholder="예) 오늘 회의는 오후 세 시 삼십 분에 시작합니다")
score_slider = gr.Slider(0, 10, value=5, step=1, label="자연스러움·명료도 (0~10)")
warn = gr.Markdown(visible=False)
with gr.Row():
submit_btn = gr.Button("제출", variant="primary", visible=False)
next_btn = gr.Button("다음 문장", variant="secondary")
reveal_box = gr.Markdown(visible=False)
with gr.Tab("집계"):
board = gr.Markdown()
gr.Button("새로고침").click(leaderboard, outputs=board)
demo.load(leaderboard, outputs=board)
next_btn.click(start, inputs=[seen_state],
outputs=[audio, item_state, heard_box, score_slider,
reveal_box, submit_btn, next_btn])
submit_btn.click(submit,
inputs=[item_state, heard_box, score_slider, seen_state, rater_state],
outputs=[reveal_box, warn, submit_btn, next_btn, seen_state])
if __name__ == "__main__":
demo.launch()