"""한국어 ASR 모델 비교 데모 (ZeroGPU). Open Ko-S2S 리더보드의 CER 숫자가 실제 음성에서 어떻게 들리는지 직접 확인하는 데모. """ from __future__ import annotations import html import time import traceback import gradio as gr import librosa import numpy as np import spaces import torch from transformers import ( Wav2Vec2ForCTC, Wav2Vec2Processor, WhisperForConditionalGeneration, WhisperProcessor, ) from models_meta import LEADERBOARD_URL, MODELS SR = 16_000 MAX_SEC = 30 DEVICE = "cuda" DTYPE = torch.float16 # --------------------------------------------------------------------------- # 모델 로딩 # # ZeroGPU 규칙: 실제 GPU는 @spaces.GPU 함수 안에서만 잡히지만, 모델은 반드시 # **모듈 레벨에서 .to('cuda')** 로 올려야 한다. 모듈 레벨에서는 PyTorch CUDA # 에뮬레이션이 동작하고, 데코레이트된 함수 안에서 실제 CUDA로 승격된다. # (lazy-load / 함수 안에서 .to('cuda') 는 공식적으로 비권장) # https://huggingface.co/docs/hub/spaces-zerogpu#model-loading # --------------------------------------------------------------------------- LOADED: dict[str, dict] = {} LOAD_ERRORS: dict[str, str] = {} for _m in MODELS: _repo = _m["repo"] try: if _m["arch"] == "whisper": _proc = WhisperProcessor.from_pretrained(_repo) _model = WhisperForConditionalGeneration.from_pretrained( _repo, torch_dtype=DTYPE ) # 일부 파인튜닝 체크포인트는 generation_config 에 forced_decoder_ids 가 # 박혀 있어서 generate(language=...) 와 충돌한다. 비워두고 매 호출마다 지정. if getattr(_model.generation_config, "forced_decoder_ids", None): _model.generation_config.forced_decoder_ids = None _model.config.forced_decoder_ids = None _model.to(DEVICE).eval() else: # CTC _proc = Wav2Vec2Processor.from_pretrained(_repo) _model = Wav2Vec2ForCTC.from_pretrained(_repo) # CTC는 fp32 유지 _model.to(DEVICE).eval() LOADED[_m["key"]] = {"processor": _proc, "model": _model} print(f"[load] OK {_repo}", flush=True) except Exception as exc: # noqa: BLE001 LOAD_ERRORS[_m["key"]] = f"{type(exc).__name__}: {exc}" print(f"[load] FAIL {_repo} -> {exc}", flush=True) traceback.print_exc() # --------------------------------------------------------------------------- # 추론 # --------------------------------------------------------------------------- def _load_audio(path: str) -> tuple[np.ndarray, float, bool]: wav, _ = librosa.load(path, sr=SR, mono=True) dur = len(wav) / SR truncated = False if dur > MAX_SEC: wav = wav[: MAX_SEC * SR] truncated = True dur = MAX_SEC return wav.astype(np.float32), dur, truncated def _sync() -> None: if torch.cuda.is_available(): torch.cuda.synchronize() @torch.inference_mode() def _run_whisper(bundle: dict, wav: np.ndarray) -> str: proc, model = bundle["processor"], bundle["model"] feats = proc(wav, sampling_rate=SR, return_tensors="pt").input_features feats = feats.to(DEVICE, DTYPE) kwargs = dict(max_new_tokens=256, num_beams=1, do_sample=False) try: ids = model.generate(feats, language="ko", task="transcribe", **kwargs) except Exception: # noqa: BLE001 - 언어 강제가 안 먹는 체크포인트 대비 traceback.print_exc() ids = model.generate(feats, **kwargs) return proc.batch_decode(ids, skip_special_tokens=True)[0].strip() @torch.inference_mode() def _run_ctc(bundle: dict, wav: np.ndarray) -> str: proc, model = bundle["processor"], bundle["model"] inputs = proc(wav, sampling_rate=SR, return_tensors="pt", padding=True) logits = model(inputs.input_values.to(DEVICE)).logits pred = torch.argmax(logits, dim=-1) return proc.batch_decode(pred)[0].strip() def _cer(v): return "—" if v is None else f"{v:.2f}%" def _render(rows: list[dict], dur: float, truncated: bool) -> str: head = ( f"
입력 길이 {dur:.1f}초" + ( " (30초 초과분은 잘렸습니다)" if truncated else "" ) + " · 시간은 GPU 추론 시간만 측정(모델 로딩 제외)
" ) cards = [] for r in rows: badge = ( "in-domain 오염" if r["in_domain"] else "" ) text = r["text"] or "(빈 결과)" cards.append( f"""먼저 마이크로 녹음하거나 오디오 파일을 업로드해 주세요.
" wav, dur, truncated = _load_audio(audio_path) if dur < 0.2: return "오디오가 너무 짧습니다. 1초 이상 말해 주세요.
" rows = [] for meta in MODELS: key = meta["key"] row = { "repo": meta["repo"], "label": meta["label"], "params": meta["params"], "cer_read": meta["cer_read"], "cer_spont": meta["cer_spont"], "in_domain": meta["in_domain"], "note": meta["note"], } bundle = LOADED.get(key) if bundle is None: row["text"] = ( f"모델 로딩 실패: " f"{html.escape(LOAD_ERRORS.get(key, 'unknown'))}" ) row["sec"] = "—" rows.append(row) continue try: _sync() t0 = time.perf_counter() if meta["arch"] == "whisper": text = _run_whisper(bundle, wav) else: text = _run_ctc(bundle, wav) _sync() elapsed = time.perf_counter() - t0 row["text"] = html.escape(text) row["sec"] = f"{elapsed:.2f}초 (RTF {elapsed / dur:.3f})" except Exception as exc: # noqa: BLE001 traceback.print_exc() row["text"] = ( f"추론 오류: " f"{html.escape(type(exc).__name__ + ': ' + str(exc))}" ) row["sec"] = "—" rows.append(row) return _render(rows, dur, truncated) # --------------------------------------------------------------------------- # UI # --------------------------------------------------------------------------- INTRO = f""" # 🎙️ 한국어 ASR 모델 비교 — CER 숫자를 귀로 확인하기 **[Open Ko-S2S 리더보드]({LEADERBOARD_URL})** 의 CER 수치가 실제 내 목소리에서 어떤 차이로 나타나는지 직접 들어보는 데모입니다. 같은 오디오를 4개 한국어 ASR 모델에 동시에 넣고, 전사 결과와 추론 시간을 나란히 보여줍니다. **이 데모가 보여주려는 것** 1. **낭독체 점수가 좋다고 자유발화도 잘하는 게 아닙니다.** `whisper-large-v3-turbo`는 낭독체(Zeroth) CER **5.47%**로 이 중 최고지만, 자유발화(KsponSpeech)에서는 **12.53%**로 무너집니다. 반대로 73M짜리 `whisper-base-komixv2`는 낭독체 8.01% / 자유발화 **7.71%** 로 11배 큰 모델을 자유발화에서 이깁니다. 즉 **10배 작은 모델이 실제 대화에서는 더 낫습니다.** 2. **in-domain 점수는 점수가 아닙니다.** `wav2vec2-large-xlsr-korean`은 Zeroth 낭독체에서 CER 1.78%를 자칭하지만, 그 Zeroth train으로 학습된 모델입니다(in-domain 오염). 빨간 배지가 붙은 모델의 점수는 다른 모델과 같은 선에서 비교하면 안 됩니다. **해보면 좋은 것**: 원고를 또박또박 읽어보고 → 그다음 아무 준비 없이 말을 더듬으며 "어, 그러니까 그게…" 처럼 자유발화로 말해보세요. 두 경우의 모델 순위가 뒤집히는 걸 볼 수 있습니다. > ⏱️ ZeroGPU 공용 GPU에서 돌아갑니다. 입력은 **최대 30초**까지만 처리하며, > 첫 실행은 GPU 할당 대기 때문에 몇 초 더 걸릴 수 있습니다. """ FOOT = f""" --- ### 수치 출처 - **낭독체(Zeroth)**: [Open Ko-S2S 리더보드]({LEADERBOARD_URL}) · Zeroth-Korean test (n=457) · CER(space-free) · RTX 4090 실측. `wav2vec2-large-xlsr-korean`만 모델 카드 자체 보고치(리더보드 미수록). - **자유발화**: 동일 하네스의 KsponSpeech 평가. CTC 모델은 미측정(`—`). - 이 데모의 "이번 추론" 시간은 ZeroGPU(NVIDIA RTX Pro 6000 Blackwell) 위에서 측정한 값이라 리더보드의 RTX 4090 RTF와 직접 비교하면 안 됩니다. **모델 간 상대 비교용**으로만 보세요. - CTC 모델은 띄어쓰기·구두점을 생성하지 않습니다. 전사가 붙어 나오는 건 버그가 아닙니다. """ with gr.Blocks(title="한국어 ASR 모델 비교", theme=gr.themes.Soft()) as demo: gr.Markdown(INTRO) with gr.Row(): with gr.Column(scale=1): audio_in = gr.Audio( sources=["microphone", "upload"], type="filepath", label="음성 입력 (녹음 또는 업로드 · 최대 30초)", ) run_btn = gr.Button("4개 모델로 동시에 전사하기", variant="primary") gr.Examples( examples=[["examples/zeroth_read_sample.wav"]], inputs=audio_in, label="낭독체 예시 (Zeroth-Korean test · CC-BY-4.0) — 마이크가 없으면 이걸로 먼저", ) gr.Markdown( "예시 정답: **몬터규는 자녀들이 사랑을 제대로 못 받고 크면 " "매우 심각한 결과가 초래된다는 결론을 내렸습니다**\n\n" "이 문장은 Zeroth-Korean **test** 셋 문장입니다. " "`wav2vec2-large-xlsr-korean`은 이 데이터셋 train으로 학습돼 " "고유명사 '몬터규'까지 정확히 맞히지만, 학습에서 이 도메인을 보지 않은 " "범용 모델들은 '몬토규'처럼 흔들립니다. **이게 in-domain 점수의 정체입니다.**" ) gr.Markdown( "**모델 구성**\n\n" + "\n".join( f"- `{m['repo']}` ({m['params']})" + (" ⚠️ in-domain" if m["in_domain"] else "") for m in MODELS ) ) with gr.Column(scale=2): out = gr.HTML(label="전사 결과") gr.Markdown(FOOT) run_btn.click(transcribe, inputs=audio_in, outputs=out, api_name="transcribe") # 녹음을 멈추면 자동 실행 (같은 함수라 별도 API 엔드포인트는 만들지 않음) audio_in.stop_recording(transcribe, inputs=audio_in, outputs=out, api_name=False) if __name__ == "__main__": demo.queue(max_size=20).launch()