feat: 존댓말/반말 모드, 포터블 exe 빌드, AI 로고/배너

말투 (존댓말 기본, 반말 선택)
- 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킨다. 상대가 정중하게
  말했는데 자막이 반말이면 뉘앙스가 뒤집히기 때문.
- 영어·중국어는 문법적 높임이 없으므로 항상 고른 모드를 따른다.
  "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>
This commit is contained in:
EJClaw
2026-09-23 01:06:23 +09:00
parent e4a048c8d5
commit 71436843d8
18 changed files with 525 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import contextlib
import logging
import sys
from pathlib import Path
from .config import AppConfig
from .constants import APP_NAME, ORG_NAME, user_data_dir
@@ -32,6 +33,7 @@ def main(argv: list[str] | None = None) -> int:
setup_logging(verbose)
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication
from .ui import MainWindow
@@ -42,6 +44,10 @@ def main(argv: list[str] | None = None) -> int:
app.setOrganizationName(ORG_NAME)
app.setQuitOnLastWindowClosed(True)
icon_path = Path(__file__).resolve().parent / "resources" / "icon.png"
if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path)))
config = AppConfig.load()
window = MainWindow(config)
if not config.start_minimized:

View File

@@ -80,6 +80,9 @@ class ModelConfig:
target_lang: str = "ko"
preload_on_start: bool = True
lora_adapter_path: str = "" # 추가학습 어댑터 (비어있으면 미사용)
#: 한국어 자막 말투. "polite"(존댓말, 기본) | "casual"(반말)
#: 반말이어도 원문이 실제로 존댓말이면 존댓말을 지킨다.
speech_level: str = "polite"
@dataclass

View File

@@ -7,7 +7,7 @@ from pathlib import Path
APP_NAME = "LiveSub"
APP_NAME_KO = "라이브섭"
APP_SLOGAN = "게임 소리를 실시간 자막으로"
APP_VERSION = "0.2.0"
APP_VERSION = "0.3.0"
ORG_NAME = "tkrmagid"
# 지원 언어 (1차: 4개)

View File

@@ -28,6 +28,7 @@ from ..constants import LANGUAGE_CODES, user_data_dir
from ..models import Glossary, ModelManager
from ..models.manager import apply_vram_limit
from ..models.packs import build_glossary
from ..models.speech_level import SpeechLevel, apply_speech_level, detect_politeness
from .events import EngineState, EngineStatus, TranslationLine
log = logging.getLogger(__name__)
@@ -103,6 +104,13 @@ class TranslationEngine:
def reload_glossary(self) -> None:
self.glossary = self._load_glossary()
def _speech_level(self) -> SpeechLevel:
"""설정 문자열을 열거형으로. 알 수 없는 값은 안전한 존댓말로."""
try:
return SpeechLevel(self.config.models.speech_level)
except ValueError:
return SpeechLevel.POLITE
def sync_performance(self) -> bool:
"""저부하 모드 설정을 모델 매니저에 반영한다. 바뀌었으면 True.
@@ -272,11 +280,19 @@ class TranslationEngine:
if segment.is_final:
# 중간 결과는 원문만 흘려보낸다. 번역은 확정 문장에만 돌려 GPU를 아낀다.
t1 = time.perf_counter()
# 말투는 원문을 보고 정한다. 원문이 실제로 존댓말이면 반말 모드여도
# 존댓말을 지켜야 하므로, 번역 전에 원문에서 높임을 읽어둔다.
politeness = detect_politeness(transcript.text, detected)
translated = translator.translate(
transcript.text,
detected,
target,
self.glossary if self.config.glossary.enabled else None,
speech_level=self._speech_level(),
source_politeness=politeness,
)
translated = apply_speech_level(
translated, target, self._speech_level(), politeness
)
mt_ms = int((time.perf_counter() - t1) * 1000)
self._last_final_text = transcript.text

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

View File

@@ -102,6 +102,22 @@ class HomePage(QWidget):
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()
@@ -182,6 +198,9 @@ class HomePage(QWidget):
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:
@@ -209,6 +228,9 @@ class HomePage(QWidget):
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:

View File

@@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
from ...config import AppConfig
from ...models import Tier, detect_gpu, ordered_tiers
from ...models.manager import tier_availability
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
@@ -28,6 +29,7 @@ class TierCard(QFrame):
def __init__(self, tier: Tier, gpu_vram_gb: float, parent: QWidget | None = None):
super().__init__(parent)
self.tier = tier
self.available, self.unavailable_reason = tier_availability(tier)
self.setObjectName("Card")
p = palette("dark")
@@ -52,6 +54,8 @@ class TierCard(QFrame):
head.addWidget(_badge("추가학습 최적", p.success))
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
head.addWidget(_badge(f"VRAM {tier.min_vram_gb:g}GB 필요", p.warning))
if not self.available:
head.addWidget(_badge("사용 불가", p.danger))
head.addStretch(1)
text.addLayout(head)
@@ -72,6 +76,16 @@ class TierCard(QFrame):
note.setWordWrap(True)
text.addWidget(note)
if not self.available:
# 포터블 exe 에는 torch 가 없어 LLM 티어를 못 쓴다. 눌러도 안 되는
# 이유를 카드에 적어둬야 사용자가 헤매지 않는다.
blocked = QLabel(self.unavailable_reason)
blocked.setStyleSheet(f"color: {p.warning};")
blocked.setWordWrap(True)
text.addWidget(blocked)
self.radio.setEnabled(False)
self.setEnabled(True)
layout.addLayout(text, 1)
def set_selected(self, selected: bool) -> None: