fix: .gitignore 가 소스 패키지 src/livesub/models 를 통째로 먹고 있었다
Some checks failed
Windows / verify (push) Has been cancelled

`models/` 처럼 슬래시 없이 쓴 규칙은 깊이에 상관없이 같은 이름의 폴더를 전부
무시한다. 의도는 루트의 모델 캐시였는데 소스 패키지까지 같이 걸렸고, 그래서
asr/translator/manager/tiers/glossary/packs/speech_level 8개 파일이 한 번도
커밋된 적이 없다.

증상: Gitea 에서 clone 하면 `ModuleNotFoundError: No module named
'livesub.models'` 로 앱이 아예 임포트되지 않는다. 로컬 작업 트리에는 파일이
있으니 개발 중에는 절대 안 보인다. 방금 붙인 Windows CI VM 이 첫 검증에서
바로 잡아냈다.

/models/, /adapters/, /data/ 로 루트에 고정하고 빠진 파일을 추가한다.
This commit is contained in:
EJClaw
2026-09-25 23:01:31 +09:00
parent ae295dc308
commit f9fd86464d
9 changed files with 1674 additions and 3 deletions

View File

@@ -0,0 +1,47 @@
"""모델 레이어 (음성인식 · 번역 · 용어집)."""
from .asr import SpeechRecognizer, Transcript
from .glossary import Glossary, GlossaryEntry
from .manager import GpuInfo, ModelManager, detect_gpu
from .tiers import (
DEFAULT_TIER,
TIERS,
MTBackend,
PromptStyle,
Tier,
get_tier,
ordered_tiers,
)
from .speech_level import (
Politeness,
SpeechLevel,
apply_speech_level,
detect_politeness,
to_casual,
)
from .translator import Translator, build_seedx_prompt, create_translator
__all__ = [
"DEFAULT_TIER",
"Glossary",
"GlossaryEntry",
"GpuInfo",
"MTBackend",
"ModelManager",
"Politeness",
"PromptStyle",
"SpeechLevel",
"SpeechRecognizer",
"TIERS",
"Tier",
"Transcript",
"Translator",
"apply_speech_level",
"build_seedx_prompt",
"detect_politeness",
"create_translator",
"detect_gpu",
"get_tier",
"ordered_tiers",
"to_casual",
]

95
src/livesub/models/asr.py Normal file
View File

@@ -0,0 +1,95 @@
"""음성인식(ASR) 래퍼 — faster-whisper(CTranslate2) 기반."""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
import numpy as np
from ..constants import models_dir
from .tiers import AsrSpec
log = logging.getLogger(__name__)
@dataclass
class Transcript:
text: str
language: str
confidence: float = 0.0
duration_s: float = 0.0
class SpeechRecognizer:
"""faster-whisper 모델 한 개를 감싼다. 스레드 안전."""
def __init__(self, spec: AsrSpec, device: str = "cuda", device_index: int = 0) -> None:
self.spec = spec
self.device = device
self.device_index = device_index
self._model = None
self._lock = threading.Lock()
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
from faster_whisper import WhisperModel
compute_type = self.spec.compute_type
if self.device == "cpu" and compute_type.endswith("float16"):
compute_type = "int8" # CPU에서는 float16이 오히려 느리다
log.info("음성인식 모델 로드: %s (%s/%s)", self.spec.repo, self.device, compute_type)
self._model = WhisperModel(
self.spec.repo,
device=self.device,
device_index=self.device_index,
compute_type=compute_type,
download_root=str(models_dir()),
)
def unload(self) -> None:
with self._lock:
self._model = None
def transcribe(
self,
audio: np.ndarray,
language: str | None = None,
fast: bool = False,
prompt: str = "",
) -> Transcript:
"""오디오 한 덩어리를 텍스트로.
`fast=True` 는 중간 결과용으로 beam search를 끄고 빠르게 돌린다.
"""
if self._model is None:
self.load()
assert self._model is not None
with self._lock:
segments, info = self._model.transcribe(
audio.astype(np.float32, copy=False),
language=language,
beam_size=1 if fast else self.spec.beam_size,
temperature=0.0,
condition_on_previous_text=False,
initial_prompt=prompt or None,
vad_filter=not fast,
vad_parameters={"min_silence_duration_ms": 300},
word_timestamps=False,
)
parts = [s.text for s in segments]
text = " ".join(p.strip() for p in parts if p.strip()).strip()
return Transcript(
text=text,
language=getattr(info, "language", language or "") or "",
confidence=float(getattr(info, "language_probability", 0.0) or 0.0),
duration_s=float(getattr(info, "duration", 0.0) or 0.0),
)

View File

@@ -0,0 +1,235 @@
"""용어집(고유명사·게임/방송 용어) 처리.
추가학습 없이도 즉시 효과를 내는 레이어다. 두 가지 방식으로 동작한다.
1. 플레이스홀더 보호 (`protect` / `restore`)
원문의 등록 용어를 `⟦0⟧` 같은 토큰으로 치환해 번역 모델이 건드리지 못하게 하고,
번역이 끝난 뒤 지정한 역어로 되돌린다. 어떤 모델에서도 쓸 수 있다.
2. 프롬프트 주입 (`prompt_hint`)
해당 문장에 등장한 용어만 골라 프롬프트에 "이 용어는 이렇게 옮겨라"로 넣는다.
조사·어미까지 문맥에 맞게 붙어 결과가 더 자연스럽다.
어느 쪽을 쓸지는 모델이 정한다. 2번은 **지시문을 이해하는 instruct 모델에서만**
동작한다. Seed-X 처럼 번역만 하도록 학습된 completion 모델은 지시문을 넣으면
학습 분포를 벗어나 오히려 번역이 망가지므로 1번을 쓴다. NLLB 같은 seq2seq 도
마찬가지다. 판정은 `MTSpec.supports_prompt_glossary` 가 한다.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
#: 번역 모델이 원문 그대로 통과시켜야 하는 자리표시자.
#:
#: 형식 선택은 취향이 아니라 실측 결과다. 처음에 쓰던 `⟦0⟧` 는 NLLB 가
#: 괄호를 통째로 날려 `0` 만 남겼고(생존률 0/3), 그 결과 용어가 자막에서
#: 사라졌다. 후보 8종을 실제 모델로 돌려 비교한 끝에 `#0#` 를 골랐다
#: (생존률 3/3, 다중 치환 4/5). 입력이 음성인식 결과라 `#` 가 자연스럽게
#: 등장할 일이 거의 없다는 점도 같이 봤다.
PLACEHOLDER = "#{}#" # #0#
PLACEHOLDER_RE = re.compile(r"#(\d+)#")
@dataclass
class GlossaryEntry:
"""용어 하나. `targets` 는 언어코드 → 역어."""
source: str
targets: dict[str, str] = field(default_factory=dict)
note: str = ""
#: 비우면 모든 소스 언어에 적용
source_lang: str = ""
#: 문장 전체가 이 표현일 때만 치환한다.
#:
#: "get in the car" 같은 절을 긴 문장 한가운데서 통째로 치환하면
#: 번역 모델이 문법을 세울 근거를 잃어 "어부 들어오는, 차 타!" 같은
#: 결과가 나온다. 반대로 그 말만 단독으로 나왔을 때는 통째로 바꾸는
#: 것이 가장 자연스럽다. 그래서 적용 범위를 문장 전체로 한정한다.
whole_only: bool = False
def target_for(self, lang: str) -> str:
return self.targets.get(lang, "")
class Glossary:
def __init__(self, entries: list[GlossaryEntry] | None = None, case_sensitive: bool = False):
self.case_sensitive = case_sensitive
self.entries: list[GlossaryEntry] = list(entries or [])
self._pattern: re.Pattern[str] | None = None
self._index: dict[str, GlossaryEntry] = {}
self._rebuild()
# --- 영속화 --------------------------------------------------------
@classmethod
def load(cls, path: str | Path, case_sensitive: bool = False) -> "Glossary":
p = Path(path)
if not p.is_file():
return cls(case_sensitive=case_sensitive)
raw = json.loads(p.read_text(encoding="utf-8"))
entries = [
GlossaryEntry(
source=item["source"],
targets=dict(item.get("targets", {})),
note=item.get("note", ""),
source_lang=item.get("source_lang", ""),
whole_only=bool(item.get("whole_only", False)),
)
for item in raw.get("entries", [])
if item.get("source")
]
return cls(entries, case_sensitive=raw.get("case_sensitive", case_sensitive))
def save(self, path: str | Path) -> Path:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
payload = {
"case_sensitive": self.case_sensitive,
"entries": [
{
"source": e.source,
"targets": e.targets,
"note": e.note,
"source_lang": e.source_lang,
"whole_only": e.whole_only,
}
for e in self.entries
],
}
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return p
# --- 편집 ----------------------------------------------------------
def add(self, entry: GlossaryEntry) -> None:
key = self._key(entry.source)
self.entries = [e for e in self.entries if self._key(e.source) != key]
self.entries.append(entry)
self._rebuild()
def remove(self, source: str) -> None:
key = self._key(source)
self.entries = [e for e in self.entries if self._key(e.source) != key]
self._rebuild()
def __len__(self) -> int:
return len(self.entries)
# --- 사용 ----------------------------------------------------------
def matches(self, text: str, target_lang: str) -> list[GlossaryEntry]:
"""문장에 실제로 등장하고 해당 언어 역어가 있는 항목만."""
if not self._pattern or not text:
return []
found: list[GlossaryEntry] = []
seen: set[str] = set()
for m in self._pattern.finditer(text):
entry = self._index.get(self._key(m.group(0)))
if entry is None or not entry.target_for(target_lang):
continue
key = self._key(entry.source)
if key not in seen:
seen.add(key)
found.append(entry)
return found
def protect(self, text: str, target_lang: str) -> tuple[str, list[str]]:
"""등록 용어를 자리표시자로 치환. (치환된 문장, 역어 목록) 반환."""
replacements: list[str] = []
if not text:
return text, replacements
whole = self._match_whole(text, target_lang)
if whole is not None:
# 문장 전체가 등록된 표현 — 통째로 바꾸는 게 가장 자연스럽다.
return PLACEHOLDER.format(0), [whole]
if not self._pattern:
return text, replacements
def _sub(m: re.Match[str]) -> str:
entry = self._index.get(self._key(m.group(0)))
if entry is None:
return m.group(0)
target = entry.target_for(target_lang)
if not target:
return m.group(0)
replacements.append(target)
return PLACEHOLDER.format(len(replacements) - 1)
return self._pattern.sub(_sub, text), replacements
@staticmethod
def surviving_placeholders(text: str) -> set[int]:
"""번역 결과에 살아남은 자리표시자 번호."""
return {int(m) for m in PLACEHOLDER_RE.findall(text)}
@classmethod
def missing_placeholders(cls, text: str, replacements: list[str]) -> list[int]:
"""번역 중에 사라진 자리표시자 번호.
번역 모델이 문장 일부를 통째로 누락하면 자리표시자도 같이 사라진다.
그대로 복원하면 해당 용어가 자막에서 증발하므로, 호출부가 이걸로
확인해 용어집 없이 다시 번역하는 쪽을 택할 수 있게 한다.
"""
present = cls.surviving_placeholders(text)
return [i for i in range(len(replacements)) if i not in present]
@staticmethod
def restore(text: str, replacements: list[str]) -> str:
"""플레이스홀더를 역어로 되돌린다."""
if not replacements:
return text
def _sub(m: re.Match[str]) -> str:
idx = int(m.group(1))
return replacements[idx] if 0 <= idx < len(replacements) else ""
return PLACEHOLDER_RE.sub(_sub, text)
def prompt_hint(self, text: str, target_lang: str, limit: int = 12) -> str:
"""LLM 프롬프트에 붙일 용어 지시문. 해당 없으면 빈 문자열."""
hits = self.matches(text, target_lang)[:limit]
if not hits:
return ""
lines = [f"- {e.source} -> {e.target_for(target_lang)}" for e in hits]
return "다음 용어는 반드시 지정된 역어로 번역하세요:\n" + "\n".join(lines)
def _match_whole(self, text: str, target_lang: str) -> str | None:
"""문장 전체가 등록 표현과 같으면 그 역어를 돌려준다."""
stripped = self._key(text.strip().rstrip(".!?…~,"))
entry = self._index.get(stripped)
if entry is None:
return None
return entry.target_for(target_lang) or None
# --- 내부 ----------------------------------------------------------
def _key(self, text: str) -> str:
return text if self.case_sensitive else text.casefold()
def _rebuild(self) -> None:
self._index = {self._key(e.source): e for e in self.entries}
if not self.entries:
self._pattern = None
return
# whole_only 항목은 부분 매칭에서 제외한다 (문장 전체일 때만 쓴다).
partial = [e.source for e in self.entries if not e.whole_only]
if not partial:
self._pattern = None
return
# 긴 용어를 먼저 매칭해야 "헤드샷"이 "헤드"에 잡아먹히지 않는다.
sources = sorted(partial, key=len, reverse=True)
flags = 0 if self.case_sensitive else re.IGNORECASE
self._pattern = re.compile(
"|".join(_bounded(re.escape(s)) for s in sources), flags
)
def _bounded(escaped: str) -> str:
"""영문/숫자로 시작·끝나는 용어에만 단어 경계를 건다.
한국어·일본어·중국어에는 \\b 가 의미 없으므로 적용하지 않는다.
"""
prefix = r"\b" if re.match(r"[A-Za-z0-9]", escaped.replace("\\", "")) else ""
suffix = r"\b" if re.search(r"[A-Za-z0-9]$", escaped.replace("\\", "")) else ""
return f"{prefix}{escaped}{suffix}"

View File

@@ -0,0 +1,242 @@
"""티어에 맞는 ASR + MT 모델 쌍을 관리한다."""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, replace
from ..config import ModelConfig
from .asr import SpeechRecognizer
from .tiers import TIERS, MTBackend, Tier, get_tier
from .translator import Translator, create_translator
log = logging.getLogger(__name__)
@dataclass
class GpuInfo:
available: bool
name: str = ""
total_vram_mb: int = 0
free_vram_mb: int = 0
reason: str = ""
def detect_gpu() -> GpuInfo:
"""CUDA 사용 가능 여부와 VRAM. torch 가 없으면 nvidia-smi 로 대체 조회."""
try:
import torch
if torch.cuda.is_available():
idx = torch.cuda.current_device()
free, total = torch.cuda.mem_get_info(idx)
return GpuInfo(
available=True,
name=torch.cuda.get_device_name(idx),
total_vram_mb=total // (1024 * 1024),
free_vram_mb=free // (1024 * 1024),
)
return GpuInfo(False, reason="CUDA를 사용할 수 없습니다 (드라이버/빌드 확인).")
except ImportError:
pass
import shutil
import subprocess
smi = shutil.which("nvidia-smi")
if not smi:
return GpuInfo(False, reason="PyTorch와 nvidia-smi 모두 찾지 못했습니다.")
try:
out = subprocess.run(
[smi, "--query-gpu=name,memory.total,memory.free",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5, check=True,
).stdout.strip().splitlines()
name, total, free = (v.strip() for v in out[0].split(","))
return GpuInfo(True, name, int(total), int(free),
reason="PyTorch 미설치 — 정보만 조회했습니다.")
except Exception as exc: # noqa: BLE001
return GpuInfo(False, reason=f"GPU 조회 실패: {exc}")
def apply_vram_limit(ratio: float, device_index: int = 0) -> bool:
"""이 프로세스가 쓸 수 있는 VRAM 상한을 건다.
게임과 동시에 돌 때 가장 큰 문제는 연산 시간이 아니라 **VRAM 고갈**이다.
게임이 쓸 메모리를 우리가 먹어버리면 텍스처 스트리밍이 시스템 메모리로
밀려나면서 프레임이 뚝뚝 끊긴다. 상한을 걸면 그 대신 우리 쪽이 먼저
OOM 을 맞으므로, 티어를 낮추라는 안내로 이어진다.
"""
if ratio <= 0 or ratio >= 1:
return False
try:
import torch
if not torch.cuda.is_available():
return False
torch.cuda.set_per_process_memory_fraction(ratio, device_index)
log.info("VRAM 상한 적용: 전체의 %d%%", int(ratio * 100))
return True
except Exception as exc: # noqa: BLE001 - 상한 실패가 치명적이지는 않다
log.warning("VRAM 상한을 걸지 못했습니다: %s", exc)
return False
def has_torch() -> bool:
"""PyTorch 가 설치돼 있는가.
포터블 exe 빌드는 torch 를 넣지 않는다. torch+CUDA 만 2.5GB 라
한 파일로 묶으면 실행할 때마다 그걸 임시폴더에 풀어야 해서 쓸 수 없다.
CTranslate2 기반 1~3티어는 torch 없이 동작하므로, 포터블은 그쪽만 담는다.
"""
import importlib.util
return importlib.util.find_spec("torch") is not None
def has_awq() -> bool:
"""4티어(AWQ Int4) 로드에 필요한 autoawq 가 있는지."""
import importlib.util
return importlib.util.find_spec("awq") is not None
def tier_availability(tier: Tier, gpu: GpuInfo | None = None) -> tuple[bool, str]:
"""이 설치 환경에서 해당 티어를 쓸 수 있는지와, 안 되면 그 이유.
torch 유무만 보면 부족하다. 실제로 막히는 경우가 둘 더 있다.
- 4티어(AWQ Int4)는 autoawq 가 있어야 로드된다. requirements.txt 에
없는 선택 의존성이라 없을 때가 많다.
- VRAM 이 모자라면 설치가 아무리 멀쩡해도 안 올라간다. 기존에도
min_vram_gb 로 경고 배지는 띄웠지만 선택은 막지 않아서, 골랐다가
로드에서 죽었다. 예: RTX 5050(7.5GB)은 정밀 10GB / 극한 16GB 라
둘 다 영영 못 쓴다.
"사용 가능"이라고 해놓고 로드에서 죽으면 사용자는 원인을 알 수 없다.
그래서 고를 수 있다고 말하기 전에 여기서 걸러낸다.
"""
if tier.mt.backend is not MTBackend.TRANSFORMERS:
return True, ""
if not has_torch():
return False, "PyTorch가 없어 사용할 수 없습니다 (포터블 버전은 1~3티어만 지원)"
if tier.mt.compute_type == "int4" and not has_awq():
return False, "autoawq가 없어 사용할 수 없습니다 (pip install autoawq)"
info = detect_gpu() if gpu is None else gpu
# free 가 아니라 total 로 본다. free 는 다른 프로그램 때문에 수시로
# 변해서, 그걸로 막으면 "아까는 되던 티어가 지금은 안 보인다"가 된다.
have_gb = info.total_vram_mb / 1024
if info.available and have_gb < tier.min_vram_gb:
return False, (
f"VRAM이 부족합니다 "
f"({tier.min_vram_gb:g}GB 필요, 이 GPU는 {have_gb:.1f}GB)"
)
return True, ""
def first_available_tier(preferred: str) -> str:
"""선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다."""
tier = get_tier(preferred)
gpu = detect_gpu()
if tier_availability(tier, gpu)[0]:
return tier.key
usable = [t for t in TIERS.values() if tier_availability(t, gpu)[0]]
if not usable:
return preferred
return max(usable, key=lambda t: t.order).key
def downgrade_compute(compute_type: str) -> str:
"""저부하 모드에서 쓸 더 가벼운 연산 정밀도."""
if compute_type in ("float16", "float32"):
return "int8_float16"
return compute_type
class ModelManager:
"""현재 선택된 티어의 모델 두 개를 들고 있는다."""
def __init__(self, config: ModelConfig, low_power: bool = False) -> None:
self.config = config
#: 저부하(게임) 모드. 연산 정밀도를 낮춰 VRAM과 GPU 점유를 줄인다.
self.low_power = low_power
self._tier: Tier = get_tier(config.tier)
self._asr: SpeechRecognizer | None = None
self._mt: Translator | None = None
self._lock = threading.Lock()
@property
def tier(self) -> Tier:
return self._tier
@property
def ready(self) -> bool:
return bool(self._asr and self._asr.loaded and self._mt and self._mt.loaded)
def effective_asr_spec(self):
"""저부하 모드가 켜져 있으면 정밀도를 낮춘 사양을 돌려준다."""
spec = self._tier.asr
if not self.low_power:
return spec
return replace(
spec, compute_type=downgrade_compute(spec.compute_type), beam_size=1
)
def effective_mt_spec(self):
spec = self._tier.mt
# 양자화 LLM(int4)은 이미 최소 상태라 더 낮출 수 없다.
if not self.low_power or spec.compute_type == "int4":
return spec
return replace(spec, compute_type=downgrade_compute(spec.compute_type))
def recognizer(self) -> SpeechRecognizer:
with self._lock:
if self._asr is None:
self._asr = SpeechRecognizer(
self.effective_asr_spec(), self.config.device, self.config.device_index
)
return self._asr
def translator(self) -> Translator:
with self._lock:
if self._mt is None:
self._mt = create_translator(
self.effective_mt_spec(),
self.config.device,
self.config.device_index,
self.config.lora_adapter_path,
)
return self._mt
def preload(self, progress=None) -> None:
"""두 모델을 미리 올린다. progress(str, float) 콜백으로 상태를 보고."""
def report(msg: str, value: float) -> None:
if progress:
progress(msg, value)
report(f"'{self._tier.name}' 음성인식 모델 준비 중…", 0.1)
self.recognizer().load()
report(f"'{self._tier.name}' 번역 모델 준비 중…", 0.6)
self.translator().load()
report("준비 완료", 1.0)
def switch_tier(self, tier_key: str) -> Tier:
"""티어 변경. 기존 모델은 내려서 VRAM을 즉시 돌려준다."""
if tier_key == self._tier.key:
return self._tier
self.unload()
self.config.tier = tier_key
self._tier = get_tier(tier_key)
log.info("모델 티어 변경: %s", self._tier.name)
return self._tier
def unload(self) -> None:
with self._lock:
if self._asr is not None:
self._asr.unload()
self._asr = None
if self._mt is not None:
self._mt.unload()
self._mt = None

152
src/livesub/models/packs.py Normal file
View File

@@ -0,0 +1,152 @@
"""기본 제공 게임 용어집 팩.
`resources/glossaries/*.json` 에 게임별로 묶어둔 용어를 읽어, 사용자가 켠 팩만
사용자 용어집과 합쳐 하나의 `Glossary` 로 만든다.
합칠 때 **사용자가 직접 등록한 항목이 항상 이긴다.** 기본 팩의 역어가 마음에
안 들면 용어집 화면에서 같은 단어를 등록하면 그쪽이 적용된다.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from .glossary import Glossary, GlossaryEntry
log = logging.getLogger(__name__)
PACK_DIR = Path(__file__).resolve().parent.parent / "resources" / "glossaries"
#: 여러 팩을 동시에 켰을 때 같은 단어의 역어가 갈리는 것을 막기 위한 계층.
#: 숫자가 클수록 나중에 적용되어 이긴다.
SCOPE_PRIORITY = {"generic": 0, "game": 1}
@dataclass(frozen=True)
class GlossaryPack:
key: str
name: str
description: str
entries: tuple[GlossaryEntry, ...]
#: "generic" = 장르 공통, "game" = 특정 게임 전용
scope: str = "game"
def __len__(self) -> int:
return len(self.entries)
@property
def priority(self) -> int:
return SCOPE_PRIORITY.get(self.scope, 1)
@property
def is_generic(self) -> bool:
return self.scope == "generic"
@lru_cache(maxsize=1)
def available_packs() -> tuple[GlossaryPack, ...]:
"""번들된 팩 목록. 파일이 깨져 있으면 그 팩만 건너뛴다."""
if not PACK_DIR.is_dir():
return ()
packs: list[GlossaryPack] = []
for path in sorted(PACK_DIR.glob("*.json")):
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
log.warning("용어집 팩을 읽지 못했습니다 (%s): %s", path.name, exc)
continue
entries = tuple(
GlossaryEntry(
source=item["source"],
targets=dict(item.get("targets", {})),
note=raw.get("name", path.stem),
whole_only=bool(item.get("whole_only", False)),
)
for item in raw.get("entries", [])
if item.get("source")
)
if not entries:
continue
packs.append(
GlossaryPack(
key=raw.get("key", path.stem),
name=raw.get("name", path.stem),
description=raw.get("description", ""),
entries=entries,
scope=raw.get("scope", "game"),
)
)
return tuple(packs)
def get_pack(key: str) -> GlossaryPack | None:
for pack in available_packs():
if pack.key == key:
return pack
return None
def resolve_packs(enabled_packs: list[str]) -> list[GlossaryPack]:
"""켜둔 팩을 적용 순서대로 돌려준다 (범용 먼저, 게임 전용 나중).
`knocked` 는 FPS 공통에서 "기절", 배그에서 "눕혔다"다. 어느 쪽이 맞는지는
지금 무슨 게임을 하느냐로 갈리므로, **게임 전용 팩이 항상 범용을 이겨야**
한다. 그렇지 않으면 체크박스를 누른 순서에 따라 결과가 달라진다.
"""
packs: list[GlossaryPack] = []
for key in enabled_packs:
pack = get_pack(key)
if pack is None:
log.warning("알 수 없는 용어집 팩입니다: %s", key)
continue
packs.append(pack)
# 같은 계층 안에서는 사용자가 켠 순서를 유지한다 (stable sort).
return sorted(packs, key=lambda p: p.priority)
def find_conflicts(enabled_packs: list[str], lang: str = "ko") -> dict[str, list[tuple[str, str]]]:
"""켜둔 게임 전용 팩끼리 역어가 갈리는 단어를 찾는다.
범용 팩은 게임 팩에 확실히 지므로 충돌로 보지 않는다. 게임 팩 둘 이상이
같은 단어를 다르게 번역하면 그건 사용자가 판단해야 할 문제라 UI로 알린다.
"""
seen: dict[str, list[tuple[str, str]]] = {}
for pack in resolve_packs(enabled_packs):
if pack.is_generic:
continue
for entry in pack.entries:
target = entry.target_for(lang)
if not target:
continue
seen.setdefault(entry.source.casefold(), []).append((pack.name, target))
return {
source: owners
for source, owners in seen.items()
if len({t for _, t in owners}) > 1
}
def build_glossary(
user_glossary: Glossary,
enabled_packs: list[str],
case_sensitive: bool = False,
) -> Glossary:
"""켜둔 팩 + 사용자 용어집을 하나로 합친다.
적용 순서: 범용 팩 → 게임 전용 팩 → 사용자 항목.
뒤에 오는 쪽이 이기므로 사용자가 직접 등록한 것이 항상 최우선이다.
"""
merged: list[GlossaryEntry] = []
for pack in resolve_packs(enabled_packs):
merged.extend(pack.entries)
merged.extend(user_glossary.entries)
combined = Glossary(case_sensitive=case_sensitive)
for entry in merged:
combined.add(entry) # add() 가 같은 원문을 교체해준다
return combined

