feat: Hearo 1차 구현 — 프로그램 소리 실시간 번역 자막

프로그램별 오디오를 캡처해 로컬 GPU에서 음성인식→번역하고 화면 위
자막으로 보여주는 데스크톱 앱. 한/영/일/중 4개 언어.

구성
- audio: WASAPI 프로그램별 캡처(C++ 보조 프로그램) + 장치 루프백 폴백,
  적응형 VAD 발화 분할
- models: 속도~품질 5단계 티어, faster-whisper + CTranslate2/LLM 2백엔드,
  용어집(플레이스홀더 보호 + 프롬프트 주입)
- core: Qt 비의존 파이프라인 엔진 (캡처/분할/추론 3스레드, 큐 연결)
- ui: 사이드바 5화면 + 무테두리 항상위 자막 오버레이, 자체 다크 테마

모델 선정 근거는 docs/MODELS.md, 추가학습 가능 여부와 방법은
docs/FINETUNING.md 참고.

검증: pytest 39개 통과 (GPU·오디오 장치 없이 실행), ruff clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-09-21 10:47:11 +09:00
parent c24e5f938d
commit 1a87ec6677
49 changed files with 4975 additions and 1 deletions

91
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,91 @@
# 구조
## 데이터 흐름
```
[캡처 스레드] [분할 스레드] [추론 스레드] [GUI 스레드]
hearo_capture.exe → Segmenter (VAD) → Whisper → 번역 모델 → 자막 오버레이
또는 WASAPI 발화 단위로 절단 용어집 적용 컨트롤 창 로그
↓ ↓ ↓ ↑
오디오 큐 구간 큐(최대 32) TranslationLine Qt Signal
(16kHz mono f32) 확정 / 중간결과
```
세 워커 스레드는 모두 큐로만 연결된다. 어느 한 단계가 밀려도 나머지가 멈추지 않는다.
큐가 가득 차면 **오래된 오디오를 버린다** — 실시간 자막에서는 밀린 소리보다 지금 나는
소리가 항상 더 가치 있기 때문이다.
## 패키지
```
src/hearo/
constants.py 전역 상수, 언어 표, 경로
config.py 설정 dataclass + JSON 영속화
app.py 진입점
audio/
base.py CaptureBackend 추상 클래스, 리샘플링
process_loopback.py 프로그램별 캡처 (네이티브 보조 프로그램 구동)
wasapi_loopback.py 출력 장치 전체 캡처 (PyAudioWPatch)
file_source.py WAV 재생 (테스트·데모용)
segmenter.py 적응형 VAD로 발화 구간 절단
models/
tiers.py 5단계 티어 정의
asr.py faster-whisper 래퍼
translator.py CTranslate2 / Transformers 두 백엔드
glossary.py 용어집 (플레이스홀더 보호 + 프롬프트 주입)
manager.py 티어별 모델 쌍 로드·해제, GPU 탐지
core/
engine.py 파이프라인 오케스트레이션 (Qt 비의존)
events.py TranslationLine, EngineStatus
ui/
theme.py 디자인 토큰 + QSS
overlay.py 자막 창 + 공용 렌더링 함수
main_window.py 사이드바 + 페이지 스택
pages/ 홈 / 모델 / 자막 / 용어집 / 설정
widgets/ Card, StatusPill, LevelMeter
native/process_loopback/ WASAPI process loopback C++ 보조 프로그램
scripts/finetune_mt.py LoRA 추가학습
```
## 설계 판단
### 캡처를 별도 실행 파일로 뺀 이유
WASAPI의 `ActivateAudioInterfaceAsync` + `AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK`
은 COM 비동기 콜백을 요구한다. ctypes로 COM vtable을 흉내 내는 것보다 C++ 200줄이
훨씬 안전하고 디버깅이 쉽다. 파이프로 생 PCM만 주고받으므로 인터페이스도 단순하다.
보조 프로그램이 없으면 `capture_capabilities()` 가 이를 알려주고 장치 루프백으로
자동 폴백한다. 빌드 없이도 프로그램은 동작한다.
### 중간 결과는 번역하지 않는다
말하는 도중에도 인식 결과를 흐리게 보여주면 체감 지연이 크게 줄어든다. 하지만
매번 번역까지 돌리면 GPU가 몇 배로 바빠지고, 문장이 완성되기 전 번역은 어차피
틀린다. 그래서 **중간 결과는 원문만, 확정 문장만 번역**한다.
### UI 코드와 렌더링 코드 분리
자막 설정 미리보기와 실제 오버레이가 `paint_subtitle()` 하나를 공유한다.
QSS로 미리보기를 흉내 내면 `text-shadow` 미지원 같은 이유로 실제와 어긋난다.
### 엔진은 Qt를 모른다
`core/engine.py` 는 콜백만 받는다. Qt 의존은 `MainWindow` 가 콜백을 Signal로
다시 던지는 지점에만 있다. 덕분에 GPU도 오디오 장치도 없는 환경에서 파이프라인
전체를 테스트할 수 있다 (`tests/test_pipeline.py`).
## 테스트
`pytest` 전체가 GPU·오디오 장치 없이 돈다.
- `test_segmenter.py` — 합성 사인파로 VAD 절단 검증
- `test_pipeline.py` — 가짜 캡처/인식/번역으로 엔진 종단 검증
- `test_glossary.py` — 최장일치, 단어경계, 왕복 변환
- `test_config.py` — 스키마 변경 내성
- `test_ui_smoke.py` — offscreen 렌더링으로 화면 생성 및 자막 픽셀 검증

