말투 (존댓말 기본, 반말 선택) - 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킨다. 상대가 정중하게 말했는데 자막이 반말이면 뉘앙스가 뒤집히기 때문. - 영어·중국어는 문법적 높임이 없으므로 항상 고른 모드를 따른다. "please" 를 존댓말 근거로 삼으면 오탐이 많아 쓰지 않았다. - 낮춤 변환은 한글 자모를 분해해 실제 활용 규칙(모음조화 + 축약)을 구현했다. 오+아->와, 지+어->져, 하+어->해, 았/었 뒤는 항상 어, 치겠습니다->칠게. 어미를 나열하는 방식보다 훨씬 넓게 맞는다. - 변환 방향은 존댓말->반말 한쪽만 한다. 번역 모델의 한국어 출력이 이미 격식체라 존댓말 모드는 손댈 필요가 없고, 반대 방향은 훨씬 자주 틀린다. - 지시문을 이해하는 Qwen3 에는 프롬프트로도 전달한다. Seed-X 는 지시문을 못 알아듣는 모델이라 후처리로만 맞춘다. 포터블 exe - PyInstaller 명세 + 빌드 스크립트. torch 를 의도적으로 제외했다. torch+CUDA 만 2.5GB 라 onefile 로 묶으면 실행할 때마다 그걸 임시폴더에 푸느라 1분 넘게 걸려 쓸 수 없다. - 음성인식(faster-whisper)도 번역(NLLB)도 CTranslate2 위에서 돌아 torch 가 필요 없다. 덕분에 1~3티어는 그대로 다 되고 exe 는 3GB -> 500MB 가 된다. - 4~5티어는 못 쓰므로 tier_availability() 로 판정해 모델 화면에 '사용 불가'와 이유를 표시한다. torch 없는 환경에서 앱 전체가 뜨는 것을 확인했다. AI 이미지 - SDXL-turbo 로 아이콘/배경 생성 (로컬 GPU, 피크 VRAM 1.9GB). - 글자는 AI 가 제대로 못 쓰므로 아트만 AI 로 만들고 타이포그래피는 정확히 렌더링해 합성했다. icon.ico 는 16~256px 멀티해상도. 검증: pytest 163개 통과 (말투 53개 신규), ruff clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
249 lines
9.5 KiB
Python
249 lines
9.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)
|
|
|
|
self.speech_combo = QComboBox()
|
|
self.speech_combo.addItem("존댓말", "polite")
|
|
self.speech_combo.addItem("반말", "casual")
|
|
self.speech_combo.setCurrentIndex(
|
|
max(0, self.speech_combo.findData(self.config.models.speech_level))
|
|
)
|
|
self.speech_combo.setToolTip(
|
|
"한국어 자막의 말투입니다.\n"
|
|
"반말을 골라도 원문이 실제로 존댓말이면 존댓말로 나옵니다.\n"
|
|
"영어·중국어처럼 높임이 없는 언어는 이 설정을 그대로 따릅니다."
|
|
)
|
|
self.speech_combo.currentIndexChanged.connect(self._on_lang_changed)
|
|
layout.addWidget(QLabel("말투"))
|
|
layout.addWidget(self.speech_combo)
|
|
|
|
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.models.speech_level = self.speech_combo.currentData()
|
|
# 한국어가 아니면 말투 규칙을 적용할 데가 없다.
|
|
self.speech_combo.setEnabled(self.config.models.target_lang == "ko")
|
|
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)
|
|
self.speech_combo.setEnabled(
|
|
not running and self.config.models.target_lang == "ko"
|
|
)
|
|
|
|
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()
|