Files
live-app-translator/src/livesub/ui/pages/home.py
EJClaw 8b36b244ff feat: LiveSub 2차 — 자막 on/off, 모니터·위치 선택, GPU 저부하, 게임 용어집
이름을 Hearo → LiveSub 로 변경. 말장난보다 하는 일이 바로 보이는 쪽이 낫다.

자막 on/off
- 전역 단축키 Ctrl+Alt+S (자막) / Ctrl+Alt+D (번역) — Windows RegisterHotKey +
  네이티브 이벤트 필터라 게임 창이 떠 있어도 동작. 다른 OS 에서는 no-op
- 단축키·체크박스·우클릭 메뉴가 모두 같은 경로를 타도록 통합

모니터 선택 + 디스코드식 배치
- placement.py: Qt 비의존 배치 계산 (모니터 목록 → 9분할 좌표)
- AnchorGrid 위젯으로 3x3 위치 선택, 드래그하면 자유 배치로 전환
- 모니터 구성이 바뀌어도 자막이 화면 밖으로 사라지지 않도록 클램프

GPU 저부하 모드 (기본 켜짐)
- 연산 정밀도 int8 강등, VRAM 상한 35%, 추론 후 60ms 양보,
  300초 무음 시 모델 언로드, 중간 결과 비활성
- 기본 티어를 균형(6GB) → 신속(4GB) 으로 하향
- 8GB GPU 기준 자막 2.8GB / 게임 5.2GB

게임 용어집 번들 (304개)
- 롤 74 · 발로란트 59 · 오버워치2 49 · FPS공통 46 · 마크 38 · 방송 38
- 체크박스로 켜고 끄며, 사용자가 직접 등록한 항목이 항상 우선

검증: pytest 91개 통과 (배치 14 + 팩 12 + UI 6 신규), ruff clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-21 17:49:26 +09:00

227 lines
8.5 KiB
Python