View File

@@ -0,0 +1,310 @@
"""말투(존댓말/반말) 처리.
규칙은 두 줄로 요약된다.
1. 원문이 **실제로 존댓말**이면 반말 모드여도 존댓말을 지킨다.
상대가 정중하게 말했는데 자막이 반말로 나오면 뉘앙스가 통째로 뒤집힌다.
2. 원문에 높임이 **없거나 알 수 없으면** 사용자가 고른 모드를 따른다.
영어·중국어는 문법적 높임이 없으므로 항상 여기에 해당한다.
한국어로 번역할 때만 동작한다. 영어·중국어는 대상 언어일 때 적용할 문법이
없고, 일본어 경어 변환은 동사 활용이 필요해 규칙만으로는 위험하다.
변환 방향도 한쪽만 한다. 번역 모델의 한국어 출력은 거의 항상 격식체
(합니다체/해요체)이므로 **존댓말 모드는 손대지 않고**, 반말 모드일 때만
낮춤 변환을 건다. 반대 방향(반말→존댓말)은 어간 정보가 없으면 훨씬 자주
틀리므로 시도하지 않는다.
"""
from __future__ import annotations
import re
from enum import Enum
class SpeechLevel(str, Enum):
"""사용자가 고르는 출력 말투."""
POLITE = "polite" # 존댓말 (기본)
CASUAL = "casual" # 반말
class Politeness(str, Enum):
"""원문에서 감지한 높임 정도."""
UNKNOWN = "unknown" # 높임이 없는 언어이거나 판단 불가
POLITE = "polite" # 확실히 존댓말
CASUAL = "casual" # 확실히 반말
#: 문법적 높임 체계가 있는 언어. 나머지는 항상 UNKNOWN 이다.
HONORIFIC_LANGUAGES = frozenset({"ko", "ja"})
# --- 원문 높임 감지 ---------------------------------------------------------
_KO_POLITE = re.compile(
r"(습니다|습니까|ㅂ니다|ㅂ니까|십시오|십시요|세요|셔요|시죠"
r"|어요|아요|에요|예요|해요|네요|지요|죠|드려|드립|주세|감사합)"
)
_JA_POLITE = re.compile(
r"(です|ます|ません|でした|ました|ましょう|ください|下さい"
r"|ございま|でしょう|いたし|申し上げ|なさい)"
)
_HAS_HANGUL = re.compile(r"[가-힣]")
_HAS_JAPANESE = re.compile(r"[぀-ヿ一-鿿]")
def detect_politeness(text: str, lang: str) -> Politeness:
"""원문의 높임 정도를 판단한다.
높임 체계가 없는 언어(영어·중국어)는 항상 UNKNOWN 이다. "please" 같은
공손 표현은 문법적 높임이 아니라 어조라, 이걸로 존댓말을 확정하면
오탐이 너무 많아진다.
한국어·일본어는 존댓말 표지가 있으면 POLITE, 없으면 CASUAL 로 본다.
두 언어 모두 문장은 둘 중 하나로 끝나므로 "중립"이라는 선택지가 사실상
없고, 어미를 일일이 열거하는 것보다 이 편이 덜 틀린다.
"""
text = (text or "").strip()
if not text or lang not in HONORIFIC_LANGUAGES:
return Politeness.UNKNOWN
polite, script = (
(_KO_POLITE, _HAS_HANGUL) if lang == "ko" else (_JA_POLITE, _HAS_JAPANESE)
)
if polite.search(text):
return Politeness.POLITE
if script.search(text):
return Politeness.CASUAL
return Politeness.UNKNOWN
# --- 한국어 낮춤 변환 -------------------------------------------------------
_HANGUL_BASE = 0xAC00
_JONGSUNG_COUNT = 28
_JUNGSUNG_COUNT = 21
_HANGUL_COUNT = 11172
# 중성 인덱스
_A, _AE, _EO, _E, _YEO, _O, _WA, _U, _WO, _EU, _I = 0, 1, 4, 5, 6, 8, 9, 13, 14, 18, 20
#: '아'로 이어지는 밝은 모음 (모음조화)
_BRIGHT = frozenset({_A, _O, 2, _WA})
#: 받침 없는 어간 + 어/아 의 축약. 오+아 -> 와, 지+어 -> 져
_CONTRACTION = {_O: _WA, _U: _WO, _I: _YEO, _EU: _EO}
#: 붙여도 모양이 그대로인 어간 모음. 가+아 -> 가, 서+어 -> 서
_ABSORBING = frozenset({_A, _EO, _AE, _E, _YEO, _WA, _WO})
_RIEUL = 8 # 종성 ㄹ
_SSANG_SIOT = 20 # 종성 ㅆ — 과거형 '았/었' 판별용
def _decompose(char: str) -> tuple[int, int, int] | None:
code = ord(char) - _HANGUL_BASE
if not (0 <= code < _HANGUL_COUNT):
return None
return (
code // (_JUNGSUNG_COUNT * _JONGSUNG_COUNT),
(code // _JONGSUNG_COUNT) % _JUNGSUNG_COUNT,
code % _JONGSUNG_COUNT,
)
def _compose(cho: int, jung: int, jong: int) -> str:
return chr(
_HANGUL_BASE + (cho * _JUNGSUNG_COUNT + jung) * _JONGSUNG_COUNT + jong
)
def _attach_eo(stem: str) -> str:
"""어간에 어/아 를 붙인다 (해체 활용).
먹->먹어, 좋->좋아, 하->해, 오->와, 주->줘, 빠지->빠져, 가->가.
한국어 활용의 실제 규칙(모음조화 + 축약)을 그대로 구현한 것이라
어미 목록을 나열하는 방식보다 훨씬 넓게 맞는다.
"""
if not stem:
return stem
parts = _decompose(stem[-1])
if parts is None:
return stem + "어"
cho, jung, jong = parts
if jong == _SSANG_SIOT:
# 과거형 '았/었/였' 뒤에는 모음조화와 무관하게 항상 '어'가 온다.
# (남았아 X -> 남았어 O)
return stem + "어"
if jong: # 받침이 있으면 그냥 붙인다
return stem + ("아" if jung in _BRIGHT else "어")
if cho == 18 and jung == _A: # 하 -> 해
return stem[:-1] + _compose(cho, _AE, 0)
if jung in _CONTRACTION: # 오+아 -> 와, 지+어 -> 져
return stem[:-1] + _compose(cho, _CONTRACTION[jung], 0)
if jung in _ABSORBING: # 가+아 -> 가
return stem
return stem + ("아" if jung in _BRIGHT else "어")
def _attach_lge(stem: str) -> str:
"""'~겠습니다' -> '~ㄹ게'. 치->칠게, 가->갈게, 먹->먹을게."""
if not stem:
return stem
parts = _decompose(stem[-1])
if parts is None:
return stem + "을게"
cho, jung, jong = parts
if jong:
return stem + "을게"
return stem[:-1] + _compose(cho, jung, _RIEUL) + "게"
#: 받침 없는 어간 + ㅂ니다 축약형. 규칙으로 풀기 어려운 것만 표로 둔다.
_CONTRACTED = {
"입니다": "야",
"됩니다": "돼",
"드립니다": "줄게",
}
#: (패턴, 치환) — 긴 어미부터 적용해야 짧은 어미가 먼저 먹지 않는다.
_CASUAL_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"있습니다"), "있어"),
(re.compile(r"없습니다"), "없어"),
(re.compile(r"이에요"), "이야"),
(re.compile(r"예요"), "야"),
(re.compile(r"네요"), "네"),
(re.compile(r"지요"), "지"),
(re.compile(r"죠"), "지"),
]
#: 어간을 활용해야 하는 어미들. 앞 글자를 잡아 _attach_* 로 넘긴다.
_GESSEUMNIDA = re.compile(r"([가-힣])겠습니다")
_SEUMNIDA = re.compile(r"([가-힣])습니다")
_BNIDA = re.compile(r"([가-힣])ᆸ니다|([가-힣])ㅂ니다")
_SEYO = re.compile(r"([가-힣])세요")
_SEUMNIKKA = re.compile(r"([가-힣])습니까")
#: 명령형 존대 '~십시오' 도 어간을 활용해야 한다. 조심하십시오 -> 조심해
_SIPSIO = re.compile(r"([가-힣])십시[오요]")
#: 문장 끝의 '요'만 떼어낸다. 문장 중간의 '요'(중요, 필요)는 건드리면 안 된다.
_TRAILING_YO = re.compile(r"([가-힣])요(\s*[.!?…,~]*)\s*$")
_TRAILING_YO_MID = re.compile(r"([가-힣])요(\s*[.!?…,~]+\s+)")
def _split_bnida(text: str) -> str:
"""'갑니다' 처럼 어간에 ㅂ 이 합쳐진 형태를 푼다. 갑니다 -> 가 + 어."""
def _sub(match: re.Match[str]) -> str:
syllable = match.group(1)
parts = _decompose(syllable)
if parts is None:
return match.group(0)
cho, jung, jong = parts
if jong != 17: # ㅂ 받침이 아니면 대상이 아니다
return match.group(0)
return _attach_eo(_compose(cho, jung, 0))
return re.sub(r"([가-힣])니다", _sub, text)
def to_casual(text: str) -> str:
"""한국어 존댓말 문장을 반말로 낮춘다.
번역 모델이 내놓는 격식체 어미를 대상으로 한 규칙 기반 변환이라
100%는 아니다. 다만 게임 자막에서 실제로 나오는 어미는 종류가
많지 않고, 활용은 실제 문법 규칙으로 처리하므로 대부분을 덮는다.
"""
if not text:
return text
out = text
for source, target in _CONTRACTED.items():
out = out.replace(source, target)
out = _GESSEUMNIDA.sub(lambda m: _attach_lge(m.group(1)), out)
out = _SIPSIO.sub(lambda m: _attach_eo(m.group(1)), out)
for pattern, target in _CASUAL_RULES:
out = pattern.sub(target, out)
out = _SEUMNIKKA.sub(lambda m: _attach_eo(m.group(1)) + "?", out)
out = _SEUMNIDA.sub(lambda m: _attach_eo(m.group(1)), out)
out = _SEYO.sub(lambda m: _attach_eo(m.group(1)), out)
out = _split_bnida(out)
# 문장 끝 '요' 떼기 — 문장 중간의 '중요/필요'가 다치면 안 된다.
out = _TRAILING_YO_MID.sub(r"\1\2", out)
out = _TRAILING_YO.sub(r"\1\2", out)
return out.strip()
def apply_speech_level(
translated: str,
target_lang: str,
level: SpeechLevel,
source_politeness: Politeness = Politeness.UNKNOWN,
) -> str:
"""번역 결과에 말투 설정을 적용한다.
존댓말 모드는 아무것도 하지 않는다. 번역 모델의 한국어 출력이 이미
격식체이고, 한국어에서 존댓말은 어떤 상황에서도 틀린 선택이 아니다.
"""
if target_lang != "ko" or level is not SpeechLevel.CASUAL:
return translated
# 사용자 규칙: 원문이 진짜 존댓말이면 반말 모드여도 존댓말을 지킨다.
if source_politeness is Politeness.POLITE:
return translated
return to_casual(translated)
def prompt_instruction(level: SpeechLevel, source_politeness: Politeness) -> str:
"""지시문을 이해하는 LLM(Qwen3 등)에 넣을 말투 지시.
Seed-X 처럼 지시문을 못 알아듣는 번역 전용 모델에는 쓰지 않는다.
그쪽은 `apply_speech_level` 후처리로만 말투를 맞춘다.
"""
if source_politeness is Politeness.POLITE:
return "The speaker is being polite, so use Korean polite speech (존댓말)."
if level is SpeechLevel.CASUAL:
return "Use casual Korean speech (반말), the way friends talk while gaming."
return "Use polite Korean speech (존댓말)."
# --- 조사 교정 --------------------------------------------------------------
#: 앞말의 받침 유무로 갈리는 조사 쌍 (받침 있음, 받침 없음)
_PARTICLE_PAIRS = [("을", "를"), ("이", "가"), ("은", "는"), ("과", "와"), ("으로", "로")]
_PARTICLE_RE = re.compile(
r"([가-힣])(" + "|".join(sorted(
{p for pair in _PARTICLE_PAIRS for p in pair}, key=len, reverse=True
)) + r")(?=\s|$|[.!?,…~])"
)
_PARTICLE_LOOKUP = {}
for _with, _without in _PARTICLE_PAIRS:
_PARTICLE_LOOKUP[_with] = (_with, _without)
_PARTICLE_LOOKUP[_without] = (_with, _without)
def fix_particles(text: str) -> str:
"""앞말 받침에 맞게 조사를 고친다. 자기장를 -> 자기장을.
용어집이 단어를 통째로 바꿔치기하기 때문에 생기는 문제다. 번역 모델은
자리표시자를 기준으로 조사를 골랐는데, 복원된 역어의 받침이 그와 다르면
"자기장를" 같은 어긋남이 남는다. 받침은 한글 코드에서 바로 읽을 수 있어
규칙만으로 정확히 고칠 수 있다.
"""
if not text:
return text
def _sub(match: re.Match[str]) -> str:
head, particle = match.group(1), match.group(2)
pair = _PARTICLE_LOOKUP.get(particle)
if pair is None:
return match.group(0)
parts = _decompose(head)
if parts is None:
return match.group(0)
has_batchim = parts[2] != 0
# '으로/로' 는 ㄹ 받침이면 '로'를 쓴다 (서울로, 칼로).
if pair == ("으로", "로") and parts[2] == _RIEUL:
return head + "로"
return head + (pair[0] if has_batchim else pair[1])
return _PARTICLE_RE.sub(_sub, text)