137
docs/FINETUNING.md Normal file
View File

@@ -0,0 +1,137 @@
# 게임·방송 용어 추가학습
## 결론부터
**가능합니다.** 다만 대부분의 경우 **추가학습보다 용어집이 먼저입니다.**
| 방법 | 준비 | 효과가 나타나는 시점 | 무엇에 좋은가 |
|---|---|---|---|
| **1. 용어집** (구현 완료) | 단어 목록만 | **즉시** | 고유명사, 스킬명, 아이템명, 캐릭터명 |
| **2. LoRA 추가학습** | 문장 쌍 1,000~3,000개 | 30분~3시간 학습 | 말투, 문장 구조, 도메인 어조 |
| **3. 풀 파인튜닝** | 문장 쌍 10,000개 이상 | 수 시간~하루 | 번역 스타일 전면 교체 |
용어집과 추가학습은 **경쟁 관계가 아니라 보완 관계**입니다. 둘 다 켜는 게 가장 좋습니다.
---
## 1. 용어집 — 먼저 이것부터
`용어집` 화면에서 "원문 → 각 언어 역어"를 등록하면 끝입니다. 학습 없이 바로 적용됩니다.
동작 방식은 번역 백엔드에 따라 다릅니다.
- **NLLB 계열(1~3티어)** — 등록 단어를 `⟦0⟧` 같은 토큰으로 바꿔치기해 모델이 아예
건드리지 못하게 한 뒤, 번역이 끝나면 지정한 역어로 되돌립니다. 100% 보장됩니다.
- **LLM 계열(4~5티어)** — 그 문장에 실제로 나온 용어만 골라 프롬프트에
"이 용어는 이렇게 옮겨라"로 넣어줍니다. 조사·어미까지 문맥에 맞게 붙습니다.
CSV로 한 번에 가져올 수 있습니다.
```csv
원문 용어,한국어 역어,English 역어,日本語 역어,中文 역어,메모
Nexus,넥서스,Nexus,ネクサス,基地,LoL
baron,바론,Baron,バロン,男爵,LoL
ult,궁,ultimate,アルティメット,大招,궁극기
gg,잘 싸웠다,gg,gg,打得好,
```
**게임 하나당 200~500개만 등록해도 체감 품질이 크게 올라갑니다.** 추가학습으로
같은 효과를 내려면 훨씬 많은 데이터가 필요합니다. 용어집을 먼저 채우세요.
---
## 2. LoRA 추가학습 — 용어집으로 안 되는 것
용어집은 "이 단어를 저 단어로"만 고칩니다. 아래는 못 잡습니다.
- 말투 — "You're getting rolled" → "탈탈 털리고 있네" (직역 아닌 게임 말투)
- 생략된 주어·목적어 복원 — 게임 음성에는 생략이 많습니다
- 방송 특유의 감탄·리액션 어조
이건 문장 쌍으로 학습시켜야 합니다.
### 필요한 데이터 양
공개 연구 기준으로,
- 언어쌍당 **약 650~1,000 문장**만으로도 LoRA 도메인 적응이 동작합니다.
- 언어쌍당 **2,000 문장 / 15~20 epoch** 수준에서 chrF++ 평균 **+6.1점** 개선이 보고됩니다.
즉 **언어쌍당 1,000~3,000 문장이 현실적인 목표**입니다. 4개 언어 전부가 아니라,
실제로 많이 쓰는 방향(예: 영어→한국어) 하나만 먼저 하는 게 효율적입니다.
### 데이터 만드는 법
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
`%APPDATA%/Hearo/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로.
형식은 JSONL 한 줄에 한 쌍입니다.
```jsonl
{"source": "Enemy missing from mid", "target": "미드 실종", "source_lang": "en", "target_lang": "ko"}
{"source": "I'll take baron", "target": "바론 내가 먹을게", "source_lang": "en", "target_lang": "ko"}
```
### 학습 실행
```bash
pip install -e ".[finetune]"
python scripts/finetune_mt.py \
--data data/game_terms.jsonl \
--tier ultimate \
--output ./adapters/game-ko \
--epochs 3
```
끝나면 `모델` 화면의 **추가학습 어댑터** 칸에 `./adapters/game-ko` 를 넣으면 적용됩니다.
### 티어별 난이도
| 티어 | 번역 모델 | 방식 | 학습에 필요한 VRAM | 난이도 |
|---|---|---|---|---|
| 1~2 | NLLB-600M | 풀 파인튜닝 가능 | ~8GB | ★ 쉬움 |
| 3 | NLLB-1.3B | LoRA | ~10GB | ★★ |
| 4 | Seed-X-PPO-7B | LoRA (권장 안 함) | ~16GB | ★★★ 까다로움 |
| 5 | **Qwen3-8B** | **LoRA** | **~16GB** | **★ 쉬움 (권장)** |
**4티어 Seed-X는 추가학습 대상으로 권하지 않습니다.** 이미 PPO(강화학습)까지 마친
모델이라 그 위에 SFT를 얹으면 기존 번역 품질이 무너지기 쉽습니다 (catastrophic forgetting).
Int4 양자화 가중치라 학습 자체도 번거롭습니다.
**추가학습을 할 거라면 5티어(Qwen3-8B)**, **VRAM이 부족하면 1~2티어(NLLB-600M 풀 파인튜닝)**
로 가는 게 맞습니다.
### 권장 하이퍼파라미터
연구에서 널리 쓰이는 설정입니다. `scripts/finetune_mt.py` 의 기본값이기도 합니다.
```
LoRA rank r = 16, alpha = 32, dropout = 0.05
대상 모듈: attention 의 q_proj, v_proj
learning rate = 2e-4 (LoRA) / 5e-5 (풀 파인튜닝)
epochs = 3 (데이터 2,000개 이상) / 10~20 (수백 개)
```
---
## 3. 어느 것부터 할지
```
1주차 용어집 300개 등록 → 이것만으로 충분한지 확인
2주차 번역 기록 켜고 실사용, 틀린 문장 수집
3주차 고친 문장 1,000개 모이면 LoRA 학습
```
**용어집만으로 만족스러우면 추가학습은 안 해도 됩니다.** 실제로 고유명사 오역이
체감 불만의 대부분입니다.
## 참고
- [Fine-Tuning NLLB-200 with LoRA on a 650-Sentence Corpus](https://medium.com/@meinnps/fine-tuning-nllb-200-with-lora-on-a-650-sentence-turkmen-english-corpus-082f68bdec71)
- [SemiAdapt / SemiLoRA: Efficient Domain Adaptation for Low-Resource MT (arXiv)](https://arxiv.org/pdf/2510.18725)
- [How to fine-tune a NLLB-200 model](https://cointegrated.medium.com/how-to-fine-tune-a-nllb-200-model-for-translating-a-new-language-a37fc706b865)

81
docs/MODELS.md Normal file
View File

@@ -0,0 +1,81 @@
# 모델 선정 근거
## 왜 두 단계인가
"소리 → 번역"을 한 모델로 끝내는 방법도 있다. Whisper에는 `translate` 태스크가 있고
Voxtral·Qwen-Omni 같은 음성 LLM도 있다. 하지만 이 프로그램에는 맞지 않는다.
- Whisper의 `translate`는 **X → 영어만** 된다. 한국어로 받아볼 수 없다.
- 음성 LLM 한 방에 처리하면 번역만 따로 교체하거나 추가학습시킬 수 없다.
- 용어집을 꽂아 넣을 지점이 사라진다.
그래서 **음성인식(ASR) → 번역(MT)** 2단 구조로 간다. 두 단계를 따로 고를 수 있어서
"인식은 최고급, 번역은 경량" 같은 조합이 가능하고, 이게 실제로 가장 가성비가 좋다.
## 음성인식
| 후보 | 판단 |
|---|---|
| **Whisper large-v3-turbo** | 디코더를 32층 → 4층으로 줄여 large-v3 대비 약 6배 빠르면서 정확도 손실은 1~2%. 809M 파라미터, int8이면 VRAM 1.6GB. **한/영/일/중 모두 지원.** |
| Whisper large-v3 | 가장 정확하지만 느리다. 최상위 티어에만. |
| distil-whisper | 빠르지만 **영어 전용**이라 탈락. |
| NVIDIA Parakeet | 실시간성은 최고지만 **영어 전용**이라 탈락. |
실행은 `faster-whisper`(CTranslate2)로 한다. 순정 `openai-whisper` 대비 4배 빠르고
VRAM을 절반만 쓴다.
→ **2~4티어의 기본은 large-v3-turbo.** 4개 언어를 모두 지원하면서 실시간을 만족하는
유일한 지점이다.
## 번역
| 후보 | VRAM | 판단 |
|---|---|---|
| **Seed-X-PPO-7B** (ByteDance) | ~5.6GB (Int4) | 28개 언어 **번역 전용**으로 학습된 7B. 자체 평가에서 Gemma3-27B, Llama4-Scout, Qwen3-235B를 앞서고 사람 평가에서 GPT-4o·Claude-3.5·Gemini-2.5-Pro와 대등. 한/영/일/중이 모두 주력 언어. |
| **Qwen3-8B** | ~11GB (fp16) | 범용 LLM이라 Seed-X보다 기본 번역은 약간 아래. 대신 **추가학습(LoRA) 생태계가 가장 두껍고**, 프롬프트 지시를 잘 따른다. |
| **NLLB-200-distilled 600M / 1.3B** | 0.8~2.9GB | 200개 언어 seq2seq. 구어체는 LLM보다 딱딱하지만 압도적으로 빠르고 가볍다. 게다가 **풀 파인튜닝이 소비자 GPU에서 된다.** |
| Gemma 3 12B | ~8GB | 140개 언어로 넓게 강하지만, 한/영/일/중만 필요한 우리에게는 Seed-X가 더 낫다. |
| opus-mt (Helsinki) | ~0.3GB | 언어쌍마다 모델이 따로라 4×3=12개를 관리해야 한다. 티어 구조와 안 맞아 탈락. |
## 다섯 티어
| # | 이름 | 음성인식 | 번역 | VRAM | 지연 |
|---|---|---|---|---|---|
| 1 | 번개 | whisper-small (int8) | NLLB-600M (int8) | ~2GB | 0.6초 |
| 2 | 신속 | large-v3-turbo (int8) | NLLB-600M (fp16) | ~4GB | 0.9초 |
| 3 | **균형** (기본) | large-v3-turbo (fp16) | NLLB-1.3B (fp16) | ~6GB | 1.2초 |
| 4 | **정밀** (추천) | large-v3-turbo (fp16) | Seed-X-PPO-7B (Int4) | ~10GB | 2.0초 |
| 5 | 극한 | large-v3 (fp16) | Qwen3-8B (fp16) + LoRA | ~16GB | 3.0초 |
## 결론: 무엇을 고를 것인가
**추가학습을 안 한다면 → 4티어 "정밀"이 최선이다.**
Seed-X는 번역만 하도록 만들어진 모델이라 7B치고 품질이 비정상적으로 좋다.
게임 대사처럼 짧고 구어체인 문장에서 NLLB 계열과 체감 차이가 크다.
**추가학습을 전제로 하면 → 5티어 "극한"(Qwen3-8B + LoRA)으로 간다.**
이유는 세 가지다.
1. Seed-X는 이미 PPO(강화학습)로 조율이 끝난 모델이다. 그 위에 다시 SFT를 얹으면
기존 품질이 깨지기 쉽다. 반면 Qwen3-8B는 instruct 베이스라 추가 학습을 전제로 만들어졌다.
2. peft / unsloth / TRL 등 LoRA 도구가 Qwen 계열에 가장 잘 맞춰져 있다.
3. 용어집을 프롬프트로 직접 지시할 수 있어 "학습 + 프롬프트" 이중으로 용어를 잡을 수 있다.
**VRAM이 8GB 이하라면 → 3티어 "균형".** 그리고 이 경우의 추가학습은
NLLB-1.3B를 **풀 파인튜닝**하는 쪽이 오히려 유리하다 (docs/FINETUNING.md 참고).
## 주의 — 벤치마크를 곧이곧대로 믿지 말 것
위 비교는 공개 벤치마크와 모델 카드에 근거한 것이고, **실제 게임/방송 음성에서의
체감 품질은 다를 수 있다.** 특히 Seed-X의 우위는 자체 발표 수치에 크게 기대고 있다.
그래서 프로그램에 5개 티어를 모두 넣었다. 실제 쓰는 콘텐츠로 3·4·5티어를 직접
번갈아 써보고 정하는 것이 가장 정확하다.
## 참고
- [Best open source STT model in 2026 (benchmarks)](https://northflank.com/blog/best-open-source-speech-to-text-stt-model-in-2026-benchmarks)
- [Whisper Large-v3 vs Turbo: Speed, WER & Cost](https://vexascribe.com/whisper-large-v3-vs-turbo)
- [Seed-X: Building Strong Multilingual Translation LLM with 7B Parameters (arXiv)](https://arxiv.org/html/2507.13618v1)
- [ByteDance-Seed/Seed-X-PPO-7B (Hugging Face)](https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B)
- [Local translation benchmark 2026 (cctrans)](https://github.com/kargnas/cctrans/blob/main/docs/local-translation-benchmark-2026.md)
- [CTranslate2 지원 모델](https://opennmt.net/CTranslate2/guides/transformers.html)

BIN
docs/images/glossary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
docs/images/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
docs/images/models.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
docs/images/overlay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
docs/images/subtitle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB