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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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개)
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
src/livesub/resources/icon.ico
Normal file
BIN
src/livesub/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
src/livesub/resources/icon.png
Normal file
BIN
src/livesub/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user