diff --git a/README.md b/README.md index 67ac725..807a682 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # LiveSub — 게임 소리를 실시간 자막으로 +![LiveSub](docs/images/banner.png) + 게임이나 프로그램에서 나오는 소리를 실시간으로 받아 번역해, 화면 위에 자막으로 얹어줍니다. 디스코드 오버레이처럼 동작하고, 번역은 전부 **내 컴퓨터의 GPU에서** 돌아갑니다. 인터넷도, API 키도 필요 없습니다. 한국어 · English · 日本語 · 中文 사이를 번역합니다. -![자막 오버레이](docs/images/overlay.png) - --- ## 무엇을 하는 프로그램인가 @@ -32,6 +32,8 @@ - **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지 - **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수 - **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다 +- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다 +- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고) - **완전 로컬** — 음성이 외부로 나가지 않습니다 --- @@ -53,6 +55,27 @@ ![설정 화면](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. 사전 준비 diff --git a/docs/images/banner.png b/docs/images/banner.png new file mode 100644 index 0000000..874d382 Binary files /dev/null and b/docs/images/banner.png differ diff --git a/docs/images/home.png b/docs/images/home.png index e5507c9..84e988b 100644 Binary files a/docs/images/home.png and b/docs/images/home.png differ diff --git a/docs/images/models.png b/docs/images/models.png index 25ad937..6df6227 100644 Binary files a/docs/images/models.png and b/docs/images/models.png differ diff --git a/packaging/build-portable.ps1 b/packaging/build-portable.ps1 new file mode 100644 index 0000000..e7d3b6c --- /dev/null +++ b/packaging/build-portable.ps1 @@ -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 +} diff --git a/packaging/livesub.spec b/packaging/livesub.spec new file mode 100644 index 0000000..58f8fc3 --- /dev/null +++ b/packaging/livesub.spec @@ -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, +) diff --git a/packaging/requirements-portable.txt b/packaging/requirements-portable.txt new file mode 100644 index 0000000..069bdd1 --- /dev/null +++ b/packaging/requirements-portable.txt @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ecfbc12..a81c490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/livesub/app.py b/src/livesub/app.py index f92b7b8..5456003 100644 --- a/src/livesub/app.py +++ b/src/livesub/app.py @@ -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: diff --git a/src/livesub/config.py b/src/livesub/config.py index 1409c97..2c46bf2 100644 --- a/src/livesub/config.py +++ b/src/livesub/config.py @@ -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 diff --git a/src/livesub/constants.py b/src/livesub/constants.py index 925dadc..889bfd5 100644 --- a/src/livesub/constants.py +++ b/src/livesub/constants.py @@ -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개) diff --git a/src/livesub/core/engine.py b/src/livesub/core/engine.py index dc7dbb4..a87371a 100644 --- a/src/livesub/core/engine.py +++ b/src/livesub/core/engine.py @@ -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 diff --git a/src/livesub/resources/icon.ico b/src/livesub/resources/icon.ico new file mode 100644 index 0000000..7cc7f0c Binary files /dev/null and b/src/livesub/resources/icon.ico differ diff --git a/src/livesub/resources/icon.png b/src/livesub/resources/icon.png new file mode 100644 index 0000000..253e38e Binary files /dev/null and b/src/livesub/resources/icon.png differ diff --git a/src/livesub/ui/pages/home.py b/src/livesub/ui/pages/home.py index 9b3b0bf..c8676bc 100644 --- a/src/livesub/ui/pages/home.py +++ b/src/livesub/ui/pages/home.py @@ -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: diff --git a/src/livesub/ui/pages/models_page.py b/src/livesub/ui/pages/models_page.py index 4185874..c5439d4 100644 --- a/src/livesub/ui/pages/models_page.py +++ b/src/livesub/ui/pages/models_page.py @@ -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: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 24f9a6f..a7f6778 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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}" diff --git a/tests/test_speech_level.py b/tests/test_speech_level.py new file mode 100644 index 0000000..4136b3b --- /dev/null +++ b/tests/test_speech_level.py @@ -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") == ( + "적이 왼쪽에서 옵니다" + )