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:
258
src/hearo/ui/main_window.py
Normal file
258
src/hearo/ui/main_window.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""메인 컨트롤 창 — 사이드바 + 페이지 스택.
|
||||
|
||||
디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만
|
||||
담당한다. 그래서 두 창이 서로 방해하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..config import AppConfig
|
||||
from ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
|
||||
from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine
|
||||
from .overlay import SubtitleOverlay
|
||||
from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage
|
||||
from .theme import SPACING, palette, stylesheet
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NAV = [
|
||||
("홈", "실시간 번역"),
|
||||
("모델", "품질 티어"),
|
||||
("자막", "표시 설정"),
|
||||
("용어집", "고유명사 고정"),
|
||||
("설정", "세부 조정"),
|
||||
]
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
#: 엔진 콜백은 워커 스레드에서 오므로 시그널로 GUI 스레드에 넘긴다.
|
||||
line_received = Signal(object)
|
||||
status_received = Signal(object)
|
||||
|
||||
def __init__(self, config: AppConfig) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.setWindowTitle(f"{APP_NAME} · {APP_SLOGAN}")
|
||||
self.resize(1060, 820)
|
||||
|
||||
self.engine = TranslationEngine(
|
||||
config,
|
||||
on_line=self.line_received.emit,
|
||||
on_status=self.status_received.emit,
|
||||
)
|
||||
self.overlay = SubtitleOverlay(config.subtitle, config.overlay)
|
||||
self._transcript_file = None
|
||||
|
||||
self._build_ui()
|
||||
self._connect()
|
||||
|
||||
self._save_timer = QTimer(self)
|
||||
self._save_timer.setSingleShot(True)
|
||||
self._save_timer.setInterval(800) # 입력 중 계속 쓰지 않도록 묶어서 저장
|
||||
self._save_timer.timeout.connect(lambda: self.config.save())
|
||||
|
||||
# --- UI -------------------------------------------------------------
|
||||
def _build_ui(self) -> None:
|
||||
self.setStyleSheet(stylesheet(self.config.theme))
|
||||
|
||||
central = QWidget()
|
||||
layout = QHBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self._build_sidebar())
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
self.home_page = HomePage(self.config)
|
||||
self.models_page = ModelsPage(self.config)
|
||||
self.subtitle_page = SubtitlePage(self.config)
|
||||
self.glossary_page = GlossaryPage(self.config)
|
||||
self.settings_page = SettingsPage(self.config)
|
||||
for page in (
|
||||
self.home_page,
|
||||
self.models_page,
|
||||
self.subtitle_page,
|
||||
self.glossary_page,
|
||||
self.settings_page,
|
||||
):
|
||||
self.stack.addWidget(_scrollable(page))
|
||||
layout.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
def _build_sidebar(self) -> QFrame:
|
||||
p = palette(self.config.theme)
|
||||
bar = QFrame()
|
||||
bar.setObjectName("Sidebar")
|
||||
bar.setFixedWidth(212)
|
||||
|
||||
layout = QVBoxLayout(bar)
|
||||
layout.setContentsMargins(14, 22, 14, 18)
|
||||
layout.setSpacing(6)
|
||||
|
||||
brand = QLabel(APP_NAME)
|
||||
brand.setStyleSheet(
|
||||
f"font-size: 24px; font-weight: 800; color: {p.accent}; padding-left: 6px;"
|
||||
)
|
||||
layout.addWidget(brand)
|
||||
tag = QLabel(APP_SLOGAN)
|
||||
tag.setObjectName("Hint")
|
||||
tag.setStyleSheet(f"color: {p.text_dim}; padding-left: 7px;")
|
||||
layout.addWidget(tag)
|
||||
layout.addSpacing(SPACING + 8)
|
||||
|
||||
self._nav_group = QButtonGroup(self)
|
||||
self._nav_group.setExclusive(True)
|
||||
for index, (title, hint) in enumerate(NAV):
|
||||
button = QPushButton(title)
|
||||
button.setObjectName("NavButton")
|
||||
button.setCheckable(True)
|
||||
button.setToolTip(hint)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self._nav_group.addButton(button, index)
|
||||
layout.addWidget(button)
|
||||
self._nav_group.button(0).setChecked(True)
|
||||
self._nav_group.idClicked.connect(self._goto_page)
|
||||
|
||||
layout.addStretch(1)
|
||||
version = QLabel(f"v{APP_VERSION}")
|
||||
version.setObjectName("Hint")
|
||||
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(version)
|
||||
return bar
|
||||
|
||||
def _connect(self) -> None:
|
||||
self.line_received.connect(self._on_line)
|
||||
self.status_received.connect(self._on_status)
|
||||
|
||||
self.home_page.start_requested.connect(self.start_engine)
|
||||
self.home_page.stop_requested.connect(self.stop_engine)
|
||||
self.home_page.config_changed.connect(self._schedule_save)
|
||||
|
||||
self.models_page.tier_changed.connect(self._on_tier_changed)
|
||||
self.models_page.config_changed.connect(self._schedule_save)
|
||||
|
||||
self.subtitle_page.style_changed.connect(self._on_style_changed)
|
||||
self.subtitle_page.overlay_toggled.connect(self._on_overlay_toggled)
|
||||
|
||||
self.glossary_page.glossary_changed.connect(self._on_glossary_changed)
|
||||
|
||||
self.settings_page.config_changed.connect(self._schedule_save)
|
||||
self.settings_page.restart_needed.connect(self._on_restart_needed)
|
||||
|
||||
self.overlay.geometry_changed.connect(lambda *_: self._schedule_save())
|
||||
self.overlay.closed.connect(
|
||||
lambda: self.subtitle_page.overlay_check.setChecked(False)
|
||||
)
|
||||
|
||||
# --- 엔진 -----------------------------------------------------------
|
||||
def start_engine(self) -> None:
|
||||
if self.config.overlay.visible:
|
||||
self.overlay.show()
|
||||
self.config.save()
|
||||
self.engine.start()
|
||||
|
||||
def stop_engine(self) -> None:
|
||||
self.engine.stop()
|
||||
self.overlay.clear()
|
||||
self._close_transcript()
|
||||
|
||||
def _on_tier_changed(self, tier_key: str) -> None:
|
||||
was_running = self.engine.running
|
||||
if was_running:
|
||||
self.engine.stop()
|
||||
self.engine.models.switch_tier(tier_key)
|
||||
if was_running:
|
||||
self.engine.start()
|
||||
|
||||
def _on_restart_needed(self) -> None:
|
||||
if self.engine.running:
|
||||
self.engine.stop()
|
||||
self.engine.start()
|
||||
|
||||
# --- 이벤트 ---------------------------------------------------------
|
||||
def _on_line(self, line: TranslationLine) -> None:
|
||||
self.overlay.set_line(line.translated_text, line.source_text, line.is_final)
|
||||
self.home_page.on_line(line)
|
||||
if line.is_final and self.config.log_transcripts:
|
||||
self._write_transcript(line)
|
||||
|
||||
def _on_status(self, status: EngineStatus) -> None:
|
||||
self.home_page.on_status(status)
|
||||
if status.state is EngineState.ERROR:
|
||||
QMessageBox.warning(self, "오류", status.message)
|
||||
|
||||
def _on_style_changed(self) -> None:
|
||||
self.overlay.update()
|
||||
self._schedule_save()
|
||||
|
||||
def _on_overlay_toggled(self, visible: bool) -> None:
|
||||
self.config.overlay.visible = visible
|
||||
self.overlay.setVisible(visible)
|
||||
self._schedule_save()
|
||||
|
||||
def _on_glossary_changed(self) -> None:
|
||||
self.engine.reload_glossary()
|
||||
self._schedule_save()
|
||||
|
||||
def _goto_page(self, index: int) -> None:
|
||||
self.stack.setCurrentIndex(index)
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
self._save_timer.start()
|
||||
|
||||
# --- 기록 -----------------------------------------------------------
|
||||
def _write_transcript(self, line: TranslationLine) -> None:
|
||||
if self._transcript_file is None:
|
||||
folder = user_data_dir() / "transcripts"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
name = datetime.now().strftime("%Y%m%d-%H%M%S.txt")
|
||||
# 세션 내내 열어두는 핸들이다. _close_transcript() 에서 닫는다.
|
||||
self._transcript_file = open(folder / name, "a", encoding="utf-8") # noqa: SIM115
|
||||
stamp = datetime.now().strftime("%H:%M:%S")
|
||||
self._transcript_file.write(
|
||||
f"[{stamp}] {line.source_text}\n {line.translated_text}\n"
|
||||
)
|
||||
self._transcript_file.flush()
|
||||
|
||||
def _close_transcript(self) -> None:
|
||||
if self._transcript_file is not None:
|
||||
self._transcript_file.close()
|
||||
self._transcript_file = None
|
||||
|
||||
# --- 종료 -----------------------------------------------------------
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self.config.save()
|
||||
self.engine.shutdown()
|
||||
self._close_transcript()
|
||||
self.overlay.close()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def _scrollable(widget: QWidget) -> QScrollArea:
|
||||
area = QScrollArea()
|
||||
area.setWidgetResizable(True)
|
||||
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
holder = QWidget()
|
||||
layout = QVBoxLayout(holder)
|
||||
layout.setContentsMargins(26, 24, 26, 24)
|
||||
layout.addWidget(widget)
|
||||
area.setWidget(holder)
|
||||
return area
|
||||
Reference in New Issue
Block a user