프로그램별 오디오를 캡처해 로컬 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>
259 lines
8.9 KiB
Python
259 lines
8.9 KiB
Python
"""메인 컨트롤 창 — 사이드바 + 페이지 스택.
|
|
|
|
디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만
|
|
담당한다. 그래서 두 창이 서로 방해하지 않는다.
|
|
"""
|
|
|
|
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
|