"""홈 — 대상 선택, 언어, 시작/정지, 실시간 로그."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
QWidget,
)
from ...audio import capture_capabilities, list_sources
from ...config import AppConfig
from ...constants import LANGUAGES
from ...core.events import EngineState, EngineStatus, TranslationLine
from ..theme import SPACING, palette
from ..widgets import Card, LevelMeter, PageHeader, StatusPill
MAX_LOG_ROWS = 300
class HomePage(QWidget):
start_requested = Signal()
stop_requested = Signal()
config_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self._sources: list = []
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("실시간 번역", "소리를 받아올 프로그램과 번역할 언어를 고르세요.")
)
root.addWidget(self._build_source_card())
root.addWidget(self._build_control_card())
root.addWidget(self._build_log_card(), 1)
self.refresh_sources()
# --- 구성 -----------------------------------------------------------
def _build_source_card(self) -> Card:
card = Card("소리 받아올 곳", "")
self.capability_label = QLabel()
self.capability_label.setObjectName("Hint")
self.capability_label.setWordWrap(True)
card.add(self.capability_label)
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.source_combo = QComboBox()
self.source_combo.setMinimumWidth(360)
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
layout.addWidget(self.source_combo, 1)
refresh = QPushButton("새로고침")
refresh.clicked.connect(self.refresh_sources)
layout.addWidget(refresh)
card.add(row)
return card
def _build_control_card(self) -> Card:
card = Card()
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
self.source_lang = QComboBox()
self.source_lang.addItem("자동 감지", "auto")
for code, meta in LANGUAGES.items():
self.source_lang.addItem(meta["label"], code)
self.source_lang.setCurrentIndex(
max(0, self.source_lang.findData(self.config.models.source_lang))
)
self.source_lang.currentIndexChanged.connect(self._on_lang_changed)
self.target_lang = QComboBox()
for code, meta in LANGUAGES.items():
self.target_lang.addItem(meta["label"], code)
self.target_lang.setCurrentIndex(
max(0, self.target_lang.findData(self.config.models.target_lang))
)
self.target_lang.currentIndexChanged.connect(self._on_lang_changed)
layout.addWidget(QLabel("원본"))
layout.addWidget(self.source_lang)
arrow = QLabel("→")
arrow.setStyleSheet(f"color: {palette('dark').accent}; font-size: 18px;")
layout.addWidget(arrow)
layout.addWidget(QLabel("번역"))
layout.addWidget(self.target_lang)
layout.addStretch(1)
self.status_pill = StatusPill()
layout.addWidget(self.status_pill)
self.toggle_button = QPushButton("번역 시작")
self.toggle_button.setObjectName("Primary")
self.toggle_button.setMinimumWidth(140)
self.toggle_button.clicked.connect(self._on_toggle)
layout.addWidget(self.toggle_button)
card.add(row)
self.level_meter = LevelMeter()
card.add(self.level_meter)
return card
def _build_log_card(self) -> Card:
card = Card("최근 자막", "확정된 문장이 위로 쌓입니다. 지연 시간도 함께 보여줍니다.")
self.log_list = QListWidget()
self.log_list.setWordWrap(True)
self.log_list.setAlternatingRowColors(False)
card.add(self.log_list)
return card
# --- 동작 -----------------------------------------------------------
def refresh_sources(self) -> None:
caps = capture_capabilities()
self._sources = list_sources()
self.source_combo.blockSignals(True)
self.source_combo.clear()
for src in self._sources:
prefix = "🎮" if src.kind == "process" else "🔊"
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
if not self._sources:
self.source_combo.addItem("사용 가능한 오디오 대상이 없습니다", None)
self._select_saved_source()
self.source_combo.blockSignals(False)
self._on_source_changed()
if caps["process"]:
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
elif caps["device"]:
msg = (
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
"프로그램 단위로 분리됩니다."
)
else:
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요."
self.capability_label.setText(msg)
def _select_saved_source(self) -> None:
cfg = self.config.audio
for i, src in enumerate(self._sources):
if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid:
self.source_combo.setCurrentIndex(i)
return
if cfg.backend == "device" and src.kind == "device" and src.device_index == cfg.device_index:
self.source_combo.setCurrentIndex(i)
return
def _on_source_changed(self, *_) -> None:
src = self.source_combo.currentData()
cfg = self.config.audio
if src is None:
return
if src.kind == "process":
cfg.backend = "process"
cfg.target_pid = src.pid
cfg.target_process_name = src.label
else:
cfg.backend = "device"
cfg.device_index = src.device_index
cfg.target_pid = 0
self.config_changed.emit()
def _on_lang_changed(self, *_) -> None:
self.config.models.source_lang = self.source_lang.currentData()
self.config.models.target_lang = self.target_lang.currentData()
self.config_changed.emit()
def _on_toggle(self) -> None:
if self.toggle_button.property("running"):
self.stop_requested.emit()
else:
self.start_requested.emit()
# --- 엔진 연동 ------------------------------------------------------
def on_status(self, status: EngineStatus) -> None:
running = status.state in (EngineState.RUNNING, EngineState.LOADING)
self.toggle_button.setProperty("running", running)
self.toggle_button.setText("번역 정지" if running else "번역 시작")
self.toggle_button.setObjectName("Danger" if running else "Primary")
self.toggle_button.style().polish(self.toggle_button)
kind = {
EngineState.RUNNING: "ok",
EngineState.LOADING: "busy",
EngineState.STOPPING: "busy",
EngineState.ERROR: "error",
}.get(status.state, "idle")
self.status_pill.set_status(status.message or status.state.value, kind)
self.level_meter.set_level(status.level)
for widget in (self.source_combo, self.source_lang, self.target_lang):
widget.setEnabled(not running)
def on_line(self, line: TranslationLine) -> None:
if not line.is_final:
return
item = QListWidgetItem(
f"{line.translated_text}\n"
f" {line.source_text} · {line.latency_s:.1f}초 "
f"(인식 {line.asr_ms}ms · 번역 {line.mt_ms}ms)"
)
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
item.setToolTip(line.source_text)
self.log_list.insertItem(0, item)
while self.log_list.count() > MAX_LOG_ROWS:
self.log_list.takeItem(self.log_list.count() - 1)
self.log_list.scrollToTop()