209
src/livesub/models/tiers.py Normal file
View File

@@ -0,0 +1,209 @@
"""모델 품질 티어 정의.
사용자에게는 실제 모델 이름 대신 5단계 티어(속도우선 → 품질우선)로 노출한다.
각 티어는 ASR(음성인식) 1개 + MT(번역) 1개의 조합이다.
선정 근거는 docs/MODELS.md 참고.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from ..constants import DEFAULT_TIER
class MTBackend(str, Enum):
"""번역 모델 실행 백엔드."""
CTRANSLATE2 = "ctranslate2" # NLLB 계열 seq2seq
TRANSFORMERS = "transformers" # LLM 계열 (Seed-X / Qwen3)
class PromptStyle(str, Enum):
"""LLM 백엔드의 프롬프트 형식. 모델 계열마다 요구사항이 다르다."""
#: 해당 없음 (seq2seq)
NONE = "none"
#: Seed-X 전용. chat template 이 없는 번역 전용 completion 모델이라
#: 모델 카드에 적힌 고정 형식 + 끝의 `<언어코드>` 태그를 그대로 지켜야 한다.
SEEDX = "seedx"
#: Qwen3 등 일반 instruct 모델. chat template + 자유 지시문 사용 가능.
INSTRUCT = "instruct"
@dataclass(frozen=True)
class AsrSpec:
repo: str
compute_type: str
beam_size: int = 1
vram_mb: int = 0
@dataclass(frozen=True)
class MTSpec:
repo: str
backend: MTBackend
compute_type: str = "float16"
vram_mb: int = 0
prompt_style: PromptStyle = PromptStyle.NONE
#: LoRA 추가학습 난이도 1(쉬움) ~ 3(까다로움)
finetune_ease: int = 2
@property
def supports_prompt_glossary(self) -> bool:
"""프롬프트로 용어집을 지시할 수 있는가.
지시문을 이해하는 instruct 모델만 가능하다. Seed-X 는 번역만 하도록
학습된 completion 모델이라 "이 용어는 이렇게 옮겨라" 같은 문장을
넣으면 학습 분포를 벗어나 오히려 번역이 망가진다. seq2seq 도 마찬가지다.
이 둘은 플레이스홀더 치환 방식으로 용어를 보호한다.
"""
return self.prompt_style is PromptStyle.INSTRUCT
@dataclass(frozen=True)
class Tier:
key: str
order: int
name: str
tagline: str
asr: AsrSpec
mt: MTSpec
approx_latency_s: float
min_vram_gb: float
notes: str = ""
recommended: bool = False
best_after_finetune: bool = False
@property
def total_vram_mb(self) -> int:
return self.asr.vram_mb + self.mt.vram_mb
@property
def download_gb(self) -> float:
return round(self.total_vram_mb / 1024 * 1.25, 1)
TIERS: dict[str, Tier] = {}
def _register(tier: Tier) -> Tier:
TIERS[tier.key] = tier
return tier
LIGHTNING = _register(
Tier(
key="lightning",
order=1,
name="번개",
tagline="속도 최우선 · 저사양 GPU",
asr=AsrSpec("Systran/faster-whisper-small", "int8_float16", 1, 700),
mt=MTSpec(
"entai2965/nllb-200-distilled-600M-ctranslate2",
MTBackend.CTRANSLATE2,
"int8_float16",
800,
finetune_ease=1,
),
approx_latency_s=0.6,
min_vram_gb=2,
notes="짧은 대사·게임 음성에 충분. 긴 문장이나 전문용어는 품질이 떨어질 수 있습니다.",
)
)
SWIFT = _register(
Tier(
key="swift",
order=2,
name="신속",
tagline="빠르면서 인식률은 최상급",
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "int8_float16", 1, 1600),
mt=MTSpec(
"entai2965/nllb-200-distilled-600M-ctranslate2",
MTBackend.CTRANSLATE2,
"float16",
1400,
finetune_ease=1,
),
approx_latency_s=0.9,
min_vram_gb=4,
notes="음성인식을 최상급으로 올리고 번역만 경량으로 유지한 구성. 가성비가 가장 좋습니다.",
)
)
BALANCE = _register(
Tier(
key="balance",
order=3,
name="균형",
tagline="기본값 · 속도와 품질의 중간",
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400),
mt=MTSpec(
"entai2965/nllb-200-distilled-1.3B-ctranslate2",
MTBackend.CTRANSLATE2,
"float16",
2900,
finetune_ease=2,
),
approx_latency_s=1.2,
min_vram_gb=6,
notes="8GB VRAM 환경에서 가장 무난한 선택.",
)
)
PRECISION = _register(
Tier(
key="precision",
order=4,
name="정밀",
tagline="추천 · 현 시점 최고 번역 품질",
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400),
mt=MTSpec(
"ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4",
MTBackend.TRANSFORMERS,
"int4",
5600,
prompt_style=PromptStyle.SEEDX,
finetune_ease=3,
),
approx_latency_s=2.0,
min_vram_gb=10,
notes="번역 전용으로 학습된 7B 모델. 한/영/일/중 구어체와 문맥 처리가 확연히 좋습니다.",
recommended=True,
)
)
ULTIMATE = _register(
Tier(
key="ultimate",
order=5,
name="극한",
tagline="품질 최우선 · 추가학습(LoRA)에 최적",
asr=AsrSpec("Systran/faster-whisper-large-v3", "float16", 5, 3100),
mt=MTSpec(
"Qwen/Qwen3-8B",
MTBackend.TRANSFORMERS,
"float16",
11000,
prompt_style=PromptStyle.INSTRUCT,
finetune_ease=1,
),
approx_latency_s=3.0,
min_vram_gb=16,
notes="게임·방송 용어를 LoRA로 추가학습해 붙일 때 가장 잘 먹히는 구성입니다.",
best_after_finetune=True,
)
)
assert DEFAULT_TIER in TIERS, f"constants.DEFAULT_TIER({DEFAULT_TIER}) 가 티어 목록에 없습니다"
def ordered_tiers() -> list[Tier]:
return sorted(TIERS.values(), key=lambda t: t.order)
def get_tier(key: str) -> Tier:
return TIERS.get(key, TIERS[DEFAULT_TIER])

View File

@@ -0,0 +1,376 @@
"""번역(MT) 래퍼.
두 백엔드를 지원한다.
* CTranslate2 — NLLB 계열 seq2seq. 가볍고 빠르며 추가학습이 쉽다.
* Transformers — Seed-X / Qwen3 같은 LLM. 품질이 좋고 LoRA 어댑터를 얹을 수 있다.
LLM 백엔드는 다시 두 가지 프롬프트 형식으로 갈린다. 같은 "LLM"이라도 요구사항이
정반대라 하나로 합치면 한쪽이 반드시 망가진다.
* PromptStyle.SEEDX — Seed-X 는 chat template 이 없는 **번역 전용 completion 모델**이다.
모델 카드가 정한 고정 문장과 끝의 `<언어코드>` 태그를 그대로 지켜야 하고,
그 밖의 지시문을 끼워 넣으면 학습 분포를 벗어난다. 따라서 용어집은 프롬프트가
아니라 **플레이스홀더 치환**으로 넣는다.
* PromptStyle.INSTRUCT — Qwen3 등. chat template 을 쓰고 용어집을 지시문으로 넣는다.
세 경우 모두 `Translator` 인터페이스를 따르므로 상위 레이어는 구분하지 않는다.
"""
from __future__ import annotations
import abc
import logging
import re
import threading
from ..constants import LANGUAGES, models_dir
from .glossary import Glossary
from .speech_level import Politeness, SpeechLevel, fix_particles, prompt_instruction
from .tiers import MTBackend, MTSpec, PromptStyle
log = logging.getLogger(__name__)
#: 문장 끝에 LLM이 붙이곤 하는 군더더기를 걷어내기 위한 패턴
_TRAILING_NOISE = re.compile(r"^\s*(?:번역[::]|Translation[::])\s*", re.IGNORECASE)
def _restore_or_retranslate(out: str, replacements: list[str], retry) -> str:
"""자리표시자를 역어로 되돌린다. 하나라도 사라졌으면 용어집 없이 다시 번역.
번역 모델이 문장 일부를 통째로 누락하면 그 안의 자리표시자도 같이
사라진다. 그대로 복원하면 용어가 자막에서 증발하는데, 그건 용어집을
아예 안 쓴 것보다 나쁘다. 그래서 그럴 때는 보호 없이 한 번 더 번역해
원문 내용이 살아 있는 결과를 쓴다.
"""
missing = Glossary.missing_placeholders(out, replacements)
if not missing:
return Glossary.restore(out, replacements)
log.debug("자리표시자 %s 소실 — 용어집 없이 재번역", missing)
return retry()
def build_seedx_prompt(text: str, source_lang: str, target_lang: str) -> str:
"""Seed-X 모델 카드가 요구하는 정확한 프롬프트.
Translate the following English sentence into Chinese:
May the force be with you <zh>
끝의 `<언어코드>` 태그는 PPO 학습에 쓰인 것이라 **필수**다. 빠지면 번역
품질이 크게 흔들린다. 형식을 바꾸면 안 되므로 지시문이나 용어집 설명을
여기 끼워 넣지 말 것.
출처: https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B
"""
src = LANGUAGES[source_lang]["english"]
tgt = LANGUAGES[target_lang]["english"]
tag = LANGUAGES[target_lang]["seedx"]
return f"Translate the following {src} sentence into {tgt}:\n{text} <{tag}>"
class Translator(abc.ABC):
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
self.spec = spec
self.device = device
self.device_index = device_index
self._lock = threading.Lock()
@property
@abc.abstractmethod
def loaded(self) -> bool: ...
@abc.abstractmethod
def load(self) -> None: ...
@abc.abstractmethod
def unload(self) -> None: ...
@abc.abstractmethod
def _translate(
self,
text: str,
source_lang: str,
target_lang: str,
glossary: Glossary | None,
speech_level: SpeechLevel,
source_politeness: Politeness,
) -> str: ...
def translate(
self,
text: str,
source_lang: str,
target_lang: str,
glossary: Glossary | None = None,
speech_level: SpeechLevel = SpeechLevel.POLITE,
source_politeness: Politeness = Politeness.UNKNOWN,
) -> str:
"""말투(speech_level)는 지시문을 이해하는 모델에만 프롬프트로 전달된다.
나머지 백엔드는 상위에서 `apply_speech_level` 후처리로 맞춘다.
"""
text = text.strip()
if not text:
return ""
if source_lang == target_lang:
return text
out = self._translate(
text, source_lang, target_lang, glossary, speech_level, source_politeness
)
out = _TRAILING_NOISE.sub("", out).strip()
if target_lang == "ko":
# 용어집이 단어를 바꿔치기하면 앞말 받침이 달라져 조사가 어긋난다.
# (자기장를 -> 자기장을)
out = fix_particles(out)
return out
class CTranslate2Translator(Translator):
"""NLLB 계열. 용어집은 플레이스홀더 보호 방식으로 적용한다."""
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
super().__init__(spec, device, device_index)
self._model = None
self._tokenizer = None
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
import ctranslate2
from huggingface_hub import snapshot_download
from transformers import AutoTokenizer
local = snapshot_download(self.spec.repo, cache_dir=str(models_dir()))
compute = self.spec.compute_type
if self.device == "cpu" and "float16" in compute:
compute = "int8"
log.info("번역 모델 로드: %s (%s/%s)", self.spec.repo, self.device, compute)
self._model = ctranslate2.Translator(
local,
device=self.device,
device_index=self.device_index,
compute_type=compute,
)
self._tokenizer = AutoTokenizer.from_pretrained(local, cache_dir=str(models_dir()))
def unload(self) -> None:
with self._lock:
self._model = None
self._tokenizer = None
def _run_model(self, text: str, source_lang: str, target_lang: str) -> str:
"""용어집 처리를 뺀 순수 번역 한 번."""
src_code = LANGUAGES[source_lang]["nllb"]
tgt_code = LANGUAGES[target_lang]["nllb"]
with self._lock:
self._tokenizer.src_lang = src_code
tokens = self._tokenizer.convert_ids_to_tokens(
self._tokenizer.encode(text, truncation=True, max_length=512)
)
results = self._model.translate_batch(
[tokens],
target_prefix=[[tgt_code]],
beam_size=2,
max_decoding_length=512,
repetition_penalty=1.1,
)
hyp = results[0].hypotheses[0]
if hyp and hyp[0] == tgt_code:
hyp = hyp[1:]
return self._tokenizer.decode(
self._tokenizer.convert_tokens_to_ids(hyp), skip_special_tokens=True
)
def _translate(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
# seq2seq 는 프롬프트가 없어 말투를 지시할 수 없다. 후처리로 맞춘다.
if self._model is None:
self.load()
assert self._model is not None and self._tokenizer is not None
if glossary is None or not len(glossary):
return self._run_model(text, source_lang, target_lang)
protected, replacements = glossary.protect(text, target_lang)
if not replacements:
return self._run_model(text, source_lang, target_lang)
out = self._run_model(protected, source_lang, target_lang)
return _restore_or_retranslate(
out, replacements,
lambda: self._run_model(text, source_lang, target_lang),
)
class LLMTranslator(Translator):
"""Seed-X / Qwen3 등 LLM 백엔드. 용어집은 프롬프트로 직접 지시한다."""
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0,
lora_path: str = "") -> None:
super().__init__(spec, device, device_index)
self.lora_path = lora_path
self._model = None
self._tokenizer = None
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
dtype = torch.float16 if self.device == "cuda" else torch.float32
log.info("번역 LLM 로드: %s (%s)", self.spec.repo, self.device)
self._tokenizer = AutoTokenizer.from_pretrained(
self.spec.repo, cache_dir=str(models_dir())
)
try:
self._model = AutoModelForCausalLM.from_pretrained(
self.spec.repo,
cache_dir=str(models_dir()),
torch_dtype=dtype,
device_map={"": self.device_index} if self.device == "cuda" else "cpu",
)
except (ImportError, RuntimeError, ValueError) as exc:
if self.spec.compute_type == "int4":
# AWQ 가중치는 전용 커널이 있어야 읽힌다. 메시지가 모호해서
# 사용자가 원인을 못 찾는 일이 잦으므로 해결 방법을 같이 알려준다.
raise RuntimeError(
f"양자화 번역 모델({self.spec.repo})을 불러오지 못했습니다.\n"
"AWQ 커널이 필요합니다: pip install autoawq\n"
"설치가 어려우면 '모델' 화면에서 다른 티어를 선택하세요.\n"
f"원인: {exc}"
) from exc
raise
if self.lora_path:
from peft import PeftModel
log.info("LoRA 어댑터 적용: %s", self.lora_path)
self._model = PeftModel.from_pretrained(self._model, self.lora_path)
self._model.eval()
def unload(self) -> None:
with self._lock:
self._model = None
self._tokenizer = None
try:
import torch
torch.cuda.empty_cache()
except Exception: # noqa: BLE001
pass
def _build_prompt(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE,
source_politeness=Politeness.UNKNOWN) -> str:
if self.spec.prompt_style is PromptStyle.SEEDX:
# Seed-X 는 지시문을 못 알아듣는다. 말투는 후처리로만 맞춘다.
return build_seedx_prompt(text, source_lang, target_lang)
return self._build_instruct_prompt(
text, source_lang, target_lang, glossary, speech_level, source_politeness
)
def _build_instruct_prompt(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE,
source_politeness=Politeness.UNKNOWN) -> str:
"""지시문을 이해하는 모델용. 가능하면 chat template 을 태운다."""
src = LANGUAGES[source_lang]["english"]
tgt = LANGUAGES[target_lang]["english"]
hint = glossary.prompt_hint(text, target_lang) if glossary else ""
parts = [
f"Translate the following {src} text into {tgt}. "
"It is a live spoken line from a game or broadcast, so keep the tone "
"casual and natural. Output only the translation."
]
if target_lang == "ko":
parts.append(prompt_instruction(speech_level, source_politeness))
if hint:
parts.append(hint)
parts.append(f"{src}: {text}\n{tgt}:")
instruction = "\n\n".join(parts)
template = getattr(self._tokenizer, "chat_template", None)
if not template:
return instruction
# Qwen3 는 기본적으로 thinking 모드가 켜져 실시간 자막에는 너무 느리다.
try:
return self._tokenizer.apply_chat_template(
[{"role": "user", "content": instruction}],
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
# enable_thinking 을 모르는 템플릿
return self._tokenizer.apply_chat_template(
[{"role": "user", "content": instruction}],
tokenize=False,
add_generation_prompt=True,
)
def _generate(self, prompt: str) -> str:
"""프롬프트 하나를 돌려 첫 줄만 돌려준다."""
import torch
with self._lock:
inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
with torch.inference_mode():
generated = self._model.generate(
**inputs,
max_new_tokens=256,
do_sample=False,
num_beams=1,
repetition_penalty=1.05,
pad_token_id=self._tokenizer.eos_token_id,
)
new_tokens = generated[0][inputs["input_ids"].shape[-1] :]
out = self._tokenizer.decode(new_tokens, skip_special_tokens=True)
return out.strip().split("\n")[0]
def _translate(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
if self._model is None:
self.load()
assert self._model is not None and self._tokenizer is not None
def run(source: str, gloss) -> str:
return self._generate(
self._build_prompt(
source, source_lang, target_lang, gloss,
speech_level, source_politeness,
)
)
# Seed-X 는 지시문을 못 알아들으므로 용어집을 자리표시자로 보호한다.
# 지시문을 이해하는 모델은 프롬프트로 넣으므로 보호가 필요 없다.
needs_placeholder = (
self.spec.prompt_style is PromptStyle.SEEDX
and glossary is not None
and len(glossary)
)
if not needs_placeholder:
return run(text, glossary)
protected, replacements = glossary.protect(text, target_lang)
if not replacements:
return run(text, glossary)
out = run(protected, None)
return _restore_or_retranslate(
out, replacements, lambda: run(text, None)
)
def create_translator(
spec: MTSpec, device: str = "cuda", device_index: int = 0, lora_path: str = ""
) -> Translator:
if spec.backend is MTBackend.CTRANSLATE2:
return CTranslate2Translator(spec, device, device_index)
return LLMTranslator(spec, device, device_index, lora_path)