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

102
tests/test_ui_smoke.py Normal file
View File

@@ -0,0 +1,102 @@
"""UI가 실제로 만들어지고 자막이 그려지는지 확인한다 (offscreen 렌더링)."""
from __future__ import annotations
import os
import pytest
pytest.importorskip("PySide6")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QImage, QPainter # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
from hearo.config import AppConfig # noqa: E402
from hearo.core.events import EngineState, EngineStatus, TranslationLine # noqa: E402
from hearo.ui.main_window import MainWindow # noqa: E402
from hearo.ui.overlay import paint_subtitle # noqa: E402
@pytest.fixture(scope="session")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture
def window(qapp, tmp_path, monkeypatch):
monkeypatch.setattr("hearo.constants.user_data_dir", lambda: tmp_path)
config = AppConfig()
config.models.preload_on_start = False
config.glossary.path = str(tmp_path / "glossary.json")
win = MainWindow(config)
yield win
win.engine.shutdown()
win.overlay.close()
win.close()
def test_main_window_builds_all_pages(window):
assert window.stack.count() == 5
def test_navigation_switches_pages(window):
for index in range(5):
window._goto_page(index)
assert window.stack.currentIndex() == index
def test_status_updates_toggle_button(window):
window._on_status(EngineStatus(EngineState.RUNNING, "듣는 중"))
assert window.home_page.toggle_button.text() == "번역 정지"
window._on_status(EngineStatus(EngineState.IDLE, "대기 중"))
assert window.home_page.toggle_button.text() == "번역 시작"
def test_final_line_reaches_overlay_and_log(window):
window.config.log_transcripts = False
window._on_line(
TranslationLine("hello", "안녕하세요", "en", "ko", is_final=True, latency_s=1.2)
)
assert window.overlay._final_text == "안녕하세요"
assert window.home_page.log_list.count() == 1
def test_partial_line_is_not_logged(window):
window.config.log_transcripts = False
window._on_line(TranslationLine("hel", "", "en", "ko", is_final=False))
assert window.home_page.log_list.count() == 0
def test_tier_selection_updates_config(window):
window.models_page._on_tier_selected(window.models_page._cards[3].tier)
assert window.config.models.tier == "precision"
def test_subtitle_style_edit_propagates(window):
window.subtitle_page.size_spin.setValue(60)
assert window.config.subtitle.font_size == 60
def test_paint_subtitle_actually_draws_pixels(qapp):
"""자막 글씨가 투명 이미지 위에 실제로 그려지는지 (빈 화면이면 실패).
배경 상자가 픽셀을 채워버리면 글씨 유무를 알 수 없으므로 배경은 끄고 잰다.
"""
st = AppConfig().subtitle
st.background_opacity = 0
image = QImage(900, 200, QImage.Format.Format_ARGB32)
image.fill(0)
painter = QPainter(image)
paint_subtitle(painter, image.rect(), st, "안녕하세요 반갑습니다", "Hello there")
painter.end()
non_transparent = sum(
1
for y in range(0, image.height(), 4)
for x in range(0, image.width(), 4)
if image.pixelColor(x, y).alpha() > 0
)
assert non_transparent > 100