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>
54
README.md
@@ -1,13 +1,13 @@
|
||||
# LiveSub — 게임 소리를 실시간 자막으로
|
||||
|
||||

|
||||
|
||||
게임이나 프로그램에서 나오는 소리를 실시간으로 받아 번역해, 화면 위에 자막으로 얹어줍니다.
|
||||
디스코드 오버레이처럼 동작하고, 번역은 전부 **내 컴퓨터의 GPU에서** 돌아갑니다.
|
||||
인터넷도, API 키도 필요 없습니다.
|
||||
|
||||
한국어 · English · 日本語 · 中文 사이를 번역합니다.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 무엇을 하는 프로그램인가
|
||||
@@ -32,6 +32,8 @@
|
||||
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지
|
||||
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수
|
||||
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
|
||||
- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다
|
||||
- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고)
|
||||
- **완전 로컬** — 음성이 외부로 나가지 않습니다
|
||||
|
||||
---
|
||||
@@ -53,6 +55,27 @@
|
||||
|
||||

|
||||
|
||||
## 말투 — 존댓말 / 반말
|
||||
|
||||
홈 화면에서 고릅니다. **기본은 존댓말**입니다.
|
||||
|
||||
| 상황 | 존댓말 모드 | 반말 모드 |
|
||||
|---|---|---|
|
||||
| 영어·중국어 원문 (높임 없음) | 존댓말 | 반말 |
|
||||
| 일본어 원문이 `です/ます` | 존댓말 | **존댓말** |
|
||||
| 일본어 원문이 반말 | 존댓말 | 반말 |
|
||||
|
||||
**원문이 실제로 존댓말이면 반말 모드여도 존댓말로 나옵니다.** 상대가 정중하게
|
||||
말했는데 자막이 반말이면 뉘앙스가 통째로 뒤집히기 때문입니다.
|
||||
영어·중국어는 문법적 높임이 없어서 항상 고른 모드를 따릅니다.
|
||||
|
||||
```
|
||||
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. 사전 준비
|
||||
|
||||
|
||||
BIN
docs/images/banner.png
Normal file
|
After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 139 KiB |
51
packaging/build-portable.ps1
Normal 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
@@ -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,
|
||||
)
|
||||
25
packaging/requirements-portable.txt
Normal 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
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "livesub"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "게임 소리를 실시간으로 번역해 화면 위 자막으로 보여주는 도구"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -52,7 +52,7 @@ livesub = "livesub.app:main"
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
livesub = ["resources/bin/*", "resources/glossaries/*.json"]
|
||||
livesub = ["resources/bin/*", "resources/glossaries/*.json", "resources/icon.*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 96 KiB |
BIN
src/livesub/resources/icon.png
Normal file
|
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:
|
||||
|
||||
@@ -68,7 +68,9 @@ class FakeTranslator:
|
||||
def unload(self):
|
||||
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}"
|
||||
|
||||
|
||||
|
||||
242
tests/test_speech_level.py
Normal 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") == (
|
||||
"적이 왼쪽에서 옵니다"
|
||||
)
|
||||