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

@@ -1,13 +1,13 @@
# LiveSub — 게임 소리를 실시간 자막으로 # LiveSub — 게임 소리를 실시간 자막으로
![LiveSub](docs/images/banner.png)
게임이나 프로그램에서 나오는 소리를 실시간으로 받아 번역해, 화면 위에 자막으로 얹어줍니다. 게임이나 프로그램에서 나오는 소리를 실시간으로 받아 번역해, 화면 위에 자막으로 얹어줍니다.
디스코드 오버레이처럼 동작하고, 번역은 전부 **내 컴퓨터의 GPU에서** 돌아갑니다. 디스코드 오버레이처럼 동작하고, 번역은 전부 **내 컴퓨터의 GPU에서** 돌아갑니다.
인터넷도, API 키도 필요 없습니다. 인터넷도, API 키도 필요 없습니다.
한국어 · English · 日本語 · 中文 사이를 번역합니다. 한국어 · English · 日本語 · 中文 사이를 번역합니다.
![자막 오버레이](docs/images/overlay.png)
--- ---
## 무엇을 하는 프로그램인가 ## 무엇을 하는 프로그램인가
@@ -32,6 +32,8 @@
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지 - **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수 - **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다 - **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다
- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고)
- **완전 로컬** — 음성이 외부로 나가지 않습니다 - **완전 로컬** — 음성이 외부로 나가지 않습니다
--- ---
@@ -53,6 +55,27 @@
![설정 화면](docs/images/settings.png) ![설정 화면](docs/images/settings.png)
## 말투 — 존댓말 / 반말
홈 화면에서 고릅니다. **기본은 존댓말**입니다.
| 상황 | 존댓말 모드 | 반말 모드 |
|---|---|---|
| 영어·중국어 원문 (높임 없음) | 존댓말 | 반말 |
| 일본어 원문이 `です/ます` | 존댓말 | **존댓말** |
| 일본어 원문이 반말 | 존댓말 | 반말 |
**원문이 실제로 존댓말이면 반말 모드여도 존댓말로 나옵니다.** 상대가 정중하게
말했는데 자막이 반말이면 뉘앙스가 통째로 뒤집히기 때문입니다.
영어·중국어는 문법적 높임이 없어서 항상 고른 모드를 따릅니다.
```
Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다
반말 → 적이 왼쪽에서 와
左から来ます (정중) 존댓말 → 적이 왼쪽에서 옵니다
반말 → 적이 왼쪽에서 옵니다 ← 원문이 존댓말
```
## 자막 위치 잡기 ## 자막 위치 잡기
`자막` 화면에서 **모니터를 고르고 네모칸을 누르면** 그 자리에 붙습니다. `자막` 화면에서 **모니터를 고르고 네모칸을 누르면** 그 자리에 붙습니다.
@@ -70,7 +93,32 @@
--- ---
## 설치 ## 설치 — 둘 중 하나
| | 포터블 (권장) | 일반 설치 |
|---|---|---|
| 준비물 | `LiveSub.exe` 하나 | Python + PyTorch |
| 크기 | 약 500MB | 약 3GB |
| 쓸 수 있는 티어 | 1~3 | 1~5 전부 |
| 번역 품질 | NLLB | NLLB + Seed-X / Qwen3 |
포터블은 PyTorch를 빼서 만듭니다. PyTorch+CUDA만 2.5GB라 한 파일로 묶으면
**실행할 때마다** 그걸 임시폴더에 푸느라 1분 넘게 걸리기 때문입니다.
음성인식도 번역도 CTranslate2 위에서 돌아가 PyTorch가 필요 없으므로,
1~3티어는 포터블에서 그대로 다 됩니다.
### 포터블 만들기
```powershell
powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
```
`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고 첫 실행 때
`%APPDATA%\LiveSub\models`로 내려받습니다.
---
## 일반 설치
### 1. 사전 준비 ### 1. 사전 준비

BIN
docs/images/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -0,0 +1,51 @@
# 포터블 LiveSub.exe 빌드 (Windows 전용)
#
# powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
#
# 결과: dist\LiveSub.exe — 설치 없이 이 파일 하나만 있으면 실행됩니다.
# 모델은 exe 에 없고 첫 실행 때 %APPDATA%\LiveSub\models 로 내려받습니다.
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Push-Location $root
try {
$venv = Join-Path $root ".venv-portable"
if (-not (Test-Path $venv)) {
Write-Host "포터블 전용 가상환경을 만듭니다 (torch 없이)..." -ForegroundColor Cyan
py -3.12 -m venv $venv
}
$py = Join-Path $venv "Scripts\python.exe"
& $py -m pip install --upgrade pip --quiet
& $py -m pip install -r packaging\requirements-portable.txt
# torch 가 섞여 들어가면 exe 가 3GB 로 불어난다. 빌드 전에 확인한다.
& $py -c "import importlib.util,sys; sys.exit(1 if importlib.util.find_spec('torch') else 0)"
if ($LASTEXITCODE -ne 0) {
Write-Error "이 환경에 torch 가 있습니다. 포터블 빌드는 torch 없는 별도 venv 에서 하세요."
}
# 프로세스별 캡처 보조 프로그램이 있으면 같이 담긴다 (없어도 빌드는 된다).
$capture = Join-Path $root "src\livesub\resources\bin\livesub_capture.exe"
if (-not (Test-Path $capture)) {
Write-Warning "livesub_capture.exe 가 없습니다. 포터블은 '출력 장치 전체' 캡처만 됩니다."
Write-Warning "프로그램별 캡처를 넣으려면 native\process_loopback\build.ps1 을 먼저 실행하세요."
}
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
& $py -m PyInstaller packaging\livesub.spec --noconfirm
$out = Join-Path $root "dist\LiveSub.exe"
if (Test-Path $out) {
$mb = [math]::Round((Get-Item $out).Length / 1MB, 1)
Write-Host ""
Write-Host "빌드 완료: $out ($mb MB)" -ForegroundColor Green
Write-Host "이 파일 하나만 복사하면 됩니다. 설치 필요 없습니다." -ForegroundColor Green
} else {
Write-Error "빌드는 끝났지만 dist\LiveSub.exe 를 찾지 못했습니다."
}
}
finally {
Pop-Location
}

89
packaging/livesub.spec Normal file
View File

@@ -0,0 +1,89 @@
# -*- mode: python ; coding: utf-8 -*-
"""포터블 LiveSub 빌드 명세 (PyInstaller).
설치 없이 exe 하나만 두고 실행하는 것이 목표다. 다만 그냥 전부 넣으면
안 된다. torch+CUDA 만 2.5GB 라 --onefile 로 묶으면 **실행할 때마다**
그 2.5GB 를 임시폴더에 풀어야 해서 시작에 1분 넘게 걸린다.
그래서 포터블 빌드는 torch 를 뺀다. 음성인식(faster-whisper)과 번역
(NLLB) 모두 CTranslate2 위에서 돌아가고 CTranslate2 는 torch 를 요구하지
않기 때문에, 1~3티어는 그대로 다 동작한다. 4~5티어(Seed-X / Qwen3)만
빠지며, 앱이 '모델' 화면에서 그 사실을 표시한다.
빌드:
pip install -r packaging/requirements-portable.txt pyinstaller
pyinstaller packaging/livesub.spec --noconfirm
결과:
dist/LiveSub.exe (약 400~600MB, 첫 실행 20~40초 / 이후 10초 내외)
모델은 exe 에 넣지 않는다. 처음 쓸 때 %APPDATA%\\LiveSub\\models 로
내려받으므로 exe 를 USB 로 옮겨도 모델은 그 PC 에 남는다.
"""
from pathlib import Path
from PyInstaller.utils.hooks import collect_dynamic_libs
BLOCK_CIPHER = None
ROOT = Path(SPECPATH).parent # noqa: F821 - PyInstaller 가 주입
SRC = ROOT / "src"
# 게임 용어집 JSON 과 (빌드돼 있다면) 프로세스 캡처 보조 프로그램을 함께 담는다.
datas = [(str(SRC / "livesub" / "resources" / "glossaries"), "livesub/resources/glossaries")]
capture_exe = SRC / "livesub" / "resources" / "bin" / "livesub_capture.exe"
if capture_exe.is_file():
datas.append((str(capture_exe), "livesub/resources/bin"))
# CTranslate2 는 CUDA/cuDNN DLL 을 곁에 두고 로드한다. 자동 수집에서 빠지기 쉽다.
binaries = collect_dynamic_libs("ctranslate2")
hiddenimports = [
"livesub.audio.process_loopback",
"livesub.audio.wasapi_loopback",
"ctranslate2",
"faster_whisper",
"tokenizers",
"sentencepiece",
"huggingface_hub",
]
# 넣으면 용량만 키우고 쓰지 않는 것들. torch 계열은 의도적으로 제외한다.
excludes = [
"torch", "torchvision", "torchaudio", "transformers", "peft",
"accelerate", "datasets", "bitsandbytes", "autoawq",
"matplotlib", "scipy", "pandas", "IPython", "notebook",
"tkinter", "PySide6.QtWebEngineCore", "PySide6.Qt3DCore",
"PySide6.QtCharts", "PySide6.QtDataVisualization", "PySide6.QtMultimedia",
"PySide6.QtQuick", "PySide6.QtQml", "PySide6.QtPdf", "PySide6.QtDesigner",
]
a = Analysis( # noqa: F821
[str(SRC / "livesub" / "__main__.py")],
pathex=[str(SRC)],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
excludes=excludes,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=BLOCK_CIPHER) # noqa: F821
exe = EXE( # noqa: F821
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="LiveSub",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # UPX 로 압축하면 백신이 오탐하는 일이 잦다
runtime_tmpdir=None,
console=False, # 콘솔 창 없이 GUI 로만
icon=str(ROOT / "src" / "livesub" / "resources" / "icon.ico"),
version=None,
)

View File

@@ -0,0 +1,25 @@
# 포터블 exe 전용 의존성 — torch 를 넣지 않는다.
#
# 음성인식(faster-whisper)도 번역(NLLB)도 CTranslate2 위에서 돌고
# CTranslate2 는 torch 를 요구하지 않는다. 덕분에 exe 가 3GB -> 500MB 가
# 된다. 대신 4~5티어(Seed-X / Qwen3)는 빠지며 앱이 그렇게 표시한다.
#
# 4~5티어까지 쓰려면 포터블이 아니라 일반 설치(requirements.txt)를 쓰세요.
PySide6>=6.6
numpy>=1.24
faster-whisper>=1.0
ctranslate2>=4.4
tokenizers>=0.19
sentencepiece>=0.2
huggingface-hub>=0.24
# Windows 오디오 캡처
PyAudioWPatch>=0.2.12.7; sys_platform == "win32"
pycaw>=20240210; sys_platform == "win32"
comtypes>=1.4; sys_platform == "win32"
psutil>=5.9; sys_platform == "win32"
webrtcvad-wheels>=2.0.14
pyinstaller>=6.6

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "livesub" name = "livesub"
version = "0.2.0" version = "0.3.0"
description = "게임 소리를 실시간으로 번역해 화면 위 자막으로 보여주는 도구" description = "게임 소리를 실시간으로 번역해 화면 위 자막으로 보여주는 도구"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -52,7 +52,7 @@ livesub = "livesub.app:main"
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
livesub = ["resources/bin/*", "resources/glossaries/*.json"] livesub = ["resources/bin/*", "resources/glossaries/*.json", "resources/icon.*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import contextlib import contextlib
import logging import logging
import sys import sys
from pathlib import Path
from .config import AppConfig from .config import AppConfig
from .constants import APP_NAME, ORG_NAME, user_data_dir 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) setup_logging(verbose)
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from .ui import MainWindow from .ui import MainWindow
@@ -42,6 +44,10 @@ def main(argv: list[str] | None = None) -> int:
app.setOrganizationName(ORG_NAME) app.setOrganizationName(ORG_NAME)
app.setQuitOnLastWindowClosed(True) 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() config = AppConfig.load()
window = MainWindow(config) window = MainWindow(config)
if not config.start_minimized: if not config.start_minimized:

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ from ..constants import LANGUAGE_CODES, user_data_dir
from ..models import Glossary, ModelManager from ..models import Glossary, ModelManager
from ..models.manager import apply_vram_limit from ..models.manager import apply_vram_limit
from ..models.packs import build_glossary from ..models.packs import build_glossary
from ..models.speech_level import SpeechLevel, apply_speech_level, detect_politeness
from .events import EngineState, EngineStatus, TranslationLine from .events import EngineState, EngineStatus, TranslationLine
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -103,6 +104,13 @@ class TranslationEngine:
def reload_glossary(self) -> None: def reload_glossary(self) -> None:
self.glossary = self._load_glossary() 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: def sync_performance(self) -> bool:
"""저부하 모드 설정을 모델 매니저에 반영한다. 바뀌었으면 True. """저부하 모드 설정을 모델 매니저에 반영한다. 바뀌었으면 True.
@@ -272,11 +280,19 @@ class TranslationEngine:
if segment.is_final: if segment.is_final:
# 중간 결과는 원문만 흘려보낸다. 번역은 확정 문장에만 돌려 GPU를 아낀다. # 중간 결과는 원문만 흘려보낸다. 번역은 확정 문장에만 돌려 GPU를 아낀다.
t1 = time.perf_counter() t1 = time.perf_counter()
# 말투는 원문을 보고 정한다. 원문이 실제로 존댓말이면 반말 모드여도
# 존댓말을 지켜야 하므로, 번역 전에 원문에서 높임을 읽어둔다.
politeness = detect_politeness(transcript.text, detected)
translated = translator.translate( translated = translator.translate(
transcript.text, transcript.text,
detected, detected,
target, target,
self.glossary if self.config.glossary.enabled else None, 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) mt_ms = int((time.perf_counter() - t1) * 1000)
self._last_final_text = transcript.text 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(arrow)
layout.addWidget(QLabel("번역")) layout.addWidget(QLabel("번역"))
layout.addWidget(self.target_lang) 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) layout.addStretch(1)
self.status_pill = StatusPill() self.status_pill = StatusPill()
@@ -182,6 +198,9 @@ class HomePage(QWidget):
def _on_lang_changed(self, *_) -> None: def _on_lang_changed(self, *_) -> None:
self.config.models.source_lang = self.source_lang.currentData() self.config.models.source_lang = self.source_lang.currentData()
self.config.models.target_lang = self.target_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() self.config_changed.emit()
def _on_toggle(self) -> None: def _on_toggle(self) -> None:
@@ -209,6 +228,9 @@ class HomePage(QWidget):
for widget in (self.source_combo, self.source_lang, self.target_lang): for widget in (self.source_combo, self.source_lang, self.target_lang):
widget.setEnabled(not running) widget.setEnabled(not running)
self.speech_combo.setEnabled(
not running and self.config.models.target_lang == "ko"
)
def on_line(self, line: TranslationLine) -> None: def on_line(self, line: TranslationLine) -> None:
if not line.is_final: if not line.is_final:

View File

@@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
from ...config import AppConfig from ...config import AppConfig
from ...models import Tier, detect_gpu, ordered_tiers from ...models import Tier, detect_gpu, ordered_tiers
from ...models.manager import tier_availability
from ..theme import SPACING, palette from ..theme import SPACING, palette
from ..widgets import Card, PageHeader 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): def __init__(self, tier: Tier, gpu_vram_gb: float, parent: QWidget | None = None):
super().__init__(parent) super().__init__(parent)
self.tier = tier self.tier = tier
self.available, self.unavailable_reason = tier_availability(tier)
self.setObjectName("Card") self.setObjectName("Card")
p = palette("dark") p = palette("dark")
@@ -52,6 +54,8 @@ class TierCard(QFrame):
head.addWidget(_badge("추가학습 최적", p.success)) head.addWidget(_badge("추가학습 최적", p.success))
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb: 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)) 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) head.addStretch(1)
text.addLayout(head) text.addLayout(head)
@@ -72,6 +76,16 @@ class TierCard(QFrame):
note.setWordWrap(True) note.setWordWrap(True)
text.addWidget(note) 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) layout.addLayout(text, 1)
def set_selected(self, selected: bool) -> None: def set_selected(self, selected: bool) -> None:

View File

@@ -68,7 +68,9 @@ class FakeTranslator:
def unload(self): def unload(self):
pass pass
def translate(self, text, source_lang, target_lang, glossary=None): def translate(self, text, source_lang, target_lang, glossary=None, **kwargs):
# 실제 Translator 와 같은 키워드(speech_level, source_politeness)를 받는다.
self.last_kwargs = kwargs
return f"[{target_lang}] {text}" return f"[{target_lang}] {text}"

242
tests/test_speech_level.py Normal file
View File

@@ -0,0 +1,242 @@
"""말투(존댓말/반말) 모드.
사용자 규칙:
- 기본은 존댓말.
- 높임이 없거나(영어·중국어) 알 수 없으면 고른 모드를 따른다.
- **원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킨다.**
"""
from __future__ import annotations
import pytest
from livesub.models.speech_level import (
Politeness,
SpeechLevel,
apply_speech_level,
detect_politeness,
prompt_instruction,
to_casual,
)
# --- 원문 높임 감지 ---------------------------------------------------------
@pytest.mark.parametrize(
"text",
["안녕하세요", "같이 가시죠", "도와주세요", "감사합니다", "지금 가겠습니다", "괜찮으세요?"],
)
def test_korean_polite_is_detected(text):
assert detect_politeness(text, "ko") is Politeness.POLITE
@pytest.mark.parametrize("text", ["같이 가자", "빨리 와", "적이 온다", "내가 할게"])
def test_korean_casual_is_detected(text):
assert detect_politeness(text, "ko") is Politeness.CASUAL
@pytest.mark.parametrize(
"text", ["こんにちは、お願いします", "行きますよ", "ありがとうございます", "待ってください"]
)
def test_japanese_polite_is_detected(text):
assert detect_politeness(text, "ja") is Politeness.POLITE
@pytest.mark.parametrize("text", ["早く来いよ", "行くぞ", "危ないだろう"])
def test_japanese_casual_is_detected(text):
assert detect_politeness(text, "ja") is Politeness.CASUAL
@pytest.mark.parametrize("lang", ["en", "zh"])
def test_languages_without_honorifics_are_always_unknown(lang):
"""영어·중국어는 문법적 높임이 없으므로 항상 모드를 따라야 한다.
"please" 를 존댓말 근거로 삼으면 오탐이 너무 많아진다.
"""
for text in ["Could you please help me, sir?", "Get down now!", "请帮我一下", "快走"]:
assert detect_politeness(text, lang) is Politeness.UNKNOWN
def test_empty_text_is_unknown():
assert detect_politeness("", "ko") is Politeness.UNKNOWN
assert detect_politeness(" ", "ja") is Politeness.UNKNOWN
# --- 반말 변환 --------------------------------------------------------------
@pytest.mark.parametrize(
("polite", "casual"),
[
("적이 왼쪽에서 옵니다", "적이 왼쪽에서 와"),
("지금 바로 빠지세요", "지금 바로 빠져"),
("바론을 치겠습니다", "바론을 칠게"),
("적을 눕혔습니다", "적을 눕혔어"),
("탄약이 없습니다", "탄약이 없어"),
("여기 적이 있습니다", "여기 적이 있어"),
("제가 하겠습니다", "제가 할게"),
("좋습니다", "좋아"),
("빨리 먹습니다", "빨리 먹어"),
("저건 함정이에요", "저건 함정이야"),
("제 차례예요", "제 차례야"),
("조심하세요", "조심해"),
("잘 했네요", "잘 했네"),
("같이 가죠", "같이 가지"),
],
)
def test_polite_to_casual(polite, casual):
assert to_casual(polite) == casual
def test_trailing_yo_is_dropped():
assert to_casual("같이 가요") == "같이 가"
assert to_casual("빨리 와요!") == "빨리 와!"
def test_mid_sentence_yo_is_preserved():
"""'중요', '필요' 의 '요'를 떼면 말이 망가진다."""
assert "중요" in to_casual("이게 제일 중요합니다")
assert "필요" in to_casual("탄약이 필요합니다")
def test_conversion_is_idempotent_on_already_casual_text():
casual = "적이 왼쪽에서 와"
assert to_casual(casual) == casual
def test_empty_stays_empty():
assert to_casual("") == ""
# --- 모드 적용 규칙 ---------------------------------------------------------
def test_polite_mode_never_alters_output():
"""존댓말 모드는 손대지 않는다 — 모델 출력이 이미 격식체다."""
text = "적이 왼쪽에서 옵니다"
for politeness in Politeness:
assert apply_speech_level(text, "ko", SpeechLevel.POLITE, politeness) == text
def test_casual_mode_lowers_when_source_is_unknown():
"""영어 원문 → 높임 정보 없음 → 고른 모드(반말)를 따른다."""
out = apply_speech_level(
"적이 왼쪽에서 옵니다", "ko", SpeechLevel.CASUAL, Politeness.UNKNOWN
)
assert out == "적이 왼쪽에서 와"
def test_casual_mode_keeps_polite_when_source_was_polite():
"""핵심 규칙 — 실제로 존댓말로 말했으면 반말 모드여도 존댓말."""
text = "도와주시겠습니까"
assert apply_speech_level(text, "ko", SpeechLevel.CASUAL, Politeness.POLITE) == text
def test_casual_mode_lowers_when_source_was_casual():
out = apply_speech_level("빨리 옵니다", "ko", SpeechLevel.CASUAL, Politeness.CASUAL)
assert out == "빨리 와"
@pytest.mark.parametrize("target", ["en", "ja", "zh"])
def test_non_korean_targets_are_untouched(target):
"""한국어 외에는 적용할 규칙이 없으므로 원본을 그대로 둔다."""
text = "Enemy incoming"
assert apply_speech_level(text, target, SpeechLevel.CASUAL, Politeness.UNKNOWN) == text
# --- LLM 프롬프트 지시문 ----------------------------------------------------
def test_prompt_asks_for_polite_when_source_was_polite():
hint = prompt_instruction(SpeechLevel.CASUAL, Politeness.POLITE)
assert "존댓말" in hint
def test_prompt_asks_for_casual_in_casual_mode():
assert "반말" in prompt_instruction(SpeechLevel.CASUAL, Politeness.UNKNOWN)
def test_prompt_asks_for_polite_in_polite_mode():
assert "존댓말" in prompt_instruction(SpeechLevel.POLITE, Politeness.UNKNOWN)
# --- 엔진 파이프라인 끝까지 도달하는지 ---------------------------------------
class _PoliteTranslator:
"""한국어 격식체를 내놓는 번역기 (NLLB/Seed-X 가 실제로 그렇다)."""
loaded = True
def load(self):
pass
def unload(self):
pass
def translate(self, text, source_lang, target_lang, glossary=None, **kwargs):
self.kwargs = kwargs
return "적이 왼쪽에서 옵니다"
def _run(monkeypatch, tmp_path, level: str, source_text: str, source_lang: str) -> str:
from livesub.config import AppConfig
from livesub.core.engine import TranslationEngine
from livesub.models.asr import Transcript
cfg = AppConfig()
cfg.models.preload_on_start = False
cfg.models.speech_level = level
cfg.glossary.path = str(tmp_path / "g.json")
cfg.glossary.enabled_packs = []
engine = TranslationEngine(cfg)
translator = _PoliteTranslator()
class Recognizer:
def transcribe(self, audio, language=None, fast=False, prompt=""):
return Transcript(text=source_text, language=source_lang)
lines = []
engine._on_line = lines.append
class Segment:
is_final = True
audio = None
ended_at = 0.0
engine._process(Segment(), Recognizer(), translator, cfg.models)
return lines[0].translated_text
def test_english_source_casual_mode_lowers(monkeypatch, tmp_path):
"""영어는 높임이 없으니 고른 모드(반말)를 따른다."""
assert _run(monkeypatch, tmp_path, "casual", "Enemy from the left", "en") == (
"적이 왼쪽에서 와"
)
def test_english_source_polite_mode_stays_polite(monkeypatch, tmp_path):
assert _run(monkeypatch, tmp_path, "polite", "Enemy from the left", "en") == (
"적이 왼쪽에서 옵니다"
)
def test_japanese_polite_source_stays_polite_even_in_casual_mode(monkeypatch, tmp_path):
"""핵심 규칙 — 실제로 존댓말로 말했으면 반말 모드여도 존댓말."""
assert _run(monkeypatch, tmp_path, "casual", "左から来ます", "ja") == (
"적이 왼쪽에서 옵니다"
)
def test_japanese_casual_source_is_lowered_in_casual_mode(monkeypatch, tmp_path):
assert _run(monkeypatch, tmp_path, "casual", "左から来るぞ", "ja") == (
"적이 왼쪽에서 와"
)
def test_unknown_speech_level_value_falls_back_to_polite(monkeypatch, tmp_path):
"""설정 파일이 손상돼도 안전한 존댓말로 떨어져야 한다."""
assert _run(monkeypatch, tmp_path, "무엇인가이상한값", "Enemy", "en") == (
"적이 왼쪽에서 옵니다"